chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,706 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/ai"
|
||||
"git.ipao.vip/rogee/go-sip/internal/callflow"
|
||||
"git.ipao.vip/rogee/go-sip/internal/calllog"
|
||||
"git.ipao.vip/rogee/go-sip/internal/callruntime"
|
||||
"git.ipao.vip/rogee/go-sip/internal/callwindow"
|
||||
"git.ipao.vip/rogee/go-sip/internal/config"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"git.ipao.vip/rogee/go-sip/internal/control"
|
||||
"git.ipao.vip/rogee/go-sip/internal/dispatcher"
|
||||
"git.ipao.vip/rogee/go-sip/internal/health"
|
||||
"git.ipao.vip/rogee/go-sip/internal/mq"
|
||||
ossclient "git.ipao.vip/rogee/go-sip/internal/oss"
|
||||
"git.ipao.vip/rogee/go-sip/internal/rpc"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := newRootCommand().Execute(); err != nil {
|
||||
slog.Error("command failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func newRootCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "sip-go-agent",
|
||||
Short: "SIP Go Agent and Dispatcher",
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
root.AddCommand(newAgentCommand(), newDispatcherCommand())
|
||||
return root
|
||||
}
|
||||
|
||||
func newAgentCommand() *cobra.Command {
|
||||
cfg := config.FromEnv()
|
||||
var realCall bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "run the file-backed Agent process",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
if realCall {
|
||||
return runCallOnce(cfg)
|
||||
}
|
||||
if err := cfg.Validate("agent"); err != nil {
|
||||
return err
|
||||
}
|
||||
spool, err := agent.NewSpool(cfg.SpoolRoot, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
report, err := spool.MarkUnknownOnBoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.GRPCListen == "" {
|
||||
return writeResult(map[string]any{
|
||||
"role": "agent", "mode": cfg.Mode, "agent_id": cfg.AgentID,
|
||||
"version": cfg.Version, "spool": spool.Root(),
|
||||
"unknown_executions": report.Unknown, "quarantined": report.Quarantined,
|
||||
})
|
||||
}
|
||||
return serveAgentRPC(cfg, spool, report)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cfg.Mode, "mode", cfg.Mode, "mock, mixed, or real")
|
||||
cmd.Flags().StringVar(&cfg.SpoolRoot, "spool", cfg.SpoolRoot, "Agent file spool root")
|
||||
cmd.Flags().StringVar(&cfg.AgentID, "agent-id", cfg.AgentID, "stable Agent identifier")
|
||||
cmd.Flags().StringVar(&cfg.CellID, "cell-id", cfg.CellID, "stable Cell identifier")
|
||||
cmd.Flags().StringVar(&cfg.Version, "version", cfg.Version, "Agent software version")
|
||||
cmd.Flags().StringVar(&cfg.StaticArtifactPath, "static-artifact", cfg.StaticArtifactPath, "management-approved static Cell artifact path")
|
||||
cmd.Flags().BoolVar(&realCall, "call-once", false, "run one explicit call through the configured transport and AI adapters")
|
||||
cmd.Flags().StringVar(&cfg.CallTarget, "call-target", cfg.CallTarget, "raw target number allowed by the static artifact")
|
||||
cmd.Flags().StringVar(&cfg.CallTrunkID, "call-trunk-id", cfg.CallTrunkID, "enabled trunk ID from the static artifact")
|
||||
cmd.Flags().StringVar(&cfg.CallCallerID, "call-caller-id", cfg.CallCallerID, "deployment-approved caller ID")
|
||||
cmd.Flags().StringVar(&cfg.CallAISnapshotPath, "call-ai-snapshot", cfg.CallAISnapshotPath, "immutable AI snapshot path")
|
||||
cmd.Flags().IntVar(&cfg.CallMediaPort, "call-media-port", cfg.CallMediaPort, "ExternalMedia UDP port")
|
||||
cmd.Flags().StringVar(&cfg.CallRecordingDirectory, "call-recording-dir", cfg.CallRecordingDirectory, "local recording directory")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mediaForCall(artifact contract.StaticCellArtifact, trunkID string) (contract.StaticMedia, error) {
|
||||
if artifact.Media == nil {
|
||||
return contract.StaticMedia{}, errors.New("static artifact has no default media profile")
|
||||
}
|
||||
for _, trunk := range artifact.Trunks {
|
||||
if trunk.TrunkID != trunkID || !trunk.Enabled {
|
||||
continue
|
||||
}
|
||||
media := *artifact.Media
|
||||
if trunk.MediaProfileID == "" {
|
||||
return media, nil
|
||||
}
|
||||
profile, ok := artifact.MediaProfiles[trunk.MediaProfileID]
|
||||
if !ok {
|
||||
return contract.StaticMedia{}, fmt.Errorf("call trunk %q references unknown media profile %q", trunkID, trunk.MediaProfileID)
|
||||
}
|
||||
media.Format = profile.Format
|
||||
media.SampleRateHz = profile.SampleRateHz
|
||||
media.Channels = profile.Channels
|
||||
media.PayloadType = profile.PayloadType
|
||||
return media, nil
|
||||
}
|
||||
return contract.StaticMedia{}, fmt.Errorf("call trunk %q is not enabled in the static artifact", trunkID)
|
||||
}
|
||||
|
||||
func buildCallEndpoint(artifact contract.StaticCellArtifact, trunkID, target string) (string, error) {
|
||||
if target == "" {
|
||||
return "", errors.New("call target is required")
|
||||
}
|
||||
allowedTarget := false
|
||||
for _, allowed := range artifact.AllowedTargets {
|
||||
if allowed == target {
|
||||
allowedTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowedTarget {
|
||||
return "", fmt.Errorf("call target is not allowed by the static artifact")
|
||||
}
|
||||
for _, trunk := range artifact.Trunks {
|
||||
if trunk.TrunkID != trunkID || !trunk.Enabled {
|
||||
continue
|
||||
}
|
||||
if trunk.SIPEndpointRef == "" {
|
||||
return "", errors.New("selected trunk has no SIP endpoint reference")
|
||||
}
|
||||
return fmt.Sprintf("PJSIP/%s%s@%s", trunk.DialPrefix, target, trunk.SIPEndpointRef), nil
|
||||
}
|
||||
return "", fmt.Errorf("call trunk %q is not enabled in the static artifact", trunkID)
|
||||
}
|
||||
|
||||
func validateCallAISnapshot(raw []byte) (ai.Snapshot, error) {
|
||||
snapshot, err := ai.Validate(raw)
|
||||
if err != nil {
|
||||
return ai.Snapshot{}, fmt.Errorf("validate AI snapshot: %w", err)
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func runCallOnce(cfg config.Config) error {
|
||||
if cfg.StaticArtifactPath == "" || cfg.CallAISnapshotPath == "" {
|
||||
return errors.New("--call-once requires --static-artifact and --call-ai-snapshot")
|
||||
}
|
||||
artifactRaw, err := os.ReadFile(cfg.StaticArtifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read static artifact: %w", err)
|
||||
}
|
||||
artifact, err := contract.ValidateStaticArtifact(artifactRaw, contract.StaticArtifactExpectation{CellID: cfg.CellID, Mode: cfg.Mode})
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate static artifact: %w", err)
|
||||
}
|
||||
if cfg.CallTrunkID == "" {
|
||||
return errors.New("--call-trunk-id is required")
|
||||
}
|
||||
trunkBound := false
|
||||
for _, trunk := range artifact.Trunks {
|
||||
if trunk.TrunkID == cfg.CallTrunkID && trunk.Enabled {
|
||||
trunkBound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !trunkBound {
|
||||
return fmt.Errorf("call trunk %q is not enabled in the static artifact", cfg.CallTrunkID)
|
||||
}
|
||||
if cfg.Mode != "mock" {
|
||||
if err := callwindow.Check(time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
snapshotRaw, err := os.ReadFile(cfg.CallAISnapshotPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read immutable AI snapshot: %w", err)
|
||||
}
|
||||
snapshot, err := validateCallAISnapshot(snapshotRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var snapshotPolicy struct {
|
||||
Conversation struct {
|
||||
Opening string `json:"opening"`
|
||||
MaxTurns int `json:"max_turns"`
|
||||
MaxMillis int `json:"max_duration_ms"`
|
||||
} `json:"conversation"`
|
||||
}
|
||||
if err := json.Unmarshal(snapshotRaw, &snapshotPolicy); err != nil {
|
||||
return fmt.Errorf("read conversation policy from AI snapshot: %w", err)
|
||||
}
|
||||
conversation := snapshotPolicy.Conversation
|
||||
|
||||
var pipeline ai.Pipeline
|
||||
switch cfg.Mode {
|
||||
case "mock", "mixed":
|
||||
pipeline = ai.MockPipeline{MaxAudioBytes: 16 << 20}
|
||||
case "real":
|
||||
providerCfg, providerErr := ai.LoadProviderPipelineConfigFromEnv()
|
||||
if providerErr != nil {
|
||||
return providerErr
|
||||
}
|
||||
pipeline, err = ai.NewProviderPipeline(providerCfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported call mode %q", cfg.Mode)
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
defer cancel()
|
||||
if cfg.Mode == "mock" {
|
||||
input := make([]byte, 6400)
|
||||
flowResult, flowErr := callflow.Execute(callCtx, callflow.NewMemorySession(input), pipeline, snapshot, "您好,这是测试流程。", 10*time.Millisecond)
|
||||
if flowErr != nil {
|
||||
return flowErr
|
||||
}
|
||||
return writeResult(map[string]any{
|
||||
"role": "agent", "mode": cfg.Mode, "ai_mode": snapshot.Mode, "call": "completed",
|
||||
"transcript_chars": len([]rune(flowResult.Turn.Transcript)),
|
||||
"reply_chars": len([]rune(flowResult.Turn.Reply)),
|
||||
"rtp": map[string]any{"received_packets": flowResult.RTP.ReceivedPackets, "received_bytes": flowResult.RTP.ReceivedBytes, "sent_packets": flowResult.RTP.SentPackets, "sent_bytes": flowResult.RTP.SentBytes},
|
||||
})
|
||||
}
|
||||
if artifact.Media == nil || artifact.ARI == nil || artifact.Recording == nil {
|
||||
return errors.New("static artifact is missing transport/media/recording sections")
|
||||
}
|
||||
if cfg.CallTarget == "" || cfg.ARIUsername == "" || cfg.ARIPassword == "" {
|
||||
return errors.New("--call-once requires target and ARI credentials")
|
||||
}
|
||||
callEndpoint, err := buildCallEndpoint(artifact, cfg.CallTrunkID, cfg.CallTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selectedMedia, err := mediaForCall(artifact, cfg.CallTrunkID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.CallMediaPort == 0 {
|
||||
cfg.CallMediaPort = selectedMedia.Port
|
||||
}
|
||||
recordingDir := cfg.CallRecordingDirectory
|
||||
if recordingDir == "" {
|
||||
recordingDir = artifact.Recording.Directory
|
||||
}
|
||||
maxCallDuration := time.Duration(conversation.MaxMillis) * time.Millisecond
|
||||
result, err := callruntime.Run(callCtx, callruntime.Config{
|
||||
ARIURL: cfg.ARIURL, ARIWebsocketURL: cfg.ARIWebsocketURL, ARIApplication: artifact.ARI.Application,
|
||||
ARIUsername: cfg.ARIUsername, ARIPassword: cfg.ARIPassword, Endpoint: callEndpoint,
|
||||
CallerID: cfg.CallCallerID, MediaBind: selectedMedia.BindAddress, MediaPort: cfg.CallMediaPort,
|
||||
MediaFormat: selectedMedia.Format, MediaSampleRate: selectedMedia.SampleRateHz,
|
||||
PayloadType: uint8(selectedMedia.PayloadType), RecordingDirectory: recordingDir,
|
||||
MaxTurns: conversation.MaxTurns, MaxCallDuration: maxCallDuration, OpeningPrompt: conversation.Opening,
|
||||
Snapshot: snapshot, Pipeline: pipeline,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
turns := make([]map[string]any, 0, len(result.Turns))
|
||||
includeText := os.Getenv("AGENT_CALL_INCLUDE_TEXT") == "1"
|
||||
for index, turn := range result.Turns {
|
||||
fact := map[string]any{
|
||||
"turn": index + 1,
|
||||
"transcript_chars": len([]rune(turn.Transcript)),
|
||||
"reply_chars": len([]rune(turn.Reply)),
|
||||
"invalid_call": turn.InvalidCall,
|
||||
"invalid_reason": turn.InvalidReason,
|
||||
}
|
||||
if includeText {
|
||||
fact["transcript"] = turn.Transcript
|
||||
fact["reply"] = turn.Reply
|
||||
}
|
||||
turns = append(turns, fact)
|
||||
}
|
||||
inboundRecordings := make([]map[string]any, 0, len(result.InboundRecordings))
|
||||
for _, recording := range result.InboundRecordings {
|
||||
inboundRecordings = append(inboundRecordings, map[string]any{"segment": recording.Segment, "path": recording.Path, "bytes": recording.Bytes, "sha256": recording.SHA256})
|
||||
}
|
||||
outboundRecordings := make([]map[string]any, 0, len(result.OutboundRecordings))
|
||||
for _, recording := range result.OutboundRecordings {
|
||||
outboundRecordings = append(outboundRecordings, map[string]any{"segment": recording.Segment, "path": recording.Path, "bytes": recording.Bytes, "sha256": recording.SHA256})
|
||||
}
|
||||
uploads, err := uploadCallRecordings(callCtx, cfg, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output := map[string]any{
|
||||
"role": "agent", "mode": cfg.Mode, "call": "completed", "channel_id": result.ChannelID,
|
||||
"transcript_chars": len([]rune(result.Transcript)), "reply_chars": len([]rune(result.Reply)),
|
||||
"invalid_call": result.InvalidCall, "invalid_reason": result.InvalidReason,
|
||||
"turns": turns,
|
||||
"inbound_recording": map[string]any{"path": result.InboundPath, "bytes": result.InboundBytes, "sha256": result.InboundSHA256},
|
||||
"outbound_recording": map[string]any{"path": result.OutboundPath, "bytes": result.OutboundBytes, "sha256": result.OutboundSHA256},
|
||||
"inbound_recordings": inboundRecordings, "outbound_recordings": outboundRecordings,
|
||||
"rtp": map[string]any{"received_packets": result.RTP.ReceivedPackets, "received_bytes": result.RTP.ReceivedBytes, "sent_packets": result.RTP.SentPackets, "sent_bytes": result.RTP.SentBytes},
|
||||
}
|
||||
if uploads != nil {
|
||||
output["oss_uploads"] = uploads
|
||||
}
|
||||
return writeResult(output)
|
||||
}
|
||||
|
||||
func serveAgentRPC(cfg config.Config, spool *agent.Spool, report agent.RecoveryReport) error {
|
||||
var staticArtifactRaw []byte
|
||||
if cfg.StaticArtifactPath != "" {
|
||||
var err error
|
||||
staticArtifactRaw, err = os.ReadFile(cfg.StaticArtifactPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read static Cell artifact: %w", err)
|
||||
}
|
||||
}
|
||||
peerCertificateFingerprints, err := config.ParseCertificateFingerprints(cfg.MTLSPeerCertificateFingerprints)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse MTLS_PEER_CERT_FINGERPRINTS: %w", err)
|
||||
}
|
||||
tlsConfig, err := rpc.LoadServerTLSConfig(cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var callLogger *calllog.Logger
|
||||
if cfg.CallLogPhoneKey != "" {
|
||||
path := cfg.CallLogPath
|
||||
if path == "" {
|
||||
path = filepath.Join(spool.Root(), "call-business.jsonl")
|
||||
}
|
||||
callLogger, err = calllog.New(path, []byte(cfg.CallLogPhoneKey), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure call business log: %w", err)
|
||||
}
|
||||
}
|
||||
listener, err := net.Listen("tcp", cfg.GRPCListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen Agent gRPC: %w", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
bootID := fmt.Sprintf("%s-%d", cfg.AgentID, time.Now().UnixNano())
|
||||
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)))
|
||||
handler := rpc.NewServer(rpc.ServerOptions{
|
||||
Mode: cfg.Mode,
|
||||
Status: &agentv1.AgentStatus{
|
||||
AgentId: cfg.AgentID,
|
||||
CellId: cfg.CellID,
|
||||
BootId: bootID,
|
||||
SoftwareVersion: cfg.Version,
|
||||
ProtocolVersion: "agent.v1",
|
||||
AdmissionState: agentv1.AdmissionState_ADMISSION_STATE_CLOSED,
|
||||
StatusReason: fmt.Sprintf("recovered_unknown=%d quarantined=%d spool=%s", len(report.Unknown), len(report.Quarantined), spool.Root()),
|
||||
Capabilities: []*agentv1.Capability{{Name: "mode", Value: cfg.Mode}, {Name: "grpc_transport", Value: "unary-mtls"}, {Name: "resource_sample", Value: "partial-unknown"}},
|
||||
Resources: health.Sampler{}.Sample(context.Background(), spool.Root()),
|
||||
},
|
||||
UploadPolicy: &agentv1.UploadPolicy{Enabled: cfg.Mode == "mock", MaxAssetBytes: 16 << 20},
|
||||
StaticArtifactRaw: staticArtifactRaw,
|
||||
StaticArtifactExpected: contract.StaticArtifactExpectation{CellID: cfg.CellID, Mode: cfg.Mode},
|
||||
RequirePeerCertificate: true,
|
||||
PeerCertificateFingerprints: peerCertificateFingerprints,
|
||||
StatePath: filepath.Join(cfg.SpoolRoot, "rpc-session.json"),
|
||||
CallLogger: callLogger,
|
||||
})
|
||||
agentv1.RegisterAgentControlServiceServer(grpcServer, handler)
|
||||
serveCtx, cancel := signalContext()
|
||||
defer cancel()
|
||||
go func() {
|
||||
<-serveCtx.Done()
|
||||
grpcServer.GracefulStop()
|
||||
}()
|
||||
if err := grpcServer.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type connectedAgentRuntime struct {
|
||||
coordinator *dispatcher.AgentCoordinator
|
||||
clients []*rpc.Client
|
||||
sessions []dispatcher.AgentSession
|
||||
}
|
||||
|
||||
// connectConfiguredAgents performs the Dispatcher startup binding for the
|
||||
// deployment-owned endpoint inventory. It is intentionally separate from SaaS
|
||||
// commands: tenant input never selects an endpoint or identity.
|
||||
func connectConfiguredAgents(ctx context.Context, cfg config.Config) (*connectedAgentRuntime, error) {
|
||||
endpoints, err := config.LoadAgentEndpoints(cfg.AgentEndpointsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime := &connectedAgentRuntime{coordinator: dispatcher.NewAgentCoordinator(nil)}
|
||||
fail := func(err error) (*connectedAgentRuntime, error) {
|
||||
return nil, errors.Join(err, runtime.Close())
|
||||
}
|
||||
epoch := fmt.Sprintf("%s-%d", cfg.DispatcherID, time.Now().UnixNano())
|
||||
for _, endpoint := range endpoints {
|
||||
client, dialErr := rpc.DialFromFiles(endpoint.Address, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, endpoint.ServerName)
|
||||
if dialErr != nil {
|
||||
return fail(fmt.Errorf("connect Agent %q: %w", endpoint.AgentID, dialErr))
|
||||
}
|
||||
runtime.clients = append(runtime.clients, client)
|
||||
if registerErr := runtime.coordinator.Register(endpoint.AgentID, client.Agent); registerErr != nil {
|
||||
return fail(fmt.Errorf("register Agent %q: %w", endpoint.AgentID, registerErr))
|
||||
}
|
||||
status, probeErr := runtime.coordinator.Probe(ctx, endpoint.AgentID, endpoint.CellID)
|
||||
if probeErr != nil {
|
||||
return fail(fmt.Errorf("probe Agent %q: %w", endpoint.AgentID, probeErr))
|
||||
}
|
||||
// Zero lets Agent-side durable session state allocate the next generation
|
||||
// after a Dispatcher restart; hard-coding 1 would self-fence recovery.
|
||||
session, activateErr := runtime.coordinator.Activate(ctx, endpoint.AgentID, endpoint.CellID, status.BootId, epoch, 0)
|
||||
if activateErr != nil {
|
||||
return fail(fmt.Errorf("activate Agent %q: %w", endpoint.AgentID, activateErr))
|
||||
}
|
||||
runtime.sessions = append(runtime.sessions, session)
|
||||
}
|
||||
return runtime, nil
|
||||
}
|
||||
|
||||
func (r *connectedAgentRuntime) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
var firstErr error
|
||||
for index := len(r.clients) - 1; index >= 0; index-- {
|
||||
if err := r.clients[index].Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
const dispatcherOutboxFlushInterval = 250 * time.Millisecond
|
||||
|
||||
func flushDispatcherOutbox(ctx context.Context, d *dispatcher.Dispatcher, batch int, interval time.Duration) {
|
||||
flush := func() {
|
||||
published, err := d.FlushOutbox(ctx, batch)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
slog.Error("flush Dispatcher outbox", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if published > 0 {
|
||||
slog.Debug("flushed Dispatcher outbox", "published", published)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDispatcherCommand() *cobra.Command {
|
||||
cfg := config.FromEnv()
|
||||
var once bool
|
||||
var consume bool
|
||||
var tenantKey string
|
||||
cmd := &cobra.Command{
|
||||
Use: "dispatcher",
|
||||
Short: "run the single-active Dispatcher process",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := cfg.Validate("dispatcher"); err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
leaseCtx, cancelLease := signalContext()
|
||||
defer cancelLease()
|
||||
lease, err := dispatcher.StartLease(leaseCtx, st, "dispatcher-active-"+cfg.DispatcherID, "dispatcher", cfg.DispatcherID, 30*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lease.Stop()
|
||||
var connectedAgents *connectedAgentRuntime
|
||||
if cfg.AgentEndpointsFile != "" {
|
||||
startupCtx, cancelStartup := context.WithTimeout(leaseCtx, 15*time.Second)
|
||||
connectedAgents, err = connectConfiguredAgents(startupCtx, cfg)
|
||||
cancelStartup()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := connectedAgents.Close(); closeErr != nil {
|
||||
slog.Error("close Agent connections", "error", closeErr)
|
||||
}
|
||||
}()
|
||||
}
|
||||
var publisher mq.Publisher
|
||||
var broker *mq.Broker
|
||||
if cfg.RabbitURL != "" {
|
||||
broker, err = mq.Open(cfg.RabbitURL, cfg.Exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer broker.Close()
|
||||
publisher = broker
|
||||
}
|
||||
d, err := dispatcher.New(st, publisher, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dispatcherGRPC *grpc.Server
|
||||
var dispatcherListener net.Listener
|
||||
if cfg.DispatcherGRPCListen != "" && !once {
|
||||
uploadClient, ossErr := ossclient.NewClient(ossclient.Config{
|
||||
Endpoint: cfg.OSSEndpoint, Region: cfg.OSSRegion, Bucket: cfg.OSSBucket,
|
||||
AccessKeyID: cfg.OSSAccessKeyID, AccessKeySecret: cfg.OSSAccessKeySecret,
|
||||
KeyPrefix: cfg.OSSKeyPrefix, GrantTTL: cfg.OSSGrantTTL, MaxAssetBytes: cfg.OSSMaxAssetBytes,
|
||||
})
|
||||
if ossErr != nil {
|
||||
return fmt.Errorf("configure Dispatcher OSS: %w", ossErr)
|
||||
}
|
||||
peerFingerprints, fingerprintErr := config.ParseCertificateFingerprints(cfg.MTLSPeerCertificateFingerprints)
|
||||
if fingerprintErr != nil {
|
||||
return fmt.Errorf("parse Dispatcher gRPC mTLS peer fingerprints: %w", fingerprintErr)
|
||||
}
|
||||
if len(peerFingerprints) == 0 {
|
||||
return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled")
|
||||
}
|
||||
allowedAgentIDs := parseCSVSet(cfg.DispatcherGRPCAllowedAgentIDs)
|
||||
uploadHandler, handlerErr := rpc.NewDispatcherUploadServerWithOptions(st, uploadClient, time.Now, rpc.DispatcherUploadOptions{
|
||||
RequirePeer: true, PeerCertificateFingerprints: peerFingerprints, AllowedAgentIDs: allowedAgentIDs,
|
||||
})
|
||||
if handlerErr != nil {
|
||||
return handlerErr
|
||||
}
|
||||
eventHandler, eventErr := rpc.NewDispatcherEventServer(st, rpc.DispatcherEventServerOptions{
|
||||
RequirePeer: true, PeerCertificateFingerprints: peerFingerprints,
|
||||
AllowedAgentIDs: allowedAgentIDs, Now: time.Now,
|
||||
})
|
||||
if eventErr != nil {
|
||||
return eventErr
|
||||
}
|
||||
dispatcherHandler := rpc.NewDispatcherServer(uploadHandler, eventHandler)
|
||||
tlsConfig, tlsErr := rpc.LoadServerTLSConfig(cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile)
|
||||
if tlsErr != nil {
|
||||
return fmt.Errorf("configure Dispatcher gRPC mTLS: %w", tlsErr)
|
||||
}
|
||||
dispatcherListener, err = net.Listen("tcp", cfg.DispatcherGRPCListen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen Dispatcher gRPC: %w", err)
|
||||
}
|
||||
dispatcherGRPC = grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)))
|
||||
agentv1.RegisterAgentControlServiceServer(dispatcherGRPC, dispatcherHandler)
|
||||
go func() {
|
||||
if serveErr := dispatcherGRPC.Serve(dispatcherListener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) {
|
||||
slog.Error("Dispatcher gRPC stopped", "error", serveErr)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
dispatcherGRPC.GracefulStop()
|
||||
_ = dispatcherListener.Close()
|
||||
}()
|
||||
}
|
||||
result := map[string]any{"role": "dispatcher", "mode": cfg.Mode, "db": cfg.DBPath}
|
||||
if dispatcherGRPC != nil {
|
||||
result["dispatcher_grpc"] = cfg.DispatcherGRPCListen
|
||||
}
|
||||
if connectedAgents != nil {
|
||||
sessions := make([]map[string]any, 0, len(connectedAgents.sessions))
|
||||
for _, session := range connectedAgents.sessions {
|
||||
sessions = append(sessions, map[string]any{
|
||||
"agent_id": session.AgentID, "cell_id": session.CellID,
|
||||
"boot_id": session.BootID, "session_generation": session.SessionGeneration,
|
||||
})
|
||||
}
|
||||
result["agent_sessions"] = sessions
|
||||
}
|
||||
if once && consume {
|
||||
return errors.New("--once cannot be combined with --consume")
|
||||
}
|
||||
if consume {
|
||||
if broker == nil || tenantKey == "" {
|
||||
return errors.New("--consume requires --rabbit-url and --tenant-key")
|
||||
}
|
||||
if cfg.ControlListen != "" {
|
||||
return errors.New("--consume cannot be combined with --control-listen")
|
||||
}
|
||||
flushCtx, cancelFlush := context.WithCancel(leaseCtx)
|
||||
flushDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(flushDone)
|
||||
flushDispatcherOutbox(flushCtx, d, cfg.OutboxBatch, dispatcherOutboxFlushInterval)
|
||||
}()
|
||||
defer func() {
|
||||
cancelFlush()
|
||||
<-flushDone
|
||||
}()
|
||||
if err := d.ConsumeTenant(leaseCtx, broker, tenantKey); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if once {
|
||||
if publisher == nil {
|
||||
return errors.New("--once requires RABBITMQ_URL or a configured publisher")
|
||||
}
|
||||
count, err := d.FlushOutbox(cmd.Context(), cfg.OutboxBatch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result["published"] = count
|
||||
}
|
||||
if cfg.ControlListen == "" {
|
||||
if dispatcherGRPC == nil {
|
||||
return writeResult(result)
|
||||
}
|
||||
select {
|
||||
case <-leaseCtx.Done():
|
||||
return nil
|
||||
case err := <-lease.Lost():
|
||||
return fmt.Errorf("dispatcher lease lost: %w", err)
|
||||
}
|
||||
}
|
||||
if once {
|
||||
return errors.New("--once cannot be combined with --control-listen")
|
||||
}
|
||||
server := &http.Server{Addr: cfg.ControlListen, Handler: control.Handler{Store: st, BearerToken: cfg.ControlToken}}
|
||||
go func() {
|
||||
select {
|
||||
case <-leaseCtx.Done():
|
||||
case <-lease.Lost():
|
||||
}
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case err := <-lease.Lost():
|
||||
return fmt.Errorf("dispatcher lease lost: %w", err)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&cfg.Mode, "mode", cfg.Mode, "mock, mixed, or real")
|
||||
cmd.Flags().StringVar(&cfg.DBPath, "db", cfg.DBPath, "Dispatcher SQLite path")
|
||||
cmd.Flags().StringVar(&cfg.DispatcherID, "dispatcher-id", cfg.DispatcherID, "single-active Dispatcher holder identity")
|
||||
cmd.Flags().StringVar(&cfg.RabbitURL, "rabbit-url", cfg.RabbitURL, "RabbitMQ URL")
|
||||
cmd.Flags().StringVar(&cfg.Exchange, "exchange", cfg.Exchange, "durable command exchange")
|
||||
cmd.Flags().IntVar(&cfg.OutboxBatch, "outbox-batch", cfg.OutboxBatch, "maximum outbox messages per run")
|
||||
cmd.Flags().StringVar(&cfg.ControlListen, "control-listen", cfg.ControlListen, "internal control HTTP listen address; empty disables server")
|
||||
cmd.Flags().StringVar(&cfg.ControlToken, "control-token", cfg.ControlToken, "bearer token for internal control HTTP")
|
||||
cmd.Flags().StringVar(&cfg.AgentEndpointsFile, "agent-endpoints-file", cfg.AgentEndpointsFile, "strict JSON file of Dispatcher-owned Agent endpoints")
|
||||
cmd.Flags().StringVar(&tenantKey, "tenant-key", "", "tenant key to consume from its command queue")
|
||||
cmd.Flags().BoolVar(&consume, "consume", false, "consume one tenant command queue")
|
||||
cmd.Flags().BoolVar(&once, "once", false, "flush one outbox batch and exit")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseCSVSet(raw string) map[string]struct{} {
|
||||
result := make(map[string]struct{})
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
result[item] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func marshalResultForTest(value any) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func writeResult(value any) error {
|
||||
data, err := marshalResultForTest(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = os.Stdout.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
"git.ipao.vip/rogee/go-sip/internal/contract"
|
||||
"git.ipao.vip/rogee/go-sip/internal/dispatcher"
|
||||
"git.ipao.vip/rogee/go-sip/internal/store"
|
||||
)
|
||||
|
||||
func TestRootHasExplicitRoles(t *testing.T) {
|
||||
root := newRootCommand()
|
||||
if _, _, err := root.Find([]string{"agent"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := root.Find([]string{"dispatcher"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteResultIsJSON(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
data, err := marshalResultForTest(map[string]string{"status": "ok"})
|
||||
if err != nil || len(data) == 0 {
|
||||
t.Fatalf("data=%q err=%v", data, err)
|
||||
}
|
||||
b.Write(data)
|
||||
if b.Len() == 0 {
|
||||
t.Fatal("empty result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallAISnapshotAllowsASROnly(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/agent-version-asr-only.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshot, err := validateCallAISnapshot(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Mode != "asr_only" {
|
||||
t.Fatalf("snapshot mode=%q, want asr_only", snapshot.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCallEndpointUsesArtifactRoute(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := contract.ValidateStaticArtifact(raw, contract.StaticArtifactExpectation{CellID: "cell-single", Mode: "real"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint, err := buildCallEndpoint(artifact, "provider-third", "15830461047")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if endpoint != "PJSIP/mka75515830461047@provider-third" {
|
||||
t.Fatalf("endpoint=%q", endpoint)
|
||||
}
|
||||
media, err := mediaForCall(artifact, "provider-third")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media.Format != "alaw" || media.SampleRateHz != 8000 || media.PayloadType != 8 {
|
||||
t.Fatalf("media=%+v", media)
|
||||
}
|
||||
if _, err := buildCallEndpoint(artifact, "provider-third", "10000000000"); err == nil {
|
||||
t.Fatal("disallowed target was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type recordingPublisher struct{}
|
||||
|
||||
func (recordingPublisher) Publish(context.Context, string, string, []byte) error { return nil }
|
||||
|
||||
func TestFlushDispatcherOutboxPublishesPending(t *testing.T) {
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
d, err := dispatcher.New(st, recordingPublisher{}, time.Now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go flushDispatcherOutbox(ctx, d, 1, time.Millisecond)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var status string
|
||||
if err := st.DB().QueryRow(`SELECT status FROM outbox LIMIT 1`).Scan(&status); err == nil && status == "published" {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("pending outbox was not published")
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
"git.ipao.vip/rogee/go-sip/internal/callruntime"
|
||||
"git.ipao.vip/rogee/go-sip/internal/config"
|
||||
"git.ipao.vip/rogee/go-sip/internal/rpc"
|
||||
)
|
||||
|
||||
func uploadCallRecordings(ctx context.Context, cfg config.Config, result callruntime.Result) ([]map[string]any, error) {
|
||||
if strings.TrimSpace(cfg.DispatcherGRPCEndpoint) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"AGENT_CALL_TENANT_ID": cfg.CallTenantID,
|
||||
"AGENT_CALL_TENANT_KEY": cfg.CallTenantKey,
|
||||
"AGENT_CALL_TASK_ID": cfg.CallTaskID,
|
||||
"AGENT_CALL_TASK_ITEM_ID": cfg.CallTaskItemID,
|
||||
} {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, fmt.Errorf("%s is required when Dispatcher OSS upload is enabled", name)
|
||||
}
|
||||
}
|
||||
if result.ChannelID == "" {
|
||||
return nil, errors.New("call result has no channel ID for upload binding")
|
||||
}
|
||||
executionID := cfg.CallExecutionID
|
||||
if executionID == "" {
|
||||
executionID = result.ChannelID
|
||||
}
|
||||
binding := &agentv1.ExecutionBinding{
|
||||
TenantId: cfg.CallTenantID,
|
||||
TenantKey: cfg.CallTenantKey,
|
||||
ExecutionId: executionID,
|
||||
TaskId: cfg.CallTaskID,
|
||||
TaskItemId: cfg.CallTaskItemID,
|
||||
TaskRevision: 1,
|
||||
CallId: result.ChannelID,
|
||||
AttemptId: result.ChannelID,
|
||||
AgentVersionId: cfg.Version,
|
||||
}
|
||||
client, err := rpc.DialFromFiles(cfg.DispatcherGRPCEndpoint, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, cfg.DispatcherGRPCServerName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial Dispatcher gRPC service: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
uploader := agent.UploadClient{Now: time.Now}
|
||||
facts := append(append([]callruntime.RecordingFact(nil), result.InboundRecordings...), result.OutboundRecordings...)
|
||||
if len(facts) == 0 {
|
||||
return nil, errors.New("call produced no recordings for OSS upload")
|
||||
}
|
||||
uploaded := make([]map[string]any, 0, len(facts))
|
||||
for index, recording := range facts {
|
||||
if recording.Path == "" || recording.Bytes <= 0 || recording.SHA256 == "" {
|
||||
return nil, fmt.Errorf("recording %d is missing path, size or checksum", index+1)
|
||||
}
|
||||
assetID := recordingAssetID(recording.Segment, recording.Path, index+1)
|
||||
asset := &agentv1.AssetDescriptor{
|
||||
Kind: agentv1.AssetKind_ASSET_KIND_RECORDING,
|
||||
AssetId: assetID,
|
||||
CallId: result.ChannelID,
|
||||
ExecutionId: executionID,
|
||||
Format: "wav",
|
||||
SizeBytes: int64(recording.Bytes),
|
||||
ChecksumSha256: recording.SHA256,
|
||||
Channels: 1,
|
||||
SampleRateHz: 16000,
|
||||
}
|
||||
uploadID := stableUploadID(binding, asset)
|
||||
meta := uploadMeta(cfg, "request", uploadID)
|
||||
grantResponse, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: uploadID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request upload %s: %w", uploadID, err)
|
||||
}
|
||||
if grantResponse == nil || grantResponse.Grant == nil || grantResponse.Receipt == nil || grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED {
|
||||
return nil, fmt.Errorf("request upload %s was rejected", uploadID)
|
||||
}
|
||||
parsed, err := url.Parse(grantResponse.Grant.TargetUrl)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("upload %s returned invalid target URL", uploadID)
|
||||
}
|
||||
uploader.AllowedHosts = map[string]struct{}{strings.ToLower(parsed.Host): {}}
|
||||
uploadResult, err := uploader.UploadFile(ctx, grantResponse.Grant, recording.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload %s data plane: %w", uploadID, err)
|
||||
}
|
||||
if uploadResult.SizeBytes != asset.SizeBytes || !strings.EqualFold(uploadResult.SHA256, asset.ChecksumSha256) {
|
||||
return nil, fmt.Errorf("upload %s local result does not match asset", uploadID)
|
||||
}
|
||||
completeResponse, err := client.CompleteUpload(ctx, &agentv1.CompleteUploadRequest{
|
||||
Meta: uploadMeta(cfg, "complete", uploadID),
|
||||
Binding: binding,
|
||||
Asset: asset,
|
||||
UploadId: uploadID,
|
||||
UploadedSizeBytes: uploadResult.SizeBytes,
|
||||
UploadedChecksumSha256: uploadResult.SHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("complete upload %s: %w", uploadID, err)
|
||||
}
|
||||
if completeResponse == nil || completeResponse.Receipt == nil || completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" {
|
||||
return nil, fmt.Errorf("complete upload %s was not verified", uploadID)
|
||||
}
|
||||
uploaded = append(uploaded, map[string]any{
|
||||
"upload_id": uploadID, "asset_id": asset.AssetId, "object_key": grantResponse.Grant.ObjectKey,
|
||||
"oss_id": completeResponse.OssId, "bytes": uploadResult.SizeBytes, "sha256": uploadResult.SHA256,
|
||||
"status_code": uploadResult.StatusCode,
|
||||
})
|
||||
}
|
||||
return uploaded, nil
|
||||
}
|
||||
|
||||
func recordingAssetID(segment, path string, index int) string {
|
||||
raw := segment
|
||||
if raw == "" {
|
||||
raw = fmt.Sprintf("segment-%d", index)
|
||||
}
|
||||
raw += "-" + filepath.Base(path)
|
||||
var builder strings.Builder
|
||||
for _, r := range raw {
|
||||
if r == '/' || r == '\\' || r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
||||
builder.WriteByte('-')
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
assetID := strings.Trim(builder.String(), "-")
|
||||
if assetID == "" {
|
||||
assetID = fmt.Sprintf("segment-%d", index)
|
||||
}
|
||||
if len([]byte(assetID)) > 120 {
|
||||
digest := sha256.Sum256([]byte(assetID))
|
||||
assetID = "recording-" + hex.EncodeToString(digest[:16])
|
||||
}
|
||||
return assetID
|
||||
}
|
||||
|
||||
func stableUploadID(binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor) string {
|
||||
value := binding.ExecutionId + "\x00" + asset.AssetId + "\x00" + asset.ChecksumSha256
|
||||
digest := sha256.Sum256([]byte(value))
|
||||
return "upload-" + hex.EncodeToString(digest[:16])
|
||||
}
|
||||
|
||||
func uploadMeta(cfg config.Config, phase, uploadID string) *agentv1.RequestMeta {
|
||||
operationID := "recording-" + phase + "-" + uploadID
|
||||
return &agentv1.RequestMeta{
|
||||
ProtocolVersion: "agent.v1",
|
||||
RequestId: operationID,
|
||||
TraceId: operationID,
|
||||
OperationId: operationID,
|
||||
IdempotencyKey: operationID,
|
||||
AgentId: cfg.AgentID,
|
||||
CellId: cfg.CellID,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user