Files
story/internal/diagnostics/service.go
T
Codex Local 0c2151ace9
ci / verify (push) Canceled after 0s
chore: checkpoint 第一版
2026-09-06 17:04:11 +08:00

663 lines
38 KiB
Go

package diagnostics
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrInvalid = errors.New("diagnostic request is invalid")
ErrAccessDenied = errors.New("diagnostic access is denied")
ErrNotFound = errors.New("diagnostic resource was not found")
ErrStateConflict = errors.New("diagnostic action conflicts with current state")
requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:/-]{2,127}$`)
searchFieldAllowed = map[string]bool{"device_id": true, "conversation_id": true, "room_id": true, "provider_task_ref": true, "request_id": true}
retryableJobTypeAllowed = map[string]bool{
"platform.noop": true,
"conversation.room_issued": true,
"conversation.agent_start_requested": true,
"conversation.agent_start_reconcile_requested": true,
"conversation.agent_stop_requested": true,
"conversation.agent_stop_reconcile_requested": true,
"conversation.content_extraction_requested": true,
"memory.index_requested": true,
"memory.delete_requested": true,
"privacy.access_requested": true,
"privacy.delete_requested": true,
"export.generate_requested": true,
"media.verify_requested": true,
"alert.webhook_test_requested": true,
}
)
type Record struct {
DeviceID string `json:"device_id"`
DeviceStatus string `json:"device_status"`
DeviceOnline bool `json:"device_online"`
FirmwareVersion string `json:"firmware_version,omitempty"`
DesiredConfigRevision int64 `json:"desired_config_revision"`
ReportedConfigRevision *int64 `json:"reported_config_revision,omitempty"`
LastDeviceErrorCode string `json:"last_device_error_code,omitempty"`
LastDeviceErrorAt *time.Time `json:"last_device_error_at,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
ConversationStatus string `json:"conversation_status,omitempty"`
ConversationFailureCode string `json:"conversation_failure_code,omitempty"`
AllocationExpiresAt *time.Time `json:"allocation_expires_at,omitempty"`
CredentialIssuedAt *time.Time `json:"credential_issued_at,omitempty"`
CredentialExpiresAt *time.Time `json:"credential_expires_at,omitempty"`
RoomID string `json:"room_id,omitempty"`
Provider string `json:"provider,omitempty"`
ProviderTaskRef string `json:"provider_task_ref,omitempty"`
ProviderTaskStatus string `json:"provider_task_status,omitempty"`
ProviderStartAttempts int `json:"provider_start_attempts"`
ProviderStopAttempts int `json:"provider_stop_attempts"`
ProviderNotificationCount int64 `json:"provider_notification_count"`
LastProviderEvent string `json:"last_provider_event,omitempty"`
LastProviderEventStatus string `json:"last_provider_event_status,omitempty"`
LastProviderEventAt *time.Time `json:"last_provider_event_at,omitempty"`
LastRequestID string `json:"last_request_id,omitempty"`
Timings *Timings `json:"timings,omitempty"`
}
type Timings struct {
ConversationCreatedAt time.Time `json:"conversation_created_at"`
RoomIssuedAt *time.Time `json:"room_issued_at,omitempty"`
DeviceActivatedAt *time.Time `json:"device_activated_at,omitempty"`
AgentActiveAt *time.Time `json:"agent_active_at,omitempty"`
StopRequestedAt *time.Time `json:"stop_requested_at,omitempty"`
ConversationEndedAt *time.Time `json:"conversation_ended_at,omitempty"`
RoomIssueMS *int64 `json:"room_issue_ms,omitempty"`
DeviceJoinMS *int64 `json:"device_join_ms,omitempty"`
AgentStartMS *int64 `json:"agent_start_ms,omitempty"`
AgentStopMS *int64 `json:"agent_stop_ms,omitempty"`
TotalMS *int64 `json:"total_ms,omitempty"`
}
type ActionResult struct {
Action string `json:"action"`
DeviceID string `json:"device_id"`
ConversationID string `json:"conversation_id,omitempty"`
ConversationStatus string `json:"conversation_status,omitempty"`
CommandID string `json:"command_id,omitempty"`
CommandStatus string `json:"command_status,omitempty"`
JobID string `json:"job_id,omitempty"`
JobType string `json:"job_type,omitempty"`
ManualRetryCount int `json:"manual_retry_count,omitempty"`
Queued bool `json:"queued"`
}
type Job struct {
ID string `json:"id"`
EventType string `json:"event_type"`
AggregateType string `json:"aggregate_type"`
Status string `json:"status"`
AttemptCount int `json:"attempt_count"`
ManualRetryCount int `json:"manual_retry_count"`
LastErrorCode string `json:"last_error_code,omitempty"`
LastManualRetryAt *time.Time `json:"last_manual_retry_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type Bundle struct {
ID string `json:"id"`
GeneratedAt time.Time `json:"generated_at"`
SearchField string `json:"search_field"`
SearchValue string `json:"search_value"`
RedactionPolicy string `json:"redaction_policy"`
Items []Record `json:"items"`
}
type ExternalDependency struct {
ServiceName string `json:"service_name"`
Dependency string `json:"dependency"`
Status string `json:"status"`
CheckedAt time.Time `json:"checked_at"`
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
LastFailureAt *time.Time `json:"last_failure_at,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
LatencyMS int64 `json:"latency_ms"`
ProbeIntervalMS int64 `json:"probe_interval_ms"`
ConsecutiveFailures int `json:"consecutive_failures"`
}
type Service struct {
pool *pgxpool.Pool
idem *idempotency.Executor
audit *audit.Repository
outbox *outbox.Store
}
func NewService(pool *pgxpool.Pool, idem *idempotency.Executor, auditRepository *audit.Repository) (*Service, error) {
if pool == nil || idem == nil || auditRepository == nil {
return nil, errors.New("diagnostic service dependencies are required")
}
return &Service{pool: pool, idem: idem, audit: auditRepository, outbox: outbox.NewStore(pool)}, nil
}
type actor struct{ tenantID, userID, userPublicID string }
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string, roles ...string) (actor, error) {
if _, err := uuid.Parse(tenantPublicID); err != nil {
return actor{}, ErrInvalid
}
var result actor
var role string
err := s.pool.QueryRow(ctx, `SELECT t.id::text,ua.id::text,ua.public_id::text,ua.platform_role FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' AND ua.account_kind='operator' WHERE t.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.userID, &result.userPublicID, &role)
if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied
}
if err != nil {
return actor{}, err
}
for _, allowed := range roles {
if role == allowed {
return result, nil
}
}
return actor{}, ErrAccessDenied
}
func (s *Service) Search(ctx context.Context, principal identity.Principal, tenantPublicID, field, value string) ([]Record, error) {
field, value = strings.TrimSpace(field), strings.TrimSpace(value)
if !searchFieldAllowed[field] || value == "" || len(value) > 200 {
return nil, ErrInvalid
}
if field == "device_id" || field == "conversation_id" {
if _, err := uuid.Parse(value); err != nil {
return nil, ErrInvalid
}
} else if !requestIDPattern.MatchString(value) {
return nil, ErrInvalid
}
currentActor, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if err != nil {
return nil, err
}
condition := map[string]string{
"device_id": "d.public_id=$2::uuid",
"conversation_id": "c.public_id=$2::uuid",
"room_id": "rt.room_id=$2",
"provider_task_ref": "COALESCE(rt.provider_task_ref,hs.provider_session_ref)=$2",
"request_id": "EXISTS(SELECT 1 FROM audit_event ae WHERE ae.tenant_id=c.tenant_id AND ae.request_id=$2 AND ae.resource_id IN(c.id,c.public_id,d.id,d.public_id))",
}[field]
query := diagnosticSelect + " WHERE d.tenant_id=$1::uuid AND " + condition + " ORDER BY c.created_at DESC NULLS LAST LIMIT 50"
rows, err := s.pool.Query(ctx, query, currentActor.tenantID, value)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Record{}
for rows.Next() {
item, scanErr := scanRecord(rows)
if scanErr != nil {
return nil, scanErr
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) ListExternalDependencies(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]ExternalDependency, error) {
if _, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin"); err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT service_name,dependency,status,checked_at,last_success_at,last_failure_at,COALESCE(last_error_code,''),latency_ms,probe_interval_ms,consecutive_failures FROM external_dependency_probe_state ORDER BY service_name,dependency`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ExternalDependency{}
for rows.Next() {
var item ExternalDependency
if err = rows.Scan(&item.ServiceName, &item.Dependency, &item.Status, &item.CheckedAt, &item.LastSuccessAt, &item.LastFailureAt, &item.LastErrorCode, &item.LatencyMS, &item.ProbeIntervalMS, &item.ConsecutiveFailures); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) StopConversation(ctx context.Context, principal identity.Principal, tenantPublicID, conversationPublicID, key, requestID, reason string) (ActionResult, bool, error) {
reason = strings.TrimSpace(reason)
if _, err := uuid.Parse(conversationPublicID); err != nil || reason == "" || len([]rune(reason)) > 500 {
return ActionResult{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return ActionResult{}, false, err
}
body, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/conversations/stop", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
var internalID, deviceID, status, runtimeStatus, runtimeType string
err := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,c.runtime_type,COALESCE(rt.status,hs.status,'') FROM conversation c JOIN device d ON d.id=c.device_id LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &runtimeType, &runtimeStatus)
if errors.Is(err, pgx.ErrNoRows) {
return idempotency.Result{}, ErrNotFound
}
if err != nil {
return idempotency.Result{}, err
}
queued := false
fromStatus := status
switch status {
case "COMPLETED", "FAILED", "EXPIRED", "STOPPING":
case "PREPARING", "ROOM_ISSUED":
status = "EXPIRED"
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='EXPIRED',stop_reason='ops_stop_before_start',ended_at=now(),lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil && runtimeType == "volc_hardware_agent" {
_, err = tx.Exec(operationContext, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
} else if err == nil {
_, err = tx.Exec(operationContext, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
}
case "STARTING_AGENT", "ACTIVE":
status, queued = "STOPPING", runtimeType != "volc_hardware_agent"
if _, err = tx.Exec(operationContext, `UPDATE conversation SET status='STOPPING',stop_reason='ops_stop',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, internalID); err == nil && runtimeType == "volc_hardware_agent" {
_, err = tx.Exec(operationContext, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
} else if err == nil {
_, err = tx.Exec(operationContext, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, internalID)
}
if err == nil && runtimeType != "volc_hardware_agent" {
dedupe := "ops-stop:" + internalID
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": "ops_stop"})
_, err = s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: internalID, EventType: "conversation.agent_stop_requested", DeduplicationKey: &dedupe, Payload: payload})
}
default:
return idempotency.Result{}, ErrStateConflict
}
if err != nil {
return idempotency.Result{}, err
}
if status != fromStatus {
if _, err = tx.Exec(operationContext, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,$3,$4,'operator')`, internalID, fromStatus, status, "ops diagnostics: "+reason); err != nil {
return idempotency.Result{}, err
}
}
if err = s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.stop", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"runtime_type": runtimeType, "from_runtime_status": runtimeStatus, "to_status": status}}); err != nil {
return idempotency.Result{}, err
}
output := ActionResult{Action: "stop_conversation", DeviceID: deviceID, ConversationID: conversationPublicID, ConversationStatus: status, Queued: queued}
encoded, _ := json.Marshal(output)
return idempotency.Result{StatusCode: 200, Body: encoded, ResourceID: &internalID}, nil
})
if err != nil {
return ActionResult{}, false, err
}
var output ActionResult
if err = json.Unmarshal(result.Body, &output); err != nil {
return ActionResult{}, false, err
}
return output, !result.Replayed, nil
}
func (s *Service) ListDeadJobs(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Job, error) {
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin")
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `SELECT public_id::text,event_type,aggregate_type,status,attempt_count,manual_retry_count,COALESCE(last_error_code,''),last_manual_retry_at,created_at FROM outbox_event WHERE tenant_id=$1::uuid AND status='dead' ORDER BY created_at DESC LIMIT 100`, a.tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Job{}
for rows.Next() {
var item Job
if err := rows.Scan(&item.ID, &item.EventType, &item.AggregateType, &item.Status, &item.AttemptCount, &item.ManualRetryCount, &item.LastErrorCode, &item.LastManualRetryAt, &item.CreatedAt); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Service) RetryDeadJob(ctx context.Context, principal identity.Principal, tenantPublicID, jobPublicID, key, requestID, reason string) (ActionResult, bool, error) {
reason = strings.TrimSpace(reason)
if _, err := uuid.Parse(jobPublicID); err != nil || reason == "" || len([]rune(reason)) > 500 {
return ActionResult{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return ActionResult{}, false, err
}
body, _ := json.Marshal(map[string]string{"job_id": jobPublicID, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/jobs/retry", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) {
var internalID, eventType, status string
var manualRetries int
if queryErr := tx.QueryRow(op, `SELECT id::text,event_type,status,manual_retry_count FROM outbox_event WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, jobPublicID).Scan(&internalID, &eventType, &status, &manualRetries); errors.Is(queryErr, pgx.ErrNoRows) {
return idempotency.Result{}, ErrNotFound
} else if queryErr != nil {
return idempotency.Result{}, queryErr
}
if status != "dead" {
return idempotency.Result{}, ErrStateConflict
}
if !retryableJobTypeAllowed[eventType] {
return idempotency.Result{}, ErrStateConflict
}
manualRetries++
if _, updateErr := tx.Exec(op, `UPDATE outbox_event SET status='pending',attempt_count=0,available_at=now(),locked_at=NULL,locked_by=NULL,dead_at=NULL,last_error_code=NULL,manual_retry_count=$2,last_manual_retry_at=now(),last_manual_retry_by=$3::uuid,updated_at=now() WHERE id=$1::uuid`, internalID, manualRetries, a.userID); updateErr != nil {
return idempotency.Result{}, updateErr
}
if auditErr := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.job.retry", ResourceType: "outbox_job", ResourceID: &jobPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"event_type": eventType, "from_status": status, "to_status": "pending", "manual_retry_count": manualRetries}}); auditErr != nil {
return idempotency.Result{}, auditErr
}
output := ActionResult{Action: "retry_job", JobID: jobPublicID, JobType: eventType, ManualRetryCount: manualRetries, Queued: true}
encoded, _ := json.Marshal(output)
return idempotency.Result{StatusCode: 200, Body: encoded, ResourceID: &internalID}, nil
})
if err != nil {
return ActionResult{}, false, err
}
var output ActionResult
if err = json.Unmarshal(result.Body, &output); err != nil {
return ActionResult{}, false, err
}
return output, !result.Replayed, nil
}
func (s *Service) RevokeDevice(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, key, requestID, reason string) (ActionResult, bool, error) {
reason = strings.TrimSpace(reason)
if _, err := uuid.Parse(devicePublicID); err != nil || reason == "" || len([]rune(reason)) > 500 {
return ActionResult{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_admin")
if err != nil {
return ActionResult{}, false, err
}
body, _ := json.Marshal(map[string]string{"device_id": devicePublicID, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/devices/revoke", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) {
var deviceID, oldStatus string
if queryErr := tx.QueryRow(op, `SELECT id::text,status FROM device WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, a.tenantID, devicePublicID).Scan(&deviceID, &oldStatus); errors.Is(queryErr, pgx.ErrNoRows) {
return idempotency.Result{}, ErrNotFound
} else if queryErr != nil {
return idempotency.Result{}, queryErr
}
if oldStatus != "revoked" {
if _, updateErr := tx.Exec(op, `UPDATE device SET status='revoked',revoked_at=COALESCE(revoked_at,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, deviceID); updateErr != nil {
return idempotency.Result{}, updateErr
}
}
var conversationInternalID, conversationPublicID, conversationStatus, runtimeStatus, runtimeType string
queued := false
conversationQueryErr := tx.QueryRow(op, `SELECT c.id::text,c.public_id::text,c.status,c.runtime_type,COALESCE(rt.status,hs.status,'') FROM conversation c LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.device_id=$2::uuid AND c.status NOT IN('COMPLETED','FAILED','EXPIRED') ORDER BY c.created_at DESC LIMIT 1 FOR UPDATE OF c`, a.tenantID, deviceID).Scan(&conversationInternalID, &conversationPublicID, &conversationStatus, &runtimeType, &runtimeStatus)
if conversationQueryErr != nil && !errors.Is(conversationQueryErr, pgx.ErrNoRows) {
return idempotency.Result{}, conversationQueryErr
}
if conversationQueryErr == nil {
fromStatus := conversationStatus
switch conversationStatus {
case "PREPARING", "ROOM_ISSUED":
conversationStatus = "EXPIRED"
if _, updateErr := tx.Exec(op, `UPDATE conversation SET status='EXPIRED',stop_reason='device_revoked_before_start',ended_at=now(),lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, conversationInternalID); updateErr != nil {
return idempotency.Result{}, updateErr
}
query := `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`
if runtimeType == "volc_hardware_agent" {
query = `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`
}
if _, updateErr := tx.Exec(op, query, conversationInternalID); updateErr != nil {
return idempotency.Result{}, updateErr
}
case "STARTING_AGENT", "ACTIVE":
conversationStatus, queued = "STOPPING", runtimeType != "volc_hardware_agent"
if _, updateErr := tx.Exec(op, `UPDATE conversation SET status='STOPPING',stop_reason='device_revoked',lease_expires_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid`, conversationInternalID); updateErr != nil {
return idempotency.Result{}, updateErr
}
query := `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`
if runtimeType == "volc_hardware_agent" {
query = `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`
}
if _, updateErr := tx.Exec(op, query, conversationInternalID); updateErr != nil {
return idempotency.Result{}, updateErr
}
if runtimeType != "volc_hardware_agent" {
dedupe := "ops-stop:" + conversationInternalID
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": "device_revoked"})
if _, enqueueErr := s.outbox.Enqueue(op, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: conversationInternalID, EventType: "conversation.agent_stop_requested", DeduplicationKey: &dedupe, Payload: payload}); enqueueErr != nil {
return idempotency.Result{}, enqueueErr
}
}
case "STOPPING":
default:
return idempotency.Result{}, ErrStateConflict
}
if conversationStatus != fromStatus {
if _, historyErr := tx.Exec(op, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source) VALUES($1::uuid,$2,$3,$4,'operator')`, conversationInternalID, fromStatus, conversationStatus, "device revoked: "+reason); historyErr != nil {
return idempotency.Result{}, historyErr
}
}
}
detail := map[string]any{"from_status": oldStatus, "to_status": "revoked", "conversation_queued": queued}
if conversationPublicID != "" {
detail["conversation_id"] = conversationPublicID
detail["conversation_status"] = conversationStatus
detail["runtime_type"] = runtimeType
detail["from_runtime_status"] = runtimeStatus
}
if auditErr := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.device.revoke", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: detail}); auditErr != nil {
return idempotency.Result{}, auditErr
}
output := ActionResult{Action: "revoke_device", DeviceID: devicePublicID, ConversationID: conversationPublicID, ConversationStatus: conversationStatus, Queued: queued}
encoded, _ := json.Marshal(output)
return idempotency.Result{StatusCode: 200, Body: encoded, ResourceID: &deviceID}, nil
})
if err != nil {
return ActionResult{}, false, err
}
var output ActionResult
if err = json.Unmarshal(result.Body, &output); err != nil {
return ActionResult{}, false, err
}
return output, !result.Replayed, nil
}
func (s *Service) ExportBundle(ctx context.Context, principal identity.Principal, tenantPublicID, field, value, key, requestID, reason string) (Bundle, bool, error) {
reason = strings.TrimSpace(reason)
if reason == "" || len([]rune(reason)) > 500 {
return Bundle{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return Bundle{}, false, err
}
items, err := s.Search(ctx, principal, tenantPublicID, field, value)
if err != nil {
return Bundle{}, false, err
}
body, _ := json.Marshal(map[string]string{"field": field, "value": value, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/bundles", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) {
bundle := Bundle{ID: uuid.NewString(), GeneratedAt: time.Now().UTC(), SearchField: field, SearchValue: value, RedactionPolicy: "ops-diagnostic-minimal-v1", Items: items}
if auditErr := s.audit.Record(op, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.bundle.export", ResourceType: "diagnostic_bundle", ResourceID: &bundle.ID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"search_field": field, "item_count": len(items), "redaction_policy": bundle.RedactionPolicy}}); auditErr != nil {
return idempotency.Result{}, auditErr
}
encoded, _ := json.Marshal(bundle)
return idempotency.Result{StatusCode: 201, Body: encoded, ResourceID: &bundle.ID}, nil
})
if err != nil {
return Bundle{}, false, err
}
var bundle Bundle
if err = json.Unmarshal(result.Body, &bundle); err != nil {
return Bundle{}, false, err
}
return bundle, !result.Replayed, nil
}
func (s *Service) ReconcileConversation(ctx context.Context, principal identity.Principal, tenantPublicID, conversationPublicID, key, requestID, reason string) (ActionResult, bool, error) {
reason = strings.TrimSpace(reason)
if _, err := uuid.Parse(conversationPublicID); err != nil || reason == "" || len([]rune(reason)) > 500 {
return ActionResult{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return ActionResult{}, false, err
}
body, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/conversations/reconcile", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
var internalID, deviceID, status, taskRef, runtimeType string
if queryErr := tx.QueryRow(operationContext, `SELECT c.id::text,d.public_id::text,c.status,COALESCE(rt.provider_task_ref,hs.provider_session_ref),c.runtime_type FROM conversation c JOIN device d ON d.id=c.device_id LEFT JOIN rtc_task rt ON rt.conversation_id=c.id LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id WHERE c.tenant_id=$1::uuid AND c.public_id=$2::uuid FOR UPDATE OF c`, a.tenantID, conversationPublicID).Scan(&internalID, &deviceID, &status, &taskRef, &runtimeType); errors.Is(queryErr, pgx.ErrNoRows) {
return idempotency.Result{}, ErrNotFound
} else if queryErr != nil {
return idempotency.Result{}, queryErr
}
eventType := ""
if status == "STARTING_AGENT" {
eventType = "conversation.agent_start_reconcile_requested"
} else if status == "STOPPING" {
eventType = "conversation.agent_stop_reconcile_requested"
}
queued := eventType != "" && runtimeType != "volc_hardware_agent"
if queued {
dedupe := "ops-reconcile:" + taskRef + ":" + key
payload, _ := json.Marshal(map[string]string{"conversation_id": conversationPublicID, "provider_task_ref": taskRef, "reason": "ops_reconcile"})
if _, enqueueErr := s.outbox.Enqueue(operationContext, tx, outbox.NewEvent{TenantID: &a.tenantID, AggregateType: "conversation", AggregateID: internalID, EventType: eventType, DeduplicationKey: &dedupe, Payload: payload}); enqueueErr != nil {
return idempotency.Result{}, enqueueErr
}
}
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.conversation.reconcile", ResourceType: "conversation", ResourceID: &conversationPublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"status": status, "runtime_type": runtimeType, "queued": queued}}); auditErr != nil {
return idempotency.Result{}, auditErr
}
output := ActionResult{Action: "reconcile_conversation", DeviceID: deviceID, ConversationID: conversationPublicID, ConversationStatus: status, Queued: queued}
encoded, _ := json.Marshal(output)
return idempotency.Result{StatusCode: 200, Body: encoded, ResourceID: &internalID}, nil
})
if err != nil {
return ActionResult{}, false, err
}
var output ActionResult
if err = json.Unmarshal(result.Body, &output); err != nil {
return ActionResult{}, false, err
}
return output, !result.Replayed, nil
}
func (s *Service) RefreshDeviceConfig(ctx context.Context, principal identity.Principal, tenantPublicID, devicePublicID, key, requestID, reason string) (ActionResult, bool, error) {
reason = strings.TrimSpace(reason)
if _, err := uuid.Parse(devicePublicID); err != nil || reason == "" || len([]rune(reason)) > 500 {
return ActionResult{}, false, ErrInvalid
}
a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_admin")
if err != nil {
return ActionResult{}, false, err
}
body, _ := json.Marshal(map[string]string{"device_id": devicePublicID, "reason": reason})
result, err := s.idem.Execute(ctx, idempotency.Request{TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/diagnostics/devices/config-refresh", Key: key, RequestHash: idempotency.Hash(body), ExpiresAfter: 24 * time.Hour}, func(operationContext context.Context, tx pgx.Tx) (idempotency.Result, error) {
var deviceID string
var revision int64
if queryErr := tx.QueryRow(operationContext, `SELECT d.id::text,dt.desired_revision FROM device d JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id WHERE d.tenant_id=$1::uuid AND d.public_id=$2::uuid AND d.status='registered' FOR SHARE OF d`, a.tenantID, devicePublicID).Scan(&deviceID, &revision); errors.Is(queryErr, pgx.ErrNoRows) {
return idempotency.Result{}, ErrNotFound
} else if queryErr != nil {
return idempotency.Result{}, queryErr
}
commandPublicID := uuid.NewString()
commandInternalID := uuid.NewString()
payload, _ := json.Marshal(map[string]any{"desired_revision": revision, "reason_code": "ops_config_refresh"})
digest := idempotency.Hash(payload)
_, insertErr := tx.Exec(operationContext, `INSERT INTO device_command(id,public_id,tenant_id,device_id,command_type,payload,status,requested_by,idempotency_key,request_digest,expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,'apply_config',$5::jsonb,'QUEUED',$6::uuid,$7,$8,now()+interval '1 hour')`, commandInternalID, commandPublicID, a.tenantID, deviceID, payload, a.userID, "ops-refresh:"+key, digest)
if insertErr != nil {
return idempotency.Result{}, insertErr
}
if auditErr := s.audit.Record(operationContext, tx, audit.Event{TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "ops.diagnostics.device.config_refresh", ResourceType: "device", ResourceID: &devicePublicID, RequestID: requestID, Purpose: reason, Result: "success", Detail: map[string]any{"desired_revision": revision, "command_id": commandPublicID}}); auditErr != nil {
return idempotency.Result{}, auditErr
}
output := ActionResult{Action: "refresh_config", DeviceID: devicePublicID, CommandID: commandPublicID, CommandStatus: "QUEUED", Queued: true}
encoded, _ := json.Marshal(output)
return idempotency.Result{StatusCode: 201, Body: encoded, ResourceID: &commandInternalID}, nil
})
if err != nil {
return ActionResult{}, false, err
}
var output ActionResult
if err = json.Unmarshal(result.Body, &output); err != nil {
return ActionResult{}, false, err
}
return output, !result.Replayed, nil
}
const diagnosticSelect = `SELECT
d.public_id::text,d.status,COALESCE(dp.online,false),COALESCE(dt.firmware_version,''),
COALESCE(dt.desired_revision,1),dt.reported_revision,COALESCE(dt.last_error_code,''),dt.last_error_at,
COALESCE(c.public_id::text,''),COALESCE(c.status,''),COALESCE(c.failure_code,''),c.allocation_expires_at,
rt.credential_issued_at,rt.credential_expires_at,
COALESCE(rt.room_id,''),COALESCE(rt.provider,hs.provider,''),COALESCE(rt.provider_task_ref,hs.provider_session_ref,''),COALESCE(rt.status,hs.status,''),
COALESCE(rt.start_attempts,0),COALESCE(rt.stop_attempts,0),COALESCE(pe.event_count,0),
COALESCE(pe.event_type,''),COALESCE(pe.event_status,''),pe.received_at,COALESCE(ae.request_id,''),
c.created_at,ch.room_issued_at,ch.device_activated_at,ch.agent_active_at,ch.stop_requested_at,c.ended_at
FROM device d
LEFT JOIN device_twin dt ON dt.device_id=d.id AND dt.tenant_id=d.tenant_id
LEFT JOIN device_presence_state dp ON dp.device_id=d.id AND dp.tenant_id=d.tenant_id
LEFT JOIN conversation c ON c.device_id=d.id AND c.tenant_id=d.tenant_id
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
LEFT JOIN LATERAL (
SELECT count(*) OVER() event_count,event_type,event_status,received_at FROM (
SELECT event_type,COALESCE(sanitized_payload->>'status','') event_status,received_at
FROM provider_event WHERE conversation_id=c.id
UNION ALL
SELECT event_type,event_type,occurred_at FROM hardware_agent_event WHERE conversation_id=c.id
) runtime_event ORDER BY received_at DESC LIMIT 1
) pe ON true
LEFT JOIN LATERAL (
SELECT
MIN(occurred_at) FILTER (WHERE to_status='ROOM_ISSUED') room_issued_at,
MIN(occurred_at) FILTER (WHERE to_status='STARTING_AGENT') device_activated_at,
MIN(occurred_at) FILTER (WHERE to_status='ACTIVE') agent_active_at,
MIN(occurred_at) FILTER (WHERE to_status='STOPPING') stop_requested_at
FROM conversation_state_history WHERE conversation_id=c.id
) ch ON c.id IS NOT NULL
LEFT JOIN LATERAL (
SELECT request_id FROM audit_event
WHERE tenant_id=d.tenant_id AND resource_id IN(d.id,d.public_id,c.id,c.public_id) AND request_id IS NOT NULL
ORDER BY occurred_at DESC LIMIT 1
) ae ON true`
type rowScanner interface{ Scan(...any) error }
func scanRecord(row rowScanner) (Record, error) {
var item Record
var createdAt, roomIssuedAt, deviceActivatedAt, agentActiveAt, stopRequestedAt, endedAt *time.Time
err := row.Scan(&item.DeviceID, &item.DeviceStatus, &item.DeviceOnline, &item.FirmwareVersion, &item.DesiredConfigRevision, &item.ReportedConfigRevision, &item.LastDeviceErrorCode, &item.LastDeviceErrorAt, &item.ConversationID, &item.ConversationStatus, &item.ConversationFailureCode, &item.AllocationExpiresAt, &item.CredentialIssuedAt, &item.CredentialExpiresAt, &item.RoomID, &item.Provider, &item.ProviderTaskRef, &item.ProviderTaskStatus, &item.ProviderStartAttempts, &item.ProviderStopAttempts, &item.ProviderNotificationCount, &item.LastProviderEvent, &item.LastProviderEventStatus, &item.LastProviderEventAt, &item.LastRequestID, &createdAt, &roomIssuedAt, &deviceActivatedAt, &agentActiveAt, &stopRequestedAt, &endedAt)
if err == nil && createdAt != nil {
item.Timings = &Timings{
ConversationCreatedAt: *createdAt,
RoomIssuedAt: roomIssuedAt,
DeviceActivatedAt: deviceActivatedAt,
AgentActiveAt: agentActiveAt,
StopRequestedAt: stopRequestedAt,
ConversationEndedAt: endedAt,
RoomIssueMS: elapsedMilliseconds(createdAt, roomIssuedAt),
DeviceJoinMS: elapsedMilliseconds(roomIssuedAt, deviceActivatedAt),
AgentStartMS: elapsedMilliseconds(deviceActivatedAt, agentActiveAt),
AgentStopMS: elapsedMilliseconds(stopRequestedAt, endedAt),
TotalMS: elapsedMilliseconds(createdAt, endedAt),
}
}
return item, err
}
func elapsedMilliseconds(start, end *time.Time) *int64 {
if start == nil || end == nil || end.Before(*start) {
return nil
}
value := end.Sub(*start).Milliseconds()
return &value
}
func (r Record) String() string {
return fmt.Sprintf("device=%s conversation=%s", r.DeviceID, r.ConversationID)
}