chore: checkpoint 第一版
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-06 17:04:11 +08:00
parent a8cdec98d1
commit 0c2151ace9
659 changed files with 185855 additions and 554 deletions
+283
View File
@@ -0,0 +1,283 @@
package orchestration
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/conversation"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/outbox"
"github.com/jackc/pgx/v5"
)
var (
ErrHardwareSessionProof = errors.New("hardware agent session proof is invalid")
ErrHardwareEventConflict = errors.New("hardware agent event conflicts with stored event")
)
func (s *Service) CreateHardwareAgent(ctx context.Context, identity deviceauth.Identity, idempotencyKey, requestID string, rawRequest []byte, input HardwareAgentCreateInput) (HardwareAgentCreateResponse, bool, error) {
tenantID := identity.TenantID
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)
}
memoryVector, memoryPolicy, err := s.prepareMemoryQuery(ctx, identity, input.RequestedGoal)
if err != nil {
return HardwareAgentCreateResponse{}, false, err
}
var vikingSubject, vikingContext string
if s.viking != nil {
readContext, cancel := context.WithTimeout(ctx, 3*time.Second)
var readErr error
vikingSubject, vikingContext, readErr = s.viking.PrepareForDevice(readContext, identity.TenantID, identity.ID)
cancel()
if readErr != nil {
slog.WarnContext(ctx, "Viking startup memory unavailable", "error", readErr)
}
}
result, err := s.executor.Execute(ctx, request, 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,
RequestedGoal: input.RequestedGoal, ConfigRevision: input.ConfigRevision,
Capabilities: Capabilities{UploadResume: boolPointer(input.Capabilities.TranscriptUpload)},
}, 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,
VikingSubjectID: vikingSubject, VikingContext: vikingContext,
MemoryPolicy: memoryPolicy, RequirePublishedRouting: s.options.RequirePublishedRouting,
RuntimeType: RuntimeVolcHardwareAgent, HardwareBotID: s.options.HardwareBotID,
HardwareTransport: s.options.HardwareTransport, HardwareCapabilities: input.Capabilities,
SubtitleCallbackURL: s.options.SubtitleCallbackURL, SubtitleCallbackSecret: s.options.SubtitleCallbackSecret,
SubtitlePrimary: s.options.SubtitlePrimary,
HardwareMemoryToolURL: s.hardwareMemoryToolURL})
if createErr != nil {
return idempotency.Result{}, createErr
}
if created.Hardware == nil {
return idempotency.Result{}, errors.New("hardware agent runtime was not allocated")
}
body, marshalErr := json.Marshal(created.Hardware)
if marshalErr != nil {
return idempotency.Result{}, marshalErr
}
actorID := identity.PublicID
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &tenantID,
ActorType: "device", ActorPublicID: &actorID, Action: "hardware_agent.session.create",
ResourceType: "conversation", ResourceID: &internalID, RequestID: requestID,
Purpose: input.RequestedGoal, Result: "success", Detail: map[string]any{
"runtime_type": RuntimeVolcHardwareAgent, "transport": created.Hardware.Runtime.Transport,
"prompt_bundle_version": created.Hardware.SessionConfig.PromptBundleVersion,
}}); auditErr != nil {
return idempotency.Result{}, auditErr
}
return idempotency.Result{StatusCode: http.StatusCreated, Body: body, ResourceID: &internalID}, nil
})
if err != nil {
return HardwareAgentCreateResponse{}, false, err
}
return s.finalizeHardwareCreate(result)
}
func (s *Service) finalizeHardwareCreate(result idempotency.Result) (HardwareAgentCreateResponse, bool, error) {
response, replayed, err := decodeHardwareCreate(result)
if err != nil || s.hardwareMemoryTool == nil || s.hardwareMemoryToolURL == "" {
return response, replayed, err
}
token, err := s.hardwareMemoryTool.Issue(response.ConversationID, response.Runtime.ProviderSessionRef, s.now().UTC().Add(s.hardwareMemoryToolTTL))
if err != nil {
return HardwareAgentCreateResponse{}, replayed, fmt.Errorf("issue hardware memory tool token: %w", err)
}
response.SessionConfig.ToolURL = s.hardwareMemoryToolURL
response.SessionConfig.ToolScopeToken = token
return response, replayed, nil
}
func decodeHardwareCreate(result idempotency.Result) (HardwareAgentCreateResponse, bool, error) {
var response HardwareAgentCreateResponse
if err := json.Unmarshal(result.Body, &response); err != nil {
return HardwareAgentCreateResponse{}, result.Replayed, fmt.Errorf("decode stored hardware agent session: %w", err)
}
return response, result.Replayed, nil
}
func boolPointer(value bool) *bool { return &value }
func (s *Service) ProcessHardwareAgentEvent(ctx context.Context, identity deviceauth.Identity, conversationPublicID string, rawBody []byte, input HardwareAgentEventInput) (HardwareAgentEventResponse, error) {
if s.pool == nil {
return HardwareAgentEventResponse{}, errors.New("hardware agent event database is unavailable")
}
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return HardwareAgentEventResponse{}, err
}
defer tx.Rollback(context.Background())
now := s.now().UTC()
var internalID, tenantID, providerSessionRef, nonceDigest, expectedBotID, expectedTransport, expectedConfigDigest string
var lastEventSequence int64
var currentStatus conversation.Status
var allocationExpires time.Time
err = tx.QueryRow(ctx, `
SELECT c.id::text,c.tenant_id::text,c.status,c.allocation_expires_at,
hs.provider_session_ref,hs.session_nonce_digest,hs.last_event_sequence,hs.bot_id,hs.transport,hs.config_digest
FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
WHERE c.public_id=$1::uuid AND c.tenant_id=$2::uuid AND c.device_id=$3::uuid
FOR UPDATE OF c,hs`, conversationPublicID, identity.TenantID, identity.ID).Scan(
&internalID, &tenantID, &currentStatus, &allocationExpires, &providerSessionRef, &nonceDigest, &lastEventSequence,
&expectedBotID, &expectedTransport, &expectedConfigDigest)
if errors.Is(err, pgx.ErrNoRows) {
return HardwareAgentEventResponse{}, ErrConversationNotFound
}
if err != nil {
return HardwareAgentEventResponse{}, fmt.Errorf("read hardware agent session: %w", err)
}
providedDigest := sha256.Sum256([]byte(input.SessionNonce))
expectedDigest, decodeErr := hex.DecodeString(nonceDigest)
if decodeErr != nil || len(expectedDigest) != len(providedDigest) || subtle.ConstantTimeCompare(expectedDigest, providedDigest[:]) != 1 || input.ProviderSessionRef != providerSessionRef {
return HardwareAgentEventResponse{}, ErrHardwareSessionProof
}
payloadDigest := sha256.Sum256(rawBody)
digestText := hex.EncodeToString(payloadDigest[:])
var storedDigest string
err = tx.QueryRow(ctx, `SELECT payload_digest FROM hardware_agent_event WHERE tenant_id=$1::uuid AND event_id=$2`, tenantID, input.EventID).Scan(&storedDigest)
if err == nil {
if storedDigest != digestText {
return HardwareAgentEventResponse{}, ErrHardwareEventConflict
}
if err = tx.Commit(ctx); err != nil {
return HardwareAgentEventResponse{}, err
}
return HardwareAgentEventResponse{ConversationID: conversationPublicID, Status: currentStatus, Action: hardwareAgentAction(currentStatus), Duplicate: true, AcceptedAt: now}, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return HardwareAgentEventResponse{}, err
}
if input.SequenceNo <= lastEventSequence {
return HardwareAgentEventResponse{}, ErrHardwareEventConflict
}
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_event(
tenant_id,conversation_id,event_id,sequence_no,event_type,occurred_at,payload_digest)
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7)`,
tenantID, internalID, input.EventID, input.SequenceNo, input.EventType, input.OccurredAt, digestText)
if err != nil {
return HardwareAgentEventResponse{}, fmt.Errorf("store hardware agent event: %w", err)
}
if input.EventType == "session_started" {
if currentStatus != conversation.StatusRoomIssued || !allocationExpires.After(now) {
return HardwareAgentEventResponse{}, ErrConversationStateConflict
}
if !input.DynamicConfigApplied || input.BotID != expectedBotID || input.Transport != expectedTransport ||
input.ConfigDigest != expectedConfigDigest || strings.TrimSpace(input.ProviderConnectionRef) == "" {
return HardwareAgentEventResponse{}, ErrHardwareSessionProof
}
leaseExpires := now.Add(time.Duration(s.options.HeartbeatInterval*5) * time.Second)
_, err = tx.Exec(ctx, `UPDATE conversation SET status='ACTIVE',started_at=COALESCE(started_at,$2),lease_expires_at=$3,version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now, leaseExpires)
if err == nil {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='ACTIVE',provider_started_at=COALESCE(provider_started_at,$2),last_event_at=$3,last_event_sequence=$4,updated_at=$3,provider_connection_ref=$5,dynamic_config_applied_at=$2 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo, input.ProviderConnectionRef)
}
if err == nil {
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,'ROOM_ISSUED','ACTIVE','device confirmed hardware agent session started','device')`, internalID)
}
currentStatus = conversation.StatusActive
} else if input.EventType == "session_ended" {
if currentStatus != conversation.StatusActive && currentStatus != conversation.StatusStopping {
return HardwareAgentEventResponse{}, ErrConversationStateConflict
}
fromStatus := currentStatus
_, err = tx.Exec(ctx, `UPDATE conversation SET status='COMPLETED',ended_at=COALESCE(ended_at,$2),lease_expires_at=NULL,stop_reason=COALESCE(stop_reason,'hardware_agent_ended'),version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now)
if err == nil {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='COMPLETED',provider_ended_at=$2,last_event_at=$3,last_event_sequence=$4,updated_at=$3 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo)
}
if err == nil {
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,'COMPLETED','device confirmed hardware agent session ended','device')`, internalID, fromStatus)
}
if err == nil {
err = enqueueHardwareContentExtraction(ctx, tx, s.outbox, tenantID, internalID)
}
currentStatus = conversation.StatusCompleted
} else if input.EventType == "session_failed" {
if conversation.IsTerminal(currentStatus) {
return HardwareAgentEventResponse{}, ErrConversationStateConflict
}
failureCode := input.ErrorCode
if failureCode == "" {
failureCode = "hardware_agent_failed"
}
_, err = tx.Exec(ctx, `UPDATE conversation SET status='FAILED',ended_at=$2,lease_expires_at=NULL,failure_code=$3,version=version+1,updated_at=$2 WHERE id=$1::uuid`, internalID, now, failureCode)
if err == nil {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=$2,last_event_at=$3,last_event_sequence=$4,updated_at=$3 WHERE conversation_id=$1::uuid`, internalID, input.OccurredAt, now, input.SequenceNo)
}
if err == nil {
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,'FAILED','device reported hardware agent session failure','device')`, internalID, currentStatus)
}
if err == nil {
err = enqueueHardwareContentExtraction(ctx, tx, s.outbox, tenantID, internalID)
}
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 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)
}
}
if err != nil {
return HardwareAgentEventResponse{}, fmt.Errorf("apply hardware agent event: %w", err)
}
actorID := identity.PublicID
if err = s.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "device", ActorPublicID: &actorID,
Action: "hardware_agent.session.event", ResourceType: "conversation", ResourceID: &internalID,
Result: "success", Detail: map[string]any{"event_id": input.EventID, "event_type": input.EventType,
"sequence_no": input.SequenceNo, "status": currentStatus}}); err != nil {
return HardwareAgentEventResponse{}, err
}
if err = tx.Commit(ctx); err != nil {
return HardwareAgentEventResponse{}, err
}
return HardwareAgentEventResponse{ConversationID: conversationPublicID, Status: currentStatus, Action: hardwareAgentAction(currentStatus), AcceptedAt: now}, nil
}
// Schedule the latest stored turns when a hardware session ends, including
// abrupt power loss. An extraction version below max(sequence_no) is ignored
// by the processor, so a constant version of 1 loses multi-turn summaries.
func enqueueHardwareContentExtraction(ctx context.Context, tx pgx.Tx, store *outbox.Store, tenantID, internalID string) error {
var version int64
if err := tx.QueryRow(ctx, `SELECT COALESCE(max(sequence_no),0) FROM conversation_turn WHERE conversation_id=$1::uuid AND tenant_id=$2::uuid`, internalID, tenantID).Scan(&version); err != nil {
return err
}
if version == 0 {
return nil
}
dedup := fmt.Sprintf("content-extraction-v1:%s:hardware-terminal:%d", internalID, version)
payload, err := json.Marshal(map[string]int64{"generation_version": version})
if err != nil {
return err
}
_, err = store.Enqueue(ctx, tx, outbox.NewEvent{TenantID: &tenantID, AggregateType: "conversation", AggregateID: internalID,
EventType: "conversation.content_extraction_requested", DeduplicationKey: &dedup, Payload: payload})
return err
}
func hardwareAgentAction(status conversation.Status) string {
if status == conversation.StatusStopping || conversation.IsTerminal(status) {
return "stop_session"
}
return "continue"
}
+132
View File
@@ -0,0 +1,132 @@
package orchestration
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNativeSubtitleSpeaker = errors.New("native subtitle speaker is not mapped")
type NativeSubtitleResult struct {
Mode string
Accepted, Duplicates int
}
type NativeSubtitleService struct{ pool *pgxpool.Pool }
func NewNativeSubtitleService(pool *pgxpool.Pool) *NativeSubtitleService {
return &NativeSubtitleService{pool: pool}
}
// Hardware low_load callbacks currently carry a device identity, but no session
// identity. Validate connectivity without guessing a conversation or storing text.
func (s *NativeSubtitleService) ProbeNativeSubtitleDevice(ctx context.Context, packet volcsubtitle.Packet) error {
var found bool
err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hardware_agent_provider_credential WHERE product_key=$1 AND provider_device_name=$2 AND status='ACTIVE')`, packet.ProductKey, packet.DeviceName).Scan(&found)
if err != nil {
return err
}
if !found {
return ErrConversationNotFound
}
return nil
}
func (s *NativeSubtitleService) ProcessNativeSubtitle(ctx context.Context, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
result := NativeSubtitleResult{Mode: "probe"}
tx, err := s.pool.Begin(ctx)
if err != nil {
return result, err
}
defer tx.Rollback(context.Background())
result, err = s.processNativeSubtitleTx(ctx, tx, publicID, packet)
if err != nil {
return NativeSubtitleResult{}, err
}
return result, tx.Commit(ctx)
}
func (s *NativeSubtitleService) processNativeSubtitleTx(ctx context.Context, tx pgx.Tx, publicID string, packet volcsubtitle.Packet) (NativeSubtitleResult, error) {
result := NativeSubtitleResult{Mode: "probe"}
var id, tenantID, subjectID, retention, status, deviceUID, agentUID, deviceName, botID string
var primary, allowed bool
err := tx.QueryRow(ctx, `SELECT c.id::text,c.tenant_id::text,c.subject_id::text,c.retention_mode,c.status,
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),COALESCE(hs.capability_snapshot->>'subtitle_agent_user_id',''),
COALESCE(hs.capability_snapshot->>'subtitle_device_user_id',''),hs.bot_id,COALESCE((hs.capability_snapshot->>'native_subtitle_primary')::boolean,false),
EXISTS(SELECT 1 FROM consent_record cr WHERE cr.tenant_id=c.tenant_id AND cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now()))
FROM conversation c JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
WHERE c.public_id=$1::uuid FOR UPDATE OF c`, publicID).Scan(&id, &tenantID, &subjectID, &retention, &status, &deviceUID, &agentUID, &deviceName, &botID, &primary, &allowed)
if errors.Is(err, pgx.ErrNoRows) {
return result, ErrConversationNotFound
}
if err != nil {
return result, err
}
if retention == "none" || !allowed {
return result, ErrTranscriptStorageNotAllowed
}
if status != "ROOM_ISSUED" && status != "ACTIVE" && status != "STOPPING" && status != "COMPLETED" && status != "FAILED" {
return result, ErrConversationStateConflict
}
if !primary {
return result, nil
}
result.Mode = "primary"
for _, part := range packet.Data {
// Server callbacks contain independent completed clauses; retaining only
// paragraph=true would silently drop all preceding clauses of the reply.
if !part.Definite || strings.TrimSpace(part.Text) == "" {
continue
}
speaker, err := nativeSubtitleSpeaker(part.UserID, deviceUID, deviceName, agentUID, botID)
if err != nil {
return result, err
}
digest := sha256.Sum256([]byte(part.UserID))
key := fmt.Sprintf("volc-subv-%s-%d-%d", hex.EncodeToString(digest[:8]), *part.RoundID, *part.Sequence)
var stored string
err = tx.QueryRow(ctx, `SELECT final_text FROM conversation_turn WHERE conversation_id=$1::uuid AND turn_key=$2`, id, key).Scan(&stored)
if err == nil {
if stored != part.Text {
return result, ErrHardwareEventConflict
}
result.Duplicates++
continue
}
if !errors.Is(err, pgx.ErrNoRows) {
return result, err
}
_, err = tx.Exec(ctx, `INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type)
SELECT $1::uuid,$2::uuid,$3::uuid,$4,COALESCE(max(sequence_no),0)+1,$5,now(),now(),$6,false,'provider' FROM conversation_turn WHERE conversation_id=$3::uuid`, tenantID, subjectID, id, key, speaker, part.Text)
if err != nil {
return result, err
}
result.Accepted++
}
if result.Accepted > 0 && (status == "COMPLETED" || status == "FAILED") {
if err = enqueueHardwareContentExtraction(ctx, tx, outbox.NewStore(s.pool), tenantID, id); err != nil {
return result, err
}
}
return result, nil
}
func nativeSubtitleSpeaker(uid, deviceUID, deviceName, agentUID, botID string) (string, error) {
subject := uid != "" && (uid == deviceUID || uid == deviceName)
agent := uid != "" && (uid == agentUID || uid == botID)
if subject == agent {
return "", ErrNativeSubtitleSpeaker
}
if subject {
return "subject", nil
}
return "agent", nil
}
@@ -0,0 +1,15 @@
package orchestration
import "testing"
func TestNativeSubtitleSpeakerRequiresExplicitMatch(t *testing.T) {
for _, tc := range []struct{ uid, want string }{{"device", "subject"}, {"bot", "agent"}, {"unknown", ""}, {"", ""}} {
got, err := nativeSubtitleSpeaker(tc.uid, "device", "device", "bot", "bot")
if got != tc.want || (err != nil) != (tc.want == "") {
t.Fatalf("%q => %q %v", tc.uid, got, err)
}
}
if _, err := nativeSubtitleSpeaker("same", "same", "same", "same", "same"); err == nil {
t.Fatal("ambiguous speaker accepted")
}
}
+44 -12
View File
@@ -39,6 +39,7 @@ type recoveryCandidate struct {
ProviderTaskRef string
StopReason string
Trigger string
RuntimeType string
}
func NewRecoveryScanner(pool *pgxpool.Pool, outboxStore *outbox.Store, logger *slog.Logger, options RecoveryOptions) (*RecoveryScanner, error) {
@@ -81,8 +82,9 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
CASE WHEN 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
THEN 'quota_duration_limit' ELSE '' END
FROM conversation c JOIN rtc_task r ON r.conversation_id=c.id
THEN 'quota_duration_limit' ELSE '' END,c.runtime_type
FROM conversation c LEFT JOIN rtc_task r ON r.conversation_id=c.id
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
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)
@@ -90,7 +92,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
OR (c.status='STARTING_AGENT' AND c.updated_at <= $2)
OR (c.status='STOPPING' AND c.updated_at <= $3)
ORDER BY c.updated_at, c.id
LIMIT $4 FOR UPDATE OF c, r SKIP LOCKED`,
LIMIT $4 FOR UPDATE OF c SKIP LOCKED`,
now, now.Add(-s.options.StartingAfter), now.Add(-s.options.StoppingAfter), s.options.BatchSize)
if err != nil {
return 0, fmt.Errorf("select conversations for recovery: %w", err)
@@ -98,7 +100,7 @@ func (s *RecoveryScanner) ScanOnce(ctx context.Context) (int, error) {
candidates := make([]recoveryCandidate, 0, s.options.BatchSize)
for rows.Next() {
var candidate recoveryCandidate
if err := rows.Scan(&candidate.InternalID, &candidate.PublicID, &candidate.TenantID, &candidate.Status, &candidate.ProviderTaskRef, &candidate.StopReason, &candidate.Trigger); err != nil {
if err := rows.Scan(&candidate.InternalID, &candidate.PublicID, &candidate.TenantID, &candidate.Status, &candidate.ProviderTaskRef, &candidate.StopReason, &candidate.Trigger, &candidate.RuntimeType); err != nil {
rows.Close()
return 0, err
}
@@ -130,9 +132,15 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
WHERE id=$1::uuid AND status='ROOM_ISSUED'`, candidate.InternalID, now); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
}
} else {
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history (
conversation_id, from_status, to_status, reason, source
@@ -153,17 +161,25 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
WHERE id=$1::uuid AND status='ACTIVE'`, candidate.InternalID, now, reason); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL,
updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
}
} else {
if _, err := tx.Exec(ctx, `UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL,
updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history (
conversation_id, from_status, to_status, reason, source
) VALUES ($1::uuid, 'ACTIVE', 'STOPPING', $2, 'worker')`, candidate.InternalID, historyReason); err != nil {
return err
}
if err := s.enqueueRecovery(ctx, tx, candidate, "conversation.agent_stop_requested", candidate.ProviderTaskRef, reason); err != nil {
return err
if candidate.RuntimeType != RuntimeVolcHardwareAgent {
if err := s.enqueueRecovery(ctx, tx, candidate, "conversation.agent_stop_requested", candidate.ProviderTaskRef, reason); err != nil {
return err
}
}
case conversation.StatusStartingAgent:
action = "reconcile_starting_task"
@@ -175,6 +191,22 @@ func (s *RecoveryScanner) recoverCandidate(ctx context.Context, tx pgx.Tx, candi
return err
}
case conversation.StatusStopping:
if candidate.RuntimeType == RuntimeVolcHardwareAgent {
action = "fail_unconfirmed_hardware_stop"
if _, err := tx.Exec(ctx, `UPDATE conversation SET status='FAILED',ended_at=$2,failure_code='hardware_agent_stop_timeout',version=version+1,updated_at=$2 WHERE id=$1::uuid AND status='STOPPING'`, candidate.InternalID, now); err != nil {
return err
}
if _, err := tx.Exec(ctx, `UPDATE hardware_agent_session SET status='FAILED',provider_ended_at=$2,updated_at=$2 WHERE conversation_id=$1::uuid`, candidate.InternalID, now); err != nil {
return err
}
if _, err := tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,'STOPPING','FAILED','hardware agent stop was not confirmed before deadline','worker')`, candidate.InternalID); err != nil {
return err
}
if err := enqueueHardwareContentExtraction(ctx, tx, s.outbox, candidate.TenantID, candidate.InternalID); err != nil {
return err
}
break
}
action = "reconcile_stopping_task"
deduplicationKey := candidate.ProviderTaskRef + ":" + strconv.FormatInt(recoveryBucket(now, s.options.StoppingAfter), 10)
reason := candidate.StopReason
+229 -17
View File
@@ -2,7 +2,9 @@ package orchestration
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -15,6 +17,7 @@ import (
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/firmwareversion"
"data-terminal-cloud/internal/retrieval"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
@@ -25,6 +28,7 @@ var (
ErrDeviceNotBound = errors.New("device is not bound to an active subject")
ErrConsentRequired = errors.New("voice chat consent is required")
ErrFirmwareUpdateRequired = errors.New("device firmware does not satisfy the minimum version")
ErrHardwareSDKUpdateRequired = errors.New("hardware agent SDK does not satisfy the minimum version")
ErrConversationAlreadyActive = errors.New("device already has an unfinished conversation")
ErrConversationNotFound = errors.New("conversation was not found for device")
ErrConversationStateConflict = errors.New("conversation state does not allow operation")
@@ -45,6 +49,8 @@ const (
)
type AllocationOptions struct {
VikingSubjectID string
VikingContext string
Now time.Time
AllocationExpires time.Time
TokenExpires time.Time
@@ -55,6 +61,14 @@ type AllocationOptions struct {
MemoryQueryVector string
MemoryPolicy string
RequirePublishedRouting bool
RuntimeType string
HardwareBotID string
HardwareTransport string
HardwareCapabilities HardwareAgentCapabilities
SubtitleCallbackURL string
SubtitleCallbackSecret []byte
SubtitlePrimary bool
HardwareMemoryToolURL string
}
type Repository struct{}
@@ -71,6 +85,7 @@ type StopResult struct {
InternalID string
ProviderTaskRef string
StopRequested bool
RuntimeType string
}
func NewRepository() *Repository { return &Repository{} }
@@ -83,14 +98,16 @@ func (r *Repository) Stop(
input StopInput,
now time.Time,
) (StopResult, error) {
var internalID, providerTaskRef string
var internalID, providerTaskRef, runtimeType string
var status conversation.Status
err := tx.QueryRow(ctx, `
SELECT c.id::text, c.status, c.provider_task_ref
FROM conversation c JOIN rtc_task r ON r.conversation_id=c.id
SELECT c.id::text, c.status, c.provider_task_ref, c.runtime_type
FROM conversation c
LEFT JOIN rtc_task r ON r.conversation_id=c.id
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
WHERE c.public_id=$1::uuid AND c.device_id=$2::uuid AND c.tenant_id=$3::uuid
FOR UPDATE OF c, r`, conversationPublicID, identity.ID, identity.TenantID,
).Scan(&internalID, &status, &providerTaskRef)
FOR UPDATE OF c`, conversationPublicID, identity.ID, identity.TenantID,
).Scan(&internalID, &status, &providerTaskRef, &runtimeType)
if errors.Is(err, pgx.ErrNoRows) {
return StopResult{}, ErrConversationNotFound
}
@@ -100,6 +117,7 @@ func (r *Repository) Stop(
result := StopResult{
Response: StopResponse{ConversationID: conversationPublicID, Status: status, AcceptedAt: now},
InternalID: internalID, ProviderTaskRef: providerTaskRef,
RuntimeType: runtimeType,
}
if conversation.IsTerminal(status) {
return result, nil
@@ -123,9 +141,12 @@ func (r *Repository) Stop(
lease_expires_at=NULL, last_heartbeat_seq=GREATEST(last_heartbeat_seq,$4),
version=version+1, updated_at=$3 WHERE id=$1::uuid AND status='ROOM_ISSUED'`,
internalID, input.Reason, now, input.LastHeartbeatSequence)
if err == nil {
if err == nil && runtimeType == RuntimeLegacyVoiceChat {
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED', lease_expires_at=NULL,
last_checked_at=$2, updated_at=$2 WHERE conversation_id=$1::uuid`, internalID, now)
} else if err == nil {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=$2
WHERE conversation_id=$1::uuid`, internalID, now)
}
if err == nil {
_, err = tx.Exec(ctx, `
@@ -151,10 +172,13 @@ func (r *Repository) Stop(
lease_expires_at=NULL, last_heartbeat_seq=GREATEST(last_heartbeat_seq,$3),
version=version+1, updated_at=$4 WHERE id=$1::uuid AND status=$5`,
internalID, input.Reason, input.LastHeartbeatSequence, now, status)
if err == nil {
if err == nil && runtimeType == RuntimeLegacyVoiceChat {
_, err = tx.Exec(ctx, `
UPDATE rtc_task SET status='STOPPING', lease_expires_at=NULL, updated_at=$2
WHERE conversation_id=$1::uuid`, internalID, now)
} else if err == nil {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=$2
WHERE conversation_id=$1::uuid`, internalID, now)
}
if err == nil {
_, err = tx.Exec(ctx, `
@@ -167,7 +191,7 @@ func (r *Repository) Stop(
return StopResult{}, fmt.Errorf("stop active conversation: %w", err)
}
result.Response.Status = conversation.StatusStopping
result.StopRequested = true
result.StopRequested = runtimeType == RuntimeLegacyVoiceChat
return result, nil
}
@@ -264,13 +288,14 @@ func (r *Repository) Create(
if options.VoiceProvider == "" {
options.VoiceProvider = "mock"
}
var householdID, householdPublicID, subjectID, subjectPublicID, subjectName string
var householdID, householdPublicID, subjectID, subjectPublicID, subjectName, desiredVoiceRuntime string
var profileDataJSON, conversationPreferencesJSON json.RawMessage
var minimumFirmwareVersion, currentFirmwareVersion *string
var profileVersion int64
var profileVersion, desiredRevision int64
err := tx.QueryRow(ctx, `
SELECT b.household_id::text, h.public_id::text, b.subject_id::text,
s.public_id::text, s.preferred_name, s.profile_data, s.conversation_preferences, s.profile_version,
s.public_id::text, s.preferred_name, s.profile_data, s.conversation_preferences, s.profile_version, dt.desired_revision,
COALESCE(dt.desired_config->>'voice_runtime','legacy_voicechat'),
d.minimum_firmware_version, dt.firmware_version
FROM device d
JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
@@ -280,7 +305,7 @@ func (r *Repository) Create(
WHERE d.id = $1::uuid AND d.tenant_id = $2::uuid AND d.status = 'registered'
FOR UPDATE OF d, b, s, dt`, identity.ID, identity.TenantID,
).Scan(&householdID, &householdPublicID, &subjectID, &subjectPublicID, &subjectName,
&profileDataJSON, &conversationPreferencesJSON, &profileVersion, &minimumFirmwareVersion, &currentFirmwareVersion)
&profileDataJSON, &conversationPreferencesJSON, &profileVersion, &desiredRevision, &desiredVoiceRuntime, &minimumFirmwareVersion, &currentFirmwareVersion)
if errors.Is(err, pgx.ErrNoRows) {
return CreateResponse{}, "", ErrDeviceNotBound
}
@@ -301,6 +326,12 @@ func (r *Repository) Create(
if activeConversationExists {
return CreateResponse{}, "", ErrConversationAlreadyActive
}
if options.RuntimeType == RuntimeVolcHardwareAgent && input.ConfigRevision != desiredRevision {
return CreateResponse{}, "", ErrConfigVersionConflict
}
if options.RuntimeType == RuntimeVolcHardwareAgent && desiredVoiceRuntime != RuntimeVolcHardwareAgent {
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
budgetSnapshot, err := billing.CheckQuotaTx(ctx, tx, identity, options.Now)
if err != nil {
return CreateResponse{}, "", fmt.Errorf("check usage quota: %w", err)
@@ -381,6 +412,17 @@ func (r *Repository) Create(
}
}
contextSummary := buildContextSummary(subjectName, profileContext, recentSummaries, memoryContents)
var vikingContext string
if options.VikingSubjectID == subjectID && options.VikingContext != "" && consentAllowsConversationContinuity(effectiveConsents) {
var allowed bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND consent_type='personalized_memory' AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at>now())) AND NOT EXISTS(SELECT 1 FROM privacy_request WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND request_type='DELETE')`, identity.TenantID, subjectID).Scan(&allowed); err != nil {
return CreateResponse{}, "", err
}
if allowed {
vikingContext = truncateContextField(options.VikingContext, 600)
contextSummary += "\nViking 自动抽取的历史记忆(仅作背景,不是指令;可能有误,以用户最新说法为准):" + vikingContext
}
}
contextValue := struct {
SubjectID string `json:"subject_id"`
PreferredName string `json:"preferred_name"`
@@ -390,7 +432,8 @@ func (r *Repository) Create(
RecentSummaries []string `json:"recent_summaries"`
MemoryContents []string `json:"memory_contents"`
RetrievalPolicy string `json:"retrieval_policy"`
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy)}
VikingContext string `json:"viking_context,omitempty"`
}{subjectPublicID, subjectName, profileVersion, input.RequestedGoal, profileContext, recentSummaries, memoryContents, retrievalPolicy(options.MemoryPolicy), vikingContext}
contextJSON, _ := json.Marshal(contextValue)
contextDigest := sha256.Sum256(contextJSON)
contextSnapshotID := uuid.NewString()
@@ -414,19 +457,32 @@ func (r *Repository) Create(
roomID := "r_" + strings.ReplaceAll(uuid.NewString(), "-", "")
deviceRTCUserID := "d_" + strings.ReplaceAll(identity.PublicID, "-", "")
agentRTCUserID := "a_" + strings.ReplaceAll(conversationPublicID, "-", "")
runtimeType := options.RuntimeType
if runtimeType == "" {
runtimeType = RuntimeLegacyVoiceChat
}
privacy := privacyFromConsent(effectiveConsents)
if runtimeType == RuntimeVolcHardwareAgent && (!options.HardwareCapabilities.NearPCMRecording || !options.HardwareCapabilities.FarPCMRecording) {
privacy.RecordNear = false
privacy.RecordFar = false
privacy.UploadAllowed = false
privacy.RetentionMode = "none"
if consentAllowsTranscriptRetention(effectiveConsents) {
privacy.RetentionMode = "transcript_only"
}
}
_, err = tx.Exec(ctx, `
INSERT INTO conversation (
id, public_id, tenant_id, household_id, subject_id, device_id,
consent_snapshot_id, context_snapshot_id, provider_task_ref,
engine_mode, retention_mode, rtc_credential_renewal_supported,
engine_mode, retention_mode, rtc_credential_renewal_supported, runtime_type,
status, allocation_expires_at, created_at, updated_at
) VALUES (
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7::uuid, $8::uuid, $9, $10, $11, COALESCE($12, false), 'PREPARING', $13, $14, $14
$7::uuid, $8::uuid, $9, $10, $11, COALESCE($12, false), $13, 'PREPARING', $14, $15, $15
)`, conversationID, conversationPublicID, identity.TenantID, householdID, subjectID, identity.ID,
consentSnapshotID, contextSnapshotID, providerTaskRef, engineMode,
privacy.RetentionMode, input.Capabilities.RTCCredentialRenewal, options.AllocationExpires, options.Now,
privacy.RetentionMode, input.Capabilities.RTCCredentialRenewal, runtimeType, options.AllocationExpires, options.Now,
)
if err != nil {
var postgresError *pgconn.PgError
@@ -438,6 +494,10 @@ func (r *Repository) Create(
if err = billing.FreezeBudgetTx(ctx, tx, conversationID, identity.TenantID, budgetSnapshot); err != nil {
return CreateResponse{}, "", fmt.Errorf("freeze conversation usage budget: %w", err)
}
if runtimeType == RuntimeVolcHardwareAgent {
return r.createHardwareAgentRuntime(ctx, tx, identity, conversationID, conversationPublicID,
providerTaskRef, promptBundleVersion, engineMode, contextSummary, privacy, input, options)
}
_, err = tx.Exec(ctx, `
INSERT INTO conversation_state_history (conversation_id, from_status, to_status, reason, source)
VALUES ($1::uuid, NULL, 'PREPARING', 'create request accepted', 'device')`, conversationID)
@@ -485,6 +545,142 @@ func (r *Repository) Create(
}, conversationID, nil
}
func (r *Repository) createHardwareAgentRuntime(
ctx context.Context,
tx pgx.Tx,
identity deviceauth.Identity,
conversationID, conversationPublicID, providerSessionRef, promptBundleVersion, engineMode, contextSummary string,
privacy Privacy,
input CreateInput,
options AllocationOptions,
) (CreateResponse, string, error) {
botID := strings.TrimSpace(options.HardwareBotID)
transport := strings.TrimSpace(options.HardwareTransport)
var boundBotID, boundTransport string
var minimumSDKVersion *string
err := tx.QueryRow(ctx, `
SELECT bot_id,transport,minimum_sdk_version FROM hardware_agent_device_binding
WHERE tenant_id=$1::uuid AND device_id=$2::uuid AND status='ACTIVE'`, identity.TenantID, identity.ID,
).Scan(&boundBotID, &boundTransport, &minimumSDKVersion)
if err == nil {
botID, transport = boundBotID, boundTransport
} else if !errors.Is(err, pgx.ErrNoRows) {
return CreateResponse{}, "", fmt.Errorf("read hardware agent binding: %w", err)
} else if options.RequirePublishedRouting {
// Production sessions must resolve an explicit device binding. The
// process-wide fallback exists only for local development and smoke tests.
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
if botID == "" {
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
if minimumSDKVersion == nil || strings.TrimSpace(*minimumSDKVersion) == "" {
if options.RequirePublishedRouting {
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
} else if !firmwareversion.AtLeast(options.HardwareCapabilities.SDKVersion, *minimumSDKVersion) {
return CreateResponse{}, "", ErrHardwareSDKUpdateRequired
}
if !options.HardwareCapabilities.DynamicSessionConfig || !supportsHardwareTransport(options.HardwareCapabilities.Transports, transport) {
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
if transport == "high_quality" && !options.HardwareCapabilities.ServerEventCallback {
return CreateResponse{}, "", ErrRuntimeConfigUnavailable
}
if transport != "high_quality" && transport != "low_load" {
transport = "high_quality"
}
var systemPrompt, personaTone string
if strings.HasPrefix(promptBundleVersion, "cfg:") {
bundleID := strings.TrimPrefix(promptBundleVersion, "cfg:")
err = tx.QueryRow(ctx, `
SELECT COALESCE(persona.payload->>'system_prompt',''),COALESCE(persona.payload->>'tone','')
FROM runtime_config_version bundle
JOIN runtime_config_version persona ON persona.tenant_id=bundle.tenant_id
AND persona.config_kind='PERSONA' AND persona.public_id::text=bundle.payload->>'persona_version_id'
WHERE bundle.tenant_id=$1::uuid AND bundle.public_id=$2::uuid AND bundle.config_kind='AGENT_BUNDLE'`,
identity.TenantID, bundleID).Scan(&systemPrompt, &personaTone)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return CreateResponse{}, "", fmt.Errorf("read hardware agent persona: %w", err)
}
}
nonceBytes := make([]byte, 32)
if _, err = rand.Read(nonceBytes); err != nil {
return CreateResponse{}, "", fmt.Errorf("generate hardware session nonce: %w", err)
}
sessionNonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
nonceDigest := sha256.Sum256([]byte(sessionNonce))
configValue := map[string]string{
"bot_id": botID, "transport": transport, "system_prompt": systemPrompt,
"persona_tone": personaTone, "authorized_context": contextSummary,
"prompt_bundle_version": promptBundleVersion,
"tool_url": options.HardwareMemoryToolURL,
}
configJSON, _ := json.Marshal(configValue)
callbackURL, callbackSignature, subtitleSource := "", "", "device"
if options.HardwareCapabilities.NativeSubtitleCallback && options.SubtitleCallbackURL != "" && len(options.SubtitleCallbackSecret) >= 32 {
callbackURL = options.SubtitleCallbackURL + "?conversation_id=" + conversationPublicID
callbackSignature = volcsubtitle.Signature(options.SubtitleCallbackSecret, conversationPublicID)
configValue["subtitle_callback_url"] = callbackURL
if options.SubtitlePrimary {
subtitleSource = "provider"
}
configValue["subtitle_source"] = subtitleSource
configJSON, _ = json.Marshal(configValue)
}
configDigest := sha256.Sum256(configJSON)
capabilitySnapshot, _ := json.Marshal(options.HardwareCapabilities)
var snapshot map[string]any
_ = json.Unmarshal(capabilitySnapshot, &snapshot)
snapshot["native_subtitle_primary"] = subtitleSource == "provider"
var callbackDeviceName string
if callbackURL != "" {
if err = tx.QueryRow(ctx, `SELECT provider_device_name FROM hardware_agent_provider_credential WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status='ACTIVE'`, identity.ID, identity.TenantID).Scan(&callbackDeviceName); err != nil {
return CreateResponse{}, "", err
}
}
snapshot["subtitle_device_user_id"] = callbackDeviceName
snapshot["subtitle_agent_user_id"] = botID
capabilitySnapshot, _ = json.Marshal(snapshot)
_, err = tx.Exec(ctx, `
INSERT INTO hardware_agent_session(
tenant_id,conversation_id,provider,provider_session_ref,bot_id,transport,
session_nonce_digest,config_digest,status,wait_for_session_update,sdk_version,capability_snapshot
) VALUES($1::uuid,$2::uuid,'volcengine',$3,$4,$5,$6,$7,'READY',true,$8,$9::jsonb)`,
identity.TenantID, conversationID, providerSessionRef, botID, transport,
hex.EncodeToString(nonceDigest[:]), hex.EncodeToString(configDigest[:]), options.HardwareCapabilities.SDKVersion, capabilitySnapshot)
if err != nil {
return CreateResponse{}, "", fmt.Errorf("create hardware agent session: %w", err)
}
_, err = tx.Exec(ctx, `UPDATE conversation SET status='ROOM_ISSUED',version=version+1,updated_at=$2
WHERE id=$1::uuid AND status='PREPARING'`, conversationID, options.Now)
if err == nil {
_, err = tx.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
VALUES
($1::uuid,NULL,'PREPARING','create request accepted','device'),
($1::uuid,'PREPARING','ROOM_ISSUED','hardware agent session configuration frozen','platform')`, conversationID)
}
if err != nil {
return CreateResponse{}, "", fmt.Errorf("issue hardware agent session: %w", err)
}
hardware := HardwareAgentCreateResponse{
ConversationID: conversationPublicID, Status: "SESSION_READY", RuntimeType: RuntimeVolcHardwareAgent,
Runtime: HardwareAgentRuntime{Provider: "volcengine", BotID: botID, Transport: transport,
ProviderSessionRef: providerSessionRef, SessionNonce: sessionNonce,
ConfigDigest: hex.EncodeToString(configDigest[:]), WaitForSessionUpdate: true},
SessionConfig: HardwareAgentSessionConfig{DialogID: conversationPublicID, SystemPrompt: systemPrompt,
PersonaTone: personaTone, AuthorizedContext: contextSummary, PromptBundleVersion: promptBundleVersion,
ToolURL: options.HardwareMemoryToolURL, SubtitleCallbackURL: callbackURL,
SubtitleCallbackSignature: callbackSignature, SubtitleSource: subtitleSource},
Privacy: privacy,
Session: Session{EngineMode: engineMode, HeartbeatIntervalSecond: options.HeartbeatInterval,
AllocationExpiresAt: options.AllocationExpires},
}
return CreateResponse{ConversationID: conversationPublicID, Status: conversation.StatusRoomIssued,
Privacy: privacy, Session: hardware.Session, Hardware: &hardware}, conversationID, nil
}
func selectRuntimeConfigurationTx(ctx context.Context, tx pgx.Tx, tenantID, subjectID, deviceID, devicePublicID string) (string, string, error) {
// Rollouts already bind an immutable configuration version and its business
// type. ConfigKey is an operator-defined version family, not a runtime
@@ -797,9 +993,10 @@ func selectRecentConversationSummaries(ctx context.Context, tx pgx.Tx, tenantID,
FROM conversation_summary summary
JOIN conversation ON conversation.id=summary.conversation_id
AND conversation.tenant_id=summary.tenant_id
AND conversation.subject_id=summary.subject_id
WHERE summary.tenant_id=$1::uuid AND summary.subject_id=$2::uuid
AND summary.summary_type='conversation_summary' AND summary.status='READY'
AND conversation.status='COMPLETED'
AND conversation.status IN ('COMPLETED','FAILED')
ORDER BY summary.conversation_id, summary.generation_version DESC, summary.created_at DESC
)
SELECT content FROM latest_summary
@@ -877,3 +1074,18 @@ func privacyFromConsent(consents json.RawMessage) Privacy {
}
return privacy
}
func consentAllowsTranscriptRetention(consents json.RawMessage) bool {
values := map[string]bool{}
_ = json.Unmarshal(consents, &values)
return values["transcript_storage"] || values["transcript"]
}
func supportsHardwareTransport(transports []string, expected string) bool {
for _, transport := range transports {
if transport == expected {
return true
}
}
return false
}
@@ -36,6 +36,10 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered",
}
resume := true
if _, err := tx.Exec(ctx, `UPDATE consent_snapshot SET effective_consents='{"voice_chat":true,"transcript_storage":true}'::jsonb
WHERE tenant_id=$1::uuid AND subject_id=(SELECT subject_id FROM device_binding WHERE device_id=$2::uuid AND unbound_at IS NULL LIMIT 1)`, identity.TenantID, identity.ID); err != nil {
t.Fatal(err)
}
if _, err := tx.Exec(ctx, `UPDATE device SET minimum_firmware_version='9.0.0' WHERE id=$1::uuid`, identity.ID); err != nil {
t.Fatal(err)
}
@@ -85,16 +89,20 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
if !credentialIssuedAt.Equal(now) || !credentialExpiresAt.Equal(now.Add(5*time.Minute)) {
t.Fatalf("credential metadata issued=%s expires=%s", credentialIssuedAt, credentialExpiresAt)
}
var desiredRevision int64
if err := tx.QueryRow(ctx, `SELECT desired_revision FROM device_twin WHERE device_id=$1::uuid`, identity.ID).Scan(&desiredRevision); err != nil {
t.Fatal(err)
}
activation, err := NewRepository().Activate(ctx, tx, identity, response.ConversationID, "request-activate", ActivateInput{
JoinedAt: now.Add(time.Second), RoomID: response.RTC.RoomID, RTCUserID: response.RTC.UserID,
RTCJoinResult: "success", ConfigRevision: 1,
RTCJoinResult: "success", ConfigRevision: desiredRevision,
}, now.Add(2*time.Second))
if err != nil || !activation.StartRequested || activation.Response.Status != "STARTING_AGENT" {
t.Fatalf("activation=%#v err=%v", activation, err)
}
duplicate, err := NewRepository().Activate(ctx, tx, identity, response.ConversationID, "request-activate-duplicate", ActivateInput{
JoinedAt: now.Add(time.Second), RoomID: response.RTC.RoomID, RTCUserID: response.RTC.UserID,
RTCJoinResult: "success", ConfigRevision: 1,
RTCJoinResult: "success", ConfigRevision: desiredRevision,
}, now.Add(3*time.Second))
if err != nil || duplicate.StartRequested || duplicate.Response.Status != "STARTING_AGENT" {
t.Fatalf("duplicate activation=%#v err=%v", duplicate, err)
@@ -118,7 +126,7 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
heartbeatInput := HeartbeatInput{
SequenceNo: 2, DeviceTime: now.Add(4 * time.Second), MonotonicMS: &monotonicMS,
ConversationStatus: "ACTIVE", RTC: RTCHeartbeat{Joined: true, Uplink: true, Downlink: true},
Network: Network{Type: "wifi"}, FirmwareVersion: "1.0.0", ReportedConfigRevision: 1,
Network: Network{Type: "wifi"}, FirmwareVersion: "1.0.0", ReportedConfigRevision: desiredRevision,
RecordingQueue: RecordingQueue{PendingSegments: 2}, CommandResults: []json.RawMessage{},
}
heartbeat, err := NewRepository().Heartbeat(ctx, tx, identity, response.ConversationID, heartbeatInput, now.Add(4*time.Second), time.Minute, 5*time.Minute, time.Minute)
@@ -132,7 +140,7 @@ func TestRepositoryCreatesOnlyRoomIssuedStateBeforeActivation(t *testing.T) {
t.Fatalf("stale heartbeat=%#v err=%v", stale, err)
}
heartbeatInput.SequenceNo = 3
heartbeatInput.ReportedConfigRevision = 2
heartbeatInput.ReportedConfigRevision = desiredRevision + 1
if _, err := NewRepository().Heartbeat(ctx, tx, identity, response.ConversationID, heartbeatInput, now.Add(31*time.Second), time.Minute, 5*time.Minute, time.Minute); !errors.Is(err, ErrConfigVersionConflict) {
t.Fatalf("future configuration revision error=%v", err)
}
+39 -10
View File
@@ -14,8 +14,10 @@ import (
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/embeddingprovider"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/memorytool"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/rtc"
"data-terminal-cloud/internal/vikingmemory"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@@ -29,21 +31,32 @@ type ServiceOptions struct {
TokenTTL time.Duration
HeartbeatInterval int
RequirePublishedRouting bool
HardwareBotID string
HardwareTransport string
SubtitleCallbackURL string
SubtitleCallbackSecret []byte
SubtitlePrimary 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
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
}
func (s *Service) ConfigureVikingMemory(memory *vikingmemory.Service) { s.viking = memory }
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")
@@ -53,6 +66,16 @@ func (s *Service) ConfigureMemoryEmbedding(pool *pgxpool.Pool, provider embeddin
return nil
}
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
}
func NewService(
executor *idempotency.Executor,
repository *Repository,
@@ -71,6 +94,12 @@ func NewService(
if options.VoiceProvider == "" {
options.VoiceProvider = "mock"
}
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")
}
return &Service{executor: executor, repository: repository, audit: auditRepository, outbox: outboxStore,
tokens: tokens, leases: leases, options: options, now: time.Now}, nil
}
@@ -21,6 +21,7 @@ import (
"data-terminal-cloud/internal/ratelimit"
"data-terminal-cloud/internal/rtc"
"data-terminal-cloud/internal/voiceprovider"
"data-terminal-cloud/internal/volcsubtitle"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -207,6 +208,120 @@ func TestServiceIdempotentlyCreatesConversationWithoutPersistingToken(t *testing
t.Fatal(err)
}
}
// A power loss changes the session outcome, not the validity of an already
// prepared summary. Active sessions and other subjects remain excluded.
for _, priorStatus := range []string{"COMPLETED", "FAILED", "ACTIVE"} {
t.Run("continuity_"+priorStatus, func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
if _, err = tx.Exec(ctx, `UPDATE conversation SET status=$2 WHERE id=$1::uuid`, testPriorConversationID, priorStatus); err != nil {
t.Fatal(err)
}
summaries, err := selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", testSubjectID, 3)
if err != nil {
t.Fatal(err)
}
want := 1
if priorStatus == "ACTIVE" {
want = 0
}
if len(summaries) != want {
t.Fatalf("status=%s summaries=%d want=%d", priorStatus, len(summaries), want)
}
summaries, err = selectRecentConversationSummaries(ctx, tx, "10000000-0000-0000-0000-000000000001", "90000000-0000-0000-0000-000000009999", 3)
if err != nil || len(summaries) != 0 {
t.Fatalf("unrelated subject summaries=%d err=%v", len(summaries), err)
}
})
}
t.Run("hardware_terminal_extracts_latest_turns_once", func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
if _, err = tx.Exec(ctx, `INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,$2::uuid,'prior-agent-2',2,'agent','2026-08-23T10:01:06Z','2026-08-23T10:01:08Z','明白了。','device')`, testSubjectID, testPriorConversationID); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err := enqueueHardwareContentExtraction(ctx, tx, outbox.NewStore(pool), "10000000-0000-0000-0000-000000000001", testPriorConversationID); err != nil {
t.Fatal(err)
}
}
var count, version int
if err := tx.QueryRow(ctx, `SELECT count(*),COALESCE(max((payload->>'generation_version')::int),0) FROM outbox_event WHERE aggregate_id=$1::uuid AND deduplication_key LIKE 'content-extraction-v1:%:hardware-terminal:%'`, testPriorConversationID).Scan(&count, &version); err != nil {
t.Fatal(err)
}
if count != 1 || version != 2 {
t.Fatalf("events=%d generation=%d; want 1 and 2", count, version)
}
})
t.Run("native_subtitles_probe_then_primary_dedup_consent", func(t *testing.T) {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(context.Background())
_, err = tx.Exec(ctx, `INSERT INTO hardware_agent_session(tenant_id,conversation_id,provider_session_ref,bot_id,transport,session_nonce_digest,config_digest,status,capability_snapshot)
VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,'native-subtitle-fixture','bot','low_load',repeat('a',64),repeat('b',64),'COMPLETED','{"subtitle_device_user_id":"device","subtitle_agent_user_id":"bot","native_subtitle_primary":false}')`, testPriorConversationID)
if err != nil {
t.Fatal(err)
}
_, err = tx.Exec(ctx, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,policy_version,notice_digest,actor_user_id) VALUES('10000000-0000-0000-0000-000000000001',$1::uuid,'transcript_storage','native-fixture',repeat('a',64),'10000000-0000-0000-0000-000000000101')`, testSubjectID)
if err != nil {
t.Fatal(err)
}
var publicID string
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE id=$1::uuid`, testPriorConversationID).Scan(&publicID); err != nil {
t.Fatal(err)
}
zero, one := int64(0), int64(1)
packet := volcsubtitle.Packet{Type: "subtitle", Data: []volcsubtitle.Segment{
{UserID: "device", RoundID: &zero, Sequence: &zero, Definite: true, Text: "第一句"},
{UserID: "bot", RoundID: &zero, Sequence: &one, Definite: true, Paragraph: true, Text: "第二句"},
}}
svc := NewNativeSubtitleService(pool)
result, err := svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
if err != nil || result.Mode != "probe" || result.Accepted != 0 {
t.Fatalf("probe %+v %v", result, err)
}
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET capability_snapshot=jsonb_set(capability_snapshot,'{native_subtitle_primary}','true') WHERE conversation_id=$1::uuid`, testPriorConversationID)
if err != nil {
t.Fatal(err)
}
result, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
if err != nil || result.Accepted != 2 {
t.Fatalf("primary %+v %v", result, err)
}
result, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet)
if err != nil || result.Accepted != 0 || result.Duplicates != 2 {
t.Fatalf("duplicate %+v %v", result, err)
}
var count, version int
if err = tx.QueryRow(ctx, `SELECT count(*),max((payload->>'generation_version')::int) FROM outbox_event WHERE aggregate_id=$1::uuid AND deduplication_key LIKE 'content-extraction-v1:%:hardware-terminal:%'`, testPriorConversationID).Scan(&count, &version); err != nil || count != 1 || version != 3 {
t.Fatalf("extraction count=%d version=%d err=%v", count, version, err)
}
packet.Data[0].UserID = "unknown"
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrNativeSubtitleSpeaker) {
t.Fatalf("unknown speaker: %v", err)
}
packet.Data[0].UserID = "device"
packet.Data[0].Text = "conflicting final"
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrHardwareEventConflict) {
t.Fatalf("conflict: %v", err)
}
_, err = tx.Exec(ctx, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, testSubjectID)
if err != nil {
t.Fatal(err)
}
if _, err = svc.processNativeSubtitleTx(ctx, tx, publicID, packet); !errors.Is(err, ErrTranscriptStorageNotAllowed) {
t.Fatalf("withdrawal: %v", err)
}
})
configService, err := opsconfig.NewService(pool, audit.NewRepository())
if err != nil {
t.Fatal(err)
@@ -141,6 +141,13 @@ func (s *TranscriptBatchService) submit(ctx context.Context, tx pgx.Tx, identity
if retentionMode == "none" {
return TranscriptBatchResponse{}, "", ErrTranscriptStorageNotAllowed
}
var nativePrimary bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM hardware_agent_session WHERE conversation_id=$1::uuid AND tenant_id=$2::uuid AND capability_snapshot->>'native_subtitle_primary'='true')`, internalID, identity.TenantID).Scan(&nativePrimary); err != nil {
return TranscriptBatchResponse{}, "", err
}
if nativePrimary {
return TranscriptBatchResponse{}, "", ErrConversationStateConflict
}
var hasConsentHistory, hasActiveConsent bool
if err := tx.QueryRow(ctx, `
SELECT
+96 -9
View File
@@ -32,11 +32,12 @@ type Network struct {
}
type CreateInput struct {
DeviceTime time.Time `json:"device_time"`
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
Capabilities Capabilities `json:"capabilities"`
Network Network `json:"network"`
RequestedGoal string `json:"requested_goal"`
DeviceTime time.Time `json:"device_time"`
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
Capabilities Capabilities `json:"capabilities"`
Network Network `json:"network"`
RequestedGoal string `json:"requested_goal"`
ConfigRevision int64 `json:"-"`
}
type RTCGrant struct {
@@ -62,12 +63,98 @@ type Session struct {
}
type CreateResponse struct {
ConversationID string `json:"conversation_id"`
Status conversation.Status `json:"status"`
FailureCode string `json:"failure_code,omitempty"`
RTC RTCGrant `json:"rtc"`
Privacy Privacy `json:"privacy"`
Session Session `json:"session"`
Hardware *HardwareAgentCreateResponse `json:"hardware,omitempty"`
}
const (
RuntimeLegacyVoiceChat = "legacy_voicechat"
RuntimeVolcHardwareAgent = "volc_hardware_agent"
)
type HardwareAgentCapabilities struct {
HardwareAgent bool `json:"hardware_agent"`
SDKVersion string `json:"sdk_version"`
Transports []string `json:"transports"`
InputAudioFormat string `json:"input_audio_format"`
OutputAudioFormat string `json:"output_audio_format"`
ServerEventCallback bool `json:"server_event_callback"`
NativeSubtitleCallback bool `json:"native_subtitle_callback"`
TranscriptUpload bool `json:"transcript_upload"`
DynamicSessionConfig bool `json:"dynamic_session_config"`
CredentialRotation bool `json:"credential_rotation"`
NearPCMRecording bool `json:"near_pcm_recording"`
FarPCMRecording bool `json:"far_pcm_recording"`
}
type HardwareAgentCreateInput struct {
DeviceTime time.Time `json:"device_time"`
MonotonicMS *int64 `json:"monotonic_ms,omitempty"`
Capabilities HardwareAgentCapabilities `json:"capabilities"`
Network Network `json:"network"`
RequestedGoal string `json:"requested_goal"`
ConfigRevision int64 `json:"config_revision"`
}
type HardwareAgentRuntime struct {
Provider string `json:"provider"`
BotID string `json:"bot_id"`
Transport string `json:"transport"`
ProviderSessionRef string `json:"provider_session_ref"`
SessionNonce string `json:"session_nonce"`
ConfigDigest string `json:"config_digest"`
WaitForSessionUpdate bool `json:"wait_for_session_update"`
}
type HardwareAgentSessionConfig struct {
DialogID string `json:"dialog_id"`
SystemPrompt string `json:"system_prompt,omitempty"`
PersonaTone string `json:"persona_tone,omitempty"`
AuthorizedContext string `json:"authorized_context"`
PromptBundleVersion string `json:"prompt_bundle_version"`
ToolURL string `json:"tool_url,omitempty"`
ToolScopeToken string `json:"tool_scope_token,omitempty"`
SubtitleCallbackURL string `json:"subtitle_callback_url,omitempty"`
SubtitleCallbackSignature string `json:"subtitle_callback_signature,omitempty"`
SubtitleSource string `json:"subtitle_source,omitempty"`
}
type HardwareAgentCreateResponse struct {
ConversationID string `json:"conversation_id"`
Status string `json:"status"`
RuntimeType string `json:"runtime_type"`
Runtime HardwareAgentRuntime `json:"runtime"`
SessionConfig HardwareAgentSessionConfig `json:"session_config"`
Privacy Privacy `json:"privacy"`
Session Session `json:"session"`
}
type HardwareAgentEventInput struct {
EventID string `json:"event_id"`
SequenceNo int64 `json:"sequence_no"`
EventType string `json:"event_type"`
OccurredAt time.Time `json:"occurred_at"`
ProviderSessionRef string `json:"provider_session_ref"`
SessionNonce string `json:"session_nonce"`
ProviderConnectionRef string `json:"provider_connection_ref,omitempty"`
BotID string `json:"bot_id,omitempty"`
Transport string `json:"transport,omitempty"`
ConfigDigest string `json:"config_digest,omitempty"`
DynamicConfigApplied bool `json:"dynamic_config_applied,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
}
type HardwareAgentEventResponse struct {
ConversationID string `json:"conversation_id"`
Status conversation.Status `json:"status"`
FailureCode string `json:"failure_code,omitempty"`
RTC RTCGrant `json:"rtc"`
Privacy Privacy `json:"privacy"`
Session Session `json:"session"`
Action string `json:"action"`
Duplicate bool `json:"duplicate"`
AcceptedAt time.Time `json:"accepted_at"`
}
type ActivateInput struct {
@@ -0,0 +1,51 @@
package orchestration
import (
"context"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/deviceauth"
"os"
"strings"
"testing"
"time"
)
func TestVikingStartupContextRequiresMatchingSubject(t *testing.T) {
db := os.Getenv("TEST_DATABASE_URL")
if db == "" {
t.Skip("TEST_DATABASE_URL required")
}
ctx := context.Background()
pool, err := database.Open(ctx, db, "viking-context-test")
if err != nil {
t.Fatal(err)
}
defer pool.Close()
for _, matches := range []bool{true, false} {
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatal(err)
}
identity := deviceauth.Identity{ID: "10000000-0000-0000-0000-000000000501", PublicID: "10000000-0000-0000-0000-000000000511", TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered"}
subject := "10000000-0000-0000-0000-000000000401"
if !matches {
subject = "10000000-0000-0000-0000-000000000402"
}
_, err = tx.Exec(ctx, `UPDATE consent_snapshot SET effective_consents='{"voice_chat":true,"transcript_storage":true,"story_extraction":true,"personalized_memory":true}' WHERE tenant_id=$1::uuid`, identity.TenantID)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
_, internalID, err := NewRepository().Create(ctx, tx, identity, CreateInput{RequestedGoal: "open_conversation"}, AllocationOptions{Now: now, AllocationExpires: now.Add(time.Minute), TokenExpires: now.Add(5 * time.Minute), RTCAppID: "test-app", EngineMode: "s2s_hybrid", HeartbeatInterval: 15, VikingSubjectID: subject, VikingContext: "独立记忆测试:喜欢茉莉花茶"})
if err != nil {
tx.Rollback(ctx)
t.Fatal(err)
}
var summary string
err = tx.QueryRow(ctx, `SELECT cx.sanitized_summary FROM conversation c JOIN context_snapshot cx ON cx.id=c.context_snapshot_id WHERE c.id=$1::uuid`, internalID).Scan(&summary)
tx.Rollback(ctx)
if err != nil || strings.Contains(summary, "独立记忆测试") != matches {
t.Fatalf("matches=%v summary=%s err=%v", matches, summary, err)
}
}
}