Files
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

814 lines
38 KiB
Go

package app
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"data-terminal-cloud/internal/alertnotification"
"data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/cache"
"data-terminal-cloud/internal/callmemory"
"data-terminal-cloud/internal/config"
"data-terminal-cloud/internal/contentmodel"
"data-terminal-cloud/internal/contentpolicy"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/dataexport"
"data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/devicefleet"
"data-terminal-cloud/internal/devicereset"
"data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/elderagent"
"data-terminal-cloud/internal/embeddingprovider"
"data-terminal-cloud/internal/externalprobe"
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/hardwareprovision"
"data-terminal-cloud/internal/health"
"data-terminal-cloud/internal/httpapi"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/media"
"data-terminal-cloud/internal/memoir"
"data-terminal-cloud/internal/memoryindex"
"data-terminal-cloud/internal/memorylifecycle"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/objectstore"
"data-terminal-cloud/internal/observability"
"data-terminal-cloud/internal/onboarding"
"data-terminal-cloud/internal/opsaccess"
"data-terminal-cloud/internal/opsconfig"
"data-terminal-cloud/internal/opsdevice"
"data-terminal-cloud/internal/orchestration"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"data-terminal-cloud/internal/privacy"
"data-terminal-cloud/internal/providermemory"
"data-terminal-cloud/internal/ratelimit"
"data-terminal-cloud/internal/rtc"
"data-terminal-cloud/internal/saas"
"data-terminal-cloud/internal/sharing"
"data-terminal-cloud/internal/vikingmemory"
"data-terminal-cloud/internal/voiceprovider"
"data-terminal-cloud/internal/webevents"
"github.com/jackc/pgx/v5/pgxpool"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)
func Run(ctx context.Context, serviceName string) error {
cfg, err := config.Load(serviceName)
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
logger := newLogger(cfg)
traceProvider, err := observability.SetupTracing(ctx, serviceName, cfg.Environment, cfg.OTLPEndpoint)
if err != nil {
return err
}
defer func() {
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := traceProvider.Shutdown(shutdownContext); err != nil {
logger.Error("trace provider shutdown failed", "error", err)
}
}()
databasePool, err := database.Open(ctx, cfg.DatabaseURL, serviceName)
if err != nil {
return err
}
defer databasePool.Close()
redisClient := cache.Open(cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
defer func() { _ = redisClient.Close() }()
var exportStore *objectstore.Store
var memoryEmbedding embeddingprovider.Provider
var providerMemoryClient providermemory.Client
var vikingMemory *vikingmemory.Service
callMemoryService := callmemory.NewService(databasePool, logger)
if bucket := cfg.TOSRecordingBucket; bucket != "" && serviceName == "api" {
recordings, recordingErr := callmemory.NewTOSRecordings(bucket, cfg.TOSRecordingRegion, cfg.VolcAccessKeyID, cfg.VolcSecretAccessKey, cfg.VolcSessionToken)
if recordingErr != nil {
return fmt.Errorf("configure cloud recordings: %w", recordingErr)
}
if err := recordings.ConfigurePlayback(cfg.TOSRecordingPlaybackEndpoint); err != nil {
return err
}
callMemoryService.ConfigureRecordings(recordings)
if account := cfg.TOSRecordingAccountID; account != "" {
callMemoryService.ConfigureMixedRecorder(callmemory.NewMixedRecorder(cfg.VolcAccessKeyID, cfg.VolcSecretAccessKey, cfg.VolcSessionToken, account, bucket))
}
}
var directTemplate []byte
directTemplatePath := os.Getenv("DIRECT_CHAT_TEMPLATE_FILE")
if directTemplatePath == "" {
directTemplatePath = cfg.VolcVoiceChatTemplate
}
if directTemplatePath != "" {
directTemplate, err = os.ReadFile(directTemplatePath)
if err != nil {
return fmt.Errorf("read direct chat template: %w", err)
}
}
if len(directTemplate) > 0 {
directTemplate, err = cfg.ApplyVolcVoiceTemplate(directTemplate)
if err != nil {
return err
}
}
callMemoryService.ConfigureDirect(callmemory.DirectOptions{Template: directTemplate, BotID: cfg.VolcHardwareAgentBotID, CallbackURL: cfg.SubtitleCallbackURL, CallbackSecret: []byte(cfg.SubtitleCallbackSecret)})
var elderAgents *elderagent.Service
if len(directTemplate) > 0 && (serviceName == "api" || serviceName == "worker") {
elderAgents = &elderagent.Service{Pool: databasePool, Provider: elderagent.NewClient(cfg.VolcAccessKeyID, cfg.VolcSecretAccessKey, cfg.VolcSessionToken, cfg.VolcRTCBaseURL, cfg.VolcRTCRegion).ForProduct(cfg.VolcHardwareInstanceID, cfg.VolcHardwareProductKey), Template: directTemplate, CallbackURL: cfg.SubtitleCallbackURL, CallbackSecret: []byte(cfg.SubtitleCallbackSecret), VikingCollectionName: cfg.VikingCollectionName, VikingEventType: cfg.VikingEventType}
}
if serviceName == "api" || serviceName == "worker" {
if cfg.VikingResourceID != "" {
client, vikingErr := vikingmemory.NewClient(vikingmemory.Options{ResourceID: cfg.VikingResourceID, CollectionName: cfg.VikingCollectionName, APIKey: cfg.VikingAPIKey, EventType: cfg.VikingEventType, ProfileType: cfg.VikingProfileType, Timeout: 8 * time.Second})
if vikingErr != nil {
return fmt.Errorf("configure Viking memory: %w", vikingErr)
}
vikingMemory = vikingmemory.NewService(databasePool, client)
callMemoryService.ConfigureVikingConsole(client)
managedMemory := vikingmemory.NewManaged(databasePool, client)
callMemoryService.ConfigureProjection(managedMemory)
callMemoryService.ConfigureManagedSync(managedMemory.SyncNext)
}
memoryEmbedding, err = newMemoryEmbeddingProvider(cfg)
if err != nil {
return fmt.Errorf("create memory embedding provider: %w", err)
}
if cfg.ProviderMemoryMode == "managed_http" {
providerMemoryClient, err = providermemory.NewHTTPClient(cfg.ProviderMemoryBaseURL, cfg.ProviderMemoryAPIKey, cfg.ProviderMemoryTimeout)
if err != nil {
return fmt.Errorf("create provider memory client: %w", err)
}
}
exportStore, err = objectstore.New(cfg.ObjectStoreEndpoint, cfg.ObjectStorePublic, cfg.ObjectStoreAccessKey, cfg.ObjectStoreSecretKey, cfg.ObjectStoreBucket, cfg.ObjectStoreRegion, cfg.ObjectStoreSecure, cfg.ObjectStorePublicSecure)
if err != nil {
return fmt.Errorf("create private export object store: %w", err)
}
ensureContext, cancel := context.WithTimeout(ctx, cfg.DependencyTimeout)
err = exportStore.EnsureBucket(ensureContext)
cancel()
if err != nil {
return fmt.Errorf("ensure private export bucket: %w", err)
}
}
readinessChecks := map[string]health.CheckFunc{
"postgres": observability.InstrumentDependencyCheck(serviceName, "postgres", databasePool.Ping),
"redis": observability.InstrumentDependencyCheck(serviceName, "redis", func(checkContext context.Context) error {
return redisClient.Ping(checkContext).Err()
}),
}
if exportStore != nil {
readinessChecks["object_store"] = observability.InstrumentDependencyCheck(serviceName, "object_store", exportStore.Ready)
}
readiness := health.NewChecker(cfg.DependencyTimeout, readinessChecks)
externalProbeRunner, err := newExternalProbeRunner(cfg, logger, databasePool)
if err != nil {
return fmt.Errorf("create external dependency probes: %w", err)
}
tokenVerifier := identity.NewOIDCVerifier(ctx, cfg.AuthIssuerURL, cfg.AuthJWKSURL, cfg.AuthAudience)
identityRepository := identity.NewRepository(databasePool)
var deviceVerifier httpapi.DeviceRequestVerifier
var onboardingService *onboarding.Service
var deviceRepository *device.Repository
var deviceTelemetryService *device.TelemetryService
var conversationCreator *orchestration.Service
var conversationHeartbeater *orchestration.HeartbeatService
var conversationTranscriptSubmitter *orchestration.TranscriptBatchService
var providerEventProcessor *orchestration.ProviderEventProcessor
var saasService *saas.Service
var mediaService *media.Service
var webEventService *webevents.Service
var sharingService *sharing.Service
var opsConfigService *opsconfig.Service
var billingService *billing.Service
var alertService *alerts.Service
var diagnosticService *diagnostics.Service
var opsAccessService *opsaccess.Service
var opsDeviceService *opsdevice.Service
var memoryToolService *memorytool.Service
var memoryToolSigner *memorytool.TokenSigner
var contentModelService *contentmodel.Service
var contentModelRegistry *contentmodel.Registry
var memoirService *memoir.Service
var hardwareProvisioningService *hardwareprovision.Service
var deviceFactoryResetService *devicereset.Service
if serviceName == "api" || serviceName == "worker" {
var fallback extraction.Generator = extraction.DeterministicGenerator{}
if cfg.ContentGenerator == "ark" {
fallback, err = extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout)
if err != nil {
return fmt.Errorf("create fallback Ark content generator: %w", err)
}
}
contentModelRegistry, err = contentmodel.NewRegistry(databasePool, cfg.ContentModelMasterKey, fallback)
if err != nil {
return fmt.Errorf("create content model registry: %w", err)
}
memoirService = &memoir.Service{Pool: databasePool, Model: cfg.ArkMemoirModel, Safety: contentpolicy.LocalClassifier{}}
if cfg.ArkAPIKey != "" {
memoirService.Generator, err = extraction.NewArkJSONGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkMemoirModel, 180*time.Second, 65536)
if err != nil {
return err
}
memoirService.Illustrator = memoir.NewIllustrator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkMemoirImageModel)
}
if cfg.DifyEnabled {
memoirService.Workflow, err = memoir.NewDifyWorkflow(cfg.DifyBaseURL, cfg.DifyMemoirAPIKey, cfg.DifyBridgeURL, cfg.DifyBridgeSecret)
if err != nil {
return err
}
}
}
if serviceName == "api" || serviceName == "callbacks" {
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
if err != nil {
return fmt.Errorf("create diagnostic service: %w", err)
}
}
if serviceName == "api" {
contentModelService, err = contentmodel.NewService(contentModelRegistry, audit.NewRepository())
if err != nil {
return fmt.Errorf("create content model service: %w", err)
}
memoryToolSigner, err = memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
if err != nil {
return fmt.Errorf("create memory tool signer: %w", err)
}
memoryToolService, err = memorytool.NewService(databasePool, memoryEmbedding, audit.NewRepository())
if err != nil {
return fmt.Errorf("create memory tool service: %w", err)
}
webEventService = webevents.NewService(databasePool)
cursorCodec, cursorErr := pagination.NewCodec([]byte(cfg.CursorSigningSecret))
if cursorErr != nil {
return fmt.Errorf("create pagination cursor codec: %w", cursorErr)
}
saasService = saas.NewService(databasePool, audit.NewRepository(), redisClient, cursorCodec)
saasService.ConfigureProviderMemory(cfg.ProviderMemoryMode == "managed_http")
if elderAgents != nil {
saasService.ConfigureVoiceAgents(elderAgents)
callMemoryService.ConfigureDirect(callmemory.DirectOptions{Prepare: elderAgents.Prepare, PrepareWithWelcome: elderAgents.PrepareWithWelcome, CallbackURL: cfg.SubtitleCallbackURL, CallbackSecret: []byte(cfg.SubtitleCallbackSecret)})
}
opsConfigService, err = opsconfig.NewService(databasePool, audit.NewRepository())
if err != nil {
return fmt.Errorf("create operations configuration service: %w", err)
}
billingService, err = billing.NewService(databasePool, audit.NewRepository())
if err != nil {
return fmt.Errorf("create usage billing service: %w", err)
}
alertService, err = alerts.NewService(databasePool, audit.NewRepository())
if err != nil {
return fmt.Errorf("create alert operations service: %w", err)
}
opsAccessService, err = opsaccess.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), exportStore, 5*time.Minute)
if err != nil {
return fmt.Errorf("create temporary sensitive access service: %w", err)
}
saasService.ConfigureExportSigning(exportStore, cfg.ExportDownloadTTL)
saasService.ConfigureFirmwareStore(exportStore, 5*time.Minute)
if exportStore != nil {
mediaService, err = media.NewService(databasePool, outbox.NewStore(databasePool), audit.NewRepository(), exportStore, 15*time.Minute)
if err != nil {
return fmt.Errorf("create media service: %w", err)
}
sharingService, err = sharing.NewService(databasePool, audit.NewRepository(), exportStore, []byte(cfg.ShareTokenSecret), 5*time.Minute)
if err != nil {
return fmt.Errorf("create sharing service: %w", err)
}
}
deviceIdentityRepository := deviceauth.NewRepository(databasePool)
deviceSecretResolver, err := deviceauth.NewDatabaseSecretResolver(databasePool, cfg.DeviceCredentialMasterKey)
if err != nil {
return fmt.Errorf("create database device secret resolver: %w", err)
}
if len(cfg.DeviceLocalSecret) >= 32 {
if err := deviceSecretResolver.ImportMissingLegacyCredentials(ctx, map[string][]byte{
"local-dev://simulated-terminal-1": []byte(cfg.DeviceLocalSecret),
"local-dev://unbound-terminal-2": []byte(cfg.DeviceLocalSecret),
}); err != nil {
return fmt.Errorf("import legacy device credentials: %w", err)
}
}
opsDeviceService, err = opsdevice.NewService(databasePool, deviceSecretResolver, audit.NewRepository(), opsdevice.Options{
OnboardingMasterKey: cfg.Onboarding.MasterKey,
DeviceOrigin: cfg.Onboarding.EntryURL,
})
if err != nil {
return fmt.Errorf("create operations device service: %w", err)
}
deviceVerifier, err = deviceauth.NewVerifier(
deviceIdentityRepository,
deviceSecretResolver,
deviceauth.NewRedisReplayGuard(redisClient, "voice-platform"),
cfg.DeviceClockSkew,
)
if err != nil {
return fmt.Errorf("create device verifier: %w", err)
}
hardwareProvisioningService, err = hardwareprovision.NewService(databasePool, audit.NewRepository(), hardwareprovision.Options{
Enabled: cfg.VolcHardwareProvisioningEnabled, InstanceID: cfg.VolcHardwareInstanceID,
ProductKey: cfg.VolcHardwareProductKey, BotID: cfg.VolcHardwareAgentBotID,
Transport: cfg.VolcHardwareTransport, MinimumSDKVersion: cfg.VolcHardwareMinimumSDKVersion,
ProductSecret: cfg.VolcHardwareProductSecret,
CredentialMasterKey: cfg.VolcHardwareDeviceCredentialMasterKey, RegisterURL: cfg.VolcHardwareRegisterURL,
RegisterTimeout: cfg.VolcHardwareRegisterTimeout, DeviceNamePrefix: cfg.VolcHardwareDeviceNamePrefix,
})
if err != nil {
return fmt.Errorf("create hardware provider provisioning service: %w", err)
}
deviceFactoryResetService, err = devicereset.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
if err != nil {
return fmt.Errorf("create device factory reset service: %w", err)
}
deviceRepository = device.NewRepository(databasePool)
deviceTelemetryService, err = device.NewTelemetryService(databasePool, idempotency.NewExecutor(databasePool))
if err != nil {
return fmt.Errorf("create device telemetry service: %w", err)
}
var tokenIssuer rtc.TokenIssuer
if cfg.RTCTokenIssuer == "volcengine" {
tokenIssuer, err = rtc.NewVolcengineTokenIssuer(cfg.RTCAppID, cfg.RTCAppKey, cfg.RTCTokenTTL)
} else {
tokenIssuer, err = rtc.NewLocalTokenIssuer([]byte(cfg.RTCTokenSecret))
}
if err != nil {
return fmt.Errorf("create RTC token issuer: %w", err)
}
leaseCache, leaseErr := orchestration.NewRedisLeaseCache(redisClient, "voice-platform")
if leaseErr != nil {
return fmt.Errorf("create conversation lease cache: %w", leaseErr)
}
conversationCreator, err = orchestration.NewService(
idempotency.NewExecutor(databasePool), orchestration.NewRepository(), audit.NewRepository(),
outbox.NewStore(databasePool), tokenIssuer, leaseCache,
orchestration.ServiceOptions{
RTCAppID: cfg.RTCAppID, VoiceProvider: cfg.VoiceProvider, EngineMode: "s2s_hybrid", AllocationTTL: cfg.AllocationTTL,
TokenTTL: cfg.RTCTokenTTL, HeartbeatInterval: cfg.HeartbeatInterval, RequirePublishedRouting: cfg.Environment == config.EnvironmentProd,
HardwareBotID: cfg.VolcHardwareAgentBotID, HardwareTransport: cfg.VolcHardwareTransport,
SubtitleCallbackURL: cfg.SubtitleCallbackURL, SubtitleCallbackSecret: []byte(cfg.SubtitleCallbackSecret), SubtitlePrimary: cfg.SubtitlePrimary,
},
)
if err != nil {
return fmt.Errorf("create conversation orchestration service: %w", err)
}
if err = conversationCreator.ConfigureMemoryEmbedding(databasePool, memoryEmbedding); err != nil {
return fmt.Errorf("configure conversation memory embedding: %w", err)
}
conversationCreator.ConfigureVikingMemory(vikingMemory)
memoryToolService.ConfigureVikingMemory(vikingMemory)
if cfg.MemoryToolBaseURL != "" {
if err = conversationCreator.ConfigureHardwareMemoryTool(memoryToolSigner, cfg.MemoryToolBaseURL, cfg.MemoryToolTokenTTL); err != nil {
return fmt.Errorf("configure hardware memory tool: %w", err)
}
}
credentialRenewalWindow := cfg.RTCTokenTTL / 3
minimumRenewalWindow := time.Duration(cfg.HeartbeatInterval*2) * time.Second
if credentialRenewalWindow < minimumRenewalWindow {
credentialRenewalWindow = minimumRenewalWindow
}
if credentialRenewalWindow >= cfg.RTCTokenTTL {
credentialRenewalWindow = cfg.RTCTokenTTL / 2
}
conversationHeartbeater, err = orchestration.NewHeartbeatService(
databasePool, orchestration.NewRepository(), leaseCache, tokenIssuer,
orchestration.HeartbeatServiceOptions{
RTCAppID: cfg.RTCAppID, LeaseTTL: time.Duration(cfg.HeartbeatInterval*5) * time.Second,
TokenTTL: cfg.RTCTokenTTL, CredentialRenewalWindow: credentialRenewalWindow,
},
)
if err != nil {
return fmt.Errorf("create conversation heartbeat service: %w", err)
}
conversationTranscriptSubmitter, err = orchestration.NewTranscriptBatchService(
databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository(), outbox.NewStore(databasePool),
)
if err != nil {
return fmt.Errorf("create conversation transcript batch service: %w", err)
}
}
if serviceName == "callbacks" {
providerEventProcessor, err = orchestration.NewProviderEventProcessor(databasePool, time.Duration(cfg.HeartbeatInterval*5)*time.Second)
if err != nil {
return fmt.Errorf("create provider event processor: %w", err)
}
}
limiter, err := ratelimit.NewPolicies(redisClient, "voice-platform:"+serviceName, map[string]ratelimit.Policy{
"network": {RatePerSecond: cfg.RateLimitRPS, Burst: cfg.RateLimitBurst},
"user": {RatePerSecond: cfg.RateLimitUserRPS, Burst: cfg.RateLimitUserBurst},
"tenant": {RatePerSecond: cfg.RateLimitTenantRPS, Burst: cfg.RateLimitTenantBurst},
"device": {RatePerSecond: cfg.RateLimitDeviceRPS, Burst: cfg.RateLimitDeviceBurst},
"provider_start": {RatePerSecond: cfg.ProviderStartRPS, Burst: cfg.ProviderStartBurst},
"memory_tool": {RatePerSecond: cfg.MemoryToolRateRPS, Burst: cfg.MemoryToolRateBurst},
})
if err != nil {
return fmt.Errorf("create rate limiter: %w", err)
}
if (serviceName == "api" || serviceName == "worker") && cfg.Onboarding.MasterKey != "" {
var sms onboarding.SMSProvider
if cfg.Onboarding.SMSLoginEnabled {
if cfg.Onboarding.SMSProvider == "tencent" {
if cfg.Onboarding.TencentSMSTemplates != nil {
sms, err = onboarding.NewTencentSMSProviderWithTemplates(cfg.Onboarding.TencentSMSSecretID, cfg.Onboarding.TencentSMSSecretKey, cfg.Onboarding.TencentSMSAppID, cfg.Onboarding.TencentSMSSignName, cfg.Onboarding.TencentSMSTemplates, cfg.Onboarding.TencentSMSTemplateParams)
} else {
sms, err = onboarding.NewTencentSMSProvider(cfg.Onboarding.TencentSMSSecretID, cfg.Onboarding.TencentSMSSecretKey, cfg.Onboarding.TencentSMSAppID, cfg.Onboarding.TencentSMSSignName, cfg.Onboarding.TencentSMSTemplateID)
}
} else {
sms, err = onboarding.NewHTTPSMSProvider(cfg.Onboarding.SMSGatewayURL, cfg.Onboarding.SMSGatewayToken)
}
if err != nil {
return err
}
}
captchaProvider, captchaErr := onboarding.NewTencentCaptchaProvider(cfg.Onboarding.TencentCaptcha)
if captchaErr != nil {
return captchaErr
}
onboardingService, err = onboarding.NewService(databasePool, redisClient, onboarding.Options{SaaSCaptchaDisabled: cfg.Onboarding.SaaSCaptchaDisabled, Captcha: captchaProvider, Enabled: cfg.Onboarding.Enabled, PauseClaims: cfg.Onboarding.PauseClaims, MasterKey: cfg.Onboarding.MasterKey, EntryURL: cfg.Onboarding.EntryURL, AllowLocalHTTP: cfg.Onboarding.AllowLocalHTTP, TrustedProxyCIDRs: cfg.Onboarding.TrustedProxyCIDRs, TokenVerifier: tokenVerifier, LegacyVerifier: deviceVerifier, ActivationEnabled: cfg.Onboarding.ActivationEnabled, ProviderMasterKey: cfg.VolcHardwareDeviceCredentialMasterKey, InstanceID: cfg.VolcHardwareInstanceID, ProductKey: cfg.VolcHardwareProductKey, ProductSecret: cfg.VolcHardwareProductSecret, BotID: cfg.VolcHardwareAgentBotID, Transport: cfg.VolcHardwareTransport, MinimumSDKVersion: cfg.VolcHardwareMinimumSDKVersion, SMS: sms, WeChatAppID: cfg.Onboarding.WeChatAppID, WeChatAppSecret: cfg.Onboarding.WeChatAppSecret, SMSLoginEnabled: cfg.Onboarding.SMSLoginEnabled, SelfSignupTenantIDs: cfg.Onboarding.SelfSignupTenantIDs, Logger: logger})
if err != nil {
return fmt.Errorf("create onboarding service: %w", err)
}
if serviceName == "api" {
deviceVerifier = onboardingService
}
}
if serviceName == "api" && saasService != nil {
downloadBaseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("FIRMWARE_DOWNLOAD_BASE_URL")), "/")
if downloadBaseURL == "" {
scheme := "http"
if cfg.ObjectStorePublicSecure {
scheme = "https"
}
downloadBaseURL = scheme + "://" + cfg.ObjectStorePublic
}
fleet := &devicefleet.Service{Pool: databasePool, Store: exportStore, BrokerURL: os.Getenv("DEVICE_MQTT_PUBLIC_URL"), DownloadBaseURL: downloadBaseURL}
address := os.Getenv("DEVICE_MQTT_ADDRESS")
if address == "" {
address = ":1883"
}
closeBroker, fleetErr := fleet.StartBroker(ctx, address, logger)
if fleetErr != nil {
return fmt.Errorf("start device MQTT: %w", fleetErr)
}
defer closeBroker()
saasService.Fleet = fleet
if onboardingService != nil {
onboardingService.Fleet = fleet
}
}
if serviceName == "worker" {
if elderAgents != nil {
go elderAgents.RunPersonaSync(ctx, logger)
}
webEventService = webevents.NewService(databasePool)
outbox.RegisterMetrics(databasePool)
observability.RegisterBusinessMetrics(databasePool)
outboxWorker := outbox.NewWorker(
outbox.NewStore(databasePool),
logger,
outbox.WorkerOptions{PollInterval: cfg.WorkerPollInterval},
)
outboxWorker.Register("platform.noop", func(context.Context, outbox.Event) error { return nil })
if onboardingService != nil {
outboxWorker.Register(onboarding.ActivationEvent, onboardingService.ProcessActivation)
go onboardingService.RunMaintenance(ctx)
}
alertWebhookProcessor, processorErr := alertnotification.NewProcessor(cfg.AlertWebhookURL, cfg.AlertWebhookSecret, cfg.AlertWebhookTimeout)
if processorErr != nil {
return fmt.Errorf("create alert webhook processor: %w", processorErr)
}
outboxWorker.Register("alert.webhook_requested", alertWebhookProcessor.Handle)
outboxWorker.Register("alert.webhook_test_requested", alertWebhookProcessor.Handle)
outboxWorker.Register("conversation.room_issued", func(_ context.Context, event outbox.Event) error {
logger.Info("conversation room issued event observed", "event_id", event.ID, "conversation_id", event.AggregateID)
return nil
})
var providerClient voiceprovider.Client
if cfg.VoiceProvider == "volcengine" {
template, readErr := os.ReadFile(cfg.VolcVoiceChatTemplate)
if readErr != nil {
return fmt.Errorf("read Volcengine voice-chat template: %w", readErr)
}
template, err = cfg.ApplyVolcVoiceTemplate(template)
if err != nil {
return err
}
providerClient, err = voiceprovider.NewVolcengineClient(voiceprovider.VolcengineOptions{
BaseURL: cfg.VolcRTCBaseURL, AccessKeyID: cfg.VolcAccessKeyID, SecretAccessKey: cfg.VolcSecretAccessKey,
SessionToken: cfg.VolcSessionToken, Region: cfg.VolcRTCRegion, AppID: cfg.RTCAppID,
APIVersion: cfg.VolcRTCAPIVersion, Template: template, Timeout: cfg.ProviderRequestTimeout,
RequireAllEngineModes: cfg.Environment == config.EnvironmentProd,
})
} else {
providerClient, err = voiceprovider.NewHTTPClient(cfg.MockProviderBaseURL, cfg.MockProviderAPIKey, cfg.ProviderRequestTimeout)
}
if err != nil {
return fmt.Errorf("create voice provider client: %w", err)
}
providerHealth, providerErr := orchestration.NewProviderHealth(databasePool, cfg.ProviderFailureThreshold, cfg.ProviderCircuitCooldown)
if providerErr != nil {
return fmt.Errorf("create voice provider health: %w", providerErr)
}
leaseTTL := time.Duration(cfg.HeartbeatInterval*5) * time.Second
startProcessor, processorErr := orchestration.NewStartProcessor(databasePool, providerClient, providerHealth, leaseTTL)
if processorErr != nil {
return fmt.Errorf("create provider start processor: %w", processorErr)
}
if processorErr = startProcessor.ConfigureStartProtection(limiter, cfg.ProviderMaxActiveTasks); processorErr != nil {
return fmt.Errorf("configure provider start protection: %w", processorErr)
}
if cfg.MemoryToolBaseURL != "" {
workerMemoryToolSigner, signerErr := memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
if signerErr != nil {
return fmt.Errorf("create worker memory tool signer: %w", signerErr)
}
if processorErr = startProcessor.ConfigureMemoryTool(workerMemoryToolSigner, cfg.MemoryToolBaseURL, cfg.MemoryToolTokenTTL); processorErr != nil {
return fmt.Errorf("configure provider memory tool: %w", processorErr)
}
}
outboxWorker.Register("conversation.agent_start_requested", startProcessor.Handle)
outboxWorker.Register("conversation.agent_start_reconcile_requested", startProcessor.Handle)
stopProcessor, processorErr := orchestration.NewStopProcessor(databasePool, providerClient, providerHealth)
if processorErr != nil {
return fmt.Errorf("create provider stop processor: %w", processorErr)
}
outboxWorker.Register("conversation.agent_stop_requested", stopProcessor.Handle)
outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle)
var contentGenerator extraction.Generator = contentModelRegistry
if cfg.DifyEnabled {
contentGenerator, err = extraction.NewDifyGenerator(cfg.DifyBaseURL, cfg.DifyExtractionAPIKey, cfg.DifyBridgeURL, cfg.DifyBridgeSecret)
if err != nil {
return err
}
}
var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{}
if cfg.ContentSafetyProvider == "managed_http" {
managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout)
if safetyErr != nil {
return fmt.Errorf("create managed content safety classifier: %w", safetyErr)
}
contentSafety = managedSafety
}
extractionProcessor, processorErr := extraction.NewProcessorWithSafety(databasePool, contentGenerator, contentSafety)
if processorErr != nil {
return fmt.Errorf("create content extraction processor: %w", processorErr)
}
// CALL-MEMORY-1.1 has one authoritative extractor. Historical AddSession
// queue entries must not repopulate independently extracted provider memory.
outboxWorker.Register(vikingmemory.SyncEvent, func(context.Context, outbox.Event) error { return nil })
outboxWorker.Register("conversation.content_extraction_requested", extractionProcessor.Handle)
outboxWorker.Register(extraction.DirectStoryEvent, extractionProcessor.HandleDirectStory)
outboxWorker.Register(extraction.ImportStoryEvent, extractionProcessor.HandleImport)
memoirService.Safety = contentSafety
go memoirService.Run(ctx)
callMemoryService.ConfigureExtractor(contentModelRegistry)
go callMemoryService.Run(ctx)
memoryIndexProcessor, processorErr := memoryindex.NewProcessor(databasePool, memoryEmbedding)
if processorErr != nil {
return fmt.Errorf("create memory index processor: %w", processorErr)
}
outboxWorker.Register("memory.index_requested", memoryIndexProcessor.Handle)
if providerMemoryClient != nil {
providerMemoryProcessor, providerMemoryErr := providermemory.NewProcessor(databasePool, providerMemoryClient)
if providerMemoryErr != nil {
return fmt.Errorf("create provider memory processor: %w", providerMemoryErr)
}
outboxWorker.Register("memory.provider_sync_requested", providerMemoryProcessor.HandleSync)
outboxWorker.Register("memory.provider_delete_requested", providerMemoryProcessor.HandleDelete)
}
memoryDeleteProcessor, processorErr := memorylifecycle.NewDeleteProcessor(databasePool)
if processorErr != nil {
return fmt.Errorf("create memory delete processor: %w", processorErr)
}
outboxWorker.Register("memory.delete_requested", memoryDeleteProcessor.Handle)
privacyAccessProcessor, processorErr := privacy.NewAccessProcessor(databasePool)
if processorErr != nil {
return fmt.Errorf("create privacy access processor: %w", processorErr)
}
outboxWorker.Register("privacy.access_requested", privacyAccessProcessor.Handle)
privacyDeleteProcessor, processorErr := privacy.NewDeleteProcessor(databasePool, redisClient, exportStore, providerMemoryClient)
if processorErr != nil {
return fmt.Errorf("create privacy delete processor: %w", processorErr)
}
if vikingMemory != nil {
privacyDeleteProcessor.ConfigureVikingMemory(vikingMemory)
}
outboxWorker.Register("privacy.delete_requested", privacyDeleteProcessor.Handle)
exportProcessor, processorErr := dataexport.NewProcessor(databasePool, exportStore, cfg.ExportObjectTTL)
if processorErr != nil {
return fmt.Errorf("create data export processor: %w", processorErr)
}
outboxWorker.Register("export.generate_requested", exportProcessor.Handle)
if exportStore != nil {
mediaProcessor, mediaErr := media.NewProcessor(databasePool, exportStore, audit.NewRepository(), cfg.MediaLocalDeleteGrace)
if mediaErr != nil {
return fmt.Errorf("create media verification processor: %w", mediaErr)
}
outboxWorker.Register("media.verify_requested", mediaProcessor.Handle)
}
expiryScanner, processorErr := dataexport.NewExpiryScanner(databasePool, exportStore, logger, cfg.WorkerPollInterval)
if processorErr != nil {
return fmt.Errorf("create export expiry scanner: %w", processorErr)
}
go expiryScanner.Run(ctx)
go outboxWorker.Run(ctx)
recoveryScanner, recoveryErr := orchestration.NewRecoveryScanner(
databasePool, outbox.NewStore(databasePool), logger,
orchestration.RecoveryOptions{
ScanInterval: cfg.RecoveryScanInterval, StartingAfter: cfg.RecoveryStartingAfter,
StoppingAfter: cfg.RecoveryStoppingAfter, BatchSize: 50,
},
)
if recoveryErr != nil {
return fmt.Errorf("create conversation recovery scanner: %w", recoveryErr)
}
go recoveryScanner.Run(ctx)
alertScanner, alertErr := alerts.NewScanner(databasePool, logger, cfg.WorkerPollInterval)
if alertErr != nil {
return fmt.Errorf("create alert scanner: %w", alertErr)
}
go alertScanner.Run(ctx)
go func() {
ticker := time.NewTicker(time.Duration(cfg.HeartbeatInterval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
staleBefore := time.Now().UTC().Add(-2 * leaseTTL)
if _, err := webEventService.ExpireDevicePresence(ctx, staleBefore, 1000); err != nil {
logger.Warn("device presence expiry scan failed", "error", err)
}
if _, err := webEventService.DeleteExpired(ctx, 5000); err != nil {
logger.Warn("expired web event cleanup failed", "error", err)
}
}
}
}()
}
router := httpapi.NewRouter(httpapi.RouterOptions{
OnboardingService: onboardingService,
ServiceName: serviceName,
Logger: logger,
RateLimiter: rateLimiterAdapter{limiter},
Readiness: readiness,
TokenVerifier: tokenVerifier,
IdentityRepository: identityRepository,
CallMemoryService: callMemoryService,
MemoirService: memoirService,
SaaSService: saasService,
DeviceVerifier: deviceVerifier,
DeviceService: deviceRepository,
DeviceTelemetrySubmitter: deviceTelemetryService,
MediaService: mediaService,
WebEventService: webEventService,
SharingService: sharingService,
OpsConfigService: opsConfigService,
BillingService: billingService,
AlertService: alertService,
DiagnosticService: diagnosticService,
OpsAccessService: opsAccessService,
OpsDeviceService: opsDeviceService,
ContentModelService: contentModelService,
ConversationCreator: conversationCreator,
ConversationActivator: conversationCreator,
DeviceConversationStateReader: conversationCreator,
ConversationHeartbeater: conversationHeartbeater,
ConversationStopper: conversationCreator,
ConversationTranscriptSubmitter: conversationTranscriptSubmitter,
HardwareAgentSessionService: conversationCreator,
HardwareAgentProvisioningService: hardwareProvisioningService,
DeviceFactoryResetService: deviceFactoryResetService,
ProviderCallbackSecret: []byte(cfg.ProviderCallbackSecret),
ProviderCallbackSkew: cfg.ProviderCallbackSkew,
ProviderCallbackProcessor: providerEventProcessor,
NativeSubtitleSecret: []byte(cfg.SubtitleCallbackSecret),
NativeSubtitleProcessor: orchestration.NewNativeSubtitleService(databasePool),
PlatformHealthCallbackSecret: []byte(cfg.PlatformHealthSecret),
PlatformHealthCallbackSkew: cfg.PlatformHealthSkew,
PlatformHealthProcessor: diagnosticService,
MemoryToolService: memoryToolService,
MemoryToolSigner: memoryToolSigner,
})
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: otelhttp.NewHandler(httpapi.Middleware(logger, serviceName, 1<<20, rateLimiterAdapter{limiter}, router), serviceName),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
serverError := make(chan error, 1)
go externalProbeRunner.Run(ctx)
go func() {
logger.Info("service listening", "address", cfg.HTTPAddr, "environment", cfg.Environment)
serverError <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
logger.Info("service shutting down")
return server.Shutdown(shutdownContext)
case err := <-serverError:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
func newExternalProbeRunner(cfg config.Config, logger *slog.Logger, pool *pgxpool.Pool) (*externalprobe.Runner, error) {
checks, err := externalProbeChecks(cfg)
if err != nil {
return nil, err
}
dependencies := make([]string, 0, len(checks))
for dependency := range checks {
dependencies = append(dependencies, dependency)
}
reconcileContext, cancel := context.WithTimeout(context.Background(), cfg.DependencyTimeout)
err = externalprobe.ReconcilePostgresState(reconcileContext, pool, cfg.ServiceName, dependencies)
cancel()
if err != nil {
return nil, err
}
observer, err := externalprobe.PostgresObserver(pool, cfg.ServiceName)
if err != nil {
return nil, err
}
return externalprobe.NewRunner(cfg.ExternalProbeInterval, cfg.DependencyTimeout, checks, logger, observer)
}
func externalProbeChecks(cfg config.Config) (map[string]externalprobe.CheckFunc, error) {
checks := map[string]externalprobe.CheckFunc{}
if cfg.ServiceName == "api" {
jwksCheck, err := externalprobe.JWKS(cfg.AuthJWKSURL, nil)
if err != nil {
return nil, err
}
checks["oidc_jwks"] = observability.InstrumentDependencyCheck(cfg.ServiceName, "oidc_jwks", jwksCheck)
}
if (cfg.ServiceName == "api" || cfg.ServiceName == "worker") && cfg.EmbeddingProvider == "ark" {
arkCheck, err := externalprobe.TLSReachability(cfg.ArkBaseURL, nil)
if err != nil {
return nil, err
}
checks["ark_tls"] = observability.InstrumentDependencyCheck(cfg.ServiceName, "ark_tls", arkCheck)
}
if cfg.ServiceName == "worker" && cfg.VoiceProvider == "volcengine" {
rtcCheck, err := externalprobe.TLSReachability(cfg.VolcRTCBaseURL, nil)
if err != nil {
return nil, err
}
checks["volc_rtc_tls"] = observability.InstrumentDependencyCheck(cfg.ServiceName, "volc_rtc_tls", rtcCheck)
}
if cfg.ServiceName == "worker" && cfg.ContentSafetyProvider == "managed_http" {
contentSafetyCheck, err := externalprobe.TLSReachability(cfg.ContentSafetyEndpoint, nil)
if err != nil {
return nil, err
}
checks["content_safety_tls"] = observability.InstrumentDependencyCheck(cfg.ServiceName, "content_safety_tls", contentSafetyCheck)
}
return checks, nil
}
func newMemoryEmbeddingProvider(cfg config.Config) (embeddingprovider.Provider, error) {
if cfg.EmbeddingProvider == "ark" {
return embeddingprovider.NewArk(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkEmbeddingModel, cfg.ArkRequestTimeout)
}
return embeddingprovider.Local{}, nil
}
type rateLimiterAdapter struct{ limiter *ratelimit.Limiter }
func (a rateLimiterAdapter) Allow(ctx context.Context, scope, identifier string) (httpapi.RateLimitResult, error) {
result, err := a.limiter.Allow(ctx, scope, identifier)
return httpapi.RateLimitResult{Allowed: result.Allowed, Remaining: result.Remaining, RetryAfter: result.RetryAfter}, err
}
func newLogger(cfg config.Config) *slog.Logger {
level := slog.LevelInfo
switch cfg.LogLevel {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})).With("service", cfg.ServiceName)
}