From 0b4b901d85b63f4c4580e093a1ed9ea3a0b6e5cc Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 25 Sep 2026 16:38:22 +0800 Subject: [PATCH] feat: implement local P1 contracts and Mock call flow --- cmd/sip-go-agent/agent_mock_originator.go | 28 + .../agent_mock_originator_test.go | 27 + cmd/sip-go-agent/control_worker.go | 27 - cmd/sip-go-agent/main.go | 148 ++- cmd/sip-go-agent/main_test.go | 46 - .../upload_failure_recovery_test.go | 75 ++ cmd/sip-go-agent/upload_recovery.go | 42 +- contracts/contracts.go | 15 +- .../call-result-v0.1-proposal.schema.json | 159 +++ .../command-next-v0.1-proposal.schema.json | 209 ++++ .../local/v0.1/config-read-v0.1.schema.json | 172 ++++ ...al-mock-recording-failure-v0.1.schema.json | 18 + .../task-discovery-v0.2-proposal.schema.json | 98 ++ deploys/env/dispatcher.env.example | 4 +- .../systemd/sip-go-agent-dispatcher.service | 2 +- .../call-result-v0.1-proposal.schema.json | 159 +++ .../command-next-v0.1-proposal.schema.json | 209 ++++ .../config-read-fields-v0.1-proposal.md | 26 +- docs/contracts/config-read-v0.1.schema.json | 12 +- ...-result-invalid-missing-checksum-v0.1.json | 44 + .../examples/call-result-uploaded-v0.1.json | 54 + .../command-next-invalid-control-id-v0.1.json | 18 + .../examples/config-read-error-v0.1.json | 2 +- .../config-read-http-statuses-v0.1.json | 71 ++ ...nfig-read-invalid-extra-property-v0.1.json | 9 + ...l-mock-recording-failure-expired-v0.1.json | 6 + ...-recording-failure-invalid-extra-v0.1.json | 7 + ...ecording-failure-invalid-timeout-v0.1.json | 6 + ...-recording-failure-upload-failed-v0.1.json | 6 + .../examples/task-discovery-changes-v0.2.json | 32 + .../task-discovery-http-statuses-v0.1.json | 82 ++ .../task-discovery-http-statuses-v0.2.json | 10 + ...ask-discovery-invalid-page-token-v0.2.json | 7 + ...discovery-invalid-queue-property-v0.1.json | 24 + .../task-discovery-invalid-queue-v0.2.json | 13 + .../task-discovery-no-change-v0.2.json | 6 + .../task-discovery-snapshot-v0.1.json | 23 + .../task-discovery-snapshot-v0.2.json | 14 + .../local-contract-manifest-v0.1.json | 84 ++ .../local-contract-manifest-v0.2.json | 18 + ...-mock-recording-failure-manifest-v0.1.json | 30 + .../local-mock-recording-failure-v0.1.md | 17 + ...al-mock-recording-failure-v0.1.schema.json | 18 + docs/contracts/mq-topology-v0.1-proposal.json | 84 ++ .../task-discovery-v0.1-proposal.schema.json | 152 +++ .../task-discovery-v0.2-proposal.schema.json | 98 ++ docs/contracts/通信与事件数据交互_v0.1.md | 8 +- docs/thirds/v0.2.md | 936 ++++++++++++++++++ .../第三方对接事件与请求消费顺序_v0.1.md | 144 +-- gen/agent/v1/agent.pb.go | 460 ++++++--- gen/agent/v1/agent_grpc.pb.go | 40 + internal/agent/mock_failure_report.go | 141 +++ internal/agent/upload.go | 27 +- internal/agent/upload_failure.go | 109 ++ internal/agent/upload_failure_report_test.go | 154 +++ internal/agent/upload_failure_state_test.go | 88 ++ internal/agent/upload_state.go | 33 +- internal/agent/upload_test.go | 33 +- internal/callwindow/policy.go | 158 +++ internal/callwindow/policy_test.go | 143 +++ internal/configread/client.go | 464 +++++++++ internal/configread/client_test.go | 178 ++++ internal/configread/control_status_test.go | 55 + internal/configread/discovery_v02_test.go | 129 +++ internal/contract/contract.go | 36 + .../contract/local_mock_recording_failure.go | 32 + .../local_mock_recording_failure_test.go | 34 + internal/contract/schema.go | 66 ++ internal/contract/schema_test.go | 25 + internal/dispatcher/agent.go | 84 +- .../dispatcher/agent_inbound_session_test.go | 56 ++ internal/dispatcher/ai_mq_integration_test.go | 165 --- internal/dispatcher/authorized_agent.go | 75 ++ internal/dispatcher/call_result_v01.go | 151 +++ internal/dispatcher/config_read.go | 183 ++++ internal/dispatcher/consumer.go | 91 -- internal/dispatcher/consumer_test.go | 190 ---- .../dispatcher/control_mq_integration_test.go | 225 ----- internal/dispatcher/control_worker.go | 83 -- internal/dispatcher/dial_policy.go | 172 ++++ internal/dispatcher/dial_policy_test.go | 161 +++ internal/dispatcher/dispatcher.go | 27 +- internal/dispatcher/local_config_read_test.go | 262 +++++ internal/dispatcher/local_flow_test.go | 45 - .../local_mock_dispatcher_deadline.go | 31 + .../local_mock_dispatcher_deadline_test.go | 55 + internal/dispatcher/local_mock_failure.go | 80 ++ internal/dispatcher/local_mock_final_event.go | 116 +++ .../local_mock_pending_result_test.go | 295 ++++++ internal/dispatcher/local_mock_recovery.go | 227 +++++ .../dispatcher/local_mock_recovery_test.go | 59 ++ internal/dispatcher/local_mock_refusal.go | 46 + .../local_mock_refusal_pending_test.go | 51 + .../dispatcher/local_mock_refusal_test.go | 50 + internal/dispatcher/local_mock_result.go | 91 ++ internal/dispatcher/local_mock_upload.go | 126 +++ internal/dispatcher/local_origination.go | 170 ++++ .../local_origination_quota_test.go | 25 + .../dispatcher/local_origination_rpc_test.go | 65 ++ internal/dispatcher/local_sqlite_full_test.go | 47 + internal/dispatcher/local_v01.go | 276 ++++++ .../dispatcher/local_v01_integration_test.go | 304 ++++++ internal/dispatcher/local_v01_test.go | 802 +++++++++++++++ internal/dispatcher/mq_integration_test.go | 137 --- .../dispatcher/outbox_payload_limit_test.go | 66 ++ .../dispatcher/query_mq_integration_test.go | 256 ----- .../dispatcher/sip_applied_config_test.go | 106 ++ internal/dispatcher/task_control_v3.go | 349 +++++++ internal/dispatcher/task_control_v3_test.go | 323 ++++++ internal/dispatcher/task_controller.go | 11 + internal/dispatcher/task_queue_v3.go | 273 +++++ .../task_queue_v3_origination_test.go | 166 ++++ .../task_queue_v3_stop_active_test.go | 131 +++ internal/dispatcher/task_queue_v3_test.go | 259 +++++ internal/dispatcher/task_runtime_v3.go | 225 +++++ internal/dispatcher/task_runtime_v3_test.go | 299 ++++++ internal/mq/amqp_v3.go | 351 +++++++ internal/mq/amqp_v3_integration_test.go | 400 ++++++++ .../mq/amqp_v3_queue_full_integration_test.go | 129 +++ internal/mq/integration_test.go | 67 ++ internal/rpc/authorized_execution.go | 123 +++ internal/rpc/authorized_execution_test.go | 201 ++++ internal/rpc/dispatcher_events.go | 34 + .../rpc/dispatcher_events_local_v3_test.go | 115 +++ internal/rpc/dispatcher_upload.go | 48 +- .../rpc/dispatcher_upload_local_v3_test.go | 102 ++ internal/rpc/execution_journal.go | 24 +- internal/rpc/server.go | 64 +- internal/rpc/session_meta_test.go | 34 + internal/store/active_task_controls.go | 55 + internal/store/control_routes.go | 32 +- internal/store/control_routes_test.go | 36 + internal/store/local_call_terminal.go | 257 +++++ internal/store/local_call_terminal_test.go | 153 +++ .../store/local_grant_after_outcome_test.go | 29 + internal/store/local_origination.go | 164 +++ internal/store/local_origination_queue.go | 45 + internal/store/local_origination_recovery.go | 116 +++ .../store/local_origination_recovery_test.go | 51 + internal/store/local_origination_test.go | 192 ++++ .../store/local_recording_error_codes_test.go | 32 + .../store/local_recording_failure_fact.go | 94 ++ .../local_recording_failure_fact_test.go | 80 ++ internal/store/local_recording_outcome.go | 134 +++ .../store/local_recording_outcome_test.go | 85 ++ internal/store/local_refusal_terminal_test.go | 38 + internal/store/local_v01.go | 327 ++++++ internal/store/local_v01_admission_test.go | 209 ++++ internal/store/local_v01_config.go | 206 ++++ internal/store/local_v01_config_test.go | 97 ++ internal/store/local_v01_control.go | 299 ++++++ internal/store/local_v01_control_test.go | 138 +++ internal/store/local_v01_discovery.go | 464 +++++++++ internal/store/local_v01_discovery_test.go | 292 ++++++ internal/store/local_v01_test.go | 148 +++ internal/store/local_v01_verify.go | 66 ++ internal/store/local_v3_upload_state_test.go | 71 ++ .../014_local_v01_config_snapshots.sql | 29 + .../015_local_v02_task_discovery.sql | 39 + .../016_local_v02_origination_decisions.sql | 11 + .../017_local_v01_call_terminals.sql | 29 + internal/store/store.go | 121 ++- internal/store/upload_grants.go | 16 +- internal/store/uploads.go | 5 +- proto/ERRORS.md | 20 +- proto/README.md | 7 + proto/agent/v1/agent.proto | 28 + proto/manifest.json | 20 +- scripts/acceptance-local.sh | 52 +- scripts/check-contracts.sh | 1 + scripts/generate-local-contract-bundle.sh | 7 + scripts/mq-integration-local.sh | 17 +- scripts/mq-only-acceptance-local.sh | 36 - scripts/validate-local-contracts.py | 353 +++++++ 174 files changed, 18044 insertions(+), 1740 deletions(-) create mode 100644 cmd/sip-go-agent/agent_mock_originator.go create mode 100644 cmd/sip-go-agent/agent_mock_originator_test.go delete mode 100644 cmd/sip-go-agent/control_worker.go create mode 100644 cmd/sip-go-agent/upload_failure_recovery_test.go create mode 100644 contracts/local/v0.1/call-result-v0.1-proposal.schema.json create mode 100644 contracts/local/v0.1/command-next-v0.1-proposal.schema.json create mode 100644 contracts/local/v0.1/config-read-v0.1.schema.json create mode 100644 contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json create mode 100644 contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json create mode 100644 docs/contracts/call-result-v0.1-proposal.schema.json create mode 100644 docs/contracts/command-next-v0.1-proposal.schema.json create mode 100644 docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json create mode 100644 docs/contracts/examples/call-result-uploaded-v0.1.json create mode 100644 docs/contracts/examples/command-next-invalid-control-id-v0.1.json create mode 100644 docs/contracts/examples/config-read-http-statuses-v0.1.json create mode 100644 docs/contracts/examples/config-read-invalid-extra-property-v0.1.json create mode 100644 docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json create mode 100644 docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json create mode 100644 docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json create mode 100644 docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json create mode 100644 docs/contracts/examples/task-discovery-changes-v0.2.json create mode 100644 docs/contracts/examples/task-discovery-http-statuses-v0.1.json create mode 100644 docs/contracts/examples/task-discovery-http-statuses-v0.2.json create mode 100644 docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json create mode 100644 docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json create mode 100644 docs/contracts/examples/task-discovery-invalid-queue-v0.2.json create mode 100644 docs/contracts/examples/task-discovery-no-change-v0.2.json create mode 100644 docs/contracts/examples/task-discovery-snapshot-v0.1.json create mode 100644 docs/contracts/examples/task-discovery-snapshot-v0.2.json create mode 100644 docs/contracts/local-contract-manifest-v0.1.json create mode 100644 docs/contracts/local-contract-manifest-v0.2.json create mode 100644 docs/contracts/local-mock-recording-failure-manifest-v0.1.json create mode 100644 docs/contracts/local-mock-recording-failure-v0.1.md create mode 100644 docs/contracts/local-mock-recording-failure-v0.1.schema.json create mode 100644 docs/contracts/mq-topology-v0.1-proposal.json create mode 100644 docs/contracts/task-discovery-v0.1-proposal.schema.json create mode 100644 docs/contracts/task-discovery-v0.2-proposal.schema.json create mode 100644 docs/thirds/v0.2.md create mode 100644 internal/agent/mock_failure_report.go create mode 100644 internal/agent/upload_failure.go create mode 100644 internal/agent/upload_failure_report_test.go create mode 100644 internal/agent/upload_failure_state_test.go create mode 100644 internal/callwindow/policy.go create mode 100644 internal/callwindow/policy_test.go create mode 100644 internal/configread/client.go create mode 100644 internal/configread/client_test.go create mode 100644 internal/configread/control_status_test.go create mode 100644 internal/configread/discovery_v02_test.go create mode 100644 internal/contract/local_mock_recording_failure.go create mode 100644 internal/contract/local_mock_recording_failure_test.go create mode 100644 internal/dispatcher/agent_inbound_session_test.go delete mode 100644 internal/dispatcher/ai_mq_integration_test.go create mode 100644 internal/dispatcher/authorized_agent.go create mode 100644 internal/dispatcher/call_result_v01.go create mode 100644 internal/dispatcher/config_read.go delete mode 100644 internal/dispatcher/consumer.go delete mode 100644 internal/dispatcher/consumer_test.go delete mode 100644 internal/dispatcher/control_mq_integration_test.go delete mode 100644 internal/dispatcher/control_worker.go create mode 100644 internal/dispatcher/dial_policy.go create mode 100644 internal/dispatcher/dial_policy_test.go create mode 100644 internal/dispatcher/local_config_read_test.go create mode 100644 internal/dispatcher/local_mock_dispatcher_deadline.go create mode 100644 internal/dispatcher/local_mock_dispatcher_deadline_test.go create mode 100644 internal/dispatcher/local_mock_failure.go create mode 100644 internal/dispatcher/local_mock_final_event.go create mode 100644 internal/dispatcher/local_mock_pending_result_test.go create mode 100644 internal/dispatcher/local_mock_recovery.go create mode 100644 internal/dispatcher/local_mock_recovery_test.go create mode 100644 internal/dispatcher/local_mock_refusal.go create mode 100644 internal/dispatcher/local_mock_refusal_pending_test.go create mode 100644 internal/dispatcher/local_mock_refusal_test.go create mode 100644 internal/dispatcher/local_mock_result.go create mode 100644 internal/dispatcher/local_mock_upload.go create mode 100644 internal/dispatcher/local_origination.go create mode 100644 internal/dispatcher/local_origination_quota_test.go create mode 100644 internal/dispatcher/local_origination_rpc_test.go create mode 100644 internal/dispatcher/local_sqlite_full_test.go create mode 100644 internal/dispatcher/local_v01.go create mode 100644 internal/dispatcher/local_v01_integration_test.go create mode 100644 internal/dispatcher/local_v01_test.go delete mode 100644 internal/dispatcher/mq_integration_test.go create mode 100644 internal/dispatcher/outbox_payload_limit_test.go delete mode 100644 internal/dispatcher/query_mq_integration_test.go create mode 100644 internal/dispatcher/sip_applied_config_test.go create mode 100644 internal/dispatcher/task_control_v3.go create mode 100644 internal/dispatcher/task_control_v3_test.go create mode 100644 internal/dispatcher/task_controller.go create mode 100644 internal/dispatcher/task_queue_v3.go create mode 100644 internal/dispatcher/task_queue_v3_origination_test.go create mode 100644 internal/dispatcher/task_queue_v3_stop_active_test.go create mode 100644 internal/dispatcher/task_queue_v3_test.go create mode 100644 internal/dispatcher/task_runtime_v3.go create mode 100644 internal/dispatcher/task_runtime_v3_test.go create mode 100644 internal/mq/amqp_v3.go create mode 100644 internal/mq/amqp_v3_integration_test.go create mode 100644 internal/mq/amqp_v3_queue_full_integration_test.go create mode 100644 internal/rpc/authorized_execution.go create mode 100644 internal/rpc/authorized_execution_test.go create mode 100644 internal/rpc/dispatcher_events_local_v3_test.go create mode 100644 internal/rpc/dispatcher_upload_local_v3_test.go create mode 100644 internal/rpc/session_meta_test.go create mode 100644 internal/store/active_task_controls.go create mode 100644 internal/store/local_call_terminal.go create mode 100644 internal/store/local_call_terminal_test.go create mode 100644 internal/store/local_grant_after_outcome_test.go create mode 100644 internal/store/local_origination.go create mode 100644 internal/store/local_origination_queue.go create mode 100644 internal/store/local_origination_recovery.go create mode 100644 internal/store/local_origination_recovery_test.go create mode 100644 internal/store/local_origination_test.go create mode 100644 internal/store/local_recording_error_codes_test.go create mode 100644 internal/store/local_recording_failure_fact.go create mode 100644 internal/store/local_recording_failure_fact_test.go create mode 100644 internal/store/local_recording_outcome.go create mode 100644 internal/store/local_recording_outcome_test.go create mode 100644 internal/store/local_refusal_terminal_test.go create mode 100644 internal/store/local_v01.go create mode 100644 internal/store/local_v01_admission_test.go create mode 100644 internal/store/local_v01_config.go create mode 100644 internal/store/local_v01_config_test.go create mode 100644 internal/store/local_v01_control.go create mode 100644 internal/store/local_v01_control_test.go create mode 100644 internal/store/local_v01_discovery.go create mode 100644 internal/store/local_v01_discovery_test.go create mode 100644 internal/store/local_v01_test.go create mode 100644 internal/store/local_v01_verify.go create mode 100644 internal/store/local_v3_upload_state_test.go create mode 100644 internal/store/migrations/014_local_v01_config_snapshots.sql create mode 100644 internal/store/migrations/015_local_v02_task_discovery.sql create mode 100644 internal/store/migrations/016_local_v02_origination_decisions.sql create mode 100644 internal/store/migrations/017_local_v01_call_terminals.sql create mode 100644 scripts/generate-local-contract-bundle.sh delete mode 100755 scripts/mq-only-acceptance-local.sh create mode 100644 scripts/validate-local-contracts.py diff --git a/cmd/sip-go-agent/agent_mock_originator.go b/cmd/sip-go-agent/agent_mock_originator.go new file mode 100644 index 0000000..a76b7c2 --- /dev/null +++ b/cmd/sip-go-agent/agent_mock_originator.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "errors" + "log/slog" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +// mockAuthorizedOriginator is deliberately isolated from SIP and Asterisk. +// Mixed/real modes have no adapter until separately authorized and verified. +func mockAuthorizedOriginator(mode string) func(context.Context, *agentv1.ExecuteAuthorizedRequest) error { + if mode != "mock" { + return nil + } + return func(ctx context.Context, request *agentv1.ExecuteAuthorizedRequest) error { + if err := ctx.Err(); err != nil { + return err + } + if request == nil || request.Binding == nil || request.Binding.ExecutionId == "" { + return errors.New("mock authorized origination has no execution identity") + } + slog.Info("isolated Mock Agent simulated authorized originate; no SIP sent", + "execution_id", request.Binding.ExecutionId, "trunk_id", request.SelectedTrunkId) + return nil + } +} diff --git a/cmd/sip-go-agent/agent_mock_originator_test.go b/cmd/sip-go-agent/agent_mock_originator_test.go new file mode 100644 index 0000000..59f2650 --- /dev/null +++ b/cmd/sip-go-agent/agent_mock_originator_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "testing" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func TestAuthorizedOriginatorIsExplicitlyMockOnly(t *testing.T) { + if mockAuthorizedOriginator("mixed") != nil || mockAuthorizedOriginator("real") != nil || mockAuthorizedOriginator("") != nil { + t.Fatal("non-mock mode exposed an authorized mock originator") + } + originator := mockAuthorizedOriginator("mock") + if originator == nil { + t.Fatal("mock mode has no isolated originator") + } + request := &agentv1.ExecuteAuthorizedRequest{Binding: &agentv1.ExecutionBinding{ExecutionId: "execution-1"}, SelectedTrunkId: "trunk-1"} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := originator(ctx, request); err == nil { + t.Fatal("cancelled mock invocation appeared to complete") + } + if err := originator(context.Background(), request); err != nil { + t.Fatalf("isolated mock invocation: %v", err) + } +} diff --git a/cmd/sip-go-agent/control_worker.go b/cmd/sip-go-agent/control_worker.go deleted file mode 100644 index c2913a8..0000000 --- a/cmd/sip-go-agent/control_worker.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "time" - - "git.ipao.vip/rogee/go-sip/internal/dispatcher" -) - -func runDispatcherControls(ctx context.Context, d *dispatcher.Dispatcher, controller dispatcher.TaskController, batch int) { - ticker := time.NewTicker(dispatcherOutboxFlushInterval) - defer ticker.Stop() - for { - if ctx.Err() != nil { - return - } - if _, err := d.ProcessTaskControls(ctx, controller, batch); err != nil && ctx.Err() == nil { - slog.Error("process Dispatcher task controls", "error", err) - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} diff --git a/cmd/sip-go-agent/main.go b/cmd/sip-go-agent/main.go index 950546d..12de036 100644 --- a/cmd/sip-go-agent/main.go +++ b/cmd/sip-go-agent/main.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net" + "net/http" "os" "os/signal" "path/filepath" @@ -22,6 +23,7 @@ import ( "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/configread" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/dispatcher" "git.ipao.vip/rogee/go-sip/internal/health" @@ -376,11 +378,12 @@ func serveAgentRPC(cfg config.Config, spool *agent.Spool, report agent.RecoveryR PeerCertificateFingerprints: peerCertificateFingerprints, StatePath: filepath.Join(cfg.SpoolRoot, "rpc-session.json"), CallLogger: callLogger, + MockAuthorizedOriginate: mockAuthorizedOriginator(cfg.Mode), }) agentv1.RegisterAgentControlServiceServer(grpcServer, handler) serveCtx, cancel := signalContext() defer cancel() - stopUploadRecovery, err := startUploadNotificationRecovery(serveCtx, cfg, spool) + stopUploadRecovery, err := startUploadNotificationRecovery(serveCtx, cfg, spool, handler.ActiveSessionMeta) if err != nil { return err } @@ -451,34 +454,6 @@ func (r *connectedAgentRuntime) Close() error { 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 openDispatcherStore(cfg config.Config) (*store.Store, error) { st, err := store.Open(cfg.DBPath) if err != nil { @@ -497,8 +472,6 @@ func newDispatcherCommand() *cobra.Command { cfg := config.FromEnv() var configFile string var once bool - var consume bool - var tenantKey string cmd := &cobra.Command{ Use: "dispatcher", Short: "run the single-active Dispatcher process", @@ -509,21 +482,18 @@ func newDispatcherCommand() *cobra.Command { if err := cfg.Validate("dispatcher"); err != nil { return err } + if cfg.Mode != "mock" && !once { + return errors.New("Dispatcher V3 authorized execution and upload are isolated Mock only; mixed/real requires separate approval and implementation") + } var publisher mq.Publisher - var broker *mq.Broker + var broker *mq.V3Broker if cfg.RabbitURL != "" { var err error - broker, err = mq.Open(cfg.RabbitURL, cfg.DispatcherID) + broker, err = mq.OpenV3(cfg.RabbitURL, cfg.DispatcherID, 1) if err != nil { return err } defer broker.Close() - if tenantKey == "" { - return errors.New("--tenant-key is required with RabbitMQ") - } - if _, err := broker.DeclareTenantQueue(tenantKey); err != nil { - return err - } publisher = broker } st, err := openDispatcherStore(cfg) @@ -558,7 +528,7 @@ func newDispatcherCommand() *cobra.Command { } }() } - d, err := dispatcher.New(st, publisher, nil) + d, err := dispatcher.NewV3(cfg.DispatcherID, st, publisher, time.Now) if err != nil { return err } @@ -581,8 +551,26 @@ func newDispatcherCommand() *cobra.Command { return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled") } allowedAgentIDs := parseCSVSet(cfg.DispatcherGRPCAllowedAgentIDs) + authorizeAgentSession := func(meta *agentv1.RequestMeta) error { + if connectedAgents == nil || connectedAgents.coordinator == nil { + return fmt.Errorf("no activated Agent coordinator: %w", store.ErrCommandConflict) + } + return connectedAgents.coordinator.AuthorizeInboundMeta(meta) + } uploadHandler, handlerErr := rpc.NewDispatcherUploadServerWithOptions(st, uploadClient, time.Now, rpc.DispatcherUploadOptions{ RequirePeer: true, PeerCertificateFingerprints: peerFingerprints, AllowedAgentIDs: allowedAgentIDs, + LocalV3Authorize: func(ctx context.Context, req *agentv1.RequestUploadRequest) error { + if err := authorizeAgentSession(req.Meta); err != nil { + return err + } + return d.AuthorizeLocalMockUpload(ctx, req) + }, + LocalV3Complete: func(ctx context.Context, req *agentv1.CompleteUploadRequest, record store.UploadRecord) (bool, error) { + if err := authorizeAgentSession(req.Meta); err != nil { + return false, err + } + return d.CompleteLocalMockRecording(ctx, req, record) + }, }) if handlerErr != nil { return handlerErr @@ -590,6 +578,8 @@ func newDispatcherCommand() *cobra.Command { eventHandler, eventErr := rpc.NewDispatcherEventServer(st, rpc.DispatcherEventServerOptions{ RequirePeer: true, PeerCertificateFingerprints: peerFingerprints, AllowedAgentIDs: allowedAgentIDs, Now: time.Now, + LocalV3RecordingFailure: d.RecordLocalMockRecordingFailure, + LocalV3SessionCheck: authorizeAgentSession, }) if eventErr != nil { return eventErr @@ -634,55 +624,45 @@ func newDispatcherCommand() *cobra.Command { } result["agent_sessions"] = sessions } - if once && consume { - return errors.New("--once cannot be combined with --consume") - } - if publisher != nil && !once && (consume || dispatcherGRPC != nil) { - 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 connectedAgents != nil && !once && (consume || dispatcherGRPC != nil) { - controlCtx, cancelControl := context.WithCancel(leaseCtx) - controlDone := make(chan struct{}) - go func() { - defer close(controlDone) - runDispatcherControls(controlCtx, d, connectedAgents.coordinator, cfg.OutboxBatch) - }() - defer func() { cancelControl(); <-controlDone }() - } - if consume { - if broker == nil || tenantKey == "" { - return errors.New("--consume requires --rabbit-url and --tenant-key") - } - if err := d.ConsumeTenant(leaseCtx, broker, tenantKey); err != nil && !errors.Is(err, context.Canceled) { - return err - } - if errors.Is(context.Cause(leaseCtx), errDispatcherIdentityLost) { - return context.Cause(leaseCtx) - } - return nil - } if once { if publisher == nil { - return errors.New("--once requires RABBITMQ_URL or a configured publisher") + return errors.New("--once requires RABBITMQ_URL") } count, err := d.FlushOutbox(cmd.Context(), cfg.OutboxBatch) if err != nil { return err } result["published"] = count - } - if dispatcherGRPC == nil { return writeResult(result) } + if broker == nil { + return errors.New("RABBITMQ_URL is required for the Dispatcher V3 runtime") + } + if connectedAgents == nil || len(connectedAgents.sessions) != 1 { + return errors.New("the Dispatcher V3 runtime requires exactly one configured Agent") + } + configClient, err := configread.NewClient(os.Getenv("DISPATCHER_CONFIG_READ_BASE_URL"), cfg.DispatcherID, + os.Getenv("DISPATCHER_SECRET_KEY"), &http.Client{Timeout: 15 * time.Second}) + if err != nil { + return fmt.Errorf("configure Dispatcher config-read client: %w", err) + } + session := connectedAgents.sessions[0] + verifier := &dispatcher.AgentSIPConfigVerifier{ + Probe: connectedAgents.coordinator, AgentID: session.AgentID, CellID: session.CellID, + } + runtime, err := dispatcher.NewLocalV01Runtime(d, broker, configClient, verifier, connectedAgents.coordinator) + if err != nil { + return err + } + if err := runtime.EnableMockAuthorizedOrigination(session.AgentID, connectedAgents.coordinator); err != nil { + return fmt.Errorf("enable isolated Mock Agent execution: %w", err) + } + result["queue_protocol"] = "v3" + if err := writeResult(result); err != nil { + return err + } + runtimeDone := make(chan error, 1) + go func() { runtimeDone <- runtime.Run(leaseCtx) }() select { case <-leaseCtx.Done(): if errors.Is(context.Cause(leaseCtx), errDispatcherIdentityLost) { @@ -691,6 +671,14 @@ func newDispatcherCommand() *cobra.Command { return nil case err := <-lease.Lost(): return fmt.Errorf("dispatcher lease lost: %w", err) + case err := <-runtimeDone: + if err != nil { + return err + } + if leaseCtx.Err() != nil { + return nil + } + return errors.New("Dispatcher V3 runtime stopped unexpectedly") } }, } @@ -700,8 +688,6 @@ func newDispatcherCommand() *cobra.Command { cmd.Flags().StringVar(&cfg.RabbitURL, "rabbit-url", cfg.RabbitURL, "RabbitMQ URL") cmd.Flags().IntVar(&cfg.OutboxBatch, "outbox-batch", cfg.OutboxBatch, "maximum outbox messages per run") 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 } diff --git a/cmd/sip-go-agent/main_test.go b/cmd/sip-go-agent/main_test.go index 0fc31d1..7b5e724 100644 --- a/cmd/sip-go-agent/main_test.go +++ b/cmd/sip-go-agent/main_test.go @@ -2,15 +2,10 @@ 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" - "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestRootHasExplicitRoles(t *testing.T) { @@ -76,44 +71,3 @@ func TestBuildCallEndpointUsesArtifactRoute(t *testing.T) { 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() - if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { - t.Fatal(err) - } - - d, err := dispatcher.New(st, recordingPublisher{}, time.Now) - if err != nil { - t.Fatal(err) - } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); 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") -} diff --git a/cmd/sip-go-agent/upload_failure_recovery_test.go b/cmd/sip-go-agent/upload_failure_recovery_test.go new file mode 100644 index 0000000..55c1049 --- /dev/null +++ b/cmd/sip-go-agent/upload_failure_recovery_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "context" + "errors" + "net/http" + "testing" + + 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/config" + "github.com/google/uuid" +) + +type mockFailureRecoveryClient struct { + reports int + fail bool +} + +func (c *mockFailureRecoveryClient) ReportExecutionEvent(_ context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + c.reports++ + if c.fail { + return nil, errors.New("Dispatcher unavailable") + } + return &agentv1.ReportExecutionEventResponse{Receipt: &agentv1.OperationReceipt{ + Result: agentv1.ResultCode_RESULT_CODE_ACCEPTED, FactId: req.Fact.FactId, ContentSha256: req.Fact.ContentSha256, + }}, nil +} + +func TestMockFailureRecoveryWaitsForActivationAndRejectsRealMode(t *testing.T) { + spool, err := agent.NewSpool(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + active := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", + DispatcherEpoch: "epoch-a", SessionGeneration: 1, + } + remote := &mockFailureRecoveryClient{fail: true} + claimAndFail := func(id string) { + t.Helper() + if err := spool.ClaimUpload(agent.UploadAttempt{ + UploadID: id, Identity: "identity-" + id, State: "attempted", RequestID: uuid.NewString(), + Binding: &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-" + id}, + Asset: &agentv1.AssetDescriptor{AssetId: "recording-" + id}, + }); err != nil { + t.Fatal(err) + } + known, err := spool.ReportMockUploadFailure(context.Background(), remote, active, id, &agent.UploadHTTPError{StatusCode: http.StatusForbidden}) + if !known || err == nil { + t.Fatalf("failed Mock PUT was not retained for recovery: known=%t err=%v", known, err) + } + } + claimAndFail("upload-a") + cfg := config.Config{Mode: "mock"} + missingSession := func() (*agentv1.RequestMeta, error) { return nil, errors.New("not activated") } + if err := recoverMockFailureNotifications(context.Background(), cfg, remote, spool, missingSession); err == nil || remote.reports != 1 { + t.Fatalf("unactivated Agent reported a durable fact: reports=%d err=%v", remote.reports, err) + } + remote.fail = false + currentSession := func() (*agentv1.RequestMeta, error) { return active, nil } + if err := recoverMockFailureNotifications(context.Background(), cfg, remote, spool, currentSession); err != nil || remote.reports != 2 { + t.Fatalf("Mock failure was not recovered through the active session: reports=%d err=%v", remote.reports, err) + } + pending, err := spool.PendingUploadFailures() + if err != nil || len(pending) != 0 { + t.Fatalf("acknowledged failure was still pending: count=%d err=%v", len(pending), err) + } + remote.fail = true + claimAndFail("upload-b") + cfg.Mode = "real" + if err := recoverMockFailureNotifications(context.Background(), cfg, remote, spool, currentSession); err == nil || remote.reports != 3 { + t.Fatalf("Mock failure escaped into real mode: reports=%d err=%v", remote.reports, err) + } +} diff --git a/cmd/sip-go-agent/upload_recovery.go b/cmd/sip-go-agent/upload_recovery.go index 7c81feb..64614b2 100644 --- a/cmd/sip-go-agent/upload_recovery.go +++ b/cmd/sip-go-agent/upload_recovery.go @@ -7,6 +7,7 @@ import ( "log/slog" "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/config" "git.ipao.vip/rogee/go-sip/internal/rpc" @@ -26,16 +27,44 @@ func recoverUploadNotifications(ctx context.Context, cfg config.Config, client r return errors.Join(failures...) } +// recoverMockFailureNotifications never treats a prior boot's persisted fact +// as a current session. The report uses metadata from the newly activated +// session, while preserving the original immutable fact identity. +func recoverMockFailureNotifications(ctx context.Context, cfg config.Config, client agent.MockFailureFactClient, spool *agent.Spool, activeMeta func() (*agentv1.RequestMeta, error)) error { + pending, err := spool.PendingUploadFailures() + if err != nil || len(pending) == 0 { + return err + } + if cfg.Mode != "mock" { + return fmt.Errorf("pending Mock recording failure cannot be reported in %q mode", cfg.Mode) + } + if activeMeta == nil { + return errors.New("pending Mock recording failure requires an active Agent session") + } + meta, err := activeMeta() + if err != nil { + return fmt.Errorf("load active Agent session for Mock recording failure: %w", err) + } + return spool.RecoverMockUploadFailures(ctx, client, meta) +} + // startUploadNotificationRecovery never requests a token or opens a source file. -// The worker owns only the durable metadata-to-Dispatcher notification path. -func startUploadNotificationRecovery(ctx context.Context, cfg config.Config, spool *agent.Spool) (func(), error) { +// The worker owns only durable metadata-to-Dispatcher notifications. +func startUploadNotificationRecovery(ctx context.Context, cfg config.Config, spool *agent.Spool, activeMeta func() (*agentv1.RequestMeta, error)) (func(), error) { pending, err := spool.PendingUploadNotifications() if err != nil { return nil, fmt.Errorf("read upload recovery journal: %w", err) } + failureFacts, err := spool.PendingUploadFailures() + if err != nil { + return nil, fmt.Errorf("read Mock failure recovery journal: %w", err) + } + if len(failureFacts) > 0 && cfg.Mode != "mock" { + return nil, errors.New("pending Mock recording failure cannot start in mixed/real mode") + } if cfg.DispatcherGRPCEndpoint == "" { - if len(pending) > 0 { - return nil, errors.New("pending upload notifications require Dispatcher gRPC endpoint") + if len(pending) > 0 || len(failureFacts) > 0 { + return nil, errors.New("pending upload facts require Dispatcher gRPC endpoint") } return func() {}, nil } @@ -51,7 +80,10 @@ func startUploadNotificationRecovery(ctx context.Context, cfg config.Config, spo defer ticker.Stop() for { attemptCtx, finish := context.WithTimeout(workerCtx, 10*time.Second) - err := recoverUploadNotifications(attemptCtx, cfg, client, spool) + err := errors.Join( + recoverUploadNotifications(attemptCtx, cfg, client, spool), + recoverMockFailureNotifications(attemptCtx, cfg, client, spool, activeMeta), + ) finish() if err != nil && workerCtx.Err() == nil { slog.Error("upload notification recovery failed; original facts retained", "error", err) diff --git a/contracts/contracts.go b/contracts/contracts.go index d474643..bae4b10 100644 --- a/contracts/contracts.go +++ b/contracts/contracts.go @@ -5,17 +5,24 @@ import ( "encoding/json" "fmt" "path" + "strings" ) -// Files is the immutable contract bundle imported from the parent repository. -// The bundle is copied from a recorded parent commit; runtime code never reads -// the parent worktree. +// Files contains pinned upstream schemas and the active local contract versions. +// Runtime code never reads a checkout or resolves schema refs online. // -//go:embed upstream +//go:embed upstream local/v0.1 local/v0.2 var Files embed.FS const SourceCommit = "v1" +func ReadLocal(version, name string) ([]byte, error) { + if (version != "v0.1" && version != "v0.2") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { + return nil, fmt.Errorf("invalid project-local schema %q/%q", version, name) + } + return Files.ReadFile(path.Join("local", version, name)) +} + func Read(name string) ([]byte, error) { return Files.ReadFile(path.Join("upstream", SourceCommit, name)) } diff --git a/contracts/local/v0.1/call-result-v0.1-proposal.schema.json b/contracts/local/v0.1/call-result-v0.1-proposal.schema.json new file mode 100644 index 0000000..52cfba0 --- /dev/null +++ b/contracts/local/v0.1/call-result-v0.1-proposal.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/call-result-v0.1-proposal.schema.json", + "title": "Project-local contract: single final call.result event; external compatibility unverified", + "$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Runtime caps each serialized UTF-8 JSON body at 8,388,608 bytes, without compression, truncation, or event splitting. Expected recording finalizes no later than call.ended_at + 15m; at most one PUT per upload_id and one immutable call.result event.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"], + "properties": { + "schema_version": {"const": "call-result.v0.1-proposal"}, + "event_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "event_type": {"const": "call.result"}, + "dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"}, + "tenant_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"}, + "trace_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "occurred_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "aggregate_type": {"const": "call"}, + "aggregate_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "aggregate_version": {"type": "integer", "minimum": 1}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["source_command_id", "execution_id", "call_id", "task_id", "task_revision", "agent_version_id", "route_policy_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "transcript", "opt_out", "recording"], + "properties": { + "source_command_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "execution_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "call_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "task_id": {"$ref": "#/$defs/task_id"}, + "task_revision": {"type": "integer", "minimum": 1}, + "agent_version_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "route_policy_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "caller_profile_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "callee": {"type": "string", "minLength": 1}, + "trunk_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "started_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "ended_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "duration_ms": {"type": "integer", "minimum": 0}, + "outcome": {"type": "string", "minLength": 1}, + "reason_code": {"type": ["string", "null"], "minLength": 1}, + "transcript": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"], + "properties": { + "turn_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "segment_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "role": {"type": "string", "minLength": 1}, + "text": {"type": "string"}, + "start_ms": {"type": "integer", "minimum": 0}, + "end_ms": {"type": "integer", "minimum": 0} + } + } + }, + "opt_out": {"type": "boolean"}, + "recording": { + "oneOf": [ + {"$ref": "#/$defs/recording_uploaded"}, + {"$ref": "#/$defs/recording_unavailable"}, + {"$ref": "#/$defs/recording_not_created"} + ] + } + }, + "$comment": "aggregate_id must equal payload.call_id; timestamps and duration must agree. Runtime enforces the 8 MiB serialized-body budget. If too large, retain the exact result in durable outbox and expose blocked_payload_too_large; never truncate, split, or discard it." + } + }, + "$defs": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "recording_asset_fields": { + "type": "object", + "required": ["recording_id", "upload_id", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"], + "properties": { + "recording_id": {"type": ["string", "null"], "minLength": 1}, + "upload_id": {"type": ["string", "null"], "minLength": 1}, + "bucket": {"type": ["string", "null"], "minLength": 1}, + "object_key": {"type": ["string", "null"], "minLength": 1}, + "format": {"type": ["string", "null"], "minLength": 1}, + "channels": {"type": ["integer", "null"], "minimum": 1}, + "sample_rate_hz": {"type": ["integer", "null"], "minimum": 1}, + "duration_ms": {"type": ["integer", "null"], "minimum": 0}, + "size_bytes": {"type": ["integer", "null"], "minimum": 0}, + "checksum_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"} + } + }, + "recording_uploaded": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status"], + "properties": { + "status": {"const": "uploaded"}, + "recording_id": {"type": "string", "minLength": 1}, + "upload_id": {"type": "string", "minLength": 1}, + "bucket": {"type": "string", "minLength": 1}, + "object_key": {"type": "string", "minLength": 1}, + "format": {"type": "string", "minLength": 1}, + "channels": {"type": "integer", "minimum": 1}, + "sample_rate_hz": {"type": "integer", "minimum": 1}, + "duration_ms": {"type": "integer", "minimum": 0}, + "size_bytes": {"type": "integer", "minimum": 0}, + "checksum_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } + } + ], + "unevaluatedProperties": false + }, + "recording_unavailable": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status", "error_code"], + "properties": { + "status": {"const": "unavailable"}, + "error_code": {"enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "upload_timeout", "deadline_exceeded", "checksum_mismatch"]}, + "recording_id": {"type": "string", "minLength": 1}, + "upload_id": {"type": "string", "minLength": 1}, + "bucket": {"type": "null"}, + "object_key": {"type": "null"}, + "format": {"type": "string", "minLength": 1}, + "channels": {"type": "integer", "minimum": 1}, + "sample_rate_hz": {"type": "integer", "minimum": 1}, + "duration_ms": {"type": "integer", "minimum": 0}, + "size_bytes": {"type": "null"}, + "checksum_sha256": {"type": "null"} + } + } + ], + "unevaluatedProperties": false + }, + "recording_not_created": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status", "reason_code"], + "properties": { + "status": {"const": "not_created"}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "recording_id": {"type": "null"}, + "upload_id": {"type": "null"}, + "bucket": {"type": "null"}, + "object_key": {"type": "null"}, + "format": {"type": "null"}, + "channels": {"type": "null"}, + "sample_rate_hz": {"type": "null"}, + "duration_ms": {"type": "null"}, + "size_bytes": {"type": "null"}, + "checksum_sha256": {"type": "null"} + } + } + ], + "unevaluatedProperties": false + } + } +} diff --git a/contracts/local/v0.1/command-next-v0.1-proposal.schema.json b/contracts/local/v0.1/command-next-v0.1-proposal.schema.json new file mode 100644 index 0000000..f3549a4 --- /dev/null +++ b/contracts/local/v0.1/command-next-v0.1-proposal.schema.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/command-next-v0.1-proposal.schema.json", + "title": "Project-local contract: SaaS command and command-result messages; external compatibility unverified", + "$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Serialized UTF-8 JSON bodies are capped at 8,388,608 bytes by runtime validation.", + "oneOf": [ + {"$ref": "#/$defs/call_execute"}, + {"$ref": "#/$defs/control_pause"}, + {"$ref": "#/$defs/control_resume"}, + {"$ref": "#/$defs/control_stop"}, + {"$ref": "#/$defs/command_result_call"}, + {"$ref": "#/$defs/command_result_control"} + ], + "$defs": { + "id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"}, + "time": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "command_base": { + "type": "object", + "required": ["schema_version", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after"], + "properties": { + "schema_version": {"const": "command-next.v0.1-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"$ref": "#/$defs/tenant_key"}, + "trace_id": {"$ref": "#/$defs/id"}, + "issued_at": {"$ref": "#/$defs/time"}, + "not_after": {"$ref": "#/$defs/time"} + } + }, + "call_execute": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_id", "command_type", "payload"], + "properties": { + "command_id": {"$ref": "#/$defs/id"}, + "command_type": {"const": "call.execute"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "callee"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "callee": {"type": "string", "minLength": 1} + } + } + } + } + ], + "unevaluatedProperties": false, + "$comment": "Check not_after against current time and bind the task/tenant snapshot before originate; not_after must be later than issued_at." + }, + "control_pause": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "active_call_policy", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "pause"}, + "active_call_policy": {"enum": ["drain", "hangup"]}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "control_resume": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "resume"}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "control_stop": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "active_call_policy", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "stop"}, + "active_call_policy": {"enum": ["drain", "hangup"]}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "event_base": { + "type": "object", + "required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version"], + "properties": { + "schema_version": {"const": "command-next.v0.1-proposal"}, + "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"const": "command.result"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"$ref": "#/$defs/tenant_key"}, + "trace_id": {"$ref": "#/$defs/id"}, + "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "task"]}, + "aggregate_id": {"$ref": "#/$defs/id"}, + "aggregate_version": {"type": "integer", "minimum": 1} + } + }, + "command_result_call": { + "allOf": [ + {"$ref": "#/$defs/event_base"}, + { + "type": "object", + "required": ["aggregate_type", "payload"], + "properties": { + "aggregate_type": {"const": "command"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["command_id", "command_type", "status", "reason_code"], + "properties": { + "command_id": {"$ref": "#/$defs/id"}, + "command_type": {"const": "call.execute"}, + "status": {"enum": ["accepted", "rejected"]}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "execution_id": {"$ref": "#/$defs/id"} + }, + "allOf": [ + { + "if": {"properties": {"status": {"const": "accepted"}}}, + "then": {"required": ["execution_id"]} + } + ] + } + } + } + ], + "unevaluatedProperties": false + }, + "command_result_control": { + "allOf": [ + {"$ref": "#/$defs/event_base"}, + { + "type": "object", + "required": ["aggregate_type", "payload"], + "properties": { + "aggregate_type": {"const": "task"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["command_type", "task_id", "action", "status", "reason_code", "task_state"], + "properties": { + "command_type": {"const": "task.control"}, + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"enum": ["pause", "resume", "stop"]}, + "status": {"enum": ["applied", "rejected"]}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "task_state": {"enum": ["running", "paused", "stopped", "finished"]} + }, + "allOf": [ + {"if": {"properties": {"action": {"const": "pause"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "paused"}}}}, + {"if": {"properties": {"action": {"const": "resume"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "running"}}}}, + {"if": {"properties": {"action": {"const": "stop"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "stopped"}}}} + ] + } + } + } + ], + "unevaluatedProperties": false, + "$comment": "No control command_id or request dedupe identity exists. event_id identifies a receipt event, not a control command." + } + } +} diff --git a/contracts/local/v0.1/config-read-v0.1.schema.json b/contracts/local/v0.1/config-read-v0.1.schema.json new file mode 100644 index 0000000..e0f239c --- /dev/null +++ b/contracts/local/v0.1/config-read-v0.1.schema.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/config-read-v0.1.schema.json", + "title": "Project-local F01 contract: read-only configuration responses; external SaaS compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/sip_response"}, + {"$ref": "#/$defs/task_response"}, + {"$ref": "#/$defs/tenant_quota_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "sip_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "revision", "snapshot_sha256", "approved_at", "artifact", "trunk_details"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "sip_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "revision": {"type": "integer", "minimum": 1}, + "snapshot_sha256": {"$ref": "#/$defs/sha256"}, + "approved_at": {"type": "string", "format": "date-time"}, + "artifact": {"$ref": "https://go-sip.local/contracts/v1/static-cell-artifact.schema.json"}, + "trunk_details": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": {"$ref": "#/$defs/trunk_details"} + } + } + }, + "task_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "task_id", "task_revision", "status", "name", "group_id", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "caller_profile_id", "allowed_trunk_ids", "schedule", "agent"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "task_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Validate <=196 UTF-8 bytes in business logic; preserve the original value and do not place tenant_key in queue/routing names."}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "task_revision": {"type": "integer", "minimum": 1}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "group_id": {"type": ["string", "null"], "maxLength": 128}, + "max_concurrent_calls": {"type": "integer", "minimum": 1}, + "ring_timeout_ms": {"type": "integer", "minimum": 1}, + "max_call_duration_ms": {"type": "integer", "minimum": 1}, + "route_policy_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "allowed_trunk_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "schedule": {"$ref": "#/$defs/task_schedule"}, + "agent": {"$ref": "#/$defs/agent"} + } + }, + "tenant_quota_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "quota_revision", "max_concurrent_calls", "valid_until"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "tenant_quota"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}, + "quota_revision": {"type": "integer", "minimum": 1}, + "max_concurrent_calls": {"type": "integer", "minimum": 0}, + "valid_until": {"type": "string", "format": "date-time"} + }, + "$comment": "Project-local response: assigned share for this Dispatcher, aggregated across all tasks of the tenant. Validate tenant_id/tenant_key mapping and valid_until in business logic." + }, + "error_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "error"}, + "error": { + "type": "object", "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_request", "unauthorized", "dispatcher_not_authorized", "resource_not_found", "tenant_quota_unavailable", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + }, + "dispatcher_id": { + "type": "string", "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "trunk_details": { + "type": "object", "additionalProperties": false, + "required": ["trunk_id", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_profiles", "schedule"], + "properties": { + "trunk_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "server_host": {"type": "string", "minLength": 1, "maxLength": 255}, + "server_port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "transport": {"enum": ["udp", "tcp", "tls", null]}, + "auth_mode": {"enum": ["ip", "digest", "none", null]}, + "registration_required": {"type": ["boolean", "null"]}, + "max_concurrent_calls": {"type": ["integer", "null"], "minimum": 1}, + "caller_profiles": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { + "type": "object", "additionalProperties": false, + "required": ["caller_profile_id", "caller_id"], + "properties": { + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_id": {"type": "string", "minLength": 1, "maxLength": 64} + } + } + }, + "schedule": {"$ref": "#/$defs/weekly_schedule"} + } + }, + "agent": { + "type": "object", "additionalProperties": false, + "required": ["agent_version_id", "authorization_id", "authorization_expires_at", "config"], + "properties": { + "agent_version_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_expires_at": {"type": "string", "format": "date-time"}, + "config": {"$ref": "https://go-sip.local/contracts/v1/ai-config.schema.json"} + } + }, + "task_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "starts_at", "ends_at", "weekly_windows", "excluded_dates"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "starts_at": {"type": ["string", "null"], "format": "date-time"}, + "ends_at": {"type": ["string", "null"], "format": "date-time"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"}, + "excluded_dates": { + "type": "array", "uniqueItems": true, + "items": {"type": "string", "format": "date"} + } + } + }, + "weekly_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "weekly_windows"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"} + } + }, + "weekly_windows": { + "type": "object", "additionalProperties": false, + "required": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], + "properties": { + "monday": {"$ref": "#/$defs/windows"}, + "tuesday": {"$ref": "#/$defs/windows"}, + "wednesday": {"$ref": "#/$defs/windows"}, + "thursday": {"$ref": "#/$defs/windows"}, + "friday": {"$ref": "#/$defs/windows"}, + "saturday": {"$ref": "#/$defs/windows"}, + "sunday": {"$ref": "#/$defs/windows"} + } + }, + "windows": {"type": "array", "items": {"$ref": "#/$defs/window"}, "$comment": "Each window is left-closed/right-open, start < end, and windows within a day must not overlap. Cross-midnight windows are split across two weekdays; 24:00 is allowed only as end."}, + "window": { + "type": "object", "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": {"type": "string", "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}, + "end": {"type": "string", "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"} + } + } + } +} diff --git a/contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json b/contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json new file mode 100644 index 0000000..1ef49a1 --- /dev/null +++ b/contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/local-mock-recording-failure-v0.1.schema.json", + "title": "Local Mock recording failure fact v0.1 (project proposal only)", + "description": "Payload of AgentControl.ReportExecutionEvent FACT_KIND_RECORDING_PROGRESS for an explicitly failed Mock recording upload. Maximum encoded payload: 4096 bytes. Not a SaaS event or mixed/real contract.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "upload_id", "recording_id", "error_code"], + "properties": { + "schema_version": {"const": "local-mock-recording-failure.v0.1"}, + "upload_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"}, + "recording_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"}, + "error_code": { + "type": "string", + "enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "checksum_mismatch"] + } + } +} diff --git a/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json b/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json new file mode 100644 index 0000000..88b9dff --- /dev/null +++ b/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.2-proposal.schema.json", + "title": "Project-local single-response Dispatcher task discovery; external compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/snapshot_response"}, + {"$ref": "#/$defs/changes_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "dispatcher_id": { + "type": "string", "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "cursor": { + "type": "string", "minLength": 1, "maxLength": 512, + "$comment": "Opaque SaaS change watermark; never compare numerically or derive from task_id." + }, + "task": { + "type": "object", "additionalProperties": false, + "required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"], + "properties": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, + "$comment": "Also enforce the UTF-8 byte limit and tenant_id mapping in business logic."}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + "change": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, + "required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key", "status", "task_revision"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"enum": ["assigned", "updated"]}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"const": "removed"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196} + } + } + ] + }, + "snapshot_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "cursor", "tasks"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "cursor": {"$ref": "#/$defs/cursor"}, + "tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}} + }, + "$comment": "No pagination or queue address in the body. Persist the entire consistent response before advancing the cursor." + }, + "changes_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "next_cursor", "changes"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "next_cursor": {"$ref": "#/$defs/cursor"}, + "changes": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/change"}} + }, + "$comment": "No change: changes=[] and next_cursor=request after. An unrepresentable complete change set requires HTTP 410 cursor_expired, never a partial 200." + }, + "error_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "resource": {"const": "error"}, + "error": { + "type": "object", "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_cursor", "cursor_expired", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + } + } +} diff --git a/deploys/env/dispatcher.env.example b/deploys/env/dispatcher.env.example index 35831a1..eab2868 100644 --- a/deploys/env/dispatcher.env.example +++ b/deploys/env/dispatcher.env.example @@ -3,7 +3,9 @@ # its example dispatcher_id with this installation's unique stable UUID v4. SIP_GO_AGENT_MODE=real DISPATCHER_DB=/var/lib/sip-go-agent/dispatcher/dispatcher.db -DISPATCHER_TENANT_KEY= +# Required by the current V3 runtime; inject approved values out of band. +# DISPATCHER_CONFIG_READ_BASE_URL= +# DISPATCHER_SECRET_KEY= # RABBITMQ_URL= DISPATCHER_AGENT_ENDPOINTS_FILE=/etc/sip-go-agent/agent-endpoints.json MTLS_CA_FILE=/etc/sip-go-agent/pki/ca.pem diff --git a/deploys/systemd/sip-go-agent-dispatcher.service b/deploys/systemd/sip-go-agent-dispatcher.service index 3e24b65..13f0893 100644 --- a/deploys/systemd/sip-go-agent-dispatcher.service +++ b/deploys/systemd/sip-go-agent-dispatcher.service @@ -9,7 +9,7 @@ User=rogee Group=rogee WorkingDirectory=/opt/sip-go-agent/current EnvironmentFile=/etc/sip-go-agent/dispatcher.env -ExecStart=/opt/sip-go-agent/current/sip-go-agent dispatcher --config /etc/sip-go-agent/dispatcher.json --consume --tenant-key ${DISPATCHER_TENANT_KEY} +ExecStart=/opt/sip-go-agent/current/sip-go-agent dispatcher --config /etc/sip-go-agent/dispatcher.json Restart=on-failure RestartSec=5s NoNewPrivileges=yes diff --git a/docs/contracts/call-result-v0.1-proposal.schema.json b/docs/contracts/call-result-v0.1-proposal.schema.json new file mode 100644 index 0000000..52cfba0 --- /dev/null +++ b/docs/contracts/call-result-v0.1-proposal.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/call-result-v0.1-proposal.schema.json", + "title": "Project-local contract: single final call.result event; external compatibility unverified", + "$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Runtime caps each serialized UTF-8 JSON body at 8,388,608 bytes, without compression, truncation, or event splitting. Expected recording finalizes no later than call.ended_at + 15m; at most one PUT per upload_id and one immutable call.result event.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version", "payload"], + "properties": { + "schema_version": {"const": "call-result.v0.1-proposal"}, + "event_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "event_type": {"const": "call.result"}, + "dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"}, + "tenant_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"}, + "trace_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "occurred_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "aggregate_type": {"const": "call"}, + "aggregate_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "aggregate_version": {"type": "integer", "minimum": 1}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["source_command_id", "execution_id", "call_id", "task_id", "task_revision", "agent_version_id", "route_policy_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "transcript", "opt_out", "recording"], + "properties": { + "source_command_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "execution_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "call_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "task_id": {"$ref": "#/$defs/task_id"}, + "task_revision": {"type": "integer", "minimum": 1}, + "agent_version_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "route_policy_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "caller_profile_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "callee": {"type": "string", "minLength": 1}, + "trunk_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "started_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "ended_at": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "duration_ms": {"type": "integer", "minimum": 0}, + "outcome": {"type": "string", "minLength": 1}, + "reason_code": {"type": ["string", "null"], "minLength": 1}, + "transcript": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"], + "properties": { + "turn_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "segment_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "role": {"type": "string", "minLength": 1}, + "text": {"type": "string"}, + "start_ms": {"type": "integer", "minimum": 0}, + "end_ms": {"type": "integer", "minimum": 0} + } + } + }, + "opt_out": {"type": "boolean"}, + "recording": { + "oneOf": [ + {"$ref": "#/$defs/recording_uploaded"}, + {"$ref": "#/$defs/recording_unavailable"}, + {"$ref": "#/$defs/recording_not_created"} + ] + } + }, + "$comment": "aggregate_id must equal payload.call_id; timestamps and duration must agree. Runtime enforces the 8 MiB serialized-body budget. If too large, retain the exact result in durable outbox and expose blocked_payload_too_large; never truncate, split, or discard it." + } + }, + "$defs": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "recording_asset_fields": { + "type": "object", + "required": ["recording_id", "upload_id", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"], + "properties": { + "recording_id": {"type": ["string", "null"], "minLength": 1}, + "upload_id": {"type": ["string", "null"], "minLength": 1}, + "bucket": {"type": ["string", "null"], "minLength": 1}, + "object_key": {"type": ["string", "null"], "minLength": 1}, + "format": {"type": ["string", "null"], "minLength": 1}, + "channels": {"type": ["integer", "null"], "minimum": 1}, + "sample_rate_hz": {"type": ["integer", "null"], "minimum": 1}, + "duration_ms": {"type": ["integer", "null"], "minimum": 0}, + "size_bytes": {"type": ["integer", "null"], "minimum": 0}, + "checksum_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"} + } + }, + "recording_uploaded": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status"], + "properties": { + "status": {"const": "uploaded"}, + "recording_id": {"type": "string", "minLength": 1}, + "upload_id": {"type": "string", "minLength": 1}, + "bucket": {"type": "string", "minLength": 1}, + "object_key": {"type": "string", "minLength": 1}, + "format": {"type": "string", "minLength": 1}, + "channels": {"type": "integer", "minimum": 1}, + "sample_rate_hz": {"type": "integer", "minimum": 1}, + "duration_ms": {"type": "integer", "minimum": 0}, + "size_bytes": {"type": "integer", "minimum": 0}, + "checksum_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"} + } + } + ], + "unevaluatedProperties": false + }, + "recording_unavailable": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status", "error_code"], + "properties": { + "status": {"const": "unavailable"}, + "error_code": {"enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "upload_timeout", "deadline_exceeded", "checksum_mismatch"]}, + "recording_id": {"type": "string", "minLength": 1}, + "upload_id": {"type": "string", "minLength": 1}, + "bucket": {"type": "null"}, + "object_key": {"type": "null"}, + "format": {"type": "string", "minLength": 1}, + "channels": {"type": "integer", "minimum": 1}, + "sample_rate_hz": {"type": "integer", "minimum": 1}, + "duration_ms": {"type": "integer", "minimum": 0}, + "size_bytes": {"type": "null"}, + "checksum_sha256": {"type": "null"} + } + } + ], + "unevaluatedProperties": false + }, + "recording_not_created": { + "allOf": [ + {"$ref": "#/$defs/recording_asset_fields"}, + { + "type": "object", + "required": ["status", "reason_code"], + "properties": { + "status": {"const": "not_created"}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "recording_id": {"type": "null"}, + "upload_id": {"type": "null"}, + "bucket": {"type": "null"}, + "object_key": {"type": "null"}, + "format": {"type": "null"}, + "channels": {"type": "null"}, + "sample_rate_hz": {"type": "null"}, + "duration_ms": {"type": "null"}, + "size_bytes": {"type": "null"}, + "checksum_sha256": {"type": "null"} + } + } + ], + "unevaluatedProperties": false + } + } +} diff --git a/docs/contracts/command-next-v0.1-proposal.schema.json b/docs/contracts/command-next-v0.1-proposal.schema.json new file mode 100644 index 0000000..f3549a4 --- /dev/null +++ b/docs/contracts/command-next-v0.1-proposal.schema.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/command-next-v0.1-proposal.schema.json", + "title": "Project-local contract: SaaS command and command-result messages; external compatibility unverified", + "$comment": "Project-local contract derived from docs/thirds/第三方对接事件与请求消费顺序_v0.1.md; not the published MQ v2 contract. Serialized UTF-8 JSON bodies are capped at 8,388,608 bytes by runtime validation.", + "oneOf": [ + {"$ref": "#/$defs/call_execute"}, + {"$ref": "#/$defs/control_pause"}, + {"$ref": "#/$defs/control_resume"}, + {"$ref": "#/$defs/control_stop"}, + {"$ref": "#/$defs/command_result_call"}, + {"$ref": "#/$defs/command_result_control"} + ], + "$defs": { + "id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/id"}, + "dispatcher_id": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/dispatcherId"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_key": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/tenantKey"}, + "time": {"$ref": "https://go-sip.local/contracts/v1/mq.schema.json#/$defs/time"}, + "command_base": { + "type": "object", + "required": ["schema_version", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after"], + "properties": { + "schema_version": {"const": "command-next.v0.1-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"$ref": "#/$defs/tenant_key"}, + "trace_id": {"$ref": "#/$defs/id"}, + "issued_at": {"$ref": "#/$defs/time"}, + "not_after": {"$ref": "#/$defs/time"} + } + }, + "call_execute": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_id", "command_type", "payload"], + "properties": { + "command_id": {"$ref": "#/$defs/id"}, + "command_type": {"const": "call.execute"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "callee"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "callee": {"type": "string", "minLength": 1} + } + } + } + } + ], + "unevaluatedProperties": false, + "$comment": "Check not_after against current time and bind the task/tenant snapshot before originate; not_after must be later than issued_at." + }, + "control_pause": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "active_call_policy", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "pause"}, + "active_call_policy": {"enum": ["drain", "hangup"]}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "control_resume": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "resume"}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "control_stop": { + "allOf": [ + {"$ref": "#/$defs/command_base"}, + { + "type": "object", + "required": ["command_type", "payload"], + "properties": { + "command_type": {"const": "task.control"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["task_id", "action", "active_call_policy", "reason"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"const": "stop"}, + "active_call_policy": {"enum": ["drain", "hangup"]}, + "reason": {"type": "string", "minLength": 1, "maxLength": 512} + } + } + } + } + ], + "unevaluatedProperties": false + }, + "event_base": { + "type": "object", + "required": ["schema_version", "event_id", "event_type", "dispatcher_id", "tenant_id", "tenant_key", "trace_id", "occurred_at", "aggregate_type", "aggregate_id", "aggregate_version"], + "properties": { + "schema_version": {"const": "command-next.v0.1-proposal"}, + "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"const": "command.result"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"$ref": "#/$defs/tenant_key"}, + "trace_id": {"$ref": "#/$defs/id"}, + "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "task"]}, + "aggregate_id": {"$ref": "#/$defs/id"}, + "aggregate_version": {"type": "integer", "minimum": 1} + } + }, + "command_result_call": { + "allOf": [ + {"$ref": "#/$defs/event_base"}, + { + "type": "object", + "required": ["aggregate_type", "payload"], + "properties": { + "aggregate_type": {"const": "command"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["command_id", "command_type", "status", "reason_code"], + "properties": { + "command_id": {"$ref": "#/$defs/id"}, + "command_type": {"const": "call.execute"}, + "status": {"enum": ["accepted", "rejected"]}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "execution_id": {"$ref": "#/$defs/id"} + }, + "allOf": [ + { + "if": {"properties": {"status": {"const": "accepted"}}}, + "then": {"required": ["execution_id"]} + } + ] + } + } + } + ], + "unevaluatedProperties": false + }, + "command_result_control": { + "allOf": [ + {"$ref": "#/$defs/event_base"}, + { + "type": "object", + "required": ["aggregate_type", "payload"], + "properties": { + "aggregate_type": {"const": "task"}, + "payload": { + "type": "object", + "additionalProperties": false, + "required": ["command_type", "task_id", "action", "status", "reason_code", "task_state"], + "properties": { + "command_type": {"const": "task.control"}, + "task_id": {"$ref": "#/$defs/task_id"}, + "action": {"enum": ["pause", "resume", "stop"]}, + "status": {"enum": ["applied", "rejected"]}, + "reason_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "task_state": {"enum": ["running", "paused", "stopped", "finished"]} + }, + "allOf": [ + {"if": {"properties": {"action": {"const": "pause"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "paused"}}}}, + {"if": {"properties": {"action": {"const": "resume"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "running"}}}}, + {"if": {"properties": {"action": {"const": "stop"}, "status": {"const": "applied"}}}, "then": {"properties": {"task_state": {"const": "stopped"}}}} + ] + } + } + } + ], + "unevaluatedProperties": false, + "$comment": "No control command_id or request dedupe identity exists. event_id identifies a receipt event, not a control command." + } + } +} diff --git a/docs/contracts/config-read-fields-v0.1-proposal.md b/docs/contracts/config-read-fields-v0.1-proposal.md index 19d4d48..ec5d9ea 100644 --- a/docs/contracts/config-read-fields-v0.1-proposal.md +++ b/docs/contracts/config-read-fields-v0.1-proposal.md @@ -1,18 +1,20 @@ -# 只读配置与租户额度接口:任务、智能体、SIP 字段与返回结构 v0.1(项目提案) +# 只读配置与租户额度接口:任务、智能体、SIP 字段与返回结构 v0.1(项目内 F01 规范) -**状态:项目自定义草案,非 SaaS 已有接口/实际 JSON、非已发布契约、非实现/验收。** 用户同意:截图可见的业务含义先映射为**项目定义的字段名**;截图没有但需求明确的结构由本项目设计。即使字段名与现有项目 Schema 或历史 OpenAPI 相同,也**不能**据此声称它是当前 SaaS 页面原有的后端键。SaaS 和 management 在 W01 签收前不得据此开始真实配置发布/拨号。 +**状态:项目内 F01 字段规范;不是 SaaS 已有接口/实际 JSON,也不是外部发布契约。** 本地字段由本文件与[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)定义,使用严格 Schema、正反例、来源/hash 和 Mock 验证;不等待 SaaS/management 外部签收即可完成本地 C。截图可见的业务含义映射为**项目定义的字段名**;截图没有但需求明确的结构由本项目设计。即使字段名与现有项目 Schema 或历史 OpenAPI 相同,也**不能**据此声称它是当前 SaaS 页面原有后端键。真实配置发布、拨号或外部切换仍需另行授权和验证。 ## 1. 来源、交付边界 - **P = 页面观察:**[SaaS 截图分析](../references/saas-page-snapshot-analysis.md) §2–4;只证明表单/列表可见,尤其§7的 **MQ 配置建议是已被新 HTTP 方向取代的历史方案**,不作为新合同。 -- **C = 本项目现有合同:**[AI 配置](../../contracts/upstream/v1/ai-config.schema.json)、[静态 Cell/SIP 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json)及[当前 MQ 消息](../../contracts/upstream/v1/mq.schema.json)。字段语义可复用,但**不是 SaaS 当前 HTTP 响应证据**。旧 [OpenAPI/MQ 只读索引](../references/OpenAPI与MQ字段索引_v0.1.md) 也仅为历史参考。 +- **C = 现行外部项目合同:**[AI 配置](../../contracts/upstream/v1/ai-config.schema.json)、[静态 Cell/SIP 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json)及[当前 MQ 消息](../../contracts/upstream/v1/mq.schema.json)。字段语义可复用,但**不是 SaaS 当前 HTTP 响应证据**。本地 HTTP 请求/错误/分页语义以[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)为准。旧 [OpenAPI/MQ 只读索引](../references/OpenAPI与MQ字段索引_v0.1.md) 也仅为历史参考。 - **N = 新项目字段:**任务每周多时段/排除日期、SIP 线路时段、任务与单 D 绑定、缓存/版本/错误返回等,由本提案定义;实际 SaaS 接口不存在已验证响应。 -交付物:[机器可读响应草案](config-read-v0.1.schema.json)、[mock SIP 成功示例](examples/config-read-sip-v0.1.json)、[mock 任务成功示例](examples/config-read-task-v0.1.json)、[mock 租户额度示例](examples/config-read-tenant-quota-v0.1.json)。前两条接口为**拟定的只读 HTTP 配置读取**:`GET /internal/v1/dispatcher/sip` 与 `GET /internal/v1/dispatcher/task/:task_id`;下一轮另拟 `GET /internal/v1/dispatcher/tasks` 和 `?after=` 发现归属任务;新增项目拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota` 按任务的租户 ID 读取本 D 的租户份额。四条请求携带 `X-DISPATCHER-id`(D UUID)及 `X-DISPATCHER-SECRET-KEY`(受控密钥),无请求体;实际 SaaS 实现仍待签收。业务呼叫/控制及回执/最终结果继续走 MQ,目标版本移除对外查询/补传;不留旧 AI/SIP 配置 MQ 回退。**HTTP 响应错误码、SIP 快照规范及 SaaS/management 实际来源仍需 W01/F07 冻结**,本文件不冒充外部权威发布物。 +路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与任务路由字段 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 为本地项目定义。它们不是截图/现网接口已验证的响应键;本地 Mock 按本契约验证。 + +交付物:[机器可读响应 Schema](config-read-v0.1.schema.json)、[mock SIP 成功示例](examples/config-read-sip-v0.1.json)、[mock 任务成功示例](examples/config-read-task-v0.1.json)、[mock 租户额度示例](examples/config-read-tenant-quota-v0.1.json)、[mock 错误响应示例](examples/config-read-error-v0.1.json)及[预期被 Schema 拒绝的非法示例](examples/config-read-invalid-extra-property-v0.1.json)。四条只读 GET 为本地目标:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks?after=` 与 `/internal/v1/dispatcher/tenant/:tenant_id/quota`。均携带 `X-DISPATCHER-id`(D UUID)及 `X-DISPATCHER-SECRET-KEY`(受控密钥),无请求体;真实 SaaS 实现和字段兼容性未验证。呼叫/控制/回执/最终结果走 MQ,目标移除对外 query/replay,不留旧 AI/SIP 配置 MQ 回退。错误状态和 code 按本文件 §2 与第三方对接契约定义并由 Mock 验证;本地 Schema 不冒充外部权威发布物。 ## 2. 请求与共同响应 -Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-DISPATCHER-SECRET-KEY` 请求归属资源;不记录密钥或在日志中打印配置提示词。接口只读、无控制副作用;SaaS 必须核验任务归属。Header 的准确校验、密钥轮换时序、HTTP 错误状态仍需 SaaS 签收。`GET /internal/v1/dispatcher/tasks`(含 `?after=`)是**第三条待 F07 签收的任务发现接口**,其快照/变更示例见[第三方对接 §2.5](../thirds/第三方对接事件与请求消费顺序_v0.1.md),不混入本文件的配置响应 Schema;第四条租户额度响应属于本文件新增草案,路径/字段未获 SaaS 签收。 +Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-DISPATCHER-SECRET-KEY` 请求归属资源;不记录密钥或在日志中打印配置提示词。接口只读、无控制副作用;服务端必须核验任务归属。Header 的本地错误约定为 HTTP 401 `unauthorized`、403 `dispatcher_not_authorized`;请求格式错误为 400 `invalid_request`,资源缺失/未归属为 404 `resource_not_found`,租户额度不可用为 503 `tenant_quota_unavailable`,临时服务故障为 503 `service_unavailable`。`GET /internal/v1/dispatcher/tasks` 的游标和分页错误按第三方契约 §2.5:400 `invalid_cursor`/`invalid_page_token`、410 `cursor_expired`/`snapshot_expired`。任务发现严格结构见[任务发现 Schema](task-discovery-v0.1-proposal.schema.json),正例见[第三方对接 §2.5](../thirds/第三方对接事件与请求消费顺序_v0.1.md),不混入本文件的配置响应 Schema。以上仅为本地 Mock/Go 契约,不代表外部 SaaS 状态码。 | 请求 | `200` 返回类型 | 何时读取 | 错误处理 | | --- | --- | --- | --- | @@ -22,7 +24,7 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X- `200` 响应中的 `schema_version` 固定 `config-read.v0.1`,`resource` 区分 SIP、任务及租户额度结构,`dispatcher_id` 必须等于 Header 中的 D。**不使用条件请求、ETag 或 `304`**:到期时重新 GET 完整响应;只有收到、验证并重新确认授权有效后才更新缓存。SaaS 变更到 D 的目标延迟约 60 秒;缓存到期且刷新失败,**不可无限期沿用旧版本发起新呼叫**。MQ 停/暂停不等待这 60 秒。已接纳/已接通呼叫固定自己的快照,不因缓存过期而漂移。 -非 `200` 返回 `resource=error`、`error.code`、`error.message` 的脱敏 JSON(HTTP 状态及 code 逐项待 SaaS 确认),不得吞成旧配置/空任务。下一版 `call.execute.payload` **只有 `task_id` 与 `callee`**;D 从已批准的任务快照读取路由/主叫/智能体版本及任务级 `ring_timeout_ms/max_call_duration_ms`,接纳前持久绑定完整快照,不能从精简命令中猜值或悄悄采用过期缓存。现行严格 MQ Schema 尚未修改。 +非 `200` 返回 `resource=error`、`error.code`、`error.message` 的脱敏 JSON(HTTP 状态与 code 按本节约定;真实 SaaS 是否一致尚未验证),不得吞成旧配置/空任务。下一版 `call.execute.payload` **只有 `task_id` 与 `callee`**;D 从已批准的任务快照读取路由/主叫/智能体版本及任务级 `ring_timeout_ms/max_call_duration_ms`,接纳前持久绑定完整快照,不能从精简命令中猜值或悄悄采用过期缓存。现行严格 MQ Schema 尚未修改。 ## 3. 任务成功响应:字段与来源 @@ -76,7 +78,7 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X- 静态制品中的端点**引用**与此提案补充的对端**值**属于同一获批版本;若 SaaS 与 management 并非同一配置来源,必须证明它们同步一致,且 Agent/Asterisk 实际加载的版本/摘要匹配。`artifact.load_evidence` 为 `null` 只表示 HTTP 获得配置,**不代表 Asterisk 已加载**;D 必须另从实际执行侧核验。样例 `mode=mock`、`transport/auth_mode/registration_required/max_concurrent_calls=null` 是故意保留的供应商待确认项;**不满足 real 放行**。只提供SaaS读接口却不能取得已批准的端点及线路时段,不得声称已经提供了完整 SIP 配置。 -## 4.1 租户额度响应(新增字段,N,未签收) +## 4.1 租户额度响应(项目内新增字段 N,外部未签收) | 字段 | 类型/约束 | 业务语义 | | --- | --- | --- | @@ -88,13 +90,13 @@ Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X- D 在同一事务预留租户/任务/线路等占用,未知继续计入;降额不强挂、占用低于新上限才再接新。额度缺失、过期/错身份、刷新失败关闭新准入,不能以任务额度代替。已确认通话终结/执行资源释放即可释放通话额度,不等录音上传或MQ确认;stop静默ACK不需申请通话名额。多D须由SaaS分份额,累计不超过租户总额;本轮只验证单D。 -## 5. 需冻结的语义与验收前置 +## 5. 外部待核事项(不阻塞本地 F01/C) -1. **来源:**确认 SaaS 的任务、智能体是该服务的权威配置;management 仍是唯一 SIP 编辑/审批方。确认 SaaS 分发的是已批准制品与线路补充字段同一版本,不能出现两份可写配置。 -2. **身份和响应:**约定请求头 `X-DISPATCHER-id` 与 `X-DISPATCHER-SECRET-KEY`;只读取归属 D 的 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/tasks` 、`/internal/v1/dispatcher/task/:task_id` 和新增拟定 `/internal/v1/dispatcher/tenant/:tenant_id/quota`,不在日志/示例保存真实密钥。具体身份校验、状态码和生效时间仍须 SaaS 签收;不采用 `ETag`/`304`。 -3. **窗口与版本:**缓存成功核验起约 60 秒;SaaS 变更对未接纳呼叫最多约 60 秒延迟,过期重新 GET 完整数据失败就停止新准入,已接纳保留原快照。更新/SIP 加载期间停执行队列,不停控制 MQ;停/暂停不等缓存。没有 `304` 延长授权的通道。跨日窗口、重叠段、当日排除、时间边界及任务与线路交集须在合同冻结。 +1. **外部来源与审批:**真实 SaaS/management 联调前,确认 SaaS 的任务、智能体是该服务的权威配置;management 仍是唯一 SIP 编辑/审批方。证明 SaaS 分发的是已批准制品与线路补充字段同一版本,不能出现两份可写配置。此项不阻塞本地 Mock。 +2. **身份和响应:**本地请求头为 `X-DISPATCHER-id` 与 `X-DISPATCHER-SECRET-KEY`;只读取归属 D 的 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/tasks`、`/internal/v1/dispatcher/task/:task_id` 和 `/internal/v1/dispatcher/tenant/:tenant_id/quota`,不在日志/示例保存真实密钥。身份校验、状态码与生效时间按本文件和第三方对接契约作为本地规则;真实 SaaS 兼容性未验证。不采用 `ETag`/`304`。 +3. **窗口与版本:**本地缓存成功核验起约 60 秒;SaaS 变更对未接纳呼叫最多约 60 秒延迟,过期重新 GET 完整数据失败就停止新准入,已接纳保留原快照。更新/SIP 加载期间停执行队列,不停控制 MQ;停/暂停不等缓存。没有 `304` 延长授权的通道。跨日窗口、重叠段、当日排除、时间边界及任务与线路交集按本地 Schema/业务测试执行;真实 SaaS 行为未验证。 4. **一致性:**`agent_version_id` 与 AI 授权一致且有效,同版内容漂移必须拒绝;任务归属/修订/route policy 以已绑定任务快照为准,MQ 命令不得覆盖;`artifact.trunks` 与 `trunk_details` 一一对应;线路 status、主叫、前缀、媒体、线路/租户/供应商额度来源和 Agent/Asterisk 实际加载不可依赖 JSON Schema 单独判断。供应商未知传输/鉴权/注册不得默认允许 real。 5. **消费状态:**pause保留原队列积压,resume最新配置/授权/额度有效才继续消费,无需SaaS重新投递;暂停不延长not_after。stop后未接纳积压静默消费ACK,不拨号、不发逐条回执/最终结果;控制本身与已在途通话结果仍回传,本地计数/错误不静默。状态优先级及例外按总计划§3.3,不加控制去重。 6. **退出与过渡:**新接口上线前现行 MQ-only/单 D/固定时段/静态 SIP 仍有效。切到新版本后配置只走 HTTP,旧 `ai.config.request/result` 停用,不做 HTTP→MQ 回退;业务 MQ 正常运行。多 D 任务归属/共享额度份额、旧命令/缓存/恢复记录和控制屏障须单独验证;任务结束只删除配置缓存,不删除未决执行与消息事实。 -**验证状态:**本地 JSON Schema 草案覆盖 SIP、任务和新增租户额度 `200` 示例及错误/非法样例;它**不能**证明真实 SaaS 接口字段名、管理平台签收、hash 规范、Agent SDK 映射、SIP 实际加载或任何生产外呼验收。 +**验证状态:**本地 JSON Schema 草案包含 SIP、任务、租户额度 `200` 成功响应、有效错误响应及一个额外字段非法样例;只有 Schema 校验通过的有效示例可作为正例,非法样例必须被拒绝。此离线校验**不能**证明真实 SaaS 接口字段名、management 签收、hash 规范、Agent SDK 映射、SIP 实际加载或任何生产外呼验收。 diff --git a/docs/contracts/config-read-v0.1.schema.json b/docs/contracts/config-read-v0.1.schema.json index b5ff285..e0f239c 100644 --- a/docs/contracts/config-read-v0.1.schema.json +++ b/docs/contracts/config-read-v0.1.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://go-sip.local/contracts/proposals/config-read-v0.1.schema.json", - "title": "PROPOSAL: project-defined SaaS read-only configuration responses; NOT a published SaaS contract", + "title": "Project-local F01 contract: read-only configuration responses; external SaaS compatibility unverified", "oneOf": [ {"$ref": "#/$defs/sip_response"}, {"$ref": "#/$defs/task_response"}, @@ -36,8 +36,8 @@ "resource": {"const": "task_config"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, - "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Also validate <=196 UTF-8 bytes and routing word boundaries using the project-owned tenant contract."}, - "task_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Validate <=196 UTF-8 bytes in business logic; preserve the original value and do not place tenant_key in queue/routing names."}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, "task_revision": {"type": "integer", "minimum": 1}, "status": {"enum": ["running", "paused", "stopped", "finished"]}, "name": {"type": "string", "minLength": 1, "maxLength": 256}, @@ -65,7 +65,7 @@ "max_concurrent_calls": {"type": "integer", "minimum": 0}, "valid_until": {"type": "string", "format": "date-time"} }, - "$comment": "PROPOSAL: SaaS-assigned share for this Dispatcher; aggregate all tasks of the tenant. Validate tenant_id/tenant_key mapping and expiry in business checks." + "$comment": "Project-local response: assigned share for this Dispatcher, aggregated across all tasks of the tenant. Validate tenant_id/tenant_key mapping and valid_until in business logic." }, "error_response": { "type": "object", @@ -78,7 +78,7 @@ "type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": { - "code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}, + "code": {"enum": ["invalid_request", "unauthorized", "dispatcher_not_authorized", "resource_not_found", "tenant_quota_unavailable", "service_unavailable"]}, "message": {"type": "string", "minLength": 1, "maxLength": 256} } } @@ -159,7 +159,7 @@ "sunday": {"$ref": "#/$defs/windows"} } }, - "windows": {"type": "array", "items": {"$ref": "#/$defs/window"}}, + "windows": {"type": "array", "items": {"$ref": "#/$defs/window"}, "$comment": "Each window is left-closed/right-open, start < end, and windows within a day must not overlap. Cross-midnight windows are split across two weekdays; 24:00 is allowed only as end."}, "window": { "type": "object", "additionalProperties": false, "required": ["start", "end"], diff --git a/docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json b/docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json new file mode 100644 index 0000000..e422022 --- /dev/null +++ b/docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json @@ -0,0 +1,44 @@ +{ + "schema_version": "call-result.v0.1-proposal", + "event_id": "call-result-invalid-001", + "event_type": "call.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:10:15+08:00", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "source_command_id": "command-a", + "execution_id": "execution-a", + "call_id": "call-a", + "task_id": "task-a", + "task_revision": 1, + "agent_version_id": "version-a", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "callee": "15003164745", + "trunk_id": "trunk-a", + "started_at": "2026-09-18T10:00:00+08:00", + "ended_at": "2026-09-18T10:10:00+08:00", + "duration_ms": 600000, + "outcome": "answered", + "reason_code": null, + "transcript": [], + "opt_out": false, + "recording": { + "status": "uploaded", + "recording_id": "recording-a", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "calls/tenant-a/call-a.wav", + "format": "wav", + "channels": 1, + "sample_rate_hz": 8000, + "duration_ms": 600000, + "size_bytes": 9600000 + } + } +} diff --git a/docs/contracts/examples/call-result-uploaded-v0.1.json b/docs/contracts/examples/call-result-uploaded-v0.1.json new file mode 100644 index 0000000..651ffd6 --- /dev/null +++ b/docs/contracts/examples/call-result-uploaded-v0.1.json @@ -0,0 +1,54 @@ +{ + "schema_version": "call-result.v0.1-proposal", + "event_id": "call-result-001", + "event_type": "call.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:10:15+08:00", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "source_command_id": "command-a", + "execution_id": "execution-a", + "call_id": "call-a", + "task_id": "task-a", + "task_revision": 1, + "agent_version_id": "version-a", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "callee": "15003164745", + "trunk_id": "trunk-a", + "started_at": "2026-09-18T10:00:00+08:00", + "ended_at": "2026-09-18T10:10:00+08:00", + "duration_ms": 600000, + "outcome": "answered", + "reason_code": null, + "transcript": [ + { + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "user", + "text": "示例转写内容", + "start_ms": 1000, + "end_ms": 2500 + } + ], + "opt_out": false, + "recording": { + "status": "uploaded", + "recording_id": "recording-a", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "calls/tenant-a/call-a.wav", + "format": "wav", + "channels": 1, + "sample_rate_hz": 8000, + "duration_ms": 600000, + "size_bytes": 9600000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } +} diff --git a/docs/contracts/examples/command-next-invalid-control-id-v0.1.json b/docs/contracts/examples/command-next-invalid-control-id-v0.1.json new file mode 100644 index 0000000..2742835 --- /dev/null +++ b/docs/contracts/examples/command-next-invalid-control-id-v0.1.json @@ -0,0 +1,18 @@ +{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "not_after": "2026-09-21T00:00:30Z", + "command_id": "control-must-not-have-id", + "expected_task_revision": 2, + "command_type": "task.control", + "payload": { + "task_id": "task-a", + "action": "pause", + "active_call_policy": "drain", + "reason": "local-test" + } +} diff --git a/docs/contracts/examples/config-read-error-v0.1.json b/docs/contracts/examples/config-read-error-v0.1.json index f370247..4f4f0a8 100644 --- a/docs/contracts/examples/config-read-error-v0.1.json +++ b/docs/contracts/examples/config-read-error-v0.1.json @@ -2,7 +2,7 @@ "schema_version": "config-read.v0.1", "resource": "error", "error": { - "code": "not_assigned", + "code": "resource_not_found", "message": "Task is not assigned to this Dispatcher." } } diff --git a/docs/contracts/examples/config-read-http-statuses-v0.1.json b/docs/contracts/examples/config-read-http-statuses-v0.1.json new file mode 100644 index 0000000..b768af7 --- /dev/null +++ b/docs/contracts/examples/config-read-http-statuses-v0.1.json @@ -0,0 +1,71 @@ +{ + "fixture_version": "config-read-http-statuses.v0.1", + "responses": [ + { + "status": 400, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "invalid_request", + "message": "Request parameters are invalid." + } + } + }, + { + "status": 401, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "unauthorized", + "message": "Dispatcher credentials are invalid." + } + } + }, + { + "status": 403, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "dispatcher_not_authorized", + "message": "Dispatcher is not authorized for this resource." + } + } + }, + { + "status": 404, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "resource_not_found", + "message": "The requested task or configuration resource was not found." + } + } + }, + { + "status": 503, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "tenant_quota_unavailable", + "message": "A current tenant quota is unavailable." + } + } + }, + { + "status": 503, + "body": { + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "service_unavailable", + "message": "Configuration service is temporarily unavailable." + } + } + } + ] +} diff --git a/docs/contracts/examples/config-read-invalid-extra-property-v0.1.json b/docs/contracts/examples/config-read-invalid-extra-property-v0.1.json new file mode 100644 index 0000000..f9b4158 --- /dev/null +++ b/docs/contracts/examples/config-read-invalid-extra-property-v0.1.json @@ -0,0 +1,9 @@ +{ + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "resource_not_found", + "message": "Task is not assigned to this Dispatcher." + }, + "unexpected": true +} diff --git a/docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json b/docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json new file mode 100644 index 0000000..fbe7b98 --- /dev/null +++ b/docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json @@ -0,0 +1,6 @@ +{ + "schema_version": "local-mock-recording-failure.v0.1", + "upload_id": "upload-a", + "recording_id": "recording-a", + "error_code": "upload_authorization_expired" +} diff --git a/docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json b/docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json new file mode 100644 index 0000000..a04bd23 --- /dev/null +++ b/docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json @@ -0,0 +1,7 @@ +{ + "schema_version": "local-mock-recording-failure.v0.1", + "upload_id": "upload-a", + "recording_id": "recording-a", + "error_code": "upload_failed", + "put_url": "https://example.invalid/never-a-real-token" +} diff --git a/docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json b/docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json new file mode 100644 index 0000000..ec60c25 --- /dev/null +++ b/docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json @@ -0,0 +1,6 @@ +{ + "schema_version": "local-mock-recording-failure.v0.1", + "upload_id": "upload-a", + "recording_id": "recording-a", + "error_code": "upload_timeout" +} diff --git a/docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json b/docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json new file mode 100644 index 0000000..2e9388c --- /dev/null +++ b/docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json @@ -0,0 +1,6 @@ +{ + "schema_version": "local-mock-recording-failure.v0.1", + "upload_id": "upload-a", + "recording_id": "recording-a", + "error_code": "upload_failed" +} diff --git a/docs/contracts/examples/task-discovery-changes-v0.2.json b/docs/contracts/examples/task-discovery-changes-v0.2.json new file mode 100644 index 0000000..36b9097 --- /dev/null +++ b/docs/contracts/examples/task-discovery-changes-v0.2.json @@ -0,0 +1,32 @@ +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "next_cursor": "opaque-watermark-004", + "changes": [ + { + "cursor": "opaque-watermark-002", + "operation": "assigned", + "task_id": "task-new", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "status": "running", + "task_revision": 1 + }, + { + "cursor": "opaque-watermark-003", + "operation": "updated", + "task_id": "task-mock", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "status": "stopped", + "task_revision": 3 + }, + { + "cursor": "opaque-watermark-004", + "operation": "removed", + "task_id": "task-old", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock" + } + ] +} diff --git a/docs/contracts/examples/task-discovery-http-statuses-v0.1.json b/docs/contracts/examples/task-discovery-http-statuses-v0.1.json new file mode 100644 index 0000000..a2a4168 --- /dev/null +++ b/docs/contracts/examples/task-discovery-http-statuses-v0.1.json @@ -0,0 +1,82 @@ +{ + "fixture_version": "task-discovery-http-statuses.v0.1", + "responses": [ + { + "status": 400, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "invalid_cursor", + "message": "The cursor is invalid." + } + } + }, + { + "status": 400, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "invalid_page_token", + "message": "The page token is invalid." + } + } + }, + { + "status": 401, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "unauthorized", + "message": "Dispatcher credentials are invalid." + } + } + }, + { + "status": 403, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "dispatcher_not_authorized", + "message": "Dispatcher is not authorized for this resource." + } + } + }, + { + "status": 410, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "cursor_expired", + "message": "The cursor expired; a full snapshot is required." + } + } + }, + { + "status": 410, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "snapshot_expired", + "message": "The snapshot expired; a full snapshot is required." + } + } + }, + { + "status": 503, + "body": { + "schema_version": "task-discovery.v0.1-proposal", + "resource": "error", + "error": { + "code": "service_unavailable", + "message": "Task discovery is temporarily unavailable." + } + } + } + ] +} diff --git a/docs/contracts/examples/task-discovery-http-statuses-v0.2.json b/docs/contracts/examples/task-discovery-http-statuses-v0.2.json new file mode 100644 index 0000000..e444aea --- /dev/null +++ b/docs/contracts/examples/task-discovery-http-statuses-v0.2.json @@ -0,0 +1,10 @@ +{ + "fixture_version": "task-discovery-http-statuses.v0.2", + "responses": [ + {"status": 400, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "invalid_cursor", "message": "The cursor is invalid."}}}, + {"status": 401, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "unauthorized", "message": "Dispatcher credentials are invalid."}}}, + {"status": 403, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "dispatcher_not_authorized", "message": "Dispatcher is not authorized for this resource."}}}, + {"status": 410, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "cursor_expired", "message": "A full task snapshot is required."}}}, + {"status": 503, "body": {"schema_version": "task-discovery.v0.2-proposal", "resource": "error", "error": {"code": "service_unavailable", "message": "Task discovery is temporarily unavailable."}}} + ] +} diff --git a/docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json b/docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json new file mode 100644 index 0000000..19ec138 --- /dev/null +++ b/docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json @@ -0,0 +1,7 @@ +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "next_cursor": "opaque-watermark-004", + "changes": [], + "next_page_token": "obsolete" +} diff --git a/docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json b/docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json new file mode 100644 index 0000000..c15aaf6 --- /dev/null +++ b/docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "task-discovery.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "mode": "snapshot", + "snapshot_id": "snapshot-1042", + "cursor": "1042", + "tasks": [ + { + "task_id": "task-a", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "running", + "task_revision": 1, + "queue": { + "exchange": "agent-call.dispatchers.v3", + "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", + "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3", + "unexpected": true + } + } + ], + "next_page_token": null +} diff --git a/docs/contracts/examples/task-discovery-invalid-queue-v0.2.json b/docs/contracts/examples/task-discovery-invalid-queue-v0.2.json new file mode 100644 index 0000000..ea31bdf --- /dev/null +++ b/docs/contracts/examples/task-discovery-invalid-queue-v0.2.json @@ -0,0 +1,13 @@ +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "cursor": "opaque-watermark-001", + "tasks": [{ + "task_id": "task-mock", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "status": "running", + "task_revision": 2, + "queue": {"queue_name": "not-allowed-in-response"} + }] +} diff --git a/docs/contracts/examples/task-discovery-no-change-v0.2.json b/docs/contracts/examples/task-discovery-no-change-v0.2.json new file mode 100644 index 0000000..2c5d74d --- /dev/null +++ b/docs/contracts/examples/task-discovery-no-change-v0.2.json @@ -0,0 +1,6 @@ +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "next_cursor": "opaque-watermark-004", + "changes": [] +} diff --git a/docs/contracts/examples/task-discovery-snapshot-v0.1.json b/docs/contracts/examples/task-discovery-snapshot-v0.1.json new file mode 100644 index 0000000..d56d2c8 --- /dev/null +++ b/docs/contracts/examples/task-discovery-snapshot-v0.1.json @@ -0,0 +1,23 @@ +{ + "schema_version": "task-discovery.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "mode": "snapshot", + "snapshot_id": "snapshot-001", + "cursor": "change-watermark-001", + "tasks": [ + { + "task_id": "task-mock", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "status": "running", + "task_revision": 2, + "queue": { + "exchange": "agent-call.dispatchers.v3", + "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.in", + "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.in", + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-mock.v3" + } + } + ], + "next_page_token": null +} diff --git a/docs/contracts/examples/task-discovery-snapshot-v0.2.json b/docs/contracts/examples/task-discovery-snapshot-v0.2.json new file mode 100644 index 0000000..4b16812 --- /dev/null +++ b/docs/contracts/examples/task-discovery-snapshot-v0.2.json @@ -0,0 +1,14 @@ +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "cursor": "opaque-watermark-001", + "tasks": [ + { + "task_id": "task-mock", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "status": "running", + "task_revision": 2 + } + ] +} diff --git a/docs/contracts/local-contract-manifest-v0.1.json b/docs/contracts/local-contract-manifest-v0.1.json new file mode 100644 index 0000000..f019006 --- /dev/null +++ b/docs/contracts/local-contract-manifest-v0.1.json @@ -0,0 +1,84 @@ +{ + "manifest_version": "local-contract-manifest.v0.1", + "hash_algorithm": "SHA-256", + "scope": "Project-local F01/F07 contracts; not external SaaS or management acceptance.", + "source": { + "path": "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md", + "sha256": "788c36a86f3d5bc34639db5ac42b7c7f565169c696c0901737c240ee0da89411" + }, + "artifacts": [ + { + "path": "docs/contracts/call-result-v0.1-proposal.schema.json", + "sha256": "8068508cfd05e35d06b4c1c06bee826104be7d176fd07b6822714704d321bf35" + }, + { + "path": "docs/contracts/command-next-v0.1-proposal.schema.json", + "sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de" + }, + { + "path": "docs/contracts/config-read-fields-v0.1-proposal.md", + "sha256": "14f89655b3565d3e2607e1509b5cfd272f090e7266ba352e16a8cccdd43aef32" + }, + { + "path": "docs/contracts/config-read-v0.1.schema.json", + "sha256": "d3fbf066295916b5322fff44c98a9e847de592135885ddff057f7f089fa4dfea" + }, + { + "path": "docs/contracts/examples/call-result-invalid-missing-checksum-v0.1.json", + "sha256": "9117dc78b53513e82f059816aea07b97d8980f3f8fc5b8ae02939450dedf09ac" + }, + { + "path": "docs/contracts/examples/call-result-uploaded-v0.1.json", + "sha256": "616c3f9e6b1ce77f98f365bc398e53177ce3dd50148db61327b4277bed6edf17" + }, + { + "path": "docs/contracts/examples/command-next-invalid-control-id-v0.1.json", + "sha256": "6c4fa5ff9e2992ac4c0a18357e16c2bfb4ae8b580b2afefa5cc219725059f5ce" + }, + { + "path": "docs/contracts/examples/config-read-error-v0.1.json", + "sha256": "90e95ab65820baa15ee44fafb7b8bdef3a09b62fba27ef178e4519467dafcfc3" + }, + { + "path": "docs/contracts/examples/config-read-http-statuses-v0.1.json", + "sha256": "2918407fbe722bffee4cbb638c4581f2f3530a8a2639a8c74085ed8ad95d2294" + }, + { + "path": "docs/contracts/examples/config-read-invalid-extra-property-v0.1.json", + "sha256": "6ad2d17767b22e28a47a88149a590e1a22b5f0f8ed036acdc6ab42f779def319" + }, + { + "path": "docs/contracts/examples/config-read-sip-v0.1.json", + "sha256": "7ff720634d3e190d91df44e9eaf3548be8c302be3b81d3d33f16b32ad0b034d4" + }, + { + "path": "docs/contracts/examples/config-read-task-v0.1.json", + "sha256": "571c1fe3c9e18108bf23b6053929ee23b12ff2f611b7194f38fb63e84003bd1c" + }, + { + "path": "docs/contracts/examples/config-read-tenant-quota-v0.1.json", + "sha256": "b95e06550a920238e9160b5bda8e305abcf0b5feef3db43f8a25b5e1b568e87d" + }, + { + "path": "docs/contracts/examples/task-discovery-http-statuses-v0.1.json", + "sha256": "67b80bd351373ad95b56e0200951b5aaebf24bbf7d58f2bbc826fc4e68fd2472" + }, + { + "path": "docs/contracts/examples/task-discovery-invalid-queue-property-v0.1.json", + "sha256": "bee48e2edddab074652bcaf8a81ca55770a725282add0ae13e458a9d31df94ef" + }, + { + "path": "docs/contracts/examples/task-discovery-snapshot-v0.1.json", + "sha256": "1b2389e58ea025097c38aaed72cf0f322c4e427d51f04b380d3e4c8173e9b2b5" + }, + { + "path": "docs/contracts/mq-topology-v0.1-proposal.json", + "sha256": "20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4" + }, + { + "path": "docs/contracts/task-discovery-v0.1-proposal.schema.json", + "sha256": "ff0d5e292272bd66654af91a5a3927c736a550e5766029f4c00c552125743b8e" + } + ], + "note": "Hashes cover the exact raw bytes of source and artifacts. This manifest does not hash itself." +} diff --git a/docs/contracts/local-contract-manifest-v0.2.json b/docs/contracts/local-contract-manifest-v0.2.json new file mode 100644 index 0000000..6c400f8 --- /dev/null +++ b/docs/contracts/local-contract-manifest-v0.2.json @@ -0,0 +1,18 @@ +{ + "manifest_version": "local-contract-manifest.v0.2", + "hash_algorithm": "SHA-256", + "source": {"path": "docs/thirds/v0.2.md", "sha256": "5358eaaecf944704975feab9150dcae8224ea89247a1086c68965ea46c253e31"}, + "artifacts": [ + {"path": "docs/contracts/config-read-v0.1.schema.json", "sha256": "d3fbf066295916b5322fff44c98a9e847de592135885ddff057f7f089fa4dfea"}, + {"path": "docs/contracts/command-next-v0.1-proposal.schema.json", "sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de"}, + {"path": "docs/contracts/call-result-v0.1-proposal.schema.json", "sha256": "8068508cfd05e35d06b4c1c06bee826104be7d176fd07b6822714704d321bf35"}, + {"path": "docs/contracts/mq-topology-v0.1-proposal.json", "sha256": "20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4"}, + {"path": "docs/contracts/task-discovery-v0.2-proposal.schema.json", "sha256": "aef9fa5c4d7e37edda5d6f0bd8fdbc4aac37c09b6fc8f38a3bc52ca544c52c34"}, + {"path": "docs/contracts/examples/task-discovery-snapshot-v0.2.json", "sha256": "1640a04a75dfe53f5f221a7be22ee4f55e3501f029948fb27e50ef5efb72592d"}, + {"path": "docs/contracts/examples/task-discovery-changes-v0.2.json", "sha256": "58bb5743fdd35b502a643422d302284a2f6060b07dc9a4d4700df70b089a62e3"}, + {"path": "docs/contracts/examples/task-discovery-no-change-v0.2.json", "sha256": "45abb87f7e8c0ea1efa58a94f15e0da7ceef2a7f10ed7cc74ddb88abcee24177"}, + {"path": "docs/contracts/examples/task-discovery-http-statuses-v0.2.json", "sha256": "e72979c2de0951fcd58ee8914b70a7133625365497c94503161a52751ba19710"}, + {"path": "docs/contracts/examples/task-discovery-invalid-queue-v0.2.json", "sha256": "44bc481f9a8022b2d5172b5052cf937dc44038363e6303feb32fa76ab2b60b92"}, + {"path": "docs/contracts/examples/task-discovery-invalid-page-token-v0.2.json", "sha256": "e370ba50597efa4c129a4ba6d87288189343b901e1233699616f657a82a809d6"} + ] +} diff --git a/docs/contracts/local-mock-recording-failure-manifest-v0.1.json b/docs/contracts/local-mock-recording-failure-manifest-v0.1.json new file mode 100644 index 0000000..9046ab6 --- /dev/null +++ b/docs/contracts/local-mock-recording-failure-manifest-v0.1.json @@ -0,0 +1,30 @@ +{ + "manifest_version": "local-mock-recording-failure-manifest.v0.1", + "hash_algorithm": "SHA-256", + "source": { + "path": "docs/contracts/local-mock-recording-failure-v0.1.md", + "sha256": "741e58d1eda31a0bc88d14bf0aae34daa824e129cc252b4cda99200f71e1ecf4" + }, + "artifacts": [ + { + "path": "docs/contracts/local-mock-recording-failure-v0.1.schema.json", + "sha256": "3f0b58aa8b0047282d9bd9215cfc9220aaa4cc5635636e13041ab58d16fa7c87" + }, + { + "path": "docs/contracts/examples/local-mock-recording-failure-expired-v0.1.json", + "sha256": "18f48df76ef633e33dfcf1564d69a85b4a2608b00df81ad8fcf46fd4b843ee54" + }, + { + "path": "docs/contracts/examples/local-mock-recording-failure-invalid-extra-v0.1.json", + "sha256": "9321796140b42b51677fd6d24903fd9510ff37e05f6e36551b5551b7c9dc7eff" + }, + { + "path": "docs/contracts/examples/local-mock-recording-failure-invalid-timeout-v0.1.json", + "sha256": "6eb0ebf2e4042e3f059f83d430d68fcd89cf34cf36568e47050133730a80a094" + }, + { + "path": "docs/contracts/examples/local-mock-recording-failure-upload-failed-v0.1.json", + "sha256": "cd1028f02ac66fa9518c026a260b0cceec9a1016c4be5afab4d174d910f3746c" + } + ] +} diff --git a/docs/contracts/local-mock-recording-failure-v0.1.md b/docs/contracts/local-mock-recording-failure-v0.1.md new file mode 100644 index 0000000..f97211c --- /dev/null +++ b/docs/contracts/local-mock-recording-failure-v0.1.md @@ -0,0 +1,17 @@ +# Local Mock 录音失败事实 v0.1(项目内合同) + +仅供单节点 P1 本地 Mock 验证;用户已批准此内部事实,不代表 SaaS/management、mixed/real 或生产协议已签收。SaaS 的 v0.1 合同及 `contracts/upstream/v1/` 不变。 + +## 消息与身份 + +- 复用同一 mTLS AgentControl listener 的 `ReportExecutionEvent`,`ExecutionFact.kind=FACT_KIND_RECORDING_PROGRESS`;Mock V3 拒绝其它旧分散执行事件。`payload_json` 必须满足 `local-mock-recording-failure-v0.1.schema.json`,编码后最多 4096 字节;不得包含上传 TOKEN、签名 URL、凭据、音频、完整对话或供应商响应正文。 +- `fact_id` 为一次生成、写入 Agent 文件日志的 UUID v4;`content_sha256` 是原始 `payload_json` 字节的 SHA-256。`source_boot_id`、`source_sequence`(正整数)及观察时间和事实一起持久化。重启后不重建事实身份或时间;每次请求的 `RequestMeta` 使用**当前已激活**的 Agent/Cell/boot/Dispatcher epoch/session generation。旧 `source_boot_id` 可与新请求的 boot 不同,不能因此丢弃原事实;无当前会话则保留事实、拒绝上报。 +- Dispatcher 先核验 mTLS、允许的 Agent、当前未过期节点会话、已发唯一外呼决定及已确认结束的 Mock 通话,再核对任务绑定、`recording_id` 与 `upload_id`。签收前将事实身份与 `unavailable` 结果在同一 SQLite 事务持久化,并使唯一 `call.result` 可靠入 outbox;途中失败由原事实/结果恢复,不重拨、不重 PUT。相同事实幂等,不同事实或矛盾状态拒绝。事实签收不等于 MQ publisher confirm,更不等于 SaaS 应用签收。 + +## 失败与期限 + +- Agent 仅上报明确失败:授权无效/过期、已确认的 PUT 拒绝、录音文件明确缺失或已证实的 checksum 不匹配。HTTP 401/403 → `upload_authorization_failed`,明确的其它 4xx(不含 408/429)→ `upload_failed`;本地授权过期 → `upload_authorization_expired`,checksum 不匹配 → `checksum_mismatch`。发送前先持久记录;报告失败或回执不匹配只重送同一事实,不重 PUT。 +- 传输未知、HTTP 408/429/5xx 不冒称明确失败;保留未知占用/录音等待,超过通话结束后 15 分钟由 Dispatcher 以 `upload_timeout` 收口。首次到达截止点后的新失败事实拒绝;截止点前已持久化的同一事实在重启后仍可幂等重送。`upload_timeout` 与 `deadline_exceeded` 只由 Dispatcher 生成,不允许 Agent 上报。 +- 通话确认结束即释放执行额度,不等待上传或 MQ;未知通话仍占用。外发只有一份最终 `call.result`,不得并行生成 `recording.uploaded` 或旧分散通话事件。8,388,608 字节以上的最终消息完整保留、持久阻塞并记录 event_id、字节数和 SHA-256,不截断或拆分。 + +当前默认 Mock originator 只模拟无应答,不生成真实录音;已上传及明确失败由隔离 Mock 测试注入,不据此宣称真实 OSS、供应商或 SaaS 验证通过。 diff --git a/docs/contracts/local-mock-recording-failure-v0.1.schema.json b/docs/contracts/local-mock-recording-failure-v0.1.schema.json new file mode 100644 index 0000000..1ef49a1 --- /dev/null +++ b/docs/contracts/local-mock-recording-failure-v0.1.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/local-mock-recording-failure-v0.1.schema.json", + "title": "Local Mock recording failure fact v0.1 (project proposal only)", + "description": "Payload of AgentControl.ReportExecutionEvent FACT_KIND_RECORDING_PROGRESS for an explicitly failed Mock recording upload. Maximum encoded payload: 4096 bytes. Not a SaaS event or mixed/real contract.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "upload_id", "recording_id", "error_code"], + "properties": { + "schema_version": {"const": "local-mock-recording-failure.v0.1"}, + "upload_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"}, + "recording_id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"}, + "error_code": { + "type": "string", + "enum": ["upload_authorization_failed", "upload_authorization_expired", "upload_failed", "checksum_mismatch"] + } + } +} diff --git a/docs/contracts/mq-topology-v0.1-proposal.json b/docs/contracts/mq-topology-v0.1-proposal.json new file mode 100644 index 0000000..51abce2 --- /dev/null +++ b/docs/contracts/mq-topology-v0.1-proposal.json @@ -0,0 +1,84 @@ +{ + "contract_version": "project-saas-dispatcher.v0.1", + "amqp_protocol": "0-9-1", + "exchanges": { + "commands": {"name": "agent-call.dispatchers.v3", "type": "topic", "durable": true}, + "results": {"name": "agent-call.saas.v3", "type": "topic", "durable": true}, + "dead_letter": {"name": "agent-call.dead-letter.v3", "type": "topic", "durable": true} + }, + "queues": { + "task": { + "owner": "saas", + "exchange": "agent-call.dispatchers.v3", + "routing_key": "d..task..in", + "binding_key": "same as routing_key", + "queue_name": "agent-call.d..task..v3", + "durable": true, + "exclusive": false, + "auto_delete": false, + "dead_letter_exchange": "agent-call.dead-letter.v3", + "dead_letter_routing_key": "d..dead-letter" + }, + "control": { + "owner": "saas", + "exchange": "agent-call.dispatchers.v3", + "routing_key": "d..control.in", + "binding_key": "same as routing_key", + "queue_name": "agent-call.d..control.v3", + "durable": true, + "exclusive": false, + "auto_delete": false, + "dead_letter_exchange": "agent-call.dead-letter.v3", + "dead_letter_routing_key": "d..dead-letter" + }, + "result": { + "owner": "saas", + "exchange": "agent-call.saas.v3", + "routing_key": "d..out", + "binding_key": "same as routing_key", + "queue_name": "agent-call.saas.d..v3", + "durable": true, + "exclusive": false, + "auto_delete": false + }, + "dead_letter": { + "owner": "saas", + "exchange": "agent-call.dead-letter.v3", + "routing_key": "d..dead-letter", + "binding_key": "same as routing_key", + "queue_name": "agent-call.d..dead-letter.v3", + "durable": true, + "exclusive": false, + "auto_delete": false + } + }, + "limits": { + "task_id_pattern": "^[A-Za-z0-9_-]{1,128}$", + "dispatcher_id_format": "lowercase canonical UUID v4", + "max_task_queues_per_dispatcher_including_draining": 256, + "max_routing_key_bytes": 255, + "max_queue_name_bytes": 255, + "task_routing_key_max_bytes": 175, + "task_queue_name_max_bytes": 186, + "json_message_body_max_bytes": 8388608 + }, + "publishing": { + "messages_persistent": true, + "mandatory": true, + "publisher_confirms": true, + "mark_outbox_delivered_only_after_no_return_and_positive_confirm": true, + "positive_confirm_is_saas_application_receipt": false, + "retry_reuses_same_identity_and_exact_body": true + }, + "ownership": { + "saas_creates_binds_and_retires_all_queues": true, + "dispatcher_may_consume_task_and_control_queues": true, + "dispatcher_may_declare_bind_or_delete_queues": false + }, + "inbound_processing": { + "ack_after_durable_inbox_and_state_commit": true, + "expired_not_after_is_application_rejection_not_broker_ttl": true, + "invalid_json_or_schema_nack_requeue_false_to_dead_letter_queue": true, + "redelivered_call_execute_must_not_originate_again": true + } +} diff --git a/docs/contracts/task-discovery-v0.1-proposal.schema.json b/docs/contracts/task-discovery-v0.1-proposal.schema.json new file mode 100644 index 0000000..f023e15 --- /dev/null +++ b/docs/contracts/task-discovery-v0.1-proposal.schema.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.1-proposal.schema.json", + "title": "Project-local contract: Dispatcher task discovery responses; external compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/snapshot_response"}, + {"$ref": "#/$defs/changes_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "dispatcher_id": { + "type": "string", + "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "cursor": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "$comment": "Opaque SaaS change watermark; never compare to task_id or infer numeric order." + }, + "task_fields": { + "type": "object", + "required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"], + "properties": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "$comment": "Also validate the existing UTF-8 byte limit and tenant_id mapping in business logic; tenant_key is not part of any queue or routing key." + }, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + "queue": { + "type": "object", + "additionalProperties": false, + "required": ["exchange", "routing_key", "binding_key", "queue_name"], + "properties": { + "exchange": {"const": "agent-call.dispatchers.v3"}, + "routing_key": {"type": "string", "maxLength": 175, "pattern": "^d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.in$"}, + "binding_key": {"type": "string", "maxLength": 175, "pattern": "^d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.in$"}, + "queue_name": {"type": "string", "maxLength": 186, "pattern": "^agent-call\\.d\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.task\\.[A-Za-z0-9_-]{1,128}\\.v3$"} + }, + "$comment": "SaaS-created and managed queue address; D only consumes. Runtime derives names from dispatcher_id/task_id, requires binding_key = routing_key, and rejects mismatches. Max 256 assigned or draining task queues per D." + }, + "snapshot_task": { + "allOf": [ + {"$ref": "#/$defs/task_fields"}, + { + "type": "object", + "required": ["queue"], + "properties": {"queue": {"$ref": "#/$defs/queue"}} + } + ], + "unevaluatedProperties": false + }, + "changed_task": { + "allOf": [ + {"$ref": "#/$defs/task_fields"}, + { + "type": "object", + "required": ["cursor", "operation", "queue"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"enum": ["assigned", "updated"]}, + "queue": {"$ref": "#/$defs/queue"} + } + } + ], + "unevaluatedProperties": false + }, + "removed_task": { + "type": "object", + "additionalProperties": false, + "required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"const": "removed"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "$comment": "Also validate the existing UTF-8 byte limit and tenant_id mapping in business logic." + } + } + }, + "snapshot_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "mode", "snapshot_id", "cursor", "tasks", "next_page_token"], + "properties": { + "schema_version": {"const": "task-discovery.v0.1-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "mode": {"const": "snapshot"}, + "snapshot_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "cursor": {"$ref": "#/$defs/cursor"}, + "tasks": {"type": "array", "maxItems": 100, "items": {"$ref": "#/$defs/snapshot_task"}}, + "next_page_token": {"type": ["string", "null"], "minLength": 1, "maxLength": 512} + }, + "$comment": "Each page has at most 100 tasks. All pages retain the first page's snapshot_id/cursor; stage and persist all pages before atomically replacing the active snapshot and advancing the cursor." + }, + "changes_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "mode", "from_cursor", "next_cursor", "changes", "next_page_token"], + "properties": { + "schema_version": {"const": "task-discovery.v0.1-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "mode": {"const": "changes"}, + "from_cursor": {"$ref": "#/$defs/cursor"}, + "next_cursor": {"$ref": "#/$defs/cursor"}, + "changes": { + "type": "array", + "maxItems": 100, + "items": { + "oneOf": [ + {"$ref": "#/$defs/changed_task"}, + {"$ref": "#/$defs/removed_task"} + ] + } + }, + "next_page_token": {"type": ["string", "null"], "minLength": 1, "maxLength": 512} + }, + "$comment": "Each page has at most 100 changes. All pages retain from_cursor/next_cursor for the same window; apply changes in response order and persist all pages before atomically advancing next_cursor. Cursor and page-token continuity are cross-response semantics." + }, + "error_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "task-discovery.v0.1-proposal"}, + "resource": {"const": "error"}, + "error": { + "type": "object", + "$comment": "Local HTTP mapping: invalid_cursor/invalid_page_token=400, unauthorized=401, dispatcher_not_authorized=403, cursor_expired/snapshot_expired=410, service_unavailable=503. Status is transport metadata and is not part of the JSON body; external SaaS compatibility is unverified.", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_cursor", "cursor_expired", "snapshot_expired", "invalid_page_token", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + } + } +} diff --git a/docs/contracts/task-discovery-v0.2-proposal.schema.json b/docs/contracts/task-discovery-v0.2-proposal.schema.json new file mode 100644 index 0000000..88b9dff --- /dev/null +++ b/docs/contracts/task-discovery-v0.2-proposal.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.2-proposal.schema.json", + "title": "Project-local single-response Dispatcher task discovery; external compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/snapshot_response"}, + {"$ref": "#/$defs/changes_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "dispatcher_id": { + "type": "string", "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "cursor": { + "type": "string", "minLength": 1, "maxLength": 512, + "$comment": "Opaque SaaS change watermark; never compare numerically or derive from task_id." + }, + "task": { + "type": "object", "additionalProperties": false, + "required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"], + "properties": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, + "$comment": "Also enforce the UTF-8 byte limit and tenant_id mapping in business logic."}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + "change": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, + "required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key", "status", "task_revision"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"enum": ["assigned", "updated"]}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + { + "type": "object", "additionalProperties": false, + "required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"], + "properties": { + "cursor": {"$ref": "#/$defs/cursor"}, + "operation": {"const": "removed"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196} + } + } + ] + }, + "snapshot_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "cursor", "tasks"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "cursor": {"$ref": "#/$defs/cursor"}, + "tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}} + }, + "$comment": "No pagination or queue address in the body. Persist the entire consistent response before advancing the cursor." + }, + "changes_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "dispatcher_id", "next_cursor", "changes"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "next_cursor": {"$ref": "#/$defs/cursor"}, + "changes": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/change"}} + }, + "$comment": "No change: changes=[] and next_cursor=request after. An unrepresentable complete change set requires HTTP 410 cursor_expired, never a partial 200." + }, + "error_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "task-discovery.v0.2-proposal"}, + "resource": {"const": "error"}, + "error": { + "type": "object", "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_cursor", "cursor_expired", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + } + } +} diff --git a/docs/contracts/通信与事件数据交互_v0.1.md b/docs/contracts/通信与事件数据交互_v0.1.md index fdd5096..f3ecac6 100644 --- a/docs/contracts/通信与事件数据交互_v0.1.md +++ b/docs/contracts/通信与事件数据交互_v0.1.md @@ -2,9 +2,9 @@ ## 1. 范围、权威与状态 -本文件保留现有外部命令/事件与内部职责目录,并明确本轮适用范围:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置、ASR-only与完整AI双模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 全量目录不等于本轮全部实现;当前只交付设计,运行通过记录另见验收证据。 +本文件保留现有外部命令/事件与内部职责目录,并明确本轮适用范围:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置、ASR-only与完整AI双模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 本地 P1 实施按配置读取计划推进;全量目录不等于本轮全部实现,运行通过记录另见验收证据。 -- **本轮用户已确认SaaS↔Dispatcher全部交互只经RabbitMQ专用Topic,禁止双方HTTP。每个D有全局唯一ID及独立接收Topic/队列,不能共享队列抢收指定D的消息。** 精确身份/拓扑/消息/关联待新版合同冻结,见[现行 MQ 机器契约](../../contracts/upstream/v1/mq.schema.json)及[第三方时序](../thirds/第三方对接事件与请求消费顺序_v0.1.md)与[归档计划§1.2/§8.2](../archive/plan-0918.md)。用户后续批准的**配置只读 HTTP**目标仅见[新计划](../plan-config-read-v0.1.md),尚未替换本现行契约。P1仍单活D;D1/D2仅用于本地路由隔离fixture,不开发多D协调。 +- **现行外部已发布 SaaS↔Dispatcher 契约仍只经 RabbitMQ 专用 Topic,禁止双方 HTTP。每个 D 有全局唯一 ID 及独立接收 Topic/队列,不能共享队列抢收指定 D 的消息。** 精确身份/拓扑/消息/关联见[现行 MQ 机器契约](../../contracts/upstream/v1/mq.schema.json)和[归档计划§1.2/§8.2] (../archive/plan-0918.md)。本轮 P1 项目内目标由[新计划](../plan-config-read-v0.1.md)与[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)定义:四条只读 GET、简化命令/控制及单份最终 `call.result`;F01/F07 Schema/正反例/hash 与 Mock C 通过后即可本地实施,不等待外部签收/连通。真实 SaaS 兼容性仍未验证;本地 C 不改变现行外部契约,也不代表生产验收。P1仍单活 D;D1/D2仅用于本地路由隔离 fixture,不开发多 D 协调。 - 旧外部业务字段以《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》正文v1.0及固定包记录为语义来源;其中HTTP传输和旧租户路由已被MQ-only修订替代。旧OpenAPI/哈希只作对照,不手改源包或只读索引,不把中文MQ语义当已发布字段。 - [OpenAPI与MQ字段索引](../references/OpenAPI与MQ字段索引_v0.1.md) 是5份OpenAPI、42个HTTP操作、115个命名组件及2份JSON Schema的只读机器提取快照,记录源哈希,不是第二套手写Schema。 - 下文 **“现有契约”** 不允许自行改字段/语义;**“内部草案”** 是待批准的gRPC方法/数据模型,不冒充已有OpenAPI;**“缺口”** 明确阻塞相应实现/验收。 @@ -29,10 +29,10 @@ ### 1.2 分期与本轮传输修订 -- P1保留call.execute、控制/查询/整体补传/录音协调的既有业务语义及8种业务事件;旧7条HTTP路径全部废弃为接入方式,对应请求响应改经MQ。整体补传恢复原结果,不重新投call.execute执行。 +- **现行外部基线**保留旧 call.execute、控制/查询/整体补传/录音协调语义及8种业务事件;旧7条HTTP路径不再作为现行接入方式,对应请求响应经MQ。此项记录现行 v1,不是本轮本地目标。 - 42操作/115组件仅为上游目录;不把管理平台30操作移入Dispatcher。按实际入口及引用闭包生成校验,来源包/只读索引仍完整留存,不通过删Schema缩小范围。 - 单租户仅指启用策略:保留tenant_key精确路由、租户独立队列/复合幂等键、有界窗口和单 Cell 全局配额;双租户公平、第二 Cell 汇总和多Dispatcher协调另立第二阶段。 -- P1不新增任务MQ模式字段或临时接口。MQ-only所需的控制/查询/配置/上传请求响应必须经GAP-10正式发布,不能伪装为现有8类事件或宽松透传。ASR-only表达沿GAP-08;R04/R06在线改配延后,但最后许可、控制、静态维护屏障和持久恢复不能延后。 +- 本轮本地目标按第三方对接契约使用四条只读配置/发现 GET;命令/控制/必要回执与单份最终 `call.result` 仍走 MQ,不恢复 query/replay、拆分实时文字/拒联/录音事件,也不新增任务 mode 字段。旧 MQ-only GAP-10 只描述外部 v1 基线;本地 C 不等待外部发布,真实兼容性另行记录。ASR-only表达沿GAP-08;R04/R06在线改配延后,但最后许可、控制、静态维护屏障和持久恢复不能延后。 ## 2. 角色、传输和可靠性边界 diff --git a/docs/thirds/v0.2.md b/docs/thirds/v0.2.md new file mode 100644 index 0000000..164cc91 --- /dev/null +++ b/docs/thirds/v0.2.md @@ -0,0 +1,936 @@ +- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.2-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。 +- 每个 SaaS↔D JSON 消息体按 UTF-8 序列化后最多 **8,388,608 bytes**。超限结果保留在持久 outbox,标记 `blocked_payload_too_large` 并记录 event_id/字节数/SHA-256;不发布、不截断、不拆分、不丢弃,需由显式版本变更处理。 +- 对已接纳且预期有录音的通话,上传阶段最迟在 `call.ended_at + 15m` 收口;OSS 成功发送 `uploaded`,明确 PUT 失败立即发送 `unavailable`,仍无确定结果则到期发送 `unavailable`。授权固定 15 分钟;每个录音/upload_id/object_key 组合最多一次 PUT。授权在 PUT 前过期时,Agent 可在上述截止时间内显式向 D 为同一 upload_id/object_key 重新申请授权;不自动续期或创建第二份资产,任何已发起 PUT 都不得重试。确认未产生录音的 `not_created` 立即收口。`call.result` 只生成一次,MQ 重投复用原 event_id。 +- 控制按 task 串行处理,并核对最新任务状态:pause 只接受权威状态 `paused`,resume 只接受 `running` 且本地未 stopped,stop 只接受 `stopped`;乱序/不一致时保持准入关闭并拒绝,stopped 不可逆。控制本身无 command_id/expected revision,不按消息身份去重,重复动作只保持状态幂等。 +- SaaS 先持久 stopped 并停止向任务队列发布,再发 stop。D 持久屏障后静默 ACK 全部未接纳积压,已接纳通话继续按策略收口;仅在任务队列排空后发 `task.control` 的 stopped/applied 回执。SaaS 收到该回执后才可删除队列/绑定;离线或无回执时保留队列。队列只由 SaaS 创建/删除,D 不声明、不绑定、不删除;任务发现 `removed` 只在该退役顺序之后发出,D 清配置但保留执行恢复和结果 outbox。 +- 每个 D 最多允许 256 个仍归属或正在退役的任务队列;task_id 仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,精确命名与最大字节数见 MQ 拓扑文件。`tasks` 每次完整返回,不分页,单 D 最多 256 个归属或正在退役的任务;增量变更一次完整返回且有相同的 256 条上限,超限返回 HTTP 410 `cursor_expired`、不得截断。`cursor` 是不透明变更水位;只有完整响应和任务归属已原子持久化后才推进。游标过期返回 HTTP 410 和 `cursor_expired`,D 关闭新准入并重新取完整快照。轮询周期 30 秒不是端到端发现 SLA。 + +## 1. 触发顺序 + +| 顺序 | 请求与触发 | SaaS 处理/返回 | +| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| 1 | D 启动或配置到期,带 `X-DISPATCHER-id`/`X-DISPATCHER-SECRET-KEY` 请求 `/internal/v1/dispatcher/sip`(拟定 HTTP GET)。 | SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。 | +| 2 | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒(暂定) `GET /internal/v1/dispatcher/tasks?after=`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,任务 ID 的更新/停止也能由变更游标发现 | +| 3 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(下一版精简 payload,**非现行 Schema**)。 | D 按消息中的任务 ID 请求 `GET /internal/v1/dispatcher/task/:task_id`,核验归属 D 的任务快照(拟定 HTTP);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。 | +| 4 | D 从任务取得 `tenant_id`,请求拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota`,与同租户其他任务共享额度后判定接纳。 | 额度缺失/过期不接新呼叫;普通接纳/拒绝有 `command.result`,**已停止任务的未接纳积压仅消费并 ACK,无逐条回传**。 | +| 按需 | SaaS 投递 `task.control` 暂停、恢复或停止(下一版草案)。 | 控制本身有回执;暂停保留积压,恢复消费原队列;停止持久生效后静默消费并 ACK 未接纳积压,不拨号、不向 SaaS 回传这些消息的结果。已在途通话仍按策略处理并给最终结果。 | +| 5 | 通话终结且录音已上传 OSS,D 投递一条本地目标事件 `call.result`。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。 | + +### 1.1 现行 MQ 地址与 JSON 字段不是一回事 + +RabbitMQ 有**发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费**四步; + +```text +SaaS→D exchange: agent-call.dispatchers.v2 + routing key: d..t..in + binding key: d..t..in + queue: agent-call.d..t..v2 + consumer: 对应 Dispatcher +D→SaaS exchange: agent-call.saas.v2 + routing key: d..t..out + queue: agent-call.saas.events.v2 + consumer: SaaS +``` + +例如 §3.1 的 JSON 带 `dispatcher_id=c046b893-8628-4589-ae50-619d049248a6`、`tenant_key=tenant-a`,SaaS 的**MQ 发布参数**就对应 `d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.in`;D 消费队列 `agent-call.d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.v2`。exchange、routing key、queue 和 binding **不在 JSON 的 `payload` 中**; + +### 1.2 本轮任务队列与事件路由 + +**硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。** 现行外部 MQ v2 拓扑保持原样;本轮本地目标使用 v3 名称,完整机器拓扑见 [MQ topology](../contracts/mq-topology-v0.1-proposal.json)。 + +```text +SaaS 创建并绑定: + exchange: agent-call.dispatchers.v3 (topic, durable) + task routing: d..task..in + task queue: agent-call.d..task..v3 + control route: d..control.in + control queue: agent-call.d..control.v3 + dead-letter: agent-call.dead-letter.v3 + D -> SaaS exchange: agent-call.saas.v3 (topic, durable) + result route: d..out + SaaS result queue: agent-call.saas.d..v3 +``` + +所有业务队列 durable、非 exclusive、非 auto-delete;发布消息设 persistent、mandatory,并启用 publisher confirm。SaaS 必须先确认目标队列及精确 binding 已就绪再发布;未路由或 confirm 不成功时保留原消息,恢复后以相同身份/正文重发。D 持久 inbox 与状态提交成功后才 ACK;`call.execute.command_id` 去重并禁止二次 originate。D 的结果 outbox 只有在无 mandatory return 且收到 positive confirm 后才标记已交付;confirm 仅证明 broker 接收,不代表 SaaS 应用处理。Schema/JSON 错误在记录脱敏事实后 `nack(requeue=false)`,由 SaaS 配置的 dead-letter binding 接收;不得静默 ACK 丢弃或无限 requeue。 + +`dispatcher_id` 是小写 canonical UUID v4;`task_id` 全局唯一且仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,不含点号、通配符或分隔符。每个 D 最多 256 个尚未退役的任务队列(含 stopped/draining);`tenant_key` 不进入 queue/routing key,仍按原值保留在消息中并用于额度归属。AMQP routing key 和 queue name 上限均为 255 bytes;上述 task routing key 最长 175 bytes、task queue 最长 186 bytes。`task.control` 走独立 D 控制队列;`command.result`/`call.result` 统一走 per-D result route。消息不设置 broker TTL,`not_after` 由 D 校验并明确拒绝过期命令;stopped 任务积压仍由 D 静默 ACK。 + +## 2. D ← SaaS:只读配置与任务发现 + +四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-ID`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(HEADER 头统一转小写判定匹配)。本地 Mock 使用隔离测试凭据;真实 SaaS 地址、认证实现及轮换未验证,不阻塞本地开发。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。 + +### 2.1 SIP 配置:200,返回本 D 的完整获批快照 + +请求(地址/Header 仍待 SaaS 实现确认): + +```http +GET /internal/v1/dispatcher/sip HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> +``` + +完整 200 响应体: + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "sip_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "revision": 1, + "snapshot_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "approved_at": "2026-09-21T08:00:00+08:00", + "artifact": { + "artifact_id": "artifact-cell-mock-1", + "source_release": "mock-release-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-mock", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "trunks": [ + { + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-profile-mock" + ], + "dial_prefix": "", + "enabled": true, + "sip_endpoint_ref": "sip-endpoint-mock", + "credential_ref": null, + "media_profile_id": "pcma-8k" + } + ], + "media_profiles": { + "pcma-8k": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + } + }, + "load_evidence": null + }, + "trunk_details": [ + { + "trunk_id": "trunk-mock", + "server_host": "sip.example.invalid", + "server_port": 5060, + "transport": null, + "auth_mode": null, + "registration_required": null, + "max_concurrent_calls": null, + "caller_profiles": [ + { + "caller_profile_id": "caller-profile-mock", + "caller_id": "BD00000000" + } + ], + "schedule": { + "time_zone": "Asia/Shanghai", + "weekly_windows": { + "monday": [ + { + "start": "09:00", + "end": "20:00" + } + ], + "tuesday": [ + { + "start": "09:00", + "end": "20:00" + } + ], + "wednesday": [ + { + "start": "09:00", + "end": "20:00" + } + ], + "thursday": [ + { + "start": "09:00", + "end": "20:00" + } + ], + "friday": [ + { + "start": "09:00", + "end": "20:00" + } + ], + "saturday": [], + "sunday": [] + } + } + } + ] +} +``` + +**字段说明/消费动作:** + > Cell:一个外呼应用Asterisk实例(当前阶段不扩展复杂分布式,写死单实例数据,仅填充Trunk 数据列表,后期根据需求调整分布式架构); + > Trunk: 一条外呼线路; +- `schema_version/resource`:草案版本 `config-read.v0.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。 +- `revision/snapshot_sha256/approved_at`:整份获批快照的修订、摘要、批准时间;摘要生成规则和版本来源仍待双方确定,示例摘要仅为占位值。 **(预留,暂不验证)** +- `artifact`:静态 Cell 制品;`artifact_id/source_release/source_digest/approval_reference` 标识制品、来源版本/摘要和批准引用;`cell_id/revision/config_sha256` 标识执行单元及制品版本;`mode` 是 mock/real 范围;`allowed_targets` 是允许的原始号码;`load_evidence` 为可空加载证据。`trunks[]` 中 `trunk_id/provider_id/egress_pool_id` 定义线路、供应商、出口;`codec` 为 PCMA;`caller_profile_ids` 为主叫引用;`dial_prefix` 仅本线路前缀;`enabled` 是否启用;`sip_endpoint_ref/credential_ref/media_profile_id` 为连接、凭据和媒体配置引用,不传实际密码。`media_profiles` 下 `format/sample_rate_hz/channels/payload_type` 定义媒体格式。 +- `trunk_details[]`:每项的 `trunk_id` 必须与 `artifact.trunks[]` 一一对应;`server_host/server_port` 为 SIP 服务端;`transport/auth_mode/registration_required` 是传输、认证和注册方式;`max_concurrent_calls` 是分配到该 D 的线路额度;`null` 代表未知,不可用于真实外呼。`caller_profiles[].caller_profile_id/caller_id` 给出主叫引用/原始标识(如含 `BD`),不可清洗成纯数字。 +- `schedule.time_zone/weekly_windows`:Asia/Shanghai 的周一至周日多时段,`start/end` 是每日左闭右开时间;空日禁止外呼。D 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP `200` 就认为已加载。 + +### 2.2 任务配置:200,ASR + LLM + TTS 模式 + +请求(`task_id` 示例为 `task-mock`): + +```http +GET /internal/v1/dispatcher/task/task-mock HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> +``` + +完整 200 响应体(仅一种智能体模式): + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "task_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "task_id": "task-mock", + "task_revision": 2, + "status": "running", + "name": "Mock task", + "group_id": null, + "max_concurrent_calls": 2, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 120000, + "route_policy_id": "route-mock", + "caller_profile_id": "caller-profile-mock", + "allowed_trunk_ids": [ + "trunk-mock" + ], + "schedule": { + "time_zone": "Asia/Shanghai", + "starts_at": "2026-09-21T00:00:00+08:00", + "ends_at": null, + "weekly_windows": { + "monday": [ + { + "start": "09:00", + "end": "11:00" + }, + { + "start": "14:00", + "end": "18:00" + } + ], + "tuesday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "wednesday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "thursday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "friday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "saturday": [], + "sunday": [] + }, + "excluded_dates": [ + "2026-10-01", + "2026-10-02" + ] + }, + "agent": { + "agent_version_id": "agent-version-mock", + "authorization_id": "auth-mock", + "authorization_expires_at": "2026-09-21T18:00:00+08:00", + "config": { + "agent_version_id": "agent-version-mock", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "Mock prompt for an isolated test.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000 + }, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "opening": "", + "allow_interrupt": true, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } + } + } +} +``` + +**字段说明/消费动作:** + +- `schema_version/resource/dispatcher_id/tenant_id/tenant_key/task_id`:版本、资源 `task_config`、归属 D、租户 ID、原值租户键和单任务 ID;D 必须验证请求归属并按 `tenant_id` 取得 §2.6 的租户额度。`task_revision` 是任务修订,`status` 为拟定 `running/paused/stopped/finished`;非 running 不接新呼叫。 +- `name/group_id` 是名称及可空分组;`max_concurrent_calls` 是本任务额度,不等于跨任务/跨 D 总额度;`ring_timeout_ms/max_call_duration_ms` 是任务级振铃/最长通话毫秒上限;`route_policy_id` 标识这份任务路由;`allowed_trunk_ids[]` 依次列出候选优先级,选择首条已加载、时段/额度有效且支持任务 `caller_profile_id` 的线路;`caller_profile_id` 明确主叫引用,不默认取首个主叫。无匹配项不接纳,选定后固定、拨号失败不自动换线重拨。有效通话上限取任务 `max_call_duration_ms` 与 AI `conversation.max_duration_ms` 的较小值,执行与 AI 控制器一致,不改原授权配置。精简命令不带这些业务值。 +- `schedule.time_zone/starts_at/ends_at` 定义时区和可空的起止时间;`weekly_windows` 按星期列出每日多个左闭右开 `{start,end}`,空数组禁呼;`excluded_dates[]` 为按 Asia/Shanghai 日期优先排除的日子。任务时段还须与线路时段相交。 +- `agent.agent_version_id`:不可变智能体版本,必须与 `agent.config.agent_version_id` 对应;`authorization_id/authorization_expires_at` 为授权身份和截止时间,到期不得由过期缓存继续放行。不返回 `content_sha256`,同一版本内容变化必须拒绝并要求新版本。 +- `agent.config.immutable/mode`:不可变标记及 `full_ai` 模式。`llm.provider_ref/model/temperature/max_tokens/timeout_ms` 为供应商引用、模型、采样、输出上限和超时;`prompt.text/allowed_variables/max_bytes` 为提示词、允许的变量和字节上限;`tts.provider_ref/model/voice/speed/format/timeout_ms` 为语音供应商引用、模型、声音、速度、音频格式与超时;`asr.provider_ref/model/language/input/interim/timeout_ms` 为识别供应商、可选模型、语种、输入格式、是否给出中间转写与超时;音频 `encoding/sample_rate_hz/channels/sample_width_bytes` 定义编码、采样率、声道和样本宽度;`conversation.opening/allow_interrupt/silence_timeout_ms/max_duration_ms/max_turns/sentence_max_chars/max_pending_audio_chunks` 控制开场、打断、静默时限、总时限、轮次及缓存上限。 +- 未接纳呼叫在有效缓存窗口可能仍用旧批准版;已接纳呼叫固定原快照。新版呼叫命令只给任务 ID 与被叫号码,D 须从有效任务配置取得固定版本和任务级超时,不从命令猜值;现行严格 Schema 仍是旧结构。 + +### 2.3 任务配置:200,仅 ASR 模式(独立情况) + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "task_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "task_id": "task-mock", + "task_revision": 2, + "status": "running", + "name": "Mock task", + "group_id": null, + "max_concurrent_calls": 2, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 120000, + "route_policy_id": "route-mock", + "caller_profile_id": "caller-profile-mock", + "allowed_trunk_ids": [ + "trunk-mock" + ], + "schedule": { + "time_zone": "Asia/Shanghai", + "starts_at": "2026-09-21T00:00:00+08:00", + "ends_at": null, + "weekly_windows": { + "monday": [ + { + "start": "09:00", + "end": "11:00" + }, + { + "start": "14:00", + "end": "18:00" + } + ], + "tuesday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "wednesday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "thursday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "friday": [ + { + "start": "09:00", + "end": "18:00" + } + ], + "saturday": [], + "sunday": [] + }, + "excluded_dates": [ + "2026-10-01", + "2026-10-02" + ] + }, + "agent": { + "agent_version_id": "agent_asr_v1", + "authorization_id": "auth-mock", + "authorization_expires_at": "2026-09-21T18:00:00+08:00", + "config": { + "agent_version_id": "agent_asr_v1", + "immutable": true, + "mode": "asr_only", + "asr": { + "provider_ref": "mock", + "model": "mock-asr-v1", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "allow_interrupt": false, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } + } + } +} +``` + +**字段说明/消费动作:** 字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。 + +### 2.4 SIP 或任务:错误返回(`resource_not_found`,HTTP 404,项目内规则) + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "resource_not_found", + "message": "Task is not assigned to this Dispatcher." + } +} +``` + +**字段说明/消费动作:**`schema_version/resource` 标识项目内错误对象;`error.code` 是机器可读错误代码(`resource_not_found` 同时表示任务不存在或不归此 D),`error.message` 是可读说明,不含密钥。本地将此错误映射为 HTTP 404;真实 SaaS 是否采用相同状态码尚未验证。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。 + +### 2.5 D ← SaaS:动态任务发现 + +第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。单次返回完整快照或完整变更集,不分页;全量超出 256 个归属任务时明确失败;增量变更超出单次返回上限时返回 HTTP 410 `cursor_expired`,重新取全量,不以部分成功跳过变更。 + +### 2.5.1 启动或重启:全量快照(HTTP 200,本地目标) + +```http +GET /internal/v1/dispatcher/tasks HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> +``` + +完整 200 响应体: + +```json +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "cursor": "1042", + "tasks": [ + { + "task_id": "task-a", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "running", + "task_revision": 1 + }, + { + "task_id": "task-old", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "stopped", + "task_revision": 3 + } + ] +} +``` + +**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;队列地址按 §1.2 双方已确定的 D/task 命名规则推导,不在响应正文重复。D 只能消费 SaaS 已创建/绑定的队列,不能声明或绑定。 + +### 2.5.2 每 30 秒:增量变化(HTTP 200,本地目标) + +```http +GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> +``` + +完整 200 响应体(包含任务退役): + +```json +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "next_cursor": "1045", + "changes": [ + { + "cursor": "1043", + "operation": "assigned", + "task_id": "task-b", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "running", + "task_revision": 1 + }, + { + "cursor": "1044", + "operation": "updated", + "task_id": "task-a", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "stopped", + "task_revision": 2 + }, + { + "cursor": "1045", + "operation": "removed", + "task_id": "task-old", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a" + } + ] +} +``` + +**增量与退役:**`next_cursor` 是本次完整变更集持久化后的下次 `after`,不按数值或 task_id 比较;`changes[]` 按 SaaS 顺序应用。没有变更时 `changes: []` 且 `next_cursor` 等于请求的 `after`;`removed` 项只带 `cursor/operation/task_id/tenant_id/tenant_key`,须在 §0 停止发布、排空、回执及 SaaS 退役队列/绑定之后发送,不等同于 stopped。D 收到后停止消费、清任务配置,但保留执行恢复和 outbox。错误正文为 `schema_version/resource:error/error:{code,message}`;400 `invalid_cursor`、401 `unauthorized`、403 `dispatcher_not_authorized`、410 `cursor_expired`、503 `service_unavailable`,均不推进游标且关闭新准入;410 重取并原子持久化全量快照后才恢复。任务队列由 SaaS 创建/维护/退役,响应不提供地址;30 秒轮询不能代替 MQ 即时控制。以上均为项目内规则,尚未获真实 SaaS 确认。 + +### 2.6 D ← SaaS:按租户 ID 获取并发额度(新增项目草案) + +D 从任务清单/单任务配置取得 `tenant_id`、原值 `tenant_key` 并核对外呼信封后,再请求额度;不能把任务额度当租户总额。以下路径和字段为**项目提案,尚未由 SaaS 发布**。 + +#### 2.6.1 有可用额度:请求与完整 200 响应 + +```http +GET /internal/v1/dispatcher/tenant/tenant-id-mock/quota HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> +``` + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "tenant_quota", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "quota_revision": 1, + "max_concurrent_calls": 3, + "valid_until": "2026-09-21T18:00:00+08:00" +} +``` + +**字段说明/消费动作:**三个身份字段必须与任务及请求一致;`quota_revision` 为额度版本;`max_concurrent_calls` 是 SaaS **分给本 D 的租户份额**,同租户所有任务共同占用,不是每任务各得3路;`valid_until` 是有效截止。成功核验后最多缓存约60秒且不超过截止时间;同租户任务复用一份额度/占用,D 同一事务核查并预留租户+任务+线路等额度。未知通话继续计数;未来多D需份额之和≤总额,不各拿一份全额。 + +#### 2.6.2 降额或额度为零:200(独立情况) + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "tenant_quota", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-id-mock", + "tenant_key": "tenant-mock", + "quota_revision": 2, + "max_concurrent_calls": 0, + "valid_until": "2026-09-21T18:00:00+08:00" +} +``` + +**字段说明/消费动作:** 0明确禁止新准入,不是无限额。降额时不强挂已有通话、不清未知占用,等占用低于新上限且授权有效才再接新。stop静默排空与控制不需要通话额度,不能因额度0卡住停止任务。 + +#### 2.6.3 无可用租户额度:错误(HTTP 503,项目内规则) + +```json +{ + "schema_version": "config-read.v0.1", + "resource": "error", + "error": { + "code": "tenant_quota_unavailable", + "message": "No valid tenant allocation is available for this dispatcher." + } +} +``` + +**字段说明/消费动作:** 服务端无法提供有效租户份额(缺失、过期或暂不可用)时,本地返回 HTTP 503 与 `tenant_quota_unavailable`;收到 `200` 但身份与请求/任务不符时,D 拒绝并关闭该租户新准入。不得用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。 + +## 3. SaaS → D:下一版精简业务命令 + +以下 JSON 是本项目 F07 冻结的完整下一版 MQ 请求;`schema_version=command-next.v0.1-proposal` 标识项目内版本,不是现行外部 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本地不设计控制命令去重。控制的乱序、重投及处理回执按本节规则和本地 Schema/Mock 测试处理;真实 SaaS 兼容性及从现行 v2 切换仍未验证,不属于本地 C 的外部验收证据。 + +### 3.1 发起外呼:call.execute + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-18T10:00:00+08:00", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-18T10:15:00+08:00", + "payload": { + "task_id": "task-a", + "callee": "15003164745" + } +} +``` + +**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。此为项目内 v0.1 payload,由[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)严格校验并由本地 C 验证;真实 SaaS 兼容性和从现行 v2 切换未验证,不属于本地通过证据。 + +### 3.2 暂停任务:task.control / pause + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "pause", + "active_call_policy": "drain", + "reason": "local-test" + } +} +``` + +**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。控制**无 `command_id`、无 `expected_task_revision`,不做按消息去重**。D 持久暂停屏障、停止该队列消费,并将已预取但未接纳的消息 `nack(requeue=true)` 回原队列;不 ACK 丢弃、不搬入本地待拨队列。已接纳通话按 `drain/hangup` 执行;控制回执在屏障持久且未接纳投递已退回后发送,不等待通话结束。SaaS 按每任务状态变更顺序发布控制,D 每任务串行处理。 + +### 3.3 恢复任务:task.control / resume + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "resume", + "reason": "operator-resume" + } +} +``` + +**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务;仅当权威状态为 `running`、D/租户归属有效且本地从未 stopped 时解除 paused。每条旧命令仍校验 `not_after`,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。控制无编号/修订,不去重;重复 resume 对状态幂等,但每次实际投递都可有独立回执。 + +### 3.4 停止任务:task.control / stop + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "stop", + "active_call_policy": "hangup", + "reason": "operator-stop" + } +} +``` + +**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 先停止该队列发布并确认已有发布处理完,再投递 stop。D 持久 stopped 屏障后静默 ACK 所有未接纳积压,不拨号、不发逐条 `command.result`/`call.result`、不申请额度;不是 purge/delete,也不影响其他任务。D 取消普通 consumer,结清已预取消息后用 `basic.get` 排空队列至空;仅在无未 ACK 投递且确认空队列后发送 stopped/applied 回执。SaaS 收到回执后才可删除队列/绑定并在任务发现中发 `removed`。ACK 丢失、重启、额度 0 或配置失效不改变排空规则;保留本地计数/错误。已接纳/在途通话按 `hangup` 或 `drain` 处理并照常发最终结果;stopped 同任务 ID 不可 resume。 + +**任务发现与控制状态规则(项目内 v0.1):** SaaS 先持久变更权威任务状态,再按每任务顺序发布控制;D 对同任务串行处理。stopped 不可逆;paused 只能由新鲜任务 GET 确认 `running` 的 resume 解锁。D 不允许旧 running 配置/快照覆盖更高 `task_revision` 或清除本地 stopped 屏障;重启恢复持久屏障,全量快照只能收紧准入,不能自行重开。pause/stop 先持久关闭准入;action 与最新任务状态不一致、读取失败或出现乱序冲突时保持关闭并返回 `state_mismatch`/`task_unavailable`。重复 pause/resume/stop 只对状态幂等,不做控制消息去重;每次处理都可产生独立 `event_id` 回执,回执自身重投复用原 event_id。MQ 发布成功不等于控制已应用。 + +### 3.5 D → SaaS:外呼命令处理回执 + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "event_id": "command-result-a", + "event_type": "command.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:00:01+08:00", + "aggregate_type": "command", + "aggregate_id": "command-a", + "aggregate_version": 1, + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "status": "accepted", + "reason_code": "accepted", + "execution_id": "execution-a" + } +} +``` + +**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**;按 `command_id` 持久去重,未知执行不得靠重投产生第二次呼叫。字段由项目内 Schema 校验。 + +### 3.6 D → SaaS:任务控制处理回执 + +```json +{ + "schema_version": "command-next.v0.1-proposal", + "event_id": "control-result-a", + "event_type": "command.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:00:01+08:00", + "aggregate_type": "task", + "aggregate_id": "task-a", + "aggregate_version": 2, + "payload": { + "command_type": "task.control", + "task_id": "task-a", + "action": "pause", + "status": "applied", + "reason_code": "applied", + "task_state": "paused" + } +} +``` + +**字段说明/消费动作:** 控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/reason_code/task_state` 说明处理事实,不提供按控制编号一对一关联,也不把 `event_id` 用作控制去重身份。D 按最新任务状态和本地终态屏障处理乱序;对同一状态的重复动作可重复回执。回执丢失时 SaaS 以最新任务 GET 和后续状态发现收敛,不能把 MQ 发布成功当控制已生效。 + +## 4. D → SaaS:唯一通话结果(项目内 v0.1 `call.result` 契约) + +同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**本轮按该简化实现和验收**,不要求 SaaS 在最终结果前收到实时文字或拒联;真实 SaaS 消费兼容性未验证。停止任务未接纳积压不产生通话事件;其它已接纳执行的消息可靠入队,断线后按原事件身份重投;这不是对外“补传命令”。结果结构由[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json)严格校验,不属于现行外部 MQ v2。 + +### 4.1 录音已上传 OSS:最终成功结果 + +```json +{ + "schema_version": "call-result.v0.1-proposal", + "event_id": "call-result-001", + "event_type": "call.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:10:15+08:00", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "source_command_id": "command-a", + "execution_id": "execution-a", + "call_id": "call-a", + "task_id": "task-a", + "task_revision": 1, + "agent_version_id": "version-a", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "callee": "15003164745", + "trunk_id": "trunk-a", + "started_at": "2026-09-18T10:00:00+08:00", + "ended_at": "2026-09-18T10:10:00+08:00", + "duration_ms": 600000, + "outcome": "answered", + "reason_code": null, + "transcript": [ + { + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "user", + "text": "示例转写内容", + "start_ms": 1000, + "end_ms": 2500 + } + ], + "opt_out": false, + "recording": { + "status": "uploaded", + "recording_id": "recording-a", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "calls/tenant-a/call-a.wav", + "format": "wav", + "channels": 1, + "sample_rate_hz": 8000, + "duration_ms": 600000, + "size_bytes": 9600000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + } +} +``` + +**字段说明/消费动作:**`schema_version/event_type` 是项目内 v0.1 的单一通话结果类型,严格由本地 Schema 校验;现行外部 v2 Schema 保持不变,不能混用。`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。 +`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是仅随最终结果发送的转写片段及时间;`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。 + +### 4.2 录音上传未完成:15 分钟内收口为最终异常结果 + +```json +{ + "schema_version": "call-result.v0.1-proposal", + "event_id": "call-result-002", + "event_type": "call.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "occurred_at": "2026-09-18T10:10:15+08:00", + "aggregate_type": "call", + "aggregate_id": "call-b", + "aggregate_version": 1, + "payload": { + "source_command_id": "execute-b", + "execution_id": "execution-b", + "call_id": "call-b", + "task_id": "task-a", + "task_revision": 1, + "agent_version_id": "version-a", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "callee": "15003164745", + "trunk_id": "trunk-a", + "started_at": "2026-09-18T10:00:00+08:00", + "ended_at": "2026-09-18T10:10:00+08:00", + "duration_ms": 600000, + "outcome": "answered", + "reason_code": null, + "transcript": [], + "opt_out": false, + "recording": { + "status": "unavailable", + "error_code": "upload_timeout", + "recording_id": "recording-b", + "upload_id": "upload-b", + "bucket": null, + "object_key": null, + "format": "wav", + "channels": 1, + "sample_rate_hz": 8000, + "duration_ms": 600000, + "size_bytes": null, + "checksum_sha256": null + } + } +} +``` + +**字段说明/消费动作:** 若录音预期存在但授权/PUT 明确失败,立即以 `recording.status=unavailable` 收口;若仍无确定结果,最迟于 `call.ended_at + 15m` 收口,`bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 仅可为 `upload_authorization_failed`、`upload_authorization_expired`、`upload_failed`、`upload_timeout`、`deadline_exceeded` 或 `checksum_mismatch`,分别记录授权、PUT、总期限或校验阶段;呼叫自身 `reason_code` 保持通话事实。不能谎称上传成功或默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;已接通/正常结束不得因录音失败改成 `failed`。同一录音最多一次 PUT;超时/结果未知不重试 PUT。 + +### 4.3 正常未产生录音:无应答结果 + +```json +{ + "schema_version": "call-result.v0.1-proposal", + "event_id": "call-result-003", + "event_type": "call.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-c", + "occurred_at": "2026-09-18T10:00:30+08:00", + "aggregate_type": "call", + "aggregate_id": "call-c", + "aggregate_version": 1, + "payload": { + "source_command_id": "command-c", + "execution_id": "execution-c", + "call_id": "call-c", + "task_id": "task-a", + "task_revision": 1, + "agent_version_id": "version-a", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "callee": "15003164745", + "trunk_id": "trunk-a", + "started_at": "2026-09-18T10:00:00+08:00", + "ended_at": "2026-09-18T10:00:30+08:00", + "duration_ms": 30000, + "outcome": "no_answer", + "reason_code": "ring_timeout", + "transcript": [], + "opt_out": false, + "recording": { + "status": "not_created", + "reason_code": "no_answer", + "recording_id": null, + "upload_id": null, + "bucket": null, + "object_key": null, + "format": null, + "channels": null, + "sample_rate_hz": null, + "duration_ms": null, + "size_bytes": null, + "checksum_sha256": null + } + } +} +``` + +**字段说明/消费动作:** 这是已接纳、已尝试但无人接听且未产生录音的呼叫;`started_at/duration_ms` 此例表示呼叫尝试起点和尝试耗时,不冒称已接通时长。正常无录音用 `not_created`,资产字段为null,确认终结后即可发送,不申请/等待上传;忙线等正常无录音同类处理,原因须与事实一致。录音本应生成却失败应为 `unavailable` 加明确阶段原因,不伪装正常无录音。普通未接纳拒绝仅有命令回执;stopped未接纳积压无回执也无最终结果,不能虚构call_id。 + +**额度与文件交付分离:** 确认通话终结、执行资源释放就释放通话额度,不等待 OSS 或最终通知确认,未知仍占额。已产生录音才按 4.1/4.2 收口;每条 JSON 消息体上限 8,388,608 bytes,超限持久阻塞 outbox,不截断、不拆分、不恢复实时事件。 + +## 5. 本地实现与外部验收边界 + +- 本文及链接的 `docs/contracts` Schema/正反例/MQ 拓扑是本轮 P1 Go/Mock 的项目内契约。完成 F01/F07 版本、来源/hash、严格校验和 Mock SaaS 端到端 C 后,可直接进入本地实现;不要求真实 SaaS、management 或供应商签收/连通。 +- `contracts/upstream/v1/` 与现行外部 MQ v2 继续作为真实 SaaS 的既有基线。本地 v3 路由和消息不得混入 v2,也不得把 Mock 通过写成 SaaS、management 或生产验收。 +- 本地 MQ 采用 v3 durable topic/queue,任务和 D 结果队列均由 SaaS 创建维护;D 只消费任务/控制并发布结果。命令用 `command_id` 持久去重防止二次 originate;任务控制无 `command_id/expected_task_revision`、不按消息去重,乱序/过期失败关闭。 +- 对外只保留必要命令/控制回执和每个已接纳通话唯一的最终 `call.result`。不保留 query/replay、实时转写/拒联/通话进度/录音拆分事件;stopped 任务未接纳积压只静默 ACK,不产生逐条结果。正常无录音立即以 `not_created` 收口;预期录音失败最晚在 `call.ended_at + 15m` 以 `unavailable` 收口;每个 upload_id 最多一次 PUT,重投复用原 event_id,不重新上传。 +- 所有 MQ JSON 正文上限为 8,388,608 bytes。超限消息留在持久 outbox 并显式阻塞,不截断、不拆分、不丢弃。该上限仅为本地 v0.1 规则;真实 SaaS 与 broker 的兼容性需另行验证。 +- 四条 GET、严格 Schema、任务发现单次完整响应/游标过期恢复、队列退役握手和 `call.result` 正反例均按本文及对应 schema 验证。旧 v0.1 分页契约及本地证据仅为历史;采用本版须重测 F03/F09,不能把旧通过记录当作本版通过。外部正式版本、部署与切换仍是独立事实和授权门禁。 diff --git a/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md b/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md index aba0234..44869e4 100644 --- a/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md +++ b/docs/thirds/第三方对接事件与请求消费顺序_v0.1.md @@ -1,17 +1,28 @@ # SaaS ↔ Dispatcher:请求与通话结果消费顺序 v0.1 -本文只描述 **SaaS 与 Dispatcher(D)**。四个配置/任务发现/租户额度 GET 与简化 `call.execute`、无 `command_id` 的 `task.control`/控制回执均是**下一版待签收草案**;现行 MQ v2 仍要求旧字段;下文“唯一通话结果 `call.result`”是**下一版本提案**,现行 Schema 和程序**尚不支持**。不能把本文的新旧示例拼接成已经可运行的单一版本。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。 +本文是本轮 P1 Go/Mock 实现使用的**项目内 SaaS 对接契约**。F01/F07 按本文和链接的机器 Schema 冻结后,直接用于本地实现和 Mock SaaS 验证;不等待外部 SaaS/management 签收或连通。下文“草案/拟定/待签收”仅表示真实 SaaS/management 兼容性未验证,不阻塞本地实现。真实 SaaS 当前仍可能运行 `contracts/upstream/v1`,其兼容性未验证;Mock 通过只证明本地契约,不代表真实 SaaS、management 或生产验收。不要把现行 v1 与本轮契约拼成一个线上协议。示例为非生产数据,JSON 代码块均为完整请求或返回体;字段说明写在块外。 + +路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 是项目定义的新增路径/字段,按本文实现并在真实对接时记录兼容状态。 + +## 0. 本轮项目内实施规则 + +- 本文是字段与状态语义的唯一说明;机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.1-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。 +- 每个 SaaS↔D JSON 消息体按 UTF-8 序列化后最多 **8,388,608 bytes**。超限结果保留在持久 outbox,标记 `blocked_payload_too_large` 并记录 event_id/字节数/SHA-256;不发布、不截断、不拆分、不丢弃,需由显式版本变更处理。 +- 对已接纳且预期有录音的通话,上传阶段最迟在 `call.ended_at + 15m` 收口;OSS 成功发送 `uploaded`,明确 PUT 失败立即发送 `unavailable`,仍无确定结果则到期发送 `unavailable`。授权固定 15 分钟;每个录音/upload_id/object_key 组合最多一次 PUT。授权在 PUT 前过期时,Agent 可在上述截止时间内显式向 D 为同一 upload_id/object_key 重新申请授权;不自动续期或创建第二份资产,任何已发起 PUT 都不得重试。确认未产生录音的 `not_created` 立即收口。`call.result` 只生成一次,MQ 重投复用原 event_id。 +- 控制按 task 串行处理,并核对最新任务状态:pause 只接受权威状态 `paused`,resume 只接受 `running` 且本地未 stopped,stop 只接受 `stopped`;乱序/不一致时保持准入关闭并拒绝,stopped 不可逆。控制本身无 command_id/expected revision,不按消息身份去重,重复动作只保持状态幂等。 +- SaaS 先持久 stopped 并停止向任务队列发布,再发 stop。D 持久屏障后静默 ACK 全部未接纳积压,已接纳通话继续按策略收口;仅在任务队列排空后发 `task.control` 的 stopped/applied 回执。SaaS 收到该回执后才可删除队列/绑定;离线或无回执时保留队列。队列只由 SaaS 创建/删除,D 不声明、不绑定、不删除;任务发现 `removed` 只在该退役顺序之后发出,D 清配置但保留执行恢复和结果 outbox。 +- 每个 D 最多允许 256 个仍归属或正在退役的任务队列;task_id 仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,精确命名与最大字节数见 MQ 拓扑文件。`tasks` 每页最多 100 条;分页期间固定 snapshot/window,只有全部页面成功持久化后才推进游标。`cursor`/page token 均为不透明值;过期返回 HTTP 410 和 `cursor_expired`/`snapshot_expired`,D 重新取全量快照。轮询周期 30 秒不是端到端发现 SLA。 ## 1. 触发顺序 | 顺序 | 请求与触发 | SaaS 处理/返回 | | --- | --- | --- | | 1 | D 启动或配置到期,带 `X-DISPATCHER-id`/`X-DISPATCHER-SECRET-KEY` 请求 `/internal/v1/dispatcher/sip`(拟定 HTTP GET)。 | SaaS 返回本 D 唯一获批版本;D 核验后才能接受新执行。 | -| 2(下一版拟定) | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /internal/v1/dispatcher/tasks?after=`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。路径、字段、30 秒时限尚待 SaaS 签收。 | +| 2(本地目标契约) | D 启动/重启 `GET /internal/v1/dispatcher/tasks` 取得本 D 的任务全量快照与变更游标,运行中每 30 秒 `GET /internal/v1/dispatcher/tasks?after=`;SaaS 创建任务时先建好任务队列/绑定再发布。 | D 发现新任务后仅消费 SaaS 已创建的队列;D 离线期间消息可留在队列,较小任务 ID 的更新/停止也能由变更游标发现。30 秒是轮询周期,不是发现 SLA;真实 SaaS 兼容性未验证。 | | 3 | SaaS 将任务固定分配给一个 D,向该 D 投递 `call.execute`(下一版精简 payload,**非现行 Schema**)。 | D 按消息中的任务 ID 请求 `GET /internal/v1/dispatcher/task/:task_id`,核验归属 D 的任务快照(拟定 HTTP);未接纳任务可受约 60 秒配置缓存延迟影响,已接纳执行固定原快照。 | | 4 | D 从任务取得 `tenant_id`,请求拟定 `GET /internal/v1/dispatcher/tenant/:tenant_id/quota`,与同租户其他任务共享额度后判定接纳。 | 额度缺失/过期不接新呼叫;普通接纳/拒绝有 `command.result`,**已停止任务的未接纳积压仅消费并 ACK,无逐条回传**。 | | 按需 | SaaS 投递 `task.control` 暂停、恢复或停止(下一版草案)。 | 控制本身有回执;暂停保留积压,恢复消费原队列;停止持久生效后静默消费并 ACK 未接纳积压,不拨号、不向 SaaS 回传这些消息的结果。已在途通话仍按策略处理并给最终结果。 | -| 5 | 通话终结且录音已上传 OSS,D 投递一条 `call.result`(**拟定 MQ 最终事件**)。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时的最终收口见 §4.2,时限尚未签收。 | +| 5 | 通话终结且录音已上传 OSS,D 投递一条本地目标事件 `call.result`。 | SaaS 只处理这条最终的通话详情,按 `event_id` 去重;录音以 `bucket/object_key` 关联,不接收文件、不提供上传会话或验证结果。上传失败/超时按 §0 和 §4.2 的 15 分钟规则收口。 | ### 1.1 现行 MQ 地址与 JSON 字段不是一回事 @@ -31,24 +42,30 @@ D→SaaS exchange: agent-call.saas.v2 例如 §3.1 的 JSON 带 `dispatcher_id=c046b893-8628-4589-ae50-619d049248a6`、`tenant_key=tenant-a`,SaaS 的**MQ 发布参数**就对应 `d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.in`;D 消费队列 `agent-call.d.c046b893-8628-4589-ae50-619d049248a6.t.tenant-a.v2`。exchange、routing key、queue 和 binding **不在 JSON 的 `payload` 中**;JSON 的 `dispatcher_id/tenant_key` 是接收后核验身份。当前消费者启动需要 `--tenant-key` 且由 D 声明租户队列;这不是动态任务发现能力。即使 D 离线,只要队列/绑定已由有权一方预先创建并且消息持久入队,重启后仍可消费;若发布时队列不存在,事后建队**不能倒灌旧消息**,SaaS 要保留原消息并确认就绪后重投,mandatory 返回与 publisher confirm 应同时核对。`dispatcher_id` 是唯一 UUID v4;`tenant_key` 保留原值。仅 publisher confirm **不等于** SaaS 已处理。 -### 1.2 下一版任务队列 KEY(项目示意,尚无发布的机器合同) +### 1.2 本轮任务队列与事件路由(项目内 v0.1 契约) -**硬边界:任务队列与绑定只由 SaaS 创建/维护/退役;D 只消费,不声明、创建、绑定或删除。** SaaS 必须先确认持久队列/精确绑定就绪,再发布 persistent 命令。下面的 `v3-draft` 名称仅展示规则,**不是现网 exchange/queue,也不是可直接上线的名字**: +**硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。** 现行外部 MQ v2 拓扑保持原样;本轮本地目标使用 v3 名称,完整机器拓扑见 [MQ topology](../contracts/mq-topology-v0.1-proposal.json)。 ```text SaaS 创建并绑定: - exchange: agent-call.dispatchers.v3-draft - routing key: d..task..in - binding key: d..task..in - queue: agent-call.d..task..v3-draft -D 仅按 SaaS /tasks 清单中的 queue 名称开始消费。 + exchange: agent-call.dispatchers.v3 (topic, durable) + task routing: d..task..in + task queue: agent-call.d..task..v3 + control route: d..control.in + control queue: agent-call.d..control.v3 + dead-letter: agent-call.dead-letter.v3 + D -> SaaS exchange: agent-call.saas.v3 (topic, durable) + result route: d..out + SaaS result queue: agent-call.saas.d..v3 ``` -`dispatcher_id` 选唯一 D,`task_id` 选该 D 的**一项任务**,`.in` 区分入站;`tenant_key` **不再参与任务队列 KEY**,仍在消息 JSON 和 `/tasks` 结果中,用于租户归属核验及跨任务汇总并发。若 task_id 有重复、点号/通配符或超长,不能直接照拼:ID 唯一性、段格式、完整 key/queue 长度及最终版本名必须先由 F07 冻结。`task.control` 另走 SaaS 创建的 **D 专用控制队列**,不能排在某个任务的呼叫积压之后;命令回执和最终结果回 SaaS 的具体新路由同样待 F07 发布,不套用这些示意名称。暂停/停止靠任务状态,**不是 KEY 中有 task_id 就会自动清空队列**;暂停不丢积压,恢复后继续消费;已停止任务的未接纳旧命令静默消费并 ACK,不产生逐条回执/最终结果,但本地计数和错误可查。 +所有业务队列 durable、非 exclusive、非 auto-delete;发布消息设 persistent、mandatory,并启用 publisher confirm。SaaS 必须先确认目标队列及精确 binding 已就绪再发布;未路由或 confirm 不成功时保留原消息,恢复后以相同身份/正文重发。D 持久 inbox 与状态提交成功后才 ACK;`call.execute.command_id` 去重并禁止二次 originate。D 的结果 outbox 只有在无 mandatory return 且收到 positive confirm 后才标记已交付;confirm 仅证明 broker 接收,不代表 SaaS 应用处理。Schema/JSON 错误在记录脱敏事实后 `nack(requeue=false)`,由 SaaS 配置的 dead-letter binding 接收;不得静默 ACK 丢弃或无限 requeue。 -## 2. D ← SaaS:只读配置与任务发现(拟定,非现网) +`dispatcher_id` 是小写 canonical UUID v4;`task_id` 全局唯一且仅允许 ASCII `[A-Za-z0-9_-]{1,128}`,不含点号、通配符或分隔符。每个 D 最多 256 个尚未退役的任务队列(含 stopped/draining);`tenant_key` 不进入 queue/routing key,仍按原值保留在消息中并用于额度归属。AMQP routing key 和 queue name 上限均为 255 bytes;上述 task routing key 最长 175 bytes、task queue 最长 186 bytes。`task.control` 走独立 D 控制队列;`command.result`/`call.result` 统一走 per-D result route。消息不设置 broker TTL,`not_after` 由 D 校验并明确拒绝过期命令;stopped 任务积压仍由 D 静默 ACK。 -拟定的四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。具体 SaaS 地址及 Header 校验/轮换仍待签收。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。 +## 2. D ← SaaS:只读配置与任务发现(本地目标契约,非现网接口) + +四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-id`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(受控密钥,绝不写入文档/日志)。本地 Mock 使用隔离测试凭据;真实 SaaS 地址、认证实现及轮换未验证,不阻塞本地开发。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。 ### 2.1 SIP 配置:200,返回本 D 的完整获批快照 @@ -427,26 +444,26 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:**字段与 2.2 相同,但 `agent.config.mode=asr_only`,**没有** LLM、提示词或 TTS 对象;配置内外 `agent_version_id` 必须一致。只能按授权的识别配置执行,不应将未提供的字段填成默认值。 -### 2.4 SIP 或任务:错误返回(拟定;HTTP 状态码未定) +### 2.4 SIP 或任务:错误返回(`resource_not_found`,HTTP 404,项目内规则) ```json { "schema_version": "config-read.v0.1", "resource": "error", "error": { - "code": "not_assigned", + "code": "resource_not_found", "message": "Task is not assigned to this Dispatcher." } } ``` -**字段说明/消费动作:**`schema_version/resource` 标识草案错误对象;`error.code` 是机器可读错误代码(示例 `not_assigned` 表示该任务不归此 D),`error.message` 是可读说明,不含密钥。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。 +**字段说明/消费动作:**`schema_version/resource` 标识项目内错误对象;`error.code` 是机器可读错误代码(`resource_not_found` 同时表示任务不存在或不归此 D),`error.message` 是可读说明,不含密钥。本地将此错误映射为 HTTP 404;真实 SaaS 是否采用相同状态码尚未验证。D 不得将失败当作空任务/无限制或使用过期配置接新呼叫;不能自动回退至 MQ 配置通道。 -### 2.5 D ← SaaS:动态任务发现(**下一版草案,现行无此接口/Schema**) +### 2.5 D ← SaaS:动态任务发现(本轮项目内契约;现行外部接口未验证) -第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。路径和两个 Header 作为本轮需求输入,响应 JSON、错误码/分页仍是项目草案,待 SaaS/F07 签收;30 秒是轮询频率,不是端到端 30 秒发现保证,也不同于单任务配置约 60 秒缓存。所有 GET 无请求体,密钥只由受控部署注入。 +第三条只读 HTTP 接口是 `GET /internal/v1/dispatcher/tasks`,与 §2.1/§2.2 两条配置响应分开;机器约束见[任务发现 Schema](../contracts/task-discovery-v0.1-proposal.schema.json)。D 启动/重启时不带 `after` 读取**一致全量快照 + 游标**,运行中**每 30 秒** `GET /internal/v1/dispatcher/tasks?after=` 读取针对本 D 的变更。`after` 是 SaaS 的变更水位,**不是最大 `task_id`**;旧任务的暂停、停止、改派也会返回。每页最多 100 条;全量续页使用 `?snapshot_id=&page_token=`,增量续页使用 `?after=<原始游标>&page_token=`。全量所有页的 `snapshot_id/cursor` 必须固定;同一增量窗口的 `from_cursor/next_cursor` 必须固定,变更按 SaaS 返回顺序应用。只有全部页已持久化后才能原子推进游标。所有 GET 无请求体,密钥只由受控部署注入。成功为 HTTP 200;无效游标/page token 为 400,身份无效为 401、D 无权为 403,`cursor_expired`/`snapshot_expired` 为 410,临时不可用为 503。遇到 410 或分页连续性错误时不得推进游标,暂停新接纳并重取完整快照。30 秒是轮询频率,不是端到端发现保证,也不同于单任务配置约 60 秒缓存。当前正文 JSON 块是项目正例,额外/未知字段拒绝例见[非法队列字段样例](../contracts/examples/task-discovery-invalid-queue-property-v0.1.json);外部兼容性未验证。 -### 2.5.1 启动或重启:全量快照(200,拟定) +### 2.5.1 启动或重启:全量快照(HTTP 200,本地目标) ```http GET /internal/v1/dispatcher/tasks HTTP/1.1 @@ -472,10 +489,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> "status": "running", "task_revision": 1, "queue": { - "exchange": "agent-call.dispatchers.v3-draft", + "exchange": "agent-call.dispatchers.v3", "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", - "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft" + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3" } }, { @@ -485,10 +502,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> "status": "stopped", "task_revision": 3, "queue": { - "exchange": "agent-call.dispatchers.v3-draft", + "exchange": "agent-call.dispatchers.v3", "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in", "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.in", - "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3-draft" + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-old.v3" } } ], @@ -496,9 +513,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时须在同一个 `snapshot_id` 下读完所有页再应用快照/水位,分页传递机制待签收。 +**字段说明/消费动作:**`dispatcher_id` 是被授权的目标 D;`snapshot_id` 锁定同一次全量读取,跨页不得混杂新旧状态;`cursor` 是此快照覆盖的 SaaS 任务变更水位(示例数字只是**不透明字符串**,D 不按大小比较任务 ID);`tasks[]` 列出本 D 全部归属任务及**已停止但队列仍有积压的任务**;`tenant_id` 用于读取 §2.6 额度,`tenant_key` 保留原值并与 tenant_id 一对一核验,用于同租户所有任务共享并发额度;`task_revision/status` 是任务版本和状态;`queue` 是**SaaS 已创建/绑定**的消费地址,D 只能读取,不能自行声明。`next_page_token` 非空时,按 `?snapshot_id=&page_token=` 续读同一快照;读完所有页后再应用快照/水位。 -### 2.5.2 每 30 秒:增量变化(200,拟定) +### 2.5.2 每 30 秒:增量变化(HTTP 200,本地目标) ```http GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1 @@ -526,10 +543,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> "status": "running", "task_revision": 1, "queue": { - "exchange": "agent-call.dispatchers.v3-draft", + "exchange": "agent-call.dispatchers.v3", "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in", "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.in", - "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3-draft" + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-b.v3" } }, { @@ -541,10 +558,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> "status": "stopped", "task_revision": 2, "queue": { - "exchange": "agent-call.dispatchers.v3-draft", + "exchange": "agent-call.dispatchers.v3", "routing_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", "binding_key": "d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.in", - "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3-draft" + "queue_name": "agent-call.d.c046b893-8628-4589-ae50-619d049248a6.task.task-a.v3" } } ], @@ -554,7 +571,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:**`from_cursor` 对应请求的 `after`,`changes[].cursor` 是 SaaS 为本 D 变更生成的顺序水位,`next_cursor` 是成功处理整份回复后的下一次 `after`;`assigned` 为新归属、`updated` 为旧任务版本/状态变化。**任务 `task-a` 的 ID 比新任务旧,却仍被增量返回**,这正是不能用最大任务 ID 当游标的原因。先由 SaaS 创建/绑定任务队列并确认 ready,才能把 `assigned` 返回且开始发布;D 只消费。`stopped` 持久生效后 D 继续读该任务未接纳积压、静默 ACK,不拨号、不逐条回传,也不删除队列;本地日志/计数保留。暂停/停止命令另走 SaaS 创建的 D 控制队列;30 秒任务清单轮询**不能代替即时控制**。同一租户 `task-a`、`task-b` 共同占用 `tenant-a` 额度。D 持久应用变更后才持久推进游标;分页时读完连续页,不得跳过未处理页。 -### 2.5.3 任务改派/退役(200,拟定;与停止不同) +### 2.5.3 任务改派/退役(HTTP 200,本地目标;与停止不同) ```json { @@ -576,9 +593,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;具体握手/退役合同待签收。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。 +**字段说明/消费动作:**`removed` 是 SaaS 确认此 D 不再消费该任务的撤销记录(tombstone),**不是** `stop` 一到就立刻删除队列。必须已停止新发布、旧队列积压和未 ACK 消息处理完毕,且改派时确认旧 D 没有未知执行后再终结旧所有权;停止回执与 SaaS 删除队列的顺序按 §0 执行。D 只停止消费,不负责删队列;队列生命周期仍归 SaaS。 -### 2.5.4 没有变更(200,拟定) +### 2.5.4 没有变更(HTTP 200,本地目标) ```json { @@ -594,7 +611,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:**SaaS 没有新变更时水位不动;D 等下一个 30 秒周期,不因空列表删除已有消费关系。 -### 2.5.5 游标失效或缺页(错误,HTTP 状态待签收) +### 2.5.5 游标失效或缺页(HTTP 410,项目内规则) ```json { @@ -607,7 +624,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。D 不推进错误游标,停受影响任务的新接纳并重新拉一致全量快照;不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。真正的错误码、游标保留期/分页格式须 F07 签收。 +**字段说明/消费动作:**`cursor_expired` 表示 SaaS 已不能提供从旧游标起的连续变更;缺页、断续或快照分页不一致也应中止增量。HTTP 410 的 `cursor_expired` 或 `snapshot_expired` 均不推进游标;D 暂停新接纳,重新拉取并完整持久化一致快照后恢复。不能把错误当无变更或盲目根据 RabbitMQ 队列列表发现任务。游标保留多久由 SaaS 决定,超出时必须返回上述 410 错误,不得返回部分成功。 + +### 2.5.6 其他发现错误的 HTTP 状态(项目内规则) + +`invalid_cursor` 和 `invalid_page_token` 返回 HTTP 400;`unauthorized` 返回 401;`dispatcher_not_authorized` 返回 403;`service_unavailable` 返回 503。HTTP 状态不写入 JSON body,错误 body 严格符合任务发现 Schema。任一失败均不得推进游标或视为空变更;D 保留已持久状态并关闭新准入,410 按 §2.5.5 重新获取并完整持久化全量快照后才能恢复。该映射是项目内规则,真实 SaaS 兼容性未验证。 ### 2.6 D ← SaaS:按租户 ID 获取并发额度(新增项目草案) @@ -654,7 +675,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:**0明确禁止新准入,不是无限额。降额时不强挂已有通话、不清未知占用,等占用低于新上限且授权有效才再接新。stop静默排空与控制不需要通话额度,不能因额度0卡住停止任务。 -#### 2.6.3 缺失或失败:错误(HTTP 状态待签收) +#### 2.6.3 无可用租户额度:错误(HTTP 503,项目内规则) ```json { @@ -667,11 +688,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**缺失、身份不符、过期或刷新失败关闭该租户新准入,不用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。 +**字段说明/消费动作:**服务端无法提供有效租户份额(缺失、过期或暂不可用)时,本地返回 HTTP 503 与 `tenant_quota_unavailable`;收到 `200` 但身份与请求/任务不符时,D 拒绝并关闭该租户新准入。不得用任务额度或无限额兜底;已有执行依原快照处理。核实通话终结并释放执行资源就释放通话额度,**不等待录音上传或最终结果 MQ 确认**;未知通话不能释放。 -## 3. SaaS → D:下一版精简业务命令(**草案,现行严格 MQ Schema 不支持**) +## 3. SaaS → D:下一版精简业务命令(**项目内 F07 v0.1 规则;现行严格 MQ Schema 不支持**) -以下 JSON 均是**完整的拟定下一版 MQ 请求**,`schema_version=command-next.v0.1-proposal` 是草案标记,不是现行 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本阶段不设计控制命令去重。没有这些字段后,控制的乱序、重投以及处理回执如何关联必须在 F07 由 SaaS 签收并如实验收,不能冒称当前协议已经支持。 +以下 JSON 是本项目 F07 冻结的完整下一版 MQ 请求;`schema_version=command-next.v0.1-proposal` 标识项目内版本,不是现行外部 `2.0`。`dispatcher_id/tenant_id/tenant_key` 确定 D 和租户,MQ 发布参数另按 §1 任务 key 精确路由;`issued_at/not_after` 限定有效期;`command_type` 区分呼叫或控制。**仅** `call.execute` 仍带 `command_id`,用来识别不可重复的外呼执行;三个 `task.control` 均不带 `command_id`、`expected_task_revision`,本地不设计控制命令去重。控制的乱序、重投及处理回执按本节规则和本地 Schema/Mock 测试处理;真实 SaaS 兼容性及从现行 v2 切换仍未验证,不属于本地 C 的外部验收证据。 ### 3.1 发起外呼:call.execute @@ -693,7 +714,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。现行 MQ `2.0` 仍要求旧 payload,新版严格 Schema 和 SaaS 发布端未签收/修改前**不能直接用此消息上线**。 +**字段说明/消费动作:**`payload` **只有** `task_id`(SaaS 任务身份)及 `callee`(原始被叫号码,不带线路前缀);租户归属从信封及 `/internal/v1/dispatcher/task/:task_id` 的授权结果核对。路由/主叫/智能体版本和任务级 `ring_timeout_ms/max_call_duration_ms` 全由有效任务配置取得,D 接纳时绑定不可漂移的执行快照;生成 `execution_id` 是 D 内部事实,不由 SaaS 逐呼提供。信封 `command_id` 仅用于外呼命令身份:重投不能第二次拨号。本例15分钟有效期仅示意,不是默认值;SaaS 须覆盖其允许的轮询/配置/额度准备及排队时间。队列ready不代表D已消费;离线或暂停不延长not_after,恢复仅执行仍有效者,过期非stopped消息明确拒绝、不自动重建命令,stopped积压静默ACK。此为项目内 v0.1 payload,由[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)严格校验并由本地 C 验证;真实 SaaS 兼容性和从现行 v2 切换未验证,不属于本地通过证据。 ### 3.2 暂停任务:task.control / pause @@ -716,7 +737,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。此版**无 `command_id`、无 `expected_task_revision`、不定义控制去重**。D 保存暂停屏障并停止消费该任务新执行,已经交付但未接纳的有界消息退回原队列,不能ACK丢弃或搬入无界本地待拨队列;恢复会继续消费原积压,无需SaaS重发。已有执行按所选策略处理,暂停控制本身有回执。 +**字段说明/消费动作:**`task_id` 定位任务;`action=pause` 停止新呼叫准入;`active_call_policy=drain` 允许在途通话自然结束;`reason` 是原因说明。控制**无 `command_id`、无 `expected_task_revision`,不做按消息去重**。D 持久暂停屏障、停止该队列消费,并将已预取但未接纳的消息 `nack(requeue=true)` 回原队列;不 ACK 丢弃、不搬入本地待拨队列。已接纳通话按 `drain/hangup` 执行;控制回执在屏障持久且未接纳投递已退回后发送,不等待通话结束。SaaS 按每任务状态变更顺序发布控制,D 每任务串行处理。 ### 3.3 恢复任务:task.control / resume @@ -738,7 +759,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务,状态running且归属/授权/额度/时段有效、本地未stopped,才能解除paused;每条旧命令仍校验not_after,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。请求无编号/修订、不设计控制去重,乱序时按下方状态优先级处理。 +**字段说明/消费动作:**resume 成功就是恢复消费**原任务队列的积压**,不是等待 SaaS 重发。D 必须绕过缓存读取最新任务;仅当权威状态为 `running`、D/租户归属有效且本地从未 stopped 时解除 paused。每条旧命令仍校验 `not_after`,过期明确拒绝,不延长期限或等待次日。已停止任务不可恢复。控制无编号/修订,不去重;重复 resume 对状态幂等,但每次实际投递都可有独立回执。 ### 3.4 停止任务:task.control / stop @@ -761,11 +782,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 同步停止继续发布;D 小批量**静默消费并ACK所有尚未接纳积压**,不拨号、不发逐条 `command.result` 或 `call.result`,不申请通话额度。不是purge/delete队列,也不影响其他任务。ACK丢失、重启、额度0、配置失效后依旧排空且不补发结果;保留本地计数/错误。**停止控制本身仍有回执**;已接纳/在途通话按 `hangup` 或 `drain` 处理,并照常给真实最终结果,不能因“静默”丢弃它们。stopped 同任务ID不能resume。 +**字段说明/消费动作:**`stop` 持久终止任务准入,SaaS 先停止该队列发布并确认已有发布处理完,再投递 stop。D 持久 stopped 屏障后静默 ACK 所有未接纳积压,不拨号、不发逐条 `command.result`/`call.result`、不申请额度;不是 purge/delete,也不影响其他任务。D 取消普通 consumer,结清已预取消息后用 `basic.get` 排空队列至空;仅在无未 ACK 投递且确认空队列后发送 stopped/applied 回执。SaaS 收到回执后才可删除队列/绑定并在任务发现中发 `removed`。ACK 丢失、重启、额度 0 或配置失效不改变排空规则;保留本地计数/错误。已接纳/在途通话按 `hangup` 或 `drain` 处理并照常发最终结果;stopped 同任务 ID 不可 resume。 -**配置、任务发现和控制的状态优先级(拟定):**SaaS 先持久变更权威任务状态,再发控制;D 对同任务串行处理控制/接纳。stopped不可逆,paused只能由上述有效resume解锁,旧running配置/清单不能解锁,低于已知task_revision的状态不可覆盖新状态。重启恢复本地屏障及全量时取更严格者;快照可关准入、不能擅自重开。pause/stop先关准入,最新权威状态不符/读取失败或迟到控制产生冲突时保守保持关闭并返回明确失败;需有效新resume才能恢复。不设计控制消息去重,可能多次回执;不能把MQ发布成功当控制已应用。该规则须F07双方签收,不是现行代码已保证。 +**任务发现与控制状态规则(项目内 v0.1):**SaaS 先持久变更权威任务状态,再按每任务顺序发布控制;D 对同任务串行处理。stopped 不可逆;paused 只能由新鲜任务 GET 确认 `running` 的 resume 解锁。D 不允许旧 running 配置/快照覆盖更高 `task_revision` 或清除本地 stopped 屏障;重启恢复持久屏障,全量快照只能收紧准入,不能自行重开。pause/stop 先持久关闭准入;action 与最新任务状态不一致、读取失败或出现乱序冲突时保持关闭并返回 `state_mismatch`/`task_unavailable`。重复 pause/resume/stop 只对状态幂等,不做控制消息去重;每次处理都可产生独立 `event_id` 回执,回执自身重投复用原 event_id。MQ 发布成功不等于控制已应用。 -### 3.5 D → SaaS:外呼命令处理回执(草案,不是通话结果) +### 3.5 D → SaaS:外呼命令处理回执(项目内 v0.1,不是通话结果) ```json { @@ -790,9 +811,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**,未知执行不得靠重投产生第二次呼叫;下一版字段/版本仍待 F07 签收。 +**字段说明/消费动作:**`payload.command_id` 仅指向 §3.1 的外呼命令;`status` 区分接纳/拒绝,`execution_id` 是 D 接纳后生成的执行身份。已停止任务的未接纳积压**不发送此回执**;其他未接纳拒绝只有命令回执、不伪造通话。MQ 回执**不代表已拨号或已完成通话**;按 `command_id` 持久去重,未知执行不得靠重投产生第二次呼叫。字段由项目内 Schema 校验。 -### 3.6 D → SaaS:任务控制处理回执(草案;与外呼回执分开) +### 3.6 D → SaaS:任务控制处理回执(项目内 v0.1;与外呼回执分开) ```json { @@ -818,11 +839,11 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/task_state` 说明任务和实际处理结果,不提供按原控制编号一对一关联,也不把 `event_id` 用作控制去重身份。SaaS 仅能据已收到的事实更新展示;对控制并发/乱序、丢失回执和重投后的最终状态判定需要 F07 明确,不能把 MQ 发布成功当控制已生效。 +**字段说明/消费动作:**控制请求不带 `command_id/expected_task_revision`,回执以 `task_id/action/status/reason_code/task_state` 说明处理事实,不提供按控制编号一对一关联,也不把 `event_id` 用作控制去重身份。D 按最新任务状态和本地终态屏障处理乱序;对同一状态的重复动作可重复回执。回执丢失时 SaaS 以最新任务 GET 和后续状态发现收敛,不能把 MQ 发布成功当控制已生效。 -## 4. D → SaaS:唯一通话结果(**拟定新 MQ 合同,尚无已发布 Schema**) +## 4. D → SaaS:唯一通话结果(项目内 v0.1 `call.result` 契约) -同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**这会改变现有“实时文字/即时拒联”的产品要求,必须在新版合同与验收中明确批准;SaaS 在最终结果到达前不会获得这些反馈。**停止任务未接纳积压不产生通话事件;其它真实执行的消息仍应可靠入队,断线后按同一事件身份重投;这不是对外“补传命令”。以下三个结构均为待签收提案,不能用现行 MQ/event Schema 校验,也不能作为已上线接口。 +同一次通话只发布一种业务反馈 `call.result`:通话状态、最终转写、拒联结果、录音资产一次返回;不再将通话进度、实时文字、拒联、通话结束、录音成功/失败各自发布对外事件。**本轮按该简化实现和验收**,不要求 SaaS 在最终结果前收到实时文字或拒联;真实 SaaS 消费兼容性未验证。停止任务未接纳积压不产生通话事件;其它已接纳执行的消息可靠入队,断线后按原事件身份重投;这不是对外“补传命令”。结果结构由[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json)严格校验,不属于现行外部 MQ v2。 ### 4.1 录音已上传 OSS:最终成功结果 @@ -883,10 +904,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**`schema_version/event_type` 是**待签收的新版本及单一通话结果类型**,现行 Schema 不认此值;`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。 -`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是最终转写片段及时间(完整用户文本是否允许外传须由本阶段业务合同确定);`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。 +**字段说明/消费动作:**`schema_version/event_type` 是项目内 v0.1 的单一通话结果类型,严格由本地 Schema 校验;现行外部 v2 Schema 保持不变,不能混用。`event_id` 是固定的事件身份,重复入队须相同;`dispatcher_id/tenant_id/tenant_key/trace_id` 限定来源和归属;`aggregate_type/aggregate_id/aggregate_version/occurred_at` 为呼叫聚合、版本和完成时间。 +`payload.source_command_id/execution_id/call_id/task_id/task_revision/agent_version_id` 绑定原外呼命令、D 生成的执行/呼叫及从任务快照绑定的固定版本;`route_policy_id/caller_profile_id/trunk_id/callee` 为路由策略、主叫配置、实际线路及原始被叫;`started_at/ended_at/duration_ms/outcome/reason_code` 给出起止、时长、结果和可空原因。`transcript[]` 中 `turn_id/segment_id/role/text/start_ms/end_ms` 是仅随最终结果发送的转写片段及时间;`opt_out` 表示通话中的拒联事实,只在最终消息里可见。`recording.status/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256` 描述已成功上传的资产,不包含文件、TOKEN 或签名 URL。SaaS 使用 `call_id` 关联、`event_id` 去重并按固定 `upload_id` 避免重复资产。 -### 4.2 录音上传未完成:最终异常结果(是否启用及截止时间待签收) +### 4.2 录音上传未完成:15 分钟内收口为最终异常结果 ```json { @@ -937,9 +958,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> } ``` -**字段说明/消费动作:**这是**防止录音永远未上传时通话结果永久消失的待定方案**:经合同规定的有限截止时间或确知不可恢复后,`recording.status=unavailable` 且 `bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 表示未得到录音资产,呼叫自身的 `reason_code` 仍为 null;不能谎称上传成功,也不能默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;若通话已接通/正常结束,不得仅因录音失败就把 `outcome` 改成 `failed`。具体结果字段、期限、未上传时是否仍发一次最终结果待 SaaS 签收;未签收前不能实施或用无限等待代替错误处理。 +**字段说明/消费动作:**若录音预期存在但授权/PUT 明确失败,立即以 `recording.status=unavailable` 收口;若仍无确定结果,最迟于 `call.ended_at + 15m` 收口,`bucket/object_key/size_bytes/checksum_sha256=null`,`recording.error_code` 仅可为 `upload_authorization_failed`、`upload_authorization_expired`、`upload_failed`、`upload_timeout`、`deadline_exceeded` 或 `checksum_mismatch`,分别记录授权、PUT、总期限或校验阶段;呼叫自身 `reason_code` 保持通话事实。不能谎称上传成功或默默丢弃最终结果。`outcome` 必须反映**通话本身**而非上传成败;已接通/正常结束不得因录音失败改成 `failed`。同一录音最多一次 PUT;超时/结果未知不重试 PUT。 -### 4.3 正常未产生录音:无应答结果(完整独立例,拟定) +### 4.3 正常未产生录音:无应答结果(完整独立例,项目内规则) ```json { @@ -992,12 +1013,13 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:**这是已接纳、已尝试但无人接听且未产生录音的呼叫;`started_at/duration_ms` 此例表示呼叫尝试起点和尝试耗时,不冒称已接通时长。正常无录音用 `not_created`,资产字段为null,确认终结后即可发送,不申请/等待上传;忙线等正常无录音同类处理,原因须与事实一致。录音本应生成却失败应为 `unavailable` 加明确阶段原因,不伪装正常无录音。普通未接纳拒绝仅有命令回执;stopped未接纳积压无回执也无最终结果,不能虚构call_id。 -**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待OSS或最终通知确认,未知仍占额。已产生录音才按4.1/4.2收口,上传失败有限截止时间仍待签收;完整文字汇总可能超过原MQ大小上限,F07须明确预算和失败处理,不能偷偷截断或恢复被移除的实时事件。 +**额度与文件交付分离:**确认通话终结、执行资源释放就释放通话额度,不等待 OSS 或最终通知确认,未知仍占额。已产生录音才按 4.1/4.2 收口;每条 JSON 消息体上限 8,388,608 bytes,超限持久阻塞 outbox,不截断、不拆分、不恢复实时事件。 -## 5. 下一版本签收前不得误用 +## 5. 本地实现与外部验收边界 -- 本文 §3/§4 都是**待签收的下一版 MQ 消息草案**,均不能直接混入现行 v2 合同;先由 SaaS 与本项目发布严格新版 Schema、正反例及新队列拓扑,再实施两端。外呼命令必须有可靠执行身份防重复拨号;控制不带编号/修订,不设计控制去重,其并发/乱序与回执关联后果必须在 F07 明确。 -- 取消对外查询与补传命令不取消 D 的持久化恢复、同一身份重投、故障对账和**未知是否已拨号时绝不重拨**。没有核实状态的内部恢复能力不得发布新版本。 -- 已产生录音的通话在上传OSS后发布含资产的最终结果;正常未产生录音用not_created并在确认终结后直接回传,不等不存在的上传。应有录音却失败/上传超时的有限期限及unavailable结构必须先签收,不无限等待;通话占用释放独立于文件上传。 -- SaaS 不再实时得知拒联及转写,会影响跨任务、跨 D 停呼与实时展示。新目标与既有产品要求冲突,须取得业务签收并修订原有验收,不能凭本文视为既有验收已通过。 -- SIP、任务及新增租户额度 HTTP 响应以[项目草案 Schema](../contracts/config-read-v0.1.schema.json)校验;`tasks` 接口、精简 `call.execute`、无编号任务控制、两种回执及新 `call.result` 均**尚无已发布 Schema**。现行 v2 仍以[`mq.schema.json`](../../contracts/upstream/v1/mq.schema.json)、[`event-payloads.schema.json`](../../contracts/upstream/v1/event-payloads.schema.json)为准,不能拿新示例冒充当前可投消息。 +- 本文及链接的 `docs/contracts` Schema/正反例/MQ 拓扑是本轮 P1 Go/Mock 的项目内契约。完成 F01/F07 版本、来源/hash、严格校验和 Mock SaaS 端到端 C 后,可直接进入本地实现;不要求真实 SaaS、management 或供应商签收/连通。 +- `contracts/upstream/v1/` 与现行外部 MQ v2 继续作为真实 SaaS 的既有基线。本地 v3 路由和消息不得混入 v2,也不得把 Mock 通过写成 SaaS、management 或生产验收。 +- 本地 MQ 采用 v3 durable topic/queue,任务和 D 结果队列均由 SaaS 创建维护;D 只消费任务/控制并发布结果。命令用 `command_id` 持久去重防止二次 originate;任务控制无 `command_id/expected_task_revision`、不按消息去重,乱序/过期失败关闭。 +- 对外只保留必要命令/控制回执和每个已接纳通话唯一的最终 `call.result`。不保留 query/replay、实时转写/拒联/通话进度/录音拆分事件;stopped 任务未接纳积压只静默 ACK,不产生逐条结果。正常无录音立即以 `not_created` 收口;预期录音失败最晚在 `call.ended_at + 15m` 以 `unavailable` 收口;每个 upload_id 最多一次 PUT,重投复用原 event_id,不重新上传。 +- 所有 MQ JSON 正文上限为 8,388,608 bytes。超限消息留在持久 outbox 并显式阻塞,不截断、不拆分、不丢弃。该上限仅为本地 v0.1 规则;真实 SaaS 与 broker 的兼容性需另行验证。 +- 四条 GET、严格 Schema、任务发现分页/游标错误、队列退役握手和 `call.result` 正反例均按本文及对应 schema 验证。外部正式版本、部署与切换仍是独立事实和授权门禁。 diff --git a/gen/agent/v1/agent.pb.go b/gen/agent/v1/agent.pb.go index a9e25a1..56b7893 100644 --- a/gen/agent/v1/agent.pb.go +++ b/gen/agent/v1/agent.pb.go @@ -1249,6 +1249,10 @@ func (x *ResourceSample) GetMissingReason() string { return "" } +// Project-local v0.1 status convention: exactly one kind="sip" entry identifies +// the SIP artifact loaded by the Agent/Asterisk; state="applied" means that exact +// artifact is active. revision and config_sha256 must match the approved artifact. +// Missing, duplicate, or mismatched SIP entries are unknown and fail closed. type AppliedConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` @@ -2641,6 +2645,178 @@ func (x *ExecuteResponse) GetState() ExecutionState { return ExecutionState_EXECUTION_STATE_UNSPECIFIED } +// Versioned project-local D→Agent execution contract. Only the Dispatcher +// evaluates the task/line schedule, allowlist, route and effective duration. +// The Agent checks the authorized deadline and transport/session identity; it +// never selects another trunk or recalculates outbound business policy. +type ExecuteAuthorizedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SchemaVersion string `protobuf:"bytes,1,opt,name=schema_version,json=schemaVersion,proto3" json:"schema_version,omitempty"` // agent-authorized-origination.v0.1 + Meta *RequestMeta `protobuf:"bytes,2,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,3,opt,name=binding,proto3" json:"binding,omitempty"` + SelectedTrunkId string `protobuf:"bytes,4,opt,name=selected_trunk_id,json=selectedTrunkId,proto3" json:"selected_trunk_id,omitempty"` + CallerId string `protobuf:"bytes,5,opt,name=caller_id,json=callerId,proto3" json:"caller_id,omitempty"` + Callee string `protobuf:"bytes,6,opt,name=callee,proto3" json:"callee,omitempty"` + RingTimeoutMs int64 `protobuf:"varint,7,opt,name=ring_timeout_ms,json=ringTimeoutMs,proto3" json:"ring_timeout_ms,omitempty"` + MaxCallDurationMs int64 `protobuf:"varint,8,opt,name=max_call_duration_ms,json=maxCallDurationMs,proto3" json:"max_call_duration_ms,omitempty"` + DialBeforeUnixMs int64 `protobuf:"varint,9,opt,name=dial_before_unix_ms,json=dialBeforeUnixMs,proto3" json:"dial_before_unix_ms,omitempty"` // Exclusive D-issued deadline. + BoundSnapshotSha256 string `protobuf:"bytes,10,opt,name=bound_snapshot_sha256,json=boundSnapshotSha256,proto3" json:"bound_snapshot_sha256,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteAuthorizedRequest) Reset() { + *x = ExecuteAuthorizedRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteAuthorizedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteAuthorizedRequest) ProtoMessage() {} + +func (x *ExecuteAuthorizedRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteAuthorizedRequest.ProtoReflect.Descriptor instead. +func (*ExecuteAuthorizedRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{25} +} + +func (x *ExecuteAuthorizedRequest) GetSchemaVersion() string { + if x != nil { + return x.SchemaVersion + } + return "" +} + +func (x *ExecuteAuthorizedRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ExecuteAuthorizedRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ExecuteAuthorizedRequest) GetSelectedTrunkId() string { + if x != nil { + return x.SelectedTrunkId + } + return "" +} + +func (x *ExecuteAuthorizedRequest) GetCallerId() string { + if x != nil { + return x.CallerId + } + return "" +} + +func (x *ExecuteAuthorizedRequest) GetCallee() string { + if x != nil { + return x.Callee + } + return "" +} + +func (x *ExecuteAuthorizedRequest) GetRingTimeoutMs() int64 { + if x != nil { + return x.RingTimeoutMs + } + return 0 +} + +func (x *ExecuteAuthorizedRequest) GetMaxCallDurationMs() int64 { + if x != nil { + return x.MaxCallDurationMs + } + return 0 +} + +func (x *ExecuteAuthorizedRequest) GetDialBeforeUnixMs() int64 { + if x != nil { + return x.DialBeforeUnixMs + } + return 0 +} + +func (x *ExecuteAuthorizedRequest) GetBoundSnapshotSha256() string { + if x != nil { + return x.BoundSnapshotSha256 + } + return "" +} + +type ExecuteAuthorizedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + State ExecutionState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.ExecutionState" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteAuthorizedResponse) Reset() { + *x = ExecuteAuthorizedResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteAuthorizedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteAuthorizedResponse) ProtoMessage() {} + +func (x *ExecuteAuthorizedResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteAuthorizedResponse.ProtoReflect.Descriptor instead. +func (*ExecuteAuthorizedResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{26} +} + +func (x *ExecuteAuthorizedResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *ExecuteAuthorizedResponse) GetState() ExecutionState { + if x != nil { + return x.State + } + return ExecutionState_EXECUTION_STATE_UNSPECIFIED +} + type GetExecutionPermitRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -2655,7 +2831,7 @@ type GetExecutionPermitRequest struct { func (x *GetExecutionPermitRequest) Reset() { *x = GetExecutionPermitRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[25] + mi := &file_agent_v1_agent_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2667,7 +2843,7 @@ func (x *GetExecutionPermitRequest) String() string { func (*GetExecutionPermitRequest) ProtoMessage() {} func (x *GetExecutionPermitRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[25] + mi := &file_agent_v1_agent_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2680,7 +2856,7 @@ func (x *GetExecutionPermitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetExecutionPermitRequest.ProtoReflect.Descriptor instead. func (*GetExecutionPermitRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{25} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{27} } func (x *GetExecutionPermitRequest) GetMeta() *RequestMeta { @@ -2741,7 +2917,7 @@ type ExecutionPermit struct { func (x *ExecutionPermit) Reset() { *x = ExecutionPermit{} - mi := &file_agent_v1_agent_proto_msgTypes[26] + mi := &file_agent_v1_agent_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2753,7 +2929,7 @@ func (x *ExecutionPermit) String() string { func (*ExecutionPermit) ProtoMessage() {} func (x *ExecutionPermit) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[26] + mi := &file_agent_v1_agent_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2766,7 +2942,7 @@ func (x *ExecutionPermit) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionPermit.ProtoReflect.Descriptor instead. func (*ExecutionPermit) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{26} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{28} } func (x *ExecutionPermit) GetPermitId() string { @@ -2835,7 +3011,7 @@ type GetExecutionPermitResponse struct { func (x *GetExecutionPermitResponse) Reset() { *x = GetExecutionPermitResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[27] + mi := &file_agent_v1_agent_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2847,7 +3023,7 @@ func (x *GetExecutionPermitResponse) String() string { func (*GetExecutionPermitResponse) ProtoMessage() {} func (x *GetExecutionPermitResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[27] + mi := &file_agent_v1_agent_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2860,7 +3036,7 @@ func (x *GetExecutionPermitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetExecutionPermitResponse.ProtoReflect.Descriptor instead. func (*GetExecutionPermitResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{27} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{29} } func (x *GetExecutionPermitResponse) GetReceipt() *OperationReceipt { @@ -2891,7 +3067,7 @@ type ApplyTaskControlRequest struct { func (x *ApplyTaskControlRequest) Reset() { *x = ApplyTaskControlRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[28] + mi := &file_agent_v1_agent_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2903,7 +3079,7 @@ func (x *ApplyTaskControlRequest) String() string { func (*ApplyTaskControlRequest) ProtoMessage() {} func (x *ApplyTaskControlRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[28] + mi := &file_agent_v1_agent_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2916,7 +3092,7 @@ func (x *ApplyTaskControlRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyTaskControlRequest.ProtoReflect.Descriptor instead. func (*ApplyTaskControlRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{28} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{30} } func (x *ApplyTaskControlRequest) GetMeta() *RequestMeta { @@ -2972,7 +3148,7 @@ type ApplyTaskControlResponse struct { func (x *ApplyTaskControlResponse) Reset() { *x = ApplyTaskControlResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[29] + mi := &file_agent_v1_agent_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2984,7 +3160,7 @@ func (x *ApplyTaskControlResponse) String() string { func (*ApplyTaskControlResponse) ProtoMessage() {} func (x *ApplyTaskControlResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[29] + mi := &file_agent_v1_agent_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2997,7 +3173,7 @@ func (x *ApplyTaskControlResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyTaskControlResponse.ProtoReflect.Descriptor instead. func (*ApplyTaskControlResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{29} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{31} } func (x *ApplyTaskControlResponse) GetReceipt() *OperationReceipt { @@ -3031,7 +3207,7 @@ type QueryExecutionRequest struct { func (x *QueryExecutionRequest) Reset() { *x = QueryExecutionRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[30] + mi := &file_agent_v1_agent_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3043,7 +3219,7 @@ func (x *QueryExecutionRequest) String() string { func (*QueryExecutionRequest) ProtoMessage() {} func (x *QueryExecutionRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[30] + mi := &file_agent_v1_agent_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3056,7 +3232,7 @@ func (x *QueryExecutionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryExecutionRequest.ProtoReflect.Descriptor instead. func (*QueryExecutionRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{30} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{32} } func (x *QueryExecutionRequest) GetMeta() *RequestMeta { @@ -3089,7 +3265,7 @@ type ExecutionSnapshot struct { func (x *ExecutionSnapshot) Reset() { *x = ExecutionSnapshot{} - mi := &file_agent_v1_agent_proto_msgTypes[31] + mi := &file_agent_v1_agent_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3101,7 +3277,7 @@ func (x *ExecutionSnapshot) String() string { func (*ExecutionSnapshot) ProtoMessage() {} func (x *ExecutionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[31] + mi := &file_agent_v1_agent_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3114,7 +3290,7 @@ func (x *ExecutionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionSnapshot.ProtoReflect.Descriptor instead. func (*ExecutionSnapshot) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{31} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{33} } func (x *ExecutionSnapshot) GetBinding() *ExecutionBinding { @@ -3184,7 +3360,7 @@ type QueryExecutionResponse struct { func (x *QueryExecutionResponse) Reset() { *x = QueryExecutionResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[32] + mi := &file_agent_v1_agent_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3196,7 +3372,7 @@ func (x *QueryExecutionResponse) String() string { func (*QueryExecutionResponse) ProtoMessage() {} func (x *QueryExecutionResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[32] + mi := &file_agent_v1_agent_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3209,7 +3385,7 @@ func (x *QueryExecutionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use QueryExecutionResponse.ProtoReflect.Descriptor instead. func (*QueryExecutionResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{32} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{34} } func (x *QueryExecutionResponse) GetMeta() *ResponseMeta { @@ -3249,7 +3425,7 @@ type ExecutionFact struct { func (x *ExecutionFact) Reset() { *x = ExecutionFact{} - mi := &file_agent_v1_agent_proto_msgTypes[33] + mi := &file_agent_v1_agent_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3261,7 +3437,7 @@ func (x *ExecutionFact) String() string { func (*ExecutionFact) ProtoMessage() {} func (x *ExecutionFact) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[33] + mi := &file_agent_v1_agent_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3274,7 +3450,7 @@ func (x *ExecutionFact) ProtoReflect() protoreflect.Message { // Deprecated: Use ExecutionFact.ProtoReflect.Descriptor instead. func (*ExecutionFact) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{33} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{35} } func (x *ExecutionFact) GetFactId() string { @@ -3343,7 +3519,7 @@ type ReportExecutionEventRequest struct { func (x *ReportExecutionEventRequest) Reset() { *x = ReportExecutionEventRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[34] + mi := &file_agent_v1_agent_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3355,7 +3531,7 @@ func (x *ReportExecutionEventRequest) String() string { func (*ReportExecutionEventRequest) ProtoMessage() {} func (x *ReportExecutionEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[34] + mi := &file_agent_v1_agent_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3368,7 +3544,7 @@ func (x *ReportExecutionEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportExecutionEventRequest.ProtoReflect.Descriptor instead. func (*ReportExecutionEventRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{34} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{36} } func (x *ReportExecutionEventRequest) GetMeta() *RequestMeta { @@ -3394,7 +3570,7 @@ type ReportExecutionEventResponse struct { func (x *ReportExecutionEventResponse) Reset() { *x = ReportExecutionEventResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[35] + mi := &file_agent_v1_agent_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3406,7 +3582,7 @@ func (x *ReportExecutionEventResponse) String() string { func (*ReportExecutionEventResponse) ProtoMessage() {} func (x *ReportExecutionEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[35] + mi := &file_agent_v1_agent_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3419,7 +3595,7 @@ func (x *ReportExecutionEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ReportExecutionEventResponse.ProtoReflect.Descriptor instead. func (*ReportExecutionEventResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{35} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{37} } func (x *ReportExecutionEventResponse) GetReceipt() *OperationReceipt { @@ -3441,7 +3617,7 @@ type RequestUploadRequest struct { func (x *RequestUploadRequest) Reset() { *x = RequestUploadRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[36] + mi := &file_agent_v1_agent_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3453,7 +3629,7 @@ func (x *RequestUploadRequest) String() string { func (*RequestUploadRequest) ProtoMessage() {} func (x *RequestUploadRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[36] + mi := &file_agent_v1_agent_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3466,7 +3642,7 @@ func (x *RequestUploadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestUploadRequest.ProtoReflect.Descriptor instead. func (*RequestUploadRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{36} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{38} } func (x *RequestUploadRequest) GetMeta() *RequestMeta { @@ -3512,7 +3688,7 @@ type UploadGrant struct { func (x *UploadGrant) Reset() { *x = UploadGrant{} - mi := &file_agent_v1_agent_proto_msgTypes[37] + mi := &file_agent_v1_agent_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3524,7 +3700,7 @@ func (x *UploadGrant) String() string { func (*UploadGrant) ProtoMessage() {} func (x *UploadGrant) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[37] + mi := &file_agent_v1_agent_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3537,7 +3713,7 @@ func (x *UploadGrant) ProtoReflect() protoreflect.Message { // Deprecated: Use UploadGrant.ProtoReflect.Descriptor instead. func (*UploadGrant) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{37} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{39} } func (x *UploadGrant) GetUploadId() string { @@ -3600,7 +3776,7 @@ type RequestUploadResponse struct { func (x *RequestUploadResponse) Reset() { *x = RequestUploadResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[38] + mi := &file_agent_v1_agent_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3612,7 +3788,7 @@ func (x *RequestUploadResponse) String() string { func (*RequestUploadResponse) ProtoMessage() {} func (x *RequestUploadResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[38] + mi := &file_agent_v1_agent_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3625,7 +3801,7 @@ func (x *RequestUploadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestUploadResponse.ProtoReflect.Descriptor instead. func (*RequestUploadResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{38} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{40} } func (x *RequestUploadResponse) GetReceipt() *OperationReceipt { @@ -3663,7 +3839,7 @@ type CompleteUploadRequest struct { func (x *CompleteUploadRequest) Reset() { *x = CompleteUploadRequest{} - mi := &file_agent_v1_agent_proto_msgTypes[39] + mi := &file_agent_v1_agent_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3675,7 +3851,7 @@ func (x *CompleteUploadRequest) String() string { func (*CompleteUploadRequest) ProtoMessage() {} func (x *CompleteUploadRequest) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[39] + mi := &file_agent_v1_agent_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3688,7 +3864,7 @@ func (x *CompleteUploadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteUploadRequest.ProtoReflect.Descriptor instead. func (*CompleteUploadRequest) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{39} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{41} } func (x *CompleteUploadRequest) GetMeta() *RequestMeta { @@ -3743,7 +3919,7 @@ type CompleteUploadResponse struct { func (x *CompleteUploadResponse) Reset() { *x = CompleteUploadResponse{} - mi := &file_agent_v1_agent_proto_msgTypes[40] + mi := &file_agent_v1_agent_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3755,7 +3931,7 @@ func (x *CompleteUploadResponse) String() string { func (*CompleteUploadResponse) ProtoMessage() {} func (x *CompleteUploadResponse) ProtoReflect() protoreflect.Message { - mi := &file_agent_v1_agent_proto_msgTypes[40] + mi := &file_agent_v1_agent_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3768,7 +3944,7 @@ func (x *CompleteUploadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CompleteUploadResponse.ProtoReflect.Descriptor instead. func (*CompleteUploadResponse) Descriptor() ([]byte, []int) { - return file_agent_v1_agent_proto_rawDescGZIP(), []int{40} + return file_agent_v1_agent_proto_rawDescGZIP(), []int{42} } func (x *CompleteUploadResponse) GetReceipt() *OperationReceipt { @@ -3974,6 +4150,21 @@ const file_agent_v1_agent_proto_rawDesc = "" + "\tpermit_id\x18\a \x01(\tR\bpermitId\"w\n" + "\x0fExecuteResponse\x124\n" + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12.\n" + + "\x05state\x18\x02 \x01(\x0e2\x18.agent.v1.ExecutionStateR\x05state\"\xbf\x03\n" + + "\x18ExecuteAuthorizedRequest\x12%\n" + + "\x0eschema_version\x18\x01 \x01(\tR\rschemaVersion\x12)\n" + + "\x04meta\x18\x02 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x03 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12*\n" + + "\x11selected_trunk_id\x18\x04 \x01(\tR\x0fselectedTrunkId\x12\x1b\n" + + "\tcaller_id\x18\x05 \x01(\tR\bcallerId\x12\x16\n" + + "\x06callee\x18\x06 \x01(\tR\x06callee\x12&\n" + + "\x0fring_timeout_ms\x18\a \x01(\x03R\rringTimeoutMs\x12/\n" + + "\x14max_call_duration_ms\x18\b \x01(\x03R\x11maxCallDurationMs\x12-\n" + + "\x13dial_before_unix_ms\x18\t \x01(\x03R\x10dialBeforeUnixMs\x122\n" + + "\x15bound_snapshot_sha256\x18\n" + + " \x01(\tR\x13boundSnapshotSha256\"\x81\x01\n" + + "\x19ExecuteAuthorizedResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12.\n" + "\x05state\x18\x02 \x01(\x0e2\x18.agent.v1.ExecutionStateR\x05state\"\xc2\x02\n" + "\x19GetExecutionPermitRequest\x12)\n" + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + @@ -4134,13 +4325,14 @@ const file_agent_v1_agent_proto_rawDesc = "" + "\x1cFACT_KIND_TRANSCRIPT_UPDATED\x10\x04\x12\x1f\n" + "\x1bFACT_KIND_TRANSCRIPT_FAILED\x10\x05\x12\x1d\n" + "\x19FACT_KIND_CONTACT_OPT_OUT\x10\x06\x12 \n" + - "\x1cFACT_KIND_RECORDING_PROGRESS\x10\a2\xc8\a\n" + + "\x1cFACT_KIND_RECORDING_PROGRESS\x10\a2\xa6\b\n" + "\x13AgentControlService\x12S\n" + "\x0eGetAgentStatus\x12\x1f.agent.v1.GetAgentStatusRequest\x1a .agent.v1.GetAgentStatusResponse\x12P\n" + "\rActivateAgent\x12\x1e.agent.v1.ActivateAgentRequest\x1a\x1f.agent.v1.ActivateAgentResponse\x12M\n" + "\fGetBootstrap\x12\x1d.agent.v1.GetBootstrapRequest\x1a\x1e.agent.v1.GetBootstrapResponse\x12\\\n" + "\x11SetAdmissionState\x12\".agent.v1.SetAdmissionStateRequest\x1a#.agent.v1.SetAdmissionStateResponse\x12>\n" + - "\aExecute\x12\x18.agent.v1.ExecuteRequest\x1a\x19.agent.v1.ExecuteResponse\x12_\n" + + "\aExecute\x12\x18.agent.v1.ExecuteRequest\x1a\x19.agent.v1.ExecuteResponse\x12\\\n" + + "\x11ExecuteAuthorized\x12\".agent.v1.ExecuteAuthorizedRequest\x1a#.agent.v1.ExecuteAuthorizedResponse\x12_\n" + "\x12GetExecutionPermit\x12#.agent.v1.GetExecutionPermitRequest\x1a$.agent.v1.GetExecutionPermitResponse\x12Y\n" + "\x10ApplyTaskControl\x12!.agent.v1.ApplyTaskControlRequest\x1a\".agent.v1.ApplyTaskControlResponse\x12S\n" + "\x0eQueryExecution\x12\x1f.agent.v1.QueryExecutionRequest\x1a .agent.v1.QueryExecutionResponse\x12e\n" + @@ -4161,7 +4353,7 @@ func file_agent_v1_agent_proto_rawDescGZIP() []byte { } var file_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 43) var file_agent_v1_agent_proto_goTypes = []any{ (ResultCode)(0), // 0: agent.v1.ResultCode (FailureCode)(0), // 1: agent.v1.FailureCode @@ -4198,22 +4390,24 @@ var file_agent_v1_agent_proto_goTypes = []any{ (*SetAdmissionStateResponse)(nil), // 32: agent.v1.SetAdmissionStateResponse (*ExecuteRequest)(nil), // 33: agent.v1.ExecuteRequest (*ExecuteResponse)(nil), // 34: agent.v1.ExecuteResponse - (*GetExecutionPermitRequest)(nil), // 35: agent.v1.GetExecutionPermitRequest - (*ExecutionPermit)(nil), // 36: agent.v1.ExecutionPermit - (*GetExecutionPermitResponse)(nil), // 37: agent.v1.GetExecutionPermitResponse - (*ApplyTaskControlRequest)(nil), // 38: agent.v1.ApplyTaskControlRequest - (*ApplyTaskControlResponse)(nil), // 39: agent.v1.ApplyTaskControlResponse - (*QueryExecutionRequest)(nil), // 40: agent.v1.QueryExecutionRequest - (*ExecutionSnapshot)(nil), // 41: agent.v1.ExecutionSnapshot - (*QueryExecutionResponse)(nil), // 42: agent.v1.QueryExecutionResponse - (*ExecutionFact)(nil), // 43: agent.v1.ExecutionFact - (*ReportExecutionEventRequest)(nil), // 44: agent.v1.ReportExecutionEventRequest - (*ReportExecutionEventResponse)(nil), // 45: agent.v1.ReportExecutionEventResponse - (*RequestUploadRequest)(nil), // 46: agent.v1.RequestUploadRequest - (*UploadGrant)(nil), // 47: agent.v1.UploadGrant - (*RequestUploadResponse)(nil), // 48: agent.v1.RequestUploadResponse - (*CompleteUploadRequest)(nil), // 49: agent.v1.CompleteUploadRequest - (*CompleteUploadResponse)(nil), // 50: agent.v1.CompleteUploadResponse + (*ExecuteAuthorizedRequest)(nil), // 35: agent.v1.ExecuteAuthorizedRequest + (*ExecuteAuthorizedResponse)(nil), // 36: agent.v1.ExecuteAuthorizedResponse + (*GetExecutionPermitRequest)(nil), // 37: agent.v1.GetExecutionPermitRequest + (*ExecutionPermit)(nil), // 38: agent.v1.ExecutionPermit + (*GetExecutionPermitResponse)(nil), // 39: agent.v1.GetExecutionPermitResponse + (*ApplyTaskControlRequest)(nil), // 40: agent.v1.ApplyTaskControlRequest + (*ApplyTaskControlResponse)(nil), // 41: agent.v1.ApplyTaskControlResponse + (*QueryExecutionRequest)(nil), // 42: agent.v1.QueryExecutionRequest + (*ExecutionSnapshot)(nil), // 43: agent.v1.ExecutionSnapshot + (*QueryExecutionResponse)(nil), // 44: agent.v1.QueryExecutionResponse + (*ExecutionFact)(nil), // 45: agent.v1.ExecutionFact + (*ReportExecutionEventRequest)(nil), // 46: agent.v1.ReportExecutionEventRequest + (*ReportExecutionEventResponse)(nil), // 47: agent.v1.ReportExecutionEventResponse + (*RequestUploadRequest)(nil), // 48: agent.v1.RequestUploadRequest + (*UploadGrant)(nil), // 49: agent.v1.UploadGrant + (*RequestUploadResponse)(nil), // 50: agent.v1.RequestUploadResponse + (*CompleteUploadRequest)(nil), // 51: agent.v1.CompleteUploadRequest + (*CompleteUploadResponse)(nil), // 52: agent.v1.CompleteUploadResponse } var file_agent_v1_agent_proto_depIdxs = []int32{ 1, // 0: agent.v1.Failure.code:type_name -> agent.v1.FailureCode @@ -4250,68 +4444,74 @@ var file_agent_v1_agent_proto_depIdxs = []int32{ 22, // 31: agent.v1.ExecuteRequest.binding:type_name -> agent.v1.ExecutionBinding 13, // 32: agent.v1.ExecuteResponse.receipt:type_name -> agent.v1.OperationReceipt 6, // 33: agent.v1.ExecuteResponse.state:type_name -> agent.v1.ExecutionState - 10, // 34: agent.v1.GetExecutionPermitRequest.meta:type_name -> agent.v1.RequestMeta - 22, // 35: agent.v1.GetExecutionPermitRequest.binding:type_name -> agent.v1.ExecutionBinding - 13, // 36: agent.v1.GetExecutionPermitResponse.receipt:type_name -> agent.v1.OperationReceipt - 36, // 37: agent.v1.GetExecutionPermitResponse.permit:type_name -> agent.v1.ExecutionPermit - 10, // 38: agent.v1.ApplyTaskControlRequest.meta:type_name -> agent.v1.RequestMeta - 22, // 39: agent.v1.ApplyTaskControlRequest.binding:type_name -> agent.v1.ExecutionBinding - 4, // 40: agent.v1.ApplyTaskControlRequest.action:type_name -> agent.v1.ControlAction - 5, // 41: agent.v1.ApplyTaskControlRequest.active_call_policy:type_name -> agent.v1.ActiveCallPolicy - 13, // 42: agent.v1.ApplyTaskControlResponse.receipt:type_name -> agent.v1.OperationReceipt - 6, // 43: agent.v1.ApplyTaskControlResponse.state:type_name -> agent.v1.ExecutionState - 10, // 44: agent.v1.QueryExecutionRequest.meta:type_name -> agent.v1.RequestMeta - 22, // 45: agent.v1.QueryExecutionRequest.binding:type_name -> agent.v1.ExecutionBinding - 22, // 46: agent.v1.ExecutionSnapshot.binding:type_name -> agent.v1.ExecutionBinding - 6, // 47: agent.v1.ExecutionSnapshot.state:type_name -> agent.v1.ExecutionState - 23, // 48: agent.v1.ExecutionSnapshot.assets:type_name -> agent.v1.AssetDescriptor - 11, // 49: agent.v1.QueryExecutionResponse.meta:type_name -> agent.v1.ResponseMeta - 41, // 50: agent.v1.QueryExecutionResponse.snapshot:type_name -> agent.v1.ExecutionSnapshot - 12, // 51: agent.v1.QueryExecutionResponse.failure:type_name -> agent.v1.Failure - 22, // 52: agent.v1.ExecutionFact.binding:type_name -> agent.v1.ExecutionBinding - 9, // 53: agent.v1.ExecutionFact.kind:type_name -> agent.v1.FactKind - 10, // 54: agent.v1.ReportExecutionEventRequest.meta:type_name -> agent.v1.RequestMeta - 43, // 55: agent.v1.ReportExecutionEventRequest.fact:type_name -> agent.v1.ExecutionFact - 13, // 56: agent.v1.ReportExecutionEventResponse.receipt:type_name -> agent.v1.OperationReceipt - 10, // 57: agent.v1.RequestUploadRequest.meta:type_name -> agent.v1.RequestMeta - 22, // 58: agent.v1.RequestUploadRequest.binding:type_name -> agent.v1.ExecutionBinding - 23, // 59: agent.v1.RequestUploadRequest.asset:type_name -> agent.v1.AssetDescriptor - 24, // 60: agent.v1.UploadGrant.headers:type_name -> agent.v1.Header - 13, // 61: agent.v1.RequestUploadResponse.receipt:type_name -> agent.v1.OperationReceipt - 47, // 62: agent.v1.RequestUploadResponse.grant:type_name -> agent.v1.UploadGrant - 8, // 63: agent.v1.RequestUploadResponse.state:type_name -> agent.v1.UploadState - 10, // 64: agent.v1.CompleteUploadRequest.meta:type_name -> agent.v1.RequestMeta - 22, // 65: agent.v1.CompleteUploadRequest.binding:type_name -> agent.v1.ExecutionBinding - 23, // 66: agent.v1.CompleteUploadRequest.asset:type_name -> agent.v1.AssetDescriptor - 13, // 67: agent.v1.CompleteUploadResponse.receipt:type_name -> agent.v1.OperationReceipt - 8, // 68: agent.v1.CompleteUploadResponse.state:type_name -> agent.v1.UploadState - 25, // 69: agent.v1.AgentControlService.GetAgentStatus:input_type -> agent.v1.GetAgentStatusRequest - 27, // 70: agent.v1.AgentControlService.ActivateAgent:input_type -> agent.v1.ActivateAgentRequest - 29, // 71: agent.v1.AgentControlService.GetBootstrap:input_type -> agent.v1.GetBootstrapRequest - 31, // 72: agent.v1.AgentControlService.SetAdmissionState:input_type -> agent.v1.SetAdmissionStateRequest - 33, // 73: agent.v1.AgentControlService.Execute:input_type -> agent.v1.ExecuteRequest - 35, // 74: agent.v1.AgentControlService.GetExecutionPermit:input_type -> agent.v1.GetExecutionPermitRequest - 38, // 75: agent.v1.AgentControlService.ApplyTaskControl:input_type -> agent.v1.ApplyTaskControlRequest - 40, // 76: agent.v1.AgentControlService.QueryExecution:input_type -> agent.v1.QueryExecutionRequest - 44, // 77: agent.v1.AgentControlService.ReportExecutionEvent:input_type -> agent.v1.ReportExecutionEventRequest - 46, // 78: agent.v1.AgentControlService.RequestUpload:input_type -> agent.v1.RequestUploadRequest - 49, // 79: agent.v1.AgentControlService.CompleteUpload:input_type -> agent.v1.CompleteUploadRequest - 26, // 80: agent.v1.AgentControlService.GetAgentStatus:output_type -> agent.v1.GetAgentStatusResponse - 28, // 81: agent.v1.AgentControlService.ActivateAgent:output_type -> agent.v1.ActivateAgentResponse - 30, // 82: agent.v1.AgentControlService.GetBootstrap:output_type -> agent.v1.GetBootstrapResponse - 32, // 83: agent.v1.AgentControlService.SetAdmissionState:output_type -> agent.v1.SetAdmissionStateResponse - 34, // 84: agent.v1.AgentControlService.Execute:output_type -> agent.v1.ExecuteResponse - 37, // 85: agent.v1.AgentControlService.GetExecutionPermit:output_type -> agent.v1.GetExecutionPermitResponse - 39, // 86: agent.v1.AgentControlService.ApplyTaskControl:output_type -> agent.v1.ApplyTaskControlResponse - 42, // 87: agent.v1.AgentControlService.QueryExecution:output_type -> agent.v1.QueryExecutionResponse - 45, // 88: agent.v1.AgentControlService.ReportExecutionEvent:output_type -> agent.v1.ReportExecutionEventResponse - 48, // 89: agent.v1.AgentControlService.RequestUpload:output_type -> agent.v1.RequestUploadResponse - 50, // 90: agent.v1.AgentControlService.CompleteUpload:output_type -> agent.v1.CompleteUploadResponse - 80, // [80:91] is the sub-list for method output_type - 69, // [69:80] is the sub-list for method input_type - 69, // [69:69] is the sub-list for extension type_name - 69, // [69:69] is the sub-list for extension extendee - 0, // [0:69] is the sub-list for field type_name + 10, // 34: agent.v1.ExecuteAuthorizedRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 35: agent.v1.ExecuteAuthorizedRequest.binding:type_name -> agent.v1.ExecutionBinding + 13, // 36: agent.v1.ExecuteAuthorizedResponse.receipt:type_name -> agent.v1.OperationReceipt + 6, // 37: agent.v1.ExecuteAuthorizedResponse.state:type_name -> agent.v1.ExecutionState + 10, // 38: agent.v1.GetExecutionPermitRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 39: agent.v1.GetExecutionPermitRequest.binding:type_name -> agent.v1.ExecutionBinding + 13, // 40: agent.v1.GetExecutionPermitResponse.receipt:type_name -> agent.v1.OperationReceipt + 38, // 41: agent.v1.GetExecutionPermitResponse.permit:type_name -> agent.v1.ExecutionPermit + 10, // 42: agent.v1.ApplyTaskControlRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 43: agent.v1.ApplyTaskControlRequest.binding:type_name -> agent.v1.ExecutionBinding + 4, // 44: agent.v1.ApplyTaskControlRequest.action:type_name -> agent.v1.ControlAction + 5, // 45: agent.v1.ApplyTaskControlRequest.active_call_policy:type_name -> agent.v1.ActiveCallPolicy + 13, // 46: agent.v1.ApplyTaskControlResponse.receipt:type_name -> agent.v1.OperationReceipt + 6, // 47: agent.v1.ApplyTaskControlResponse.state:type_name -> agent.v1.ExecutionState + 10, // 48: agent.v1.QueryExecutionRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 49: agent.v1.QueryExecutionRequest.binding:type_name -> agent.v1.ExecutionBinding + 22, // 50: agent.v1.ExecutionSnapshot.binding:type_name -> agent.v1.ExecutionBinding + 6, // 51: agent.v1.ExecutionSnapshot.state:type_name -> agent.v1.ExecutionState + 23, // 52: agent.v1.ExecutionSnapshot.assets:type_name -> agent.v1.AssetDescriptor + 11, // 53: agent.v1.QueryExecutionResponse.meta:type_name -> agent.v1.ResponseMeta + 43, // 54: agent.v1.QueryExecutionResponse.snapshot:type_name -> agent.v1.ExecutionSnapshot + 12, // 55: agent.v1.QueryExecutionResponse.failure:type_name -> agent.v1.Failure + 22, // 56: agent.v1.ExecutionFact.binding:type_name -> agent.v1.ExecutionBinding + 9, // 57: agent.v1.ExecutionFact.kind:type_name -> agent.v1.FactKind + 10, // 58: agent.v1.ReportExecutionEventRequest.meta:type_name -> agent.v1.RequestMeta + 45, // 59: agent.v1.ReportExecutionEventRequest.fact:type_name -> agent.v1.ExecutionFact + 13, // 60: agent.v1.ReportExecutionEventResponse.receipt:type_name -> agent.v1.OperationReceipt + 10, // 61: agent.v1.RequestUploadRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 62: agent.v1.RequestUploadRequest.binding:type_name -> agent.v1.ExecutionBinding + 23, // 63: agent.v1.RequestUploadRequest.asset:type_name -> agent.v1.AssetDescriptor + 24, // 64: agent.v1.UploadGrant.headers:type_name -> agent.v1.Header + 13, // 65: agent.v1.RequestUploadResponse.receipt:type_name -> agent.v1.OperationReceipt + 49, // 66: agent.v1.RequestUploadResponse.grant:type_name -> agent.v1.UploadGrant + 8, // 67: agent.v1.RequestUploadResponse.state:type_name -> agent.v1.UploadState + 10, // 68: agent.v1.CompleteUploadRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 69: agent.v1.CompleteUploadRequest.binding:type_name -> agent.v1.ExecutionBinding + 23, // 70: agent.v1.CompleteUploadRequest.asset:type_name -> agent.v1.AssetDescriptor + 13, // 71: agent.v1.CompleteUploadResponse.receipt:type_name -> agent.v1.OperationReceipt + 8, // 72: agent.v1.CompleteUploadResponse.state:type_name -> agent.v1.UploadState + 25, // 73: agent.v1.AgentControlService.GetAgentStatus:input_type -> agent.v1.GetAgentStatusRequest + 27, // 74: agent.v1.AgentControlService.ActivateAgent:input_type -> agent.v1.ActivateAgentRequest + 29, // 75: agent.v1.AgentControlService.GetBootstrap:input_type -> agent.v1.GetBootstrapRequest + 31, // 76: agent.v1.AgentControlService.SetAdmissionState:input_type -> agent.v1.SetAdmissionStateRequest + 33, // 77: agent.v1.AgentControlService.Execute:input_type -> agent.v1.ExecuteRequest + 35, // 78: agent.v1.AgentControlService.ExecuteAuthorized:input_type -> agent.v1.ExecuteAuthorizedRequest + 37, // 79: agent.v1.AgentControlService.GetExecutionPermit:input_type -> agent.v1.GetExecutionPermitRequest + 40, // 80: agent.v1.AgentControlService.ApplyTaskControl:input_type -> agent.v1.ApplyTaskControlRequest + 42, // 81: agent.v1.AgentControlService.QueryExecution:input_type -> agent.v1.QueryExecutionRequest + 46, // 82: agent.v1.AgentControlService.ReportExecutionEvent:input_type -> agent.v1.ReportExecutionEventRequest + 48, // 83: agent.v1.AgentControlService.RequestUpload:input_type -> agent.v1.RequestUploadRequest + 51, // 84: agent.v1.AgentControlService.CompleteUpload:input_type -> agent.v1.CompleteUploadRequest + 26, // 85: agent.v1.AgentControlService.GetAgentStatus:output_type -> agent.v1.GetAgentStatusResponse + 28, // 86: agent.v1.AgentControlService.ActivateAgent:output_type -> agent.v1.ActivateAgentResponse + 30, // 87: agent.v1.AgentControlService.GetBootstrap:output_type -> agent.v1.GetBootstrapResponse + 32, // 88: agent.v1.AgentControlService.SetAdmissionState:output_type -> agent.v1.SetAdmissionStateResponse + 34, // 89: agent.v1.AgentControlService.Execute:output_type -> agent.v1.ExecuteResponse + 36, // 90: agent.v1.AgentControlService.ExecuteAuthorized:output_type -> agent.v1.ExecuteAuthorizedResponse + 39, // 91: agent.v1.AgentControlService.GetExecutionPermit:output_type -> agent.v1.GetExecutionPermitResponse + 41, // 92: agent.v1.AgentControlService.ApplyTaskControl:output_type -> agent.v1.ApplyTaskControlResponse + 44, // 93: agent.v1.AgentControlService.QueryExecution:output_type -> agent.v1.QueryExecutionResponse + 47, // 94: agent.v1.AgentControlService.ReportExecutionEvent:output_type -> agent.v1.ReportExecutionEventResponse + 50, // 95: agent.v1.AgentControlService.RequestUpload:output_type -> agent.v1.RequestUploadResponse + 52, // 96: agent.v1.AgentControlService.CompleteUpload:output_type -> agent.v1.CompleteUploadResponse + 85, // [85:97] is the sub-list for method output_type + 73, // [73:85] is the sub-list for method input_type + 73, // [73:73] is the sub-list for extension type_name + 73, // [73:73] is the sub-list for extension extendee + 0, // [0:73] is the sub-list for field type_name } func init() { file_agent_v1_agent_proto_init() } @@ -4325,7 +4525,7 @@ func file_agent_v1_agent_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_v1_agent_proto_rawDesc), len(file_agent_v1_agent_proto_rawDesc)), NumEnums: 10, - NumMessages: 41, + NumMessages: 43, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/agent/v1/agent_grpc.pb.go b/gen/agent/v1/agent_grpc.pb.go index f514a9e..2b2771c 100644 --- a/gen/agent/v1/agent_grpc.pb.go +++ b/gen/agent/v1/agent_grpc.pb.go @@ -24,6 +24,7 @@ const ( AgentControlService_GetBootstrap_FullMethodName = "/agent.v1.AgentControlService/GetBootstrap" AgentControlService_SetAdmissionState_FullMethodName = "/agent.v1.AgentControlService/SetAdmissionState" AgentControlService_Execute_FullMethodName = "/agent.v1.AgentControlService/Execute" + AgentControlService_ExecuteAuthorized_FullMethodName = "/agent.v1.AgentControlService/ExecuteAuthorized" AgentControlService_GetExecutionPermit_FullMethodName = "/agent.v1.AgentControlService/GetExecutionPermit" AgentControlService_ApplyTaskControl_FullMethodName = "/agent.v1.AgentControlService/ApplyTaskControl" AgentControlService_QueryExecution_FullMethodName = "/agent.v1.AgentControlService/QueryExecution" @@ -45,6 +46,8 @@ type AgentControlServiceClient interface { GetBootstrap(ctx context.Context, in *GetBootstrapRequest, opts ...grpc.CallOption) (*GetBootstrapResponse, error) SetAdmissionState(ctx context.Context, in *SetAdmissionStateRequest, opts ...grpc.CallOption) (*SetAdmissionStateResponse, error) Execute(ctx context.Context, in *ExecuteRequest, opts ...grpc.CallOption) (*ExecuteResponse, error) + // Local P1 Mock: accepts a Dispatcher-authorized, immutable dial decision. + ExecuteAuthorized(ctx context.Context, in *ExecuteAuthorizedRequest, opts ...grpc.CallOption) (*ExecuteAuthorizedResponse, error) GetExecutionPermit(ctx context.Context, in *GetExecutionPermitRequest, opts ...grpc.CallOption) (*GetExecutionPermitResponse, error) ApplyTaskControl(ctx context.Context, in *ApplyTaskControlRequest, opts ...grpc.CallOption) (*ApplyTaskControlResponse, error) QueryExecution(ctx context.Context, in *QueryExecutionRequest, opts ...grpc.CallOption) (*QueryExecutionResponse, error) @@ -111,6 +114,16 @@ func (c *agentControlServiceClient) Execute(ctx context.Context, in *ExecuteRequ return out, nil } +func (c *agentControlServiceClient) ExecuteAuthorized(ctx context.Context, in *ExecuteAuthorizedRequest, opts ...grpc.CallOption) (*ExecuteAuthorizedResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecuteAuthorizedResponse) + err := c.cc.Invoke(ctx, AgentControlService_ExecuteAuthorized_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *agentControlServiceClient) GetExecutionPermit(ctx context.Context, in *GetExecutionPermitRequest, opts ...grpc.CallOption) (*GetExecutionPermitResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetExecutionPermitResponse) @@ -184,6 +197,8 @@ type AgentControlServiceServer interface { GetBootstrap(context.Context, *GetBootstrapRequest) (*GetBootstrapResponse, error) SetAdmissionState(context.Context, *SetAdmissionStateRequest) (*SetAdmissionStateResponse, error) Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) + // Local P1 Mock: accepts a Dispatcher-authorized, immutable dial decision. + ExecuteAuthorized(context.Context, *ExecuteAuthorizedRequest) (*ExecuteAuthorizedResponse, error) GetExecutionPermit(context.Context, *GetExecutionPermitRequest) (*GetExecutionPermitResponse, error) ApplyTaskControl(context.Context, *ApplyTaskControlRequest) (*ApplyTaskControlResponse, error) QueryExecution(context.Context, *QueryExecutionRequest) (*QueryExecutionResponse, error) @@ -215,6 +230,9 @@ func (UnimplementedAgentControlServiceServer) SetAdmissionState(context.Context, func (UnimplementedAgentControlServiceServer) Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) { return nil, status.Error(codes.Unimplemented, "method Execute not implemented") } +func (UnimplementedAgentControlServiceServer) ExecuteAuthorized(context.Context, *ExecuteAuthorizedRequest) (*ExecuteAuthorizedResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExecuteAuthorized not implemented") +} func (UnimplementedAgentControlServiceServer) GetExecutionPermit(context.Context, *GetExecutionPermitRequest) (*GetExecutionPermitResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetExecutionPermit not implemented") } @@ -344,6 +362,24 @@ func _AgentControlService_Execute_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _AgentControlService_ExecuteAuthorized_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecuteAuthorizedRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).ExecuteAuthorized(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_ExecuteAuthorized_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).ExecuteAuthorized(ctx, req.(*ExecuteAuthorizedRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AgentControlService_GetExecutionPermit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetExecutionPermitRequest) if err := dec(in); err != nil { @@ -479,6 +515,10 @@ var AgentControlService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Execute", Handler: _AgentControlService_Execute_Handler, }, + { + MethodName: "ExecuteAuthorized", + Handler: _AgentControlService_ExecuteAuthorized_Handler, + }, { MethodName: "GetExecutionPermit", Handler: _AgentControlService_GetExecutionPermit_Handler, diff --git a/internal/agent/mock_failure_report.go b/internal/agent/mock_failure_report.go new file mode 100644 index 0000000..1cf654a --- /dev/null +++ b/internal/agent/mock_failure_report.go @@ -0,0 +1,141 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +type MockFailureFactClient interface { + ReportExecutionEvent(context.Context, *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) +} + +// MockUploadFailureCode returns empty for uncertain PUT outcomes. In particular +// a transport error, HTTP 408/429 or 5xx never claims that OSS rejected the +// object; the Dispatcher retains the unknown outcome until its deadline. +func MockUploadFailureCode(cause error) string { + switch { + case errors.Is(cause, ErrUploadGrantExpired): + return "upload_authorization_expired" + case errors.Is(cause, ErrUploadGrantInvalid): + return "upload_authorization_failed" + case errors.Is(cause, ErrUploadChecksumMismatch): + return "checksum_mismatch" + case errors.Is(cause, os.ErrNotExist): + return "upload_failed" + } + var response *UploadHTTPError + if errors.As(cause, &response) && response.StatusCode >= 400 && response.StatusCode < 500 && + response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests { + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return "upload_authorization_failed" + } + return "upload_failed" + } + return "" +} + +func validateMockFailureActiveMeta(meta *agentv1.RequestMeta) error { + if meta == nil || meta.ProtocolVersion != "agent.v1" || meta.AgentId == "" || meta.CellId == "" || + meta.BootId == "" || meta.DispatcherEpoch == "" || meta.SessionGeneration == 0 { + return errors.New("Mock failure requires the current activated Agent session") + } + return nil +} + +// ReportMockUploadFailure persists a known, explicit failure before trying to +// notify the Dispatcher. It never retries a PUT or claims an uncertain result. +// The caller supplies metadata from its activated Agent session, not a newly +// invented boot identity. A pending fact is retried with the same ID after +// restart, while the request metadata uses the new active session. +func (s *Spool) ReportMockUploadFailure(ctx context.Context, client MockFailureFactClient, active *agentv1.RequestMeta, uploadID string, cause error) (bool, error) { + code := MockUploadFailureCode(cause) + if code == "" { + return false, nil + } + if err := validateMockFailureActiveMeta(active); err != nil { + return true, err + } + record, err := s.LoadUploadAttempt(uploadID) + if err != nil { + return true, err + } + if record.FailureFact == nil { + if record.Binding == nil || record.Asset == nil { + return true, errors.New("Mock failure has no bound recording attempt") + } + payload, err := json.Marshal(contract.LocalMockRecordingFailure{ + SchemaVersion: contract.LocalMockRecordingFailureVersion, + UploadID: record.UploadID, RecordingID: record.Asset.AssetId, ErrorCode: code, + }) + if err != nil { + return true, err + } + sum := sha256.Sum256(payload) + fact := &agentv1.ExecutionFact{ + FactId: uuid.NewString(), Binding: proto.Clone(record.Binding).(*agentv1.ExecutionBinding), + Kind: agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS, PayloadJson: payload, + ContentSha256: hex.EncodeToString(sum[:]), ObservedAtUnixMs: s.now().UTC().UnixMilli(), + SourceBootId: active.BootId, SourceSequence: 1, + } + if err := s.RecordUploadFailureFact(uploadID, fact); err != nil { + return true, fmt.Errorf("persist Mock failure before RPC: %w", err) + } + record, err = s.LoadUploadAttempt(uploadID) + if err != nil { + return true, err + } + } + return true, s.deliverMockFailure(ctx, client, active, record) +} + +func (s *Spool) RecoverMockUploadFailures(ctx context.Context, client MockFailureFactClient, active *agentv1.RequestMeta) error { + pending, err := s.PendingUploadFailures() + if err != nil || len(pending) == 0 { + return err + } + if err := validateMockFailureActiveMeta(active); err != nil { + return err + } + for _, record := range pending { + if err := s.deliverMockFailure(ctx, client, active, record); err != nil { + return fmt.Errorf("recover Mock recording failure %s: %w", record.UploadID, err) + } + } + return nil +} + +func (s *Spool) deliverMockFailure(ctx context.Context, client MockFailureFactClient, active *agentv1.RequestMeta, record UploadAttempt) error { + if client == nil || record.FailureFact == nil { + return errors.New("Mock failure reporter or durable fact is missing") + } + if err := validateMockFailureActiveMeta(active); err != nil { + return err + } + meta := proto.Clone(active).(*agentv1.RequestMeta) + meta.RequestId = record.FailureFact.FactId + meta.OperationId = record.FailureFact.FactId + meta.IdempotencyKey = "mock-upload-failure:" + record.FailureFact.FactId + response, err := client.ReportExecutionEvent(ctx, &agentv1.ReportExecutionEventRequest{ + Meta: meta, Fact: proto.Clone(record.FailureFact).(*agentv1.ExecutionFact), + }) + if err != nil { + return err + } + receipt := response.GetReceipt() + if receipt.GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || + receipt.GetFactId() != record.FailureFact.FactId || receipt.GetContentSha256() != record.FailureFact.ContentSha256 { + return errors.New("Dispatcher did not acknowledge the original Mock failure fact") + } + return s.CompleteUploadFailureReport(record.UploadID, record.FailureFact.FactId) +} diff --git a/internal/agent/upload.go b/internal/agent/upload.go index 711900b..1825ad5 100644 --- a/internal/agent/upload.go +++ b/internal/agent/upload.go @@ -31,6 +31,13 @@ type UploadClient struct { } var ErrUploadGrantExpired = errors.New("upload grant is expired") +var ErrUploadGrantInvalid = errors.New("upload grant is invalid") +var ErrUploadChecksumMismatch = errors.New("upload checksum mismatch") + +// UploadHTTPError records only the status, never the signed URL or OSS body. +type UploadHTTPError struct{ StatusCode int } + +func (e *UploadHTTPError) Error() string { return fmt.Sprintf("upload returned HTTP %d", e.StatusCode) } type UploadResult struct { StatusCode int @@ -41,13 +48,13 @@ type UploadResult struct { func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant, path string) (result UploadResult, err error) { if grant == nil { - return UploadResult{}, errors.New("upload grant is required") + return UploadResult{}, fmt.Errorf("%w: grant is required", ErrUploadGrantInvalid) } if grant.TargetUrl == "" || grant.UploadId == "" || grant.ObjectKey == "" { - return UploadResult{}, errors.New("upload URL, ID and object key are required") + return UploadResult{}, fmt.Errorf("%w: URL, ID and object key are required", ErrUploadGrantInvalid) } if grant.ExpiresAtUnixMs <= 0 { - return UploadResult{}, errors.New("upload grant expiry is required") + return UploadResult{}, fmt.Errorf("%w: expiry is required", ErrUploadGrantInvalid) } now := time.Now if c.Now != nil { @@ -58,14 +65,14 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant } parsed, err := url.Parse(grant.TargetUrl) if err != nil || parsed.Host == "" { - return UploadResult{}, errors.New("upload URL is invalid") + return UploadResult{}, fmt.Errorf("%w: URL is invalid", ErrUploadGrantInvalid) } if parsed.Scheme != "https" && !(c.AllowInsecureHTTP && parsed.Scheme == "http") { - return UploadResult{}, errors.New("upload URL must use HTTPS") + return UploadResult{}, fmt.Errorf("%w: URL must use HTTPS", ErrUploadGrantInvalid) } if len(c.AllowedHosts) > 0 { if _, ok := c.AllowedHosts[strings.ToLower(parsed.Host)]; !ok { - return UploadResult{}, fmt.Errorf("upload host %q is not allowed", parsed.Host) + return UploadResult{}, fmt.Errorf("%w: host %q is not allowed", ErrUploadGrantInvalid, parsed.Host) } } if err := ctx.Err(); err != nil { @@ -99,7 +106,7 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant return UploadResult{}, err } if grant.RequiredChecksumSha256 != "" && !strings.EqualFold(grant.RequiredChecksumSha256, digest) { - return UploadResult{}, errors.New("asset checksum does not match upload grant") + return UploadResult{}, fmt.Errorf("%w: asset does not match grant", ErrUploadChecksumMismatch) } if _, err := file.Seek(0, io.SeekStart); err != nil { @@ -114,7 +121,7 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant req.ContentLength = stat.Size() for _, header := range grant.Headers { if strings.EqualFold(header.Name, "host") || strings.EqualFold(header.Name, "content-length") { - return UploadResult{}, errors.New("upload grant contains a forbidden header") + return UploadResult{}, fmt.Errorf("%w: forbidden header", ErrUploadGrantInvalid) } req.Header.Set(header.Name, header.Value) } @@ -141,14 +148,14 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)) - return UploadResult{}, fmt.Errorf("upload returned HTTP %d", resp.StatusCode) + return UploadResult{}, &UploadHTTPError{StatusCode: resp.StatusCode} } if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)); err != nil { return UploadResult{}, fmt.Errorf("read upload response: %w", err) } sentDigest, sentBytes := transmitted.result() if sentBytes != stat.Size() || sentDigest != digest { - return UploadResult{}, errors.New("transmitted upload bytes do not match the validated asset") + return UploadResult{}, fmt.Errorf("%w: transmitted bytes differ from validated asset", ErrUploadChecksumMismatch) } return UploadResult{StatusCode: resp.StatusCode, SizeBytes: sentBytes, SHA256: sentDigest, ETag: resp.Header.Get("ETag")}, nil } diff --git a/internal/agent/upload_failure.go b/internal/agent/upload_failure.go new file mode 100644 index 0000000..a1e53e9 --- /dev/null +++ b/internal/agent/upload_failure.go @@ -0,0 +1,109 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +func validateUploadFailureFact(record UploadAttempt, fact *agentv1.ExecutionFact) error { + if record.State != "attempted" || record.Binding == nil || record.Asset == nil || record.Result.SizeBytes != 0 || + fact == nil || fact.Binding == nil || fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS || + !proto.Equal(record.Binding, fact.Binding) || fact.SourceBootId == "" || fact.SourceSequence == 0 || fact.ObservedAtUnixMs <= 0 { + return errors.New("Mock failure requires a matching uncompleted upload attempt") + } + parsed, err := uuid.Parse(fact.FactId) + if err != nil || parsed.Version() != 4 { + return errors.New("Mock failure fact ID must be UUID v4") + } + failure, err := contract.DecodeLocalMockRecordingFailure(fact.PayloadJson) + if err != nil { + return err + } + if failure.UploadID != record.UploadID || failure.RecordingID != record.Asset.AssetId { + return errors.New("Mock failure payload does not match claimed upload") + } + sum := sha256.Sum256(fact.PayloadJson) + if hex.EncodeToString(sum[:]) != fact.ContentSha256 { + return errors.New("Mock failure payload checksum mismatch") + } + return nil +} + +// RecordUploadFailureFact persists a single terminal failure identity before +// any network report. Unknown PUT results never enter this path and cannot be +// replayed as a second upload after restart. +func (s *Spool) RecordUploadFailureFact(id string, fact *agentv1.ExecutionFact) error { + s.mu.Lock() + defer s.mu.Unlock() + record, err := s.LoadUploadAttempt(id) + if err != nil { + return err + } + if err := validateUploadFailureFact(record, fact); err != nil { + return err + } + if record.FailureFact != nil { + if !proto.Equal(record.FailureFact, fact) { + return errors.New("Mock upload failure fact identity changed") + } + return nil + } + record.FailureFact = proto.Clone(fact).(*agentv1.ExecutionFact) + return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) +} + +func (s *Spool) CompleteUploadFailureReport(id, factID string) error { + s.mu.Lock() + defer s.mu.Unlock() + record, err := s.LoadUploadAttempt(id) + if err != nil { + return err + } + if record.FailureFact == nil || record.FailureFact.FactId != factID || record.State != "attempted" { + return errors.New("failure report acknowledgement does not match upload") + } + if record.FailureDelivered { + return nil + } + record.FailureDelivered = true + return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) +} + +// PendingUploadFailures retries only the fact notification. It never requests +// another token, reads the source recording or repeats an uncertain PUT. +func (s *Spool) PendingUploadFailures() ([]UploadAttempt, error) { + entries, err := os.ReadDir(filepath.Join(s.root, ".uploads")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var pending []UploadAttempt + for _, entry := range entries { + if !entry.IsDir() { + return nil, fmt.Errorf("unexpected upload journal entry %q", entry.Name()) + } + record, err := s.LoadUploadAttempt(entry.Name()) + if err != nil { + return nil, err + } + if record.FailureFact == nil || record.FailureDelivered { + continue + } + if err := validateUploadFailureFact(record, record.FailureFact); err != nil { + return nil, fmt.Errorf("invalid persisted Mock failure for %s: %w", record.UploadID, err) + } + pending = append(pending, record) + } + return pending, nil +} diff --git a/internal/agent/upload_failure_report_test.go b/internal/agent/upload_failure_report_test.go new file mode 100644 index 0000000..b3adeb6 --- /dev/null +++ b/internal/agent/upload_failure_report_test.go @@ -0,0 +1,154 @@ +package agent + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +type mockFailureRPC struct { + requests []*agentv1.ReportExecutionEventRequest + unavailable bool + wrongReceipt bool +} + +func (m *mockFailureRPC) ReportExecutionEvent(_ context.Context, request *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + m.requests = append(m.requests, proto.Clone(request).(*agentv1.ReportExecutionEventRequest)) + if m.unavailable { + return nil, errors.New("Dispatcher unavailable before receipt") + } + factID := request.Fact.FactId + if m.wrongReceipt { + factID = uuid.NewString() + } + return &agentv1.ReportExecutionEventResponse{Receipt: &agentv1.OperationReceipt{ + Result: agentv1.ResultCode_RESULT_CODE_ACCEPTED, FactId: factID, ContentSha256: request.Fact.ContentSha256, + }}, nil +} + +func TestMockUploadFailureReportReusesDurableFactAcrossActiveBoots(t *testing.T) { + root := t.TempDir() + observed := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + spool, err := NewSpool(root, func() time.Time { return observed }) + if err != nil { + t.Fatal(err) + } + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"} + if err := spool.ClaimUpload(UploadAttempt{ + UploadID: "upload-a", Identity: "identity-a", State: "attempted", RequestID: uuid.NewString(), + Binding: binding, Asset: &agentv1.AssetDescriptor{AssetId: "recording-a"}, + }); err != nil { + t.Fatal(err) + } + active := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-old", + DispatcherEpoch: "epoch-a", SessionGeneration: 1, + } + remote := &mockFailureRPC{unavailable: true} + known, err := spool.ReportMockUploadFailure(context.Background(), remote, active, "upload-a", &UploadHTTPError{StatusCode: http.StatusForbidden}) + if !known || err == nil || len(remote.requests) != 1 { + t.Fatalf("explicit 403 failure was not durably sent: known=%t requests=%d err=%v", known, len(remote.requests), err) + } + record, err := spool.LoadUploadAttempt("upload-a") + if err != nil || record.FailureFact == nil || record.FailureDelivered { + t.Fatalf("fact lost before receipt: record=%+v err=%v", record, err) + } + factID, digest, sourceBoot := record.FailureFact.FactId, record.FailureFact.ContentSha256, record.FailureFact.SourceBootId + if sourceBoot != active.BootId || record.FailureFact.ObservedAtUnixMs != observed.UnixMilli() { + t.Fatalf("fact lost source boot or observation clock: boot=%q observed=%d", sourceBoot, record.FailureFact.ObservedAtUnixMs) + } + restarted, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + activeNew := proto.Clone(active).(*agentv1.RequestMeta) + activeNew.BootId = "boot-new" + activeNew.SessionGeneration = 2 + remote.unavailable, remote.wrongReceipt = false, true + if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err == nil { + t.Fatal("unrelated accepted receipt closed a durable failure fact") + } + record, err = restarted.LoadUploadAttempt("upload-a") + if err != nil || record.FailureDelivered { + t.Fatalf("false receipt advanced failure state: record=%+v err=%v", record, err) + } + remote.wrongReceipt = false + if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err != nil { + t.Fatalf("failure fact not recovered after boot change: %v", err) + } + record, err = restarted.LoadUploadAttempt("upload-a") + if err != nil || !record.FailureDelivered || record.State != "attempted" { + t.Fatalf("accepted receipt did not close only the fact: record=%+v err=%v", record, err) + } + if len(remote.requests) != 3 { + t.Fatalf("expected original, false receipt and recovery; got %d reports", len(remote.requests)) + } + for _, request := range remote.requests { + if request.Fact.FactId != factID || request.Fact.ContentSha256 != digest || request.Fact.SourceBootId != sourceBoot { + t.Fatalf("recovery generated a new failure identity: %+v", request.Fact) + } + } + if remote.requests[2].Meta.BootId != activeNew.BootId || remote.requests[2].Meta.SessionGeneration != activeNew.SessionGeneration { + t.Fatalf("recovery did not use the current active session: %+v", remote.requests[2].Meta) + } + if err := restarted.RecoverMockUploadFailures(context.Background(), remote, activeNew); err != nil || len(remote.requests) != 3 { + t.Fatalf("acknowledged failure re-reported: requests=%d err=%v", len(remote.requests), err) + } +} + +func TestMockUploadFailureClassificationKeepsUncertainOutcomesUnknown(t *testing.T) { + for _, tc := range []struct { + name string + err error + code string + }{ + {"expired grant", ErrUploadGrantExpired, "upload_authorization_expired"}, + {"invalid grant", ErrUploadGrantInvalid, "upload_authorization_failed"}, + {"checksum mismatch", ErrUploadChecksumMismatch, "checksum_mismatch"}, + {"explicit 400", &UploadHTTPError{StatusCode: http.StatusBadRequest}, "upload_failed"}, + {"explicit 403", &UploadHTTPError{StatusCode: http.StatusForbidden}, "upload_authorization_failed"}, + {"missing recording", os.ErrNotExist, "upload_failed"}, + {"request timeout", &UploadHTTPError{StatusCode: http.StatusRequestTimeout}, ""}, + {"throttled", &UploadHTTPError{StatusCode: http.StatusTooManyRequests}, ""}, + {"server uncertain", &UploadHTTPError{StatusCode: http.StatusInternalServerError}, ""}, + {"transport uncertain", errors.New("connection reset"), ""}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := MockUploadFailureCode(tc.err); got != tc.code { + t.Fatalf("classification %q, want %q", got, tc.code) + } + }) + } + spool, err := NewSpool(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + if err := spool.ClaimUpload(UploadAttempt{ + UploadID: "unknown-a", Identity: "identity-a", State: "attempted", RequestID: uuid.NewString(), + Binding: &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"}, + Asset: &agentv1.AssetDescriptor{AssetId: "recording-a"}, + }); err != nil { + t.Fatal(err) + } + active := &agentv1.RequestMeta{ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", DispatcherEpoch: "epoch-a", SessionGeneration: 1} + remote := &mockFailureRPC{} + known, err := spool.ReportMockUploadFailure(context.Background(), remote, active, "unknown-a", &UploadHTTPError{StatusCode: 500}) + if err != nil || known || len(remote.requests) != 0 { + t.Fatalf("unknown PUT was reported as failed: known=%t reports=%d err=%v", known, len(remote.requests), err) + } + pending, err := spool.PendingUploadFailures() + if err != nil || len(pending) != 0 { + t.Fatalf("unknown PUT produced a terminal fact: pending=%d err=%v", len(pending), err) + } + if _, err := os.Stat(filepath.Join(spool.root, ".uploads", "unknown-a", "state.json")); err != nil { + t.Fatal(err) + } +} diff --git a/internal/agent/upload_failure_state_test.go b/internal/agent/upload_failure_state_test.go new file mode 100644 index 0000000..7f30ea2 --- /dev/null +++ b/internal/agent/upload_failure_state_test.go @@ -0,0 +1,88 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +func TestMockRecordingFailureFactSurvivesRestartWithoutAnotherPUT(t *testing.T) { + root := t.TempDir() + spool, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"} + asset := &agentv1.AssetDescriptor{AssetId: "recording-a"} + requestID := uuid.NewString() + if err := spool.ClaimUpload(UploadAttempt{ + RequestID: requestID, UploadID: "upload-a", Identity: "identity-a", State: "attempted", + Binding: binding, Asset: asset, + }); err != nil { + t.Fatal(err) + } + payload := []byte(`{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_failed"}`) + sum := sha256.Sum256(payload) + fact := &agentv1.ExecutionFact{ + FactId: uuid.NewString(), Binding: binding, Kind: agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS, + PayloadJson: payload, ContentSha256: hex.EncodeToString(sum[:]), ObservedAtUnixMs: time.Now().UnixMilli(), + SourceBootId: uuid.NewString(), SourceSequence: 1, + } + if err := spool.RecordUploadFailureFact("upload-a", fact); err != nil { + t.Fatal(err) + } + restarted, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + pending, err := restarted.PendingUploadFailures() + if err != nil || len(pending) != 1 || pending[0].FailureFact.GetFactId() != fact.FactId { + t.Fatalf("failure fact lost across restart: pending=%+v err=%v", pending, err) + } + if err := restarted.RecordUploadFailureFact("upload-a", fact); err != nil { + t.Fatalf("same fact must be idempotent: %v", err) + } + conflict := proto.Clone(fact).(*agentv1.ExecutionFact) + conflict.FactId = uuid.NewString() + if err := restarted.RecordUploadFailureFact("upload-a", conflict); err == nil { + t.Fatal("a second failure identity replaced the original") + } + if err := restarted.ReserveUploadRetry("upload-a", uuid.NewString()); err == nil { + t.Fatal("final failure still permitted an explicit new PUT") + } + if err := restarted.RecordUploadResult("upload-a", UploadResult{SizeBytes: 4, SHA256: "checksum", StatusCode: 200}); err == nil { + t.Fatal("failed upload was replaced by a success") + } + if err := restarted.CompleteUploadFailureReport("upload-a", fact.FactId); err != nil { + t.Fatal(err) + } + pending, err = restarted.PendingUploadFailures() + if err != nil || len(pending) != 0 { + t.Fatalf("acknowledged fact was redelivered: pending=%d err=%v", len(pending), err) + } + if err := restarted.CompleteUploadFailureReport("upload-a", fact.FactId); err != nil { + t.Fatalf("same acknowledgement must be idempotent: %v", err) + } + if err := restarted.CompleteUploadFailureReport("upload-a", uuid.NewString()); err == nil { + t.Fatal("unrelated acknowledgement closed failure fact") + } + persisted, err := restarted.LoadUploadAttempt("upload-a") + if err != nil || persisted.State != "attempted" || !persisted.FailureDelivered { + t.Fatalf("failed PUT attempt regressed: %+v err=%v", persisted, err) + } + data, err := os.ReadFile(filepath.Join(root, ".uploads", "upload-a", "state.json")) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 || errors.Is(err, os.ErrNotExist) { + t.Fatal("failure fact was not durably journaled") + } +} diff --git a/internal/agent/upload_state.go b/internal/agent/upload_state.go index bd38a35..dc645d3 100644 --- a/internal/agent/upload_state.go +++ b/internal/agent/upload_state.go @@ -13,14 +13,16 @@ import ( // UploadAttempt records no signed URLs or credentials. An attempted PUT whose // result is unknown must never be repeated by restart recovery. type UploadAttempt struct { - RequestID string `json:"request_id"` - UploadID string `json:"upload_id"` - Identity string `json:"identity"` - State string `json:"state"` - ObjectKey string `json:"object_key"` - Result UploadResult `json:"result"` - Binding *agentv1.ExecutionBinding `json:"binding"` - Asset *agentv1.AssetDescriptor `json:"asset"` + RequestID string `json:"request_id"` + UploadID string `json:"upload_id"` + Identity string `json:"identity"` + State string `json:"state"` + ObjectKey string `json:"object_key"` + Result UploadResult `json:"result"` + Binding *agentv1.ExecutionBinding `json:"binding"` + Asset *agentv1.AssetDescriptor `json:"asset"` + FailureFact *agentv1.ExecutionFact `json:"failure_fact,omitempty"` + FailureDelivered bool `json:"failure_delivered,omitempty"` } func (s *Spool) uploadAttemptDir(id string) string { return filepath.Join(s.root, ".uploads", id) } @@ -34,8 +36,8 @@ func (s *Spool) ClaimUpload(record UploadAttempt) error { return err } } - if record.Identity == "" || record.State != "attempted" { - return errors.New("upload attempt identity and attempted state are required") + if record.Identity == "" || record.State != "attempted" || record.FailureFact != nil || record.FailureDelivered { + return errors.New("upload attempt must start without a failure fact") } root := filepath.Join(s.root, ".uploads") if err := os.MkdirAll(root, 0700); err != nil { @@ -77,8 +79,8 @@ func (s *Spool) ReserveUploadRetry(id, requestID string) error { if err != nil { return err } - if record.State != "attempted" || record.RequestID == "" || requestID == record.RequestID { - return errors.New("only an unsuccessful attempt can use an explicit new request") + if record.State != "attempted" || record.FailureFact != nil || record.RequestID == "" || requestID == record.RequestID { + return errors.New("only an unsuccessful, non-terminal attempt can use an explicit new request") } requests := filepath.Join(s.uploadAttemptDir(id), "requests") if err := os.Mkdir(filepath.Join(requests, requestID), 0700); err != nil { @@ -118,6 +120,9 @@ func (s *Spool) LoadUploadAttempt(id string) (UploadAttempt, error) { default: return record, fmt.Errorf("invalid persisted upload state %q", record.State) } + if record.FailureDelivered && record.FailureFact == nil || record.FailureFact != nil && record.State != "attempted" { + return record, errors.New("persisted upload failure contradicts attempt state") + } return record, nil } @@ -128,8 +133,8 @@ func (s *Spool) RecordUploadResult(id string, result UploadResult) error { if err != nil { return err } - if record.State != "attempted" { - return errors.New("only an attempted upload may record its PUT result") + if record.State != "attempted" || record.FailureFact != nil { + return errors.New("only an attempted upload without a terminal failure may record its PUT result") } if result.SizeBytes <= 0 || result.SHA256 == "" || result.StatusCode < 200 || result.StatusCode >= 300 { return errors.New("successful upload result is required") diff --git a/internal/agent/upload_test.go b/internal/agent/upload_test.go index 8a6817e..3b9dbf4 100644 --- a/internal/agent/upload_test.go +++ b/internal/agent/upload_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "io" "net/http" "net/http/httptest" @@ -81,12 +82,32 @@ func TestUploadClientFailsClosedForGrantMismatchAndHTTP(t *testing.T) { t.Fatal(err) } grant := &agentv1.UploadGrant{UploadId: "upload-2", TargetUrl: "http://127.0.0.1:1/upload", ObjectKey: "recording-2", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), RequiredChecksumSha256: strings.Repeat("a", 64), MaxBytes: 1024} - if _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath); err == nil { - t.Fatal("expected checksum mismatch") + if _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath); !errors.Is(err, ErrUploadChecksumMismatch) { + t.Fatalf("expected typed checksum mismatch, got %v", err) } grant.RequiredChecksumSha256 = "" - if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil { - t.Fatal("expected HTTP upload URL rejection") + if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); !errors.Is(err, ErrUploadGrantInvalid) { + t.Fatalf("expected typed grant rejection, got %v", err) + } +} + +func TestUploadClientHTTPFailureHasStatusWithoutResponseBody(t *testing.T) { + assetPath := filepath.Join(t.TempDir(), "recording.bin") + body := []byte("bytes") + if err := os.WriteFile(assetPath, body, 0o600); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("DO_NOT_LOG_SECRET")) + })) + defer server.Close() + sum := sha256.Sum256(body) + grant := &agentv1.UploadGrant{UploadId: "upload-forbidden", TargetUrl: server.URL, ObjectKey: "recording-forbidden", ExpiresAtUnixMs: time.Now().Add(time.Minute).UnixMilli(), RequiredChecksumSha256: hex.EncodeToString(sum[:]), MaxBytes: 1024} + _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath) + var response *UploadHTTPError + if !errors.As(err, &response) || response.StatusCode != http.StatusForbidden || strings.Contains(err.Error(), "DO_NOT_LOG_SECRET") { + t.Fatalf("explicit PUT rejection was not typed and redacted: %v", err) } } @@ -111,7 +132,7 @@ func TestUploadClientRejectsExpiredGrant(t *testing.T) { t.Fatal(err) } grant := &agentv1.UploadGrant{UploadId: "upload-expired", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-expired", ExpiresAtUnixMs: time.Unix(100, 0).UnixMilli(), MaxBytes: 1024} - if _, err := (UploadClient{Now: func() time.Time { return time.Unix(100, 0) }}).UploadFile(context.Background(), grant, assetPath); err == nil { - t.Fatal("expected expired grant rejection") + if _, err := (UploadClient{Now: func() time.Time { return time.Unix(100, 0) }}).UploadFile(context.Background(), grant, assetPath); !errors.Is(err, ErrUploadGrantExpired) { + t.Fatalf("expected typed expired grant rejection, got %v", err) } } diff --git a/internal/callwindow/policy.go b/internal/callwindow/policy.go new file mode 100644 index 0000000..03b56da --- /dev/null +++ b/internal/callwindow/policy.go @@ -0,0 +1,158 @@ +package callwindow + +import ( + "errors" + "fmt" + "time" +) + +// WeeklySchedule is a Shanghai-local, half-open weekly timetable. A period +// crossing midnight must be represented by two windows on adjacent days. +type WeeklySchedule struct { + TimeZone string `json:"time_zone"` + WeeklyWindows map[string][]Window `json:"weekly_windows"` +} + +type Window struct { + Start string `json:"start"` + End string `json:"end"` +} + +type TaskSchedule struct { + WeeklySchedule + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + ExcludedDates []string `json:"excluded_dates"` +} + +var weekdays = [...]string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"} + +var ErrWindowClosed = errors.New("outside the allowed calling window") + +// Evaluate returns the earliest time at which the task/line intersection +// closes. A rejected command must not wait for the next window or choose a +// different line after selection. +func Evaluate(task TaskSchedule, line WeeklySchedule, at time.Time) (time.Time, error) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.Time{}, fmt.Errorf("load outbound time zone: %w", err) + } + if err := task.WeeklySchedule.validate(); err != nil { + return time.Time{}, fmt.Errorf("task schedule: %w", err) + } + if err := line.validate(); err != nil { + return time.Time{}, fmt.Errorf("line schedule: %w", err) + } + var end time.Time + if task.StartsAt != "" { + start, err := time.Parse(time.RFC3339, task.StartsAt) + if err != nil { + return time.Time{}, fmt.Errorf("parse task starts_at: %w", err) + } + if at.Before(start) { + return time.Time{}, fmt.Errorf("%w: task is before its validity period", ErrWindowClosed) + } + if task.EndsAt != "" { + end, err = time.Parse(time.RFC3339, task.EndsAt) + if err != nil { + return time.Time{}, fmt.Errorf("parse task ends_at: %w", err) + } + if !start.Before(end) { + return time.Time{}, errors.New("task schedule starts_at must precede ends_at") + } + } + } else if task.EndsAt != "" { + var err error + end, err = time.Parse(time.RFC3339, task.EndsAt) + if err != nil { + return time.Time{}, fmt.Errorf("parse task ends_at: %w", err) + } + } + if !end.IsZero() && !at.Before(end) { + return time.Time{}, fmt.Errorf("%w: task is past its validity period", ErrWindowClosed) + } + seen := make(map[string]struct{}, len(task.ExcludedDates)) + for _, date := range task.ExcludedDates { + parsed, err := time.Parse("2006-01-02", date) + if err != nil || parsed.Format("2006-01-02") != date { + return time.Time{}, fmt.Errorf("invalid excluded date %q", date) + } + if _, duplicate := seen[date]; duplicate { + return time.Time{}, fmt.Errorf("duplicate excluded date %q", date) + } + seen[date] = struct{}{} + } + if _, excluded := seen[at.In(loc).Format("2006-01-02")]; excluded { + return time.Time{}, fmt.Errorf("%w: task excludes the Shanghai calendar date", ErrWindowClosed) + } + taskEnd, err := task.WeeklySchedule.endAt(at, loc) + if err != nil { + return time.Time{}, fmt.Errorf("task schedule: %w", err) + } + lineEnd, err := line.endAt(at, loc) + if err != nil { + return time.Time{}, fmt.Errorf("line schedule: %w", err) + } + if lineEnd.Before(taskEnd) { + taskEnd = lineEnd + } + if !end.IsZero() && end.Before(taskEnd) { + taskEnd = end + } + return taskEnd, nil +} + +func (schedule WeeklySchedule) validate() error { + if schedule.TimeZone != "Asia/Shanghai" || len(schedule.WeeklyWindows) != len(weekdays) { + return errors.New("schedule must define all seven weekdays in Asia/Shanghai") + } + for _, name := range weekdays { + windows, exists := schedule.WeeklyWindows[name] + if !exists { + return fmt.Errorf("missing weekday %s", name) + } + previousEnd := 0 + for _, window := range windows { + start, err := minuteOfDay(window.Start, false) + if err != nil { + return fmt.Errorf("%s start: %w", name, err) + } + end, err := minuteOfDay(window.End, true) + if err != nil { + return fmt.Errorf("%s end: %w", name, err) + } + if start >= end || start < previousEnd { + return fmt.Errorf("%s windows must be ordered, disjoint, and split at midnight", name) + } + previousEnd = end + } + } + return nil +} + +func minuteOfDay(clock string, allowEndOfDay bool) (int, error) { + if len(clock) != 5 || clock[2] != ':' || clock[0] < '0' || clock[0] > '9' || clock[1] < '0' || clock[1] > '9' || clock[3] < '0' || clock[3] > '9' || clock[4] < '0' || clock[4] > '9' { + return 0, fmt.Errorf("invalid clock time %q", clock) + } + hours := int(clock[0]-'0')*10 + int(clock[1]-'0') + minutes := int(clock[3]-'0')*10 + int(clock[4]-'0') + if minutes > 59 || hours > 24 || hours == 24 && (minutes != 0 || !allowEndOfDay) { + return 0, fmt.Errorf("invalid clock time %q", clock) + } + return hours*60 + minutes, nil +} + +func (schedule WeeklySchedule) endAt(at time.Time, loc *time.Location) (time.Time, error) { + local := at.In(loc) + windows := schedule.WeeklyWindows[weekdays[local.Weekday()]] + minute := local.Hour()*60 + local.Minute() + for _, window := range windows { + start, _ := minuteOfDay(window.Start, false) // validated above + end, _ := minuteOfDay(window.End, true) + if minute >= start && minute < end { + midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc) + return midnight.Add(time.Duration(end) * time.Minute), nil + } + } + return time.Time{}, ErrWindowClosed +} diff --git a/internal/callwindow/policy_test.go b/internal/callwindow/policy_test.go new file mode 100644 index 0000000..e382ce0 --- /dev/null +++ b/internal/callwindow/policy_test.go @@ -0,0 +1,143 @@ +package callwindow + +import ( + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func week(windows ...Window) map[string][]Window { + return map[string][]Window{ + "monday": windows, "tuesday": windows, "wednesday": windows, + "thursday": windows, "friday": windows, "saturday": windows, "sunday": windows, + } +} + +func atShanghai(t *testing.T, dateTime string) time.Time { + t.Helper() + at, err := time.Parse(time.RFC3339, dateTime) + if err != nil { + t.Fatal(err) + } + return at +} + +func TestEvaluateTaskAndLineIntersectionAtBoundaries(t *testing.T) { + task := TaskSchedule{WeeklySchedule: WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"09:00", "20:00"})}} + line := WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"10:00", "18:00"})} + for _, tc := range []struct { + when, end string + allow bool + }{ + {"2026-09-21T09:59:59+08:00", "", false}, + {"2026-09-21T10:00:00+08:00", "2026-09-21T18:00:00+08:00", true}, + {"2026-09-21T17:59:59+08:00", "2026-09-21T18:00:00+08:00", true}, + {"2026-09-21T18:00:00+08:00", "", false}, + } { + end, err := Evaluate(task, line, atShanghai(t, tc.when)) + if (err == nil) != tc.allow { + t.Errorf("at %s: end=%v err=%v allow=%v", tc.when, end, err, tc.allow) + } else if tc.allow && !end.Equal(atShanghai(t, tc.end)) { + t.Errorf("at %s: end=%v, want %s", tc.when, end, tc.end) + } + } +} + +func TestEvaluateActualTaskAndLineConfigShape(t *testing.T) { + readSchedule := func(path string, dest any) { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, dest); err != nil { + t.Fatal(err) + } + } + var task struct { + Schedule TaskSchedule `json:"schedule"` + } + readSchedule("../../docs/contracts/examples/config-read-task-v0.1.json", &task) + var sip struct { + TrunkDetails []struct { + Schedule WeeklySchedule `json:"schedule"` + } `json:"trunk_details"` + } + readSchedule("../../docs/contracts/examples/config-read-sip-v0.1.json", &sip) + if len(sip.TrunkDetails) != 1 { + t.Fatalf("line sample count=%d", len(sip.TrunkDetails)) + } + end, err := Evaluate(task.Schedule, sip.TrunkDetails[0].Schedule, atShanghai(t, "2026-09-21T09:30:00+08:00")) + if err != nil || !end.Equal(atShanghai(t, "2026-09-21T11:00:00+08:00")) { + t.Fatalf("actual task and line schedules: end=%v err=%v", end, err) + } +} + +func TestEvaluateCrossMidnightSplitAndExcludedDate(t *testing.T) { + weekly := week() + weekly["monday"] = []Window{{"23:00", "24:00"}} + weekly["tuesday"] = []Window{{"00:00", "01:00"}} + task := TaskSchedule{WeeklySchedule: WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: weekly}} + line := WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: weekly} + for _, tc := range []struct{ when, end string }{ + {"2026-09-21T23:59:59+08:00", "2026-09-22T00:00:00+08:00"}, + {"2026-09-22T00:00:00+08:00", "2026-09-22T01:00:00+08:00"}, + } { + end, err := Evaluate(task, line, atShanghai(t, tc.when)) + if err != nil || !end.Equal(atShanghai(t, tc.end)) { + t.Fatalf("cross-midnight at %s: end=%v err=%v", tc.when, end, err) + } + } + task.ExcludedDates = []string{"2026-09-22"} + if _, err := Evaluate(task, line, atShanghai(t, "2026-09-22T00:00:00+08:00")); err == nil { + t.Fatal("excluded Shanghai calendar date allowed") + } +} + +func TestEvaluateTaskValidityAndAuthorizationBounds(t *testing.T) { + task := TaskSchedule{WeeklySchedule: WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"09:00", "20:00"})}, + StartsAt: "2026-09-21T10:00:00+08:00", EndsAt: "2026-09-21T11:00:00+08:00"} + line := WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"09:00", "20:00"})} + if _, err := Evaluate(task, line, atShanghai(t, "2026-09-21T09:59:59+08:00")); err == nil { + t.Fatal("before task validity allowed") + } + end, err := Evaluate(task, line, atShanghai(t, "2026-09-21T10:00:00+08:00")) + if err != nil || !end.Equal(atShanghai(t, "2026-09-21T11:00:00+08:00")) { + t.Fatalf("task start/end: %v %v", end, err) + } + if _, err := Evaluate(task, line, atShanghai(t, "2026-09-21T11:00:00+08:00")); err == nil { + t.Fatal("at task validity end allowed") + } +} + +func TestEvaluateRejectsInvalidOrAmbiguousSchedules(t *testing.T) { + for _, tc := range []struct { + name string + windows []Window + }{ + {"cross-day unsplit", []Window{{"23:00", "01:00"}}}, + {"overlap", []Window{{"09:00", "11:00"}, {"10:00", "12:00"}}}, + {"24:00 start", []Window{{"24:00", "24:00"}}}, + {"non-time", []Window{{"9:00", "20:00"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + bad := TaskSchedule{WeeklySchedule: WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(tc.windows...)}} + line := WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"09:00", "20:00"})} + if _, err := Evaluate(bad, line, atShanghai(t, "2026-09-21T10:00:00+08:00")); err == nil { + t.Fatal("invalid schedule allowed") + } + }) + } + badDate := TaskSchedule{WeeklySchedule: WeeklySchedule{TimeZone: "Asia/Shanghai", WeeklyWindows: week(Window{"09:00", "20:00"})}, ExcludedDates: []string{"2026-02-30"}} + if _, err := Evaluate(badDate, badDate.WeeklySchedule, atShanghai(t, "2026-09-21T10:00:00+08:00")); err == nil || !strings.Contains(err.Error(), "date") { + t.Fatalf("invalid excluded date: %v", err) + } + badZone := badDate + badZone.ExcludedDates = nil + badZone.TimeZone = "UTC" + if _, err := Evaluate(badZone, badDate.WeeklySchedule, atShanghai(t, "2026-09-21T10:00:00+08:00")); err == nil { + t.Fatal("wrong timezone allowed") + } +} diff --git a/internal/configread/client.go b/internal/configread/client.go new file mode 100644 index 0000000..34b9283 --- /dev/null +++ b/internal/configread/client.go @@ -0,0 +1,464 @@ +package configread + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +const dispatcherIDHeader = "X-DISPATCHER-id" +const dispatcherSecretHeader = "X-DISPATCHER-SECRET-KEY" +const configReadPath = "/internal/v1/dispatcher" + +const ( + resourceSIPConfig = "sip_config" + resourceTaskConfig = "task_config" + resourceTenantQuota = "tenant_quota" + taskDiscoverySnapshot = "snapshot" +) + +type Client struct { + baseURL string + dispatcherID string + secret string + httpClient *http.Client +} + +type Snapshot struct { + TaskID string + TenantID string + TenantKey string + Discovery TaskDiscovery + SIPRevision int64 + SIPSnapshotSHA256 string + SIPCellID string + SIPArtifactRevision int64 + SIPArtifactConfigSHA256 string + TaskRevision int64 + TaskStatus string + TaskMaxConcurrentCalls int64 + TaskRingTimeoutMS int64 + TaskMaxCallDurationMS int64 + TaskRoutePolicyID string + TaskCallerProfileID string + TaskAllowedTrunkIDs []string + AgentVersionID string + AgentAuthorizationID string + AgentAuthorizationExpiresAt time.Time + QuotaRevision int64 + TenantMaxConcurrentCalls int64 + QuotaValidUntil time.Time + DiscoveryCursor string + FetchedAt time.Time + ExpiresAt time.Time + SIP json.RawMessage + Task json.RawMessage + Tasks json.RawMessage + TenantQuota json.RawMessage +} + +type TaskDiscovery struct { + DispatcherID string + Mode string + Cursor string + FromCursor string + NextCursor string + Tasks []DiscoveredTask + Changes []TaskDiscoveryChange + Body json.RawMessage + Recovered bool +} + +type DiscoveredTask struct { + TaskID string `json:"task_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + Status string `json:"status"` + TaskRevision int64 `json:"task_revision"` +} + +type TaskDiscoveryChange struct { + Cursor string `json:"cursor"` + Operation string `json:"operation"` + TaskID string `json:"task_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + Status string `json:"status"` + TaskRevision int64 `json:"task_revision"` +} + +type HTTPError struct { + StatusCode int + Code string +} + +func (e *HTTPError) Error() string { + if e.Code == "" { + return fmt.Sprintf("configuration service returned HTTP %d", e.StatusCode) + } + return fmt.Sprintf("configuration service returned HTTP %d (%s)", e.StatusCode, e.Code) +} + +func NewClient(baseURL, dispatcherID, secret string, httpClient *http.Client) (*Client, error) { + parsed, err := url.ParseRequestURI(baseURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, errors.New("configuration service URL must be an absolute HTTP(S) URL without credentials, query, or fragment") + } + if strings.TrimSpace(dispatcherID) == "" || strings.TrimSpace(secret) == "" { + return nil, errors.New("dispatcher ID and secret are required") + } + if strings.ContainsAny(dispatcherID+secret, "\r\n") { + return nil, errors.New("dispatcher credentials contain invalid header characters") + } + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{baseURL: strings.TrimRight(baseURL, "/"), dispatcherID: dispatcherID, secret: secret, httpClient: httpClient}, nil +} + +func (c *Client) DispatcherID() string { return c.dispatcherID } + +func (c *Client) ReadTask(ctx context.Context, taskID, tenantID string) (Snapshot, error) { + if taskID == "" || tenantID == "" { + return Snapshot{}, errors.New("task ID and tenant ID are required") + } + sipBody, err := c.getConfig(ctx, configReadPath+"/sip") + if err != nil { + return Snapshot{}, err + } + var sip sipConfigResponse + if err := json.Unmarshal(sipBody, &sip); err != nil { + return Snapshot{}, fmt.Errorf("decode SIP configuration identity: %w", err) + } + if sip.Resource != resourceSIPConfig || sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 || sip.SnapshotSHA256 == "" || + sip.Artifact.CellID == "" || sip.Artifact.Revision <= 0 || sip.Artifact.ConfigSHA256 == "" { + return Snapshot{}, errors.New("SIP configuration identity or revision does not match the request") + } + + discovery, err := c.ReadTaskDiscovery(ctx, "") + if err != nil { + return Snapshot{}, err + } + if discovery.DispatcherID != c.dispatcherID || discovery.Mode != taskDiscoverySnapshot || discovery.Cursor == "" { + return Snapshot{}, errors.New("task discovery snapshot identity or mode does not match the request") + } + var discovered *DiscoveredTask + for i := range discovery.Tasks { + if discovery.Tasks[i].TaskID == taskID { + if discovered != nil { + return Snapshot{}, errors.New("task discovery contains duplicate task IDs") + } + discovered = &discovery.Tasks[i] + } + } + if discovered == nil || discovered.TenantID != tenantID { + return Snapshot{}, errors.New("task discovery does not bind the requested task to the requested tenant") + } + tasksBody := append(json.RawMessage(nil), discovery.Body...) + + taskPath := configReadPath + "/task/" + url.PathEscape(taskID) + taskBody, err := c.getConfig(ctx, taskPath) + if err != nil { + return Snapshot{}, err + } + var task taskConfigResponse + if err := json.Unmarshal(taskBody, &task); err != nil { + return Snapshot{}, fmt.Errorf("decode task configuration identity: %w", err) + } + if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID || task.TenantID != tenantID || + task.TenantKey == "" || task.TaskRevision <= 0 || task.MaxConcurrentCalls < 0 || task.RingTimeoutMS <= 0 || + task.MaxCallDurationMS <= 0 || task.Agent.AgentVersionID == "" || task.Agent.AgentVersionID != task.Agent.Config.AgentVersionID { + return Snapshot{}, errors.New("task configuration identity or limits do not match discovery") + } + if discovered.TenantKey != task.TenantKey || discovered.TaskRevision != task.TaskRevision || discovered.Status != task.Status { + return Snapshot{}, errors.New("task discovery and task configuration snapshots disagree") + } + authorizationExpiresAt, err := time.Parse(time.RFC3339, task.Agent.AuthorizationExpiresAt) + if err != nil { + return Snapshot{}, errors.New("task configuration has an invalid Agent authorization expiry") + } + + quotaPath := configReadPath + "/tenant/" + url.PathEscape(tenantID) + "/quota" + quotaBody, err := c.getConfig(ctx, quotaPath) + if err != nil { + return Snapshot{}, err + } + var quota tenantQuotaResponse + if err := json.Unmarshal(quotaBody, "a); err != nil { + return Snapshot{}, fmt.Errorf("decode tenant quota identity: %w", err) + } + if quota.Resource != resourceTenantQuota || quota.DispatcherID != c.dispatcherID || quota.TenantID != tenantID || quota.TenantKey != task.TenantKey || quota.QuotaRevision <= 0 { + return Snapshot{}, errors.New("tenant quota identity does not match task configuration") + } + quotaValidUntil, err := time.Parse(time.RFC3339, quota.ValidUntil) + if err != nil { + return Snapshot{}, errors.New("tenant quota has an invalid validity deadline") + } + + return Snapshot{ + TaskID: taskID, TenantID: tenantID, TenantKey: task.TenantKey, Discovery: discovery, + SIPRevision: sip.Revision, SIPSnapshotSHA256: sip.SnapshotSHA256, + SIPCellID: sip.Artifact.CellID, SIPArtifactRevision: sip.Artifact.Revision, + SIPArtifactConfigSHA256: sip.Artifact.ConfigSHA256, + TaskRevision: task.TaskRevision, TaskStatus: task.Status, + TaskMaxConcurrentCalls: int64(task.MaxConcurrentCalls), TaskRingTimeoutMS: int64(task.RingTimeoutMS), + TaskMaxCallDurationMS: int64(task.MaxCallDurationMS), TaskRoutePolicyID: task.RoutePolicyID, + TaskCallerProfileID: task.CallerProfileID, TaskAllowedTrunkIDs: append([]string(nil), task.AllowedTrunkIDs...), + AgentVersionID: task.Agent.AgentVersionID, AgentAuthorizationID: task.Agent.AuthorizationID, + AgentAuthorizationExpiresAt: authorizationExpiresAt, QuotaRevision: quota.QuotaRevision, + TenantMaxConcurrentCalls: int64(quota.MaxConcurrentCalls), QuotaValidUntil: quotaValidUntil, + DiscoveryCursor: discovery.Cursor, + SIP: sipBody, Task: taskBody, Tasks: tasksBody, TenantQuota: quotaBody, + }, nil +} + +// TaskStatus is the authoritative task identity and control status read from +// the task resource without requiring unrelated SIP or quota resources. +type TaskStatus struct { + DispatcherID string + TaskID string + TenantID string + TenantKey string + Status string + TaskRevision int64 +} + +// ReadTaskStatus fetches only one task resource. Control handling uses this +// read even when execution configuration or quota is unavailable. +func (c *Client) ReadTaskStatus(ctx context.Context, taskID, tenantID, tenantKey string) (TaskStatus, error) { + if taskID == "" || tenantID == "" || tenantKey == "" { + return TaskStatus{}, errors.New("task ID, tenant ID, and tenant key are required") + } + path := configReadPath + "/task/" + url.PathEscape(taskID) + body, err := c.getConfig(ctx, path) + if err != nil { + return TaskStatus{}, err + } + var task taskConfigResponse + if err := json.Unmarshal(body, &task); err != nil { + return TaskStatus{}, fmt.Errorf("decode task control status: %w", err) + } + if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID || + task.TenantID != tenantID || task.TenantKey != tenantKey || task.TaskRevision <= 0 { + return TaskStatus{}, errors.New("task control status identity does not match the request") + } + switch task.Status { + case "running", "paused", "stopped", "finished": + default: + return TaskStatus{}, fmt.Errorf("unsupported authoritative task status %q", task.Status) + } + return TaskStatus{ + DispatcherID: task.DispatcherID, TaskID: task.TaskID, TenantID: task.TenantID, + TenantKey: task.TenantKey, Status: task.Status, TaskRevision: task.TaskRevision, + }, nil +} + +// ReadTaskDiscovery accepts exactly one complete snapshot or change response. +// On HTTP 410 cursor_expired it fetches one full snapshot instead of advancing. +func (c *Client) ReadTaskDiscovery(ctx context.Context, after string) (TaskDiscovery, error) { + return c.readTaskDiscovery(ctx, after, false) +} + +func (c *Client) readTaskDiscovery(ctx context.Context, after string, recovered bool) (TaskDiscovery, error) { + path := configReadPath + "/tasks" + if after != "" { + path += "?" + url.Values{"after": {after}}.Encode() + } + body, err := c.getTaskDiscovery(ctx, path) + if err != nil { + var statusErr *HTTPError + if !recovered && after != "" && errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusGone && statusErr.Code == "cursor_expired" { + full, recoveryErr := c.readTaskDiscovery(ctx, "", true) + if recoveryErr != nil { + return TaskDiscovery{}, fmt.Errorf("recover expired task-discovery cursor with full snapshot: %w", recoveryErr) + } + return full, nil + } + return TaskDiscovery{}, err + } + var response taskDiscoveryResponse + if err := json.Unmarshal(body, &response); err != nil { + return TaskDiscovery{}, fmt.Errorf("decode task discovery: %w", err) + } + if response.DispatcherID != c.dispatcherID { + return TaskDiscovery{}, errors.New("task-discovery dispatcher identity does not match the request") + } + result := TaskDiscovery{DispatcherID: response.DispatcherID, FromCursor: after, Body: append(json.RawMessage(nil), body...), Recovered: recovered} + tenantByID, idByTenant := make(map[string]string), make(map[string]string) + if after == "" { + result.Mode, result.Cursor, result.Tasks = taskDiscoverySnapshot, response.Cursor, response.Tasks + seen := make(map[string]struct{}, len(result.Tasks)) + for _, task := range result.Tasks { + if _, duplicate := seen[task.TaskID]; duplicate { + return TaskDiscovery{}, fmt.Errorf("task discovery contains duplicate task ID %q", task.TaskID) + } + seen[task.TaskID] = struct{}{} + if err := validateTenantBinding(tenantByID, idByTenant, task.TenantID, task.TenantKey); err != nil { + return TaskDiscovery{}, err + } + } + } else { + result.Mode, result.NextCursor, result.Changes = "changes", response.NextCursor, response.Changes + if (len(result.Changes) == 0 && result.NextCursor != after) || (len(result.Changes) != 0 && result.NextCursor == after) { + return TaskDiscovery{}, errors.New("task discovery change cursor does not match the complete response") + } + for _, change := range result.Changes { + if err := validateTenantBinding(tenantByID, idByTenant, change.TenantID, change.TenantKey); err != nil { + return TaskDiscovery{}, err + } + } + } + return result, nil +} + +func validateTenantBinding(tenantByID, idByTenant map[string]string, tenantID, tenantKey string) error { + if len([]byte(tenantKey)) > 196 { + return errors.New("task discovery tenant_key exceeds the 196-byte limit") + } + if existing, ok := tenantByID[tenantID]; ok && existing != tenantKey { + return errors.New("task discovery changes the tenant_key bound to a tenant_id") + } + if existing, ok := idByTenant[tenantKey]; ok && existing != tenantID { + return errors.New("task discovery binds one tenant_key to multiple tenant IDs") + } + tenantByID[tenantID] = tenantKey + idByTenant[tenantKey] = tenantID + return nil +} + +func (c *Client) getConfig(ctx context.Context, path string) (json.RawMessage, error) { + body, err := c.get(ctx, path) + if err != nil { + return nil, err + } + if err := contract.ValidateLocalConfigRead(body); err != nil { + return nil, fmt.Errorf("validate configuration response: %w", err) + } + return body, nil +} + +func (c *Client) getTaskDiscovery(ctx context.Context, path string) (json.RawMessage, error) { + body, err := c.get(ctx, path) + if err != nil { + return nil, err + } + if err := contract.ValidateLocalTaskDiscovery(body); err != nil { + return nil, fmt.Errorf("validate task-discovery response: %w", err) + } + return body, nil +} + +func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) { + relative, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("parse configuration path: %w", err) + } + base, err := url.Parse(c.baseURL) + if err != nil { + return nil, fmt.Errorf("parse configuration base URL: %w", err) + } + base = base.JoinPath(relative.Path) + base.RawQuery = relative.RawQuery + request, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil) + if err != nil { + return nil, fmt.Errorf("build configuration request: %w", err) + } + request.Header.Set("Accept", "application/json") + request.Header.Set(dispatcherIDHeader, c.dispatcherID) + request.Header.Set(dispatcherSecretHeader, c.secret) + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("request configuration service: %w", err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("read configuration response: %w", err) + } + if response.StatusCode != http.StatusOK { + return nil, &HTTPError{StatusCode: response.StatusCode, Code: responseErrorCode(body)} + } + mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return nil, errors.New("configuration service response must use application/json") + } + if !json.Valid(body) { + return nil, errors.New("configuration service returned invalid JSON") + } + return json.RawMessage(body), nil +} + +func responseErrorCode(body []byte) string { + var response struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(body, &response); err != nil { + return "" + } + return response.Error.Code +} + +type sipConfigResponse struct { + Resource string `json:"resource"` + DispatcherID string `json:"dispatcher_id"` + Revision int64 `json:"revision"` + SnapshotSHA256 string `json:"snapshot_sha256"` + Artifact struct { + CellID string `json:"cell_id"` + Revision int64 `json:"revision"` + ConfigSHA256 string `json:"config_sha256"` + } `json:"artifact"` +} + +type taskDiscoveryResponse struct { + DispatcherID string `json:"dispatcher_id"` + Cursor string `json:"cursor"` + NextCursor string `json:"next_cursor"` + Tasks []DiscoveredTask `json:"tasks"` + Changes []TaskDiscoveryChange `json:"changes"` +} + +type taskConfigResponse struct { + Resource string `json:"resource"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TaskID string `json:"task_id"` + TaskRevision int64 `json:"task_revision"` + Status string `json:"status"` + MaxConcurrentCalls int `json:"max_concurrent_calls"` + RingTimeoutMS int `json:"ring_timeout_ms"` + MaxCallDurationMS int `json:"max_call_duration_ms"` + RoutePolicyID string `json:"route_policy_id"` + CallerProfileID string `json:"caller_profile_id"` + AllowedTrunkIDs []string `json:"allowed_trunk_ids"` + Agent struct { + AgentVersionID string `json:"agent_version_id"` + AuthorizationID string `json:"authorization_id"` + AuthorizationExpiresAt string `json:"authorization_expires_at"` + Config struct { + AgentVersionID string `json:"agent_version_id"` + } `json:"config"` + } `json:"agent"` +} + +type tenantQuotaResponse struct { + Resource string `json:"resource"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + QuotaRevision int64 `json:"quota_revision"` + MaxConcurrentCalls int `json:"max_concurrent_calls"` + ValidUntil string `json:"valid_until"` +} diff --git a/internal/configread/client_test.go b/internal/configread/client_test.go new file mode 100644 index 0000000..2b713ba --- /dev/null +++ b/internal/configread/client_test.go @@ -0,0 +1,178 @@ +package configread + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const ( + mockDispatcherID = "c046b893-8628-4589-ae50-619d049248a6" + mockTaskID = "task-mock" + mockTenantID = "tenant-id-mock" + mockTenantKey = "tenant-mock" + mockTestSecret = "test-secret" +) + +func TestClientReadTaskRequestsAndValidatesFourEndpoints(t *testing.T) { + fixtures := validConfigFixtures(t) + var got []string + mux := http.NewServeMux() + for path, body := range fixtures { + path, body := path, body + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + got = append(got, r.Method+" "+r.URL.RequestURI()) + if r.Method != http.MethodGet { + t.Errorf("method = %q, want GET", r.Method) + } + if r.Header.Get(dispatcherIDHeader) != mockDispatcherID { + t.Errorf("dispatcher ID header = %q", r.Header.Get(dispatcherIDHeader)) + } + if r.Header.Get(dispatcherSecretHeader) != mockTestSecret { + t.Errorf("dispatcher secret header = %q", r.Header.Get(dispatcherSecretHeader)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + }) + } + server := httptest.NewServer(mux) + defer server.Close() + + client := newMockClient(t, server) + snapshot, err := client.ReadTask(context.Background(), mockTaskID, mockTenantID) + if err != nil { + t.Fatal(err) + } + if len(snapshot.SIP) == 0 || len(snapshot.Task) == 0 || len(snapshot.Tasks) == 0 || len(snapshot.TenantQuota) == 0 { + t.Fatalf("incomplete config snapshot: %+v", snapshot) + } + if snapshot.TaskID != mockTaskID || snapshot.TenantID != mockTenantID || snapshot.TenantKey != mockTenantKey { + t.Fatalf("snapshot identity = %q/%q/%q", snapshot.TaskID, snapshot.TenantID, snapshot.TenantKey) + } + want := []string{ + "GET /internal/v1/dispatcher/sip", + "GET /internal/v1/dispatcher/tasks", + "GET /internal/v1/dispatcher/task/" + mockTaskID, + "GET /internal/v1/dispatcher/tenant/" + mockTenantID + "/quota", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("requests = %#v, want %#v", got, want) + } +} + +func TestClientReadTaskRejectsSchemaInvalidResponse(t *testing.T) { + fixtures := validConfigFixtures(t) + fixtures[configReadPath+"/sip"] = readConfigFixture(t, "config-read-invalid-extra-property-v0.1.json") + server := configFixtureServer(t, fixtures) + defer server.Close() + if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { + t.Fatal("ReadTask accepted a response rejected by the frozen Schema") + } +} + +func TestClientReadTaskRejectsIdentityMismatch(t *testing.T) { + cases := []struct { + name string + path string + old string + new string + }{ + {"task id", configReadPath + "/task/" + mockTaskID, `"task_id": "task-mock"`, `"task_id": "task-other"`}, + {"quota tenant key", configReadPath + "/tenant/" + mockTenantID + "/quota", `"tenant_key": "tenant-mock"`, `"tenant_key": "tenant-other"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fixtures := validConfigFixtures(t) + fixtures[tc.path] = replaceFixtureText(t, fixtures[tc.path], tc.old, tc.new) + server := configFixtureServer(t, fixtures) + defer server.Close() + if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { + t.Fatal("ReadTask accepted an identity mismatch") + } + }) + } +} + +func TestClientReadTaskReturnsStructuredHTTPFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = fmt.Fprint(w, `{"schema_version":"config-read.v0.1","resource":"error","error":{"code":"service_unavailable","message":"temporarily unavailable"}}`) + })) + defer server.Close() + + _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID) + var statusErr *HTTPError + if !errors.As(err, &statusErr) { + t.Fatalf("error = %v, want *HTTPError", err) + } + if statusErr.StatusCode != http.StatusServiceUnavailable || statusErr.Code != "service_unavailable" { + t.Fatalf("HTTP error = %+v", statusErr) + } +} + +func marshalDiscoveryResponse(t *testing.T, response map[string]any) []byte { + t.Helper() + body, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + return body +} + +func newMockClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + client, err := NewClient(server.URL, mockDispatcherID, mockTestSecret, server.Client()) + if err != nil { + t.Fatal(err) + } + return client +} + +func validConfigFixtures(t *testing.T) map[string][]byte { + t.Helper() + return map[string][]byte{ + configReadPath + "/sip": readConfigFixture(t, "config-read-sip-v0.1.json"), + configReadPath + "/tasks": readConfigFixture(t, "task-discovery-snapshot-v0.2.json"), + configReadPath + "/task/" + mockTaskID: readConfigFixture(t, "config-read-task-v0.1.json"), + configReadPath + "/tenant/" + mockTenantID + "/quota": readConfigFixture(t, "config-read-tenant-quota-v0.1.json"), + } +} + +func configFixtureServer(t *testing.T, fixtures map[string][]byte) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, ok := fixtures[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) +} + +func readConfigFixture(t *testing.T, name string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "..", "docs", "contracts", "examples", name)) + if err != nil { + t.Fatal(err) + } + return body +} + +func replaceFixtureText(t *testing.T, body []byte, old, replacement string) []byte { + t.Helper() + if !strings.Contains(string(body), old) { + t.Fatalf("fixture does not contain %q", old) + } + return []byte(strings.Replace(string(body), old, replacement, 1)) +} diff --git a/internal/configread/control_status_test.go b/internal/configread/control_status_test.go new file mode 100644 index 0000000..7a892eb --- /dev/null +++ b/internal/configread/control_status_test.go @@ -0,0 +1,55 @@ +package configread + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientReadTaskStatusRequestsOnlyTheAuthorizedTask(t *testing.T) { + fixtures := validConfigFixtures(t) + path := configReadPath + "/task/" + mockTaskID + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.Method+" "+r.URL.RequestURI()) + if r.Header.Get(dispatcherIDHeader) != mockDispatcherID || r.Header.Get(dispatcherSecretHeader) != mockTestSecret { + t.Errorf("request identity headers = %q/%q", r.Header.Get(dispatcherIDHeader), r.Header.Get(dispatcherSecretHeader)) + } + if r.Method != http.MethodGet || r.URL.Path != path { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixtures[path]) + })) + defer server.Close() + + got, err := newMockClient(t, server).ReadTaskStatus(context.Background(), mockTaskID, mockTenantID, mockTenantKey) + if err != nil { + t.Fatal(err) + } + var expected taskConfigResponse + if err := json.Unmarshal(fixtures[path], &expected); err != nil { + t.Fatal(err) + } + if got.DispatcherID != mockDispatcherID || got.TaskID != expected.TaskID || got.TenantID != expected.TenantID || + got.TenantKey != expected.TenantKey || got.Status != expected.Status || got.TaskRevision != expected.TaskRevision { + t.Fatalf("task status = %+v; response = %+v", got, expected) + } + if len(requests) != 1 || requests[0] != "GET "+path { + t.Fatalf("requests = %#v, want one task GET", requests) + } +} + +func TestClientReadTaskStatusRejectsTenantBindingMismatch(t *testing.T) { + fixtures := validConfigFixtures(t) + path := configReadPath + "/task/" + mockTaskID + fixtures[path] = replaceFixtureText(t, fixtures[path], `"tenant_key": "tenant-mock"`, `"tenant_key": "tenant-other"`) + server := configFixtureServer(t, fixtures) + defer server.Close() + if _, err := newMockClient(t, server).ReadTaskStatus(context.Background(), mockTaskID, mockTenantID, mockTenantKey); err == nil { + t.Fatal("ReadTaskStatus accepted a task bound to another tenant key") + } +} diff --git a/internal/configread/discovery_v02_test.go b/internal/configread/discovery_v02_test.go new file mode 100644 index 0000000..47e0a45 --- /dev/null +++ b/internal/configread/discovery_v02_test.go @@ -0,0 +1,129 @@ +package configread + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" +) + +func TestTaskDiscoveryV02SnapshotSingleResponseAndNoQueue(t *testing.T) { + body := readConfigFixture(t, "task-discovery-snapshot-v0.2.json") + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + result, err := newMockClient(t, server).ReadTaskDiscovery(context.Background(), "") + if err != nil { + t.Fatal(err) + } + if result.Mode != "snapshot" || result.Cursor != "opaque-watermark-001" || len(result.Tasks) != 1 || string(result.Body) != string(body) { + t.Fatalf("snapshot = %+v", result) + } + if !reflect.DeepEqual(requests, []string{"/internal/v1/dispatcher/tasks"}) { + t.Fatalf("requests = %v, want one request", requests) + } +} + +func TestTaskDiscoveryV02ChangesAndEmptyCursor(t *testing.T) { + for _, tc := range []struct { + name, file, after, next string + count int + }{ + {"changes", "task-discovery-changes-v0.2.json", "opaque-watermark-001", "opaque-watermark-004", 3}, + {"empty", "task-discovery-no-change-v0.2.json", "opaque-watermark-004", "opaque-watermark-004", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + body := readConfigFixture(t, tc.file) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("after") != tc.after || r.URL.Query().Has("page_token") { + t.Errorf("unexpected request %s", r.URL.String()) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + result, err := newMockClient(t, server).ReadTaskDiscovery(context.Background(), tc.after) + if err != nil { + t.Fatal(err) + } + if result.Mode != "changes" || result.NextCursor != tc.next || len(result.Changes) != tc.count || result.FromCursor != tc.after { + t.Fatalf("changes = %+v", result) + } + if tc.count != 0 && (result.Changes[2].Operation != "removed" || result.Changes[2].TaskID != "task-old") { + t.Fatalf("removal = %+v", result.Changes[2]) + } + }) + } +} + +func TestTaskDiscoveryV02ExpiredCursorFetchesOneFullSnapshot(t *testing.T) { + var requests []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Has("after") { + w.WriteHeader(http.StatusGone) + _, _ = fmt.Fprint(w, `{"schema_version":"task-discovery.v0.2-proposal","resource":"error","error":{"code":"cursor_expired","message":"full snapshot required"}}`) + return + } + _, _ = w.Write(readConfigFixture(t, "task-discovery-snapshot-v0.2.json")) + })) + defer server.Close() + result, err := newMockClient(t, server).ReadTaskDiscovery(context.Background(), "stale") + if err != nil || !result.Recovered || result.Mode != "snapshot" || len(result.Tasks) != 1 { + t.Fatalf("recovery = %+v, %v", result, err) + } + if !reflect.DeepEqual(requests, []string{"/internal/v1/dispatcher/tasks?after=stale", "/internal/v1/dispatcher/tasks"}) { + t.Fatalf("requests = %v", requests) + } +} + +func TestTaskDiscoveryV02RejectsObsoleteFieldsAndOversize(t *testing.T) { + for _, file := range []string{"task-discovery-invalid-queue-v0.2.json", "task-discovery-invalid-page-token-v0.2.json"} { + t.Run(file, func(t *testing.T) { + body := readConfigFixture(t, file) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + if _, err := newMockClient(t, server).ReadTaskDiscovery(context.Background(), ""); err == nil { + t.Fatal("accepted obsolete field") + } + }) + } + for _, count := range []int{256, 257} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + var base map[string]any + if err := json.Unmarshal(readConfigFixture(t, "task-discovery-snapshot-v0.2.json"), &base); err != nil { + t.Fatal(err) + } + tasks := make([]any, count) + for i := range tasks { + tasks[i] = map[string]any{"task_id": fmt.Sprintf("task-%03d", i), "tenant_id": mockTenantID, "tenant_key": mockTenantKey, "status": "running", "task_revision": 1} + } + base["tasks"] = tasks + body := marshalDiscoveryResponse(t, base) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + result, err := newMockClient(t, server).ReadTaskDiscovery(context.Background(), "") + if count == 256 && (err != nil || len(result.Tasks) != count) { + t.Fatalf("256 tasks: %d, %v", len(result.Tasks), err) + } + if count == 257 && (err == nil || !strings.Contains(err.Error(), "validate")) { + t.Fatalf("257 tasks: %v", err) + } + }) + } +} diff --git a/internal/contract/contract.go b/internal/contract/contract.go index 7694561..c8a60ec 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "unicode/utf8" @@ -67,6 +68,41 @@ func ValidateEvent(raw []byte) error { return ValidateSourceSchema("event-payloads.schema.json", raw) } +func ValidateLocalConfigRead(raw []byte) error { + return validateLocalSchema("config-read-v0.1.schema.json", raw) +} + +func ValidateLocalTaskDiscovery(raw []byte) error { + return validateLocalSchema("task-discovery-v0.2-proposal.schema.json", raw) +} + +func ValidateLocalCommandNext(raw []byte) error { + return validateLocalSchema("command-next-v0.1-proposal.schema.json", raw) +} + +func ValidateLocalCallResult(raw []byte) error { + return validateLocalSchema("call-result-v0.1-proposal.schema.json", raw) +} + +func validateLocalSchema(schemaName string, raw []byte) error { + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("decode %s response: %w", schemaName, err) + } + schema, err := localSchemaFromBundle(schemaName) + if err != nil { + return err + } + if err := schema.Validate(value); err != nil { + var validation *jsonschema.ValidationError + if errors.As(err, &validation) { + return fmt.Errorf("%s validation failed at /%s", schemaName, strings.Join(validation.InstanceLocation, "/")) + } + return fmt.Errorf("%s validation failed", schemaName) + } + return nil +} + func ValidateSourceSchema(schemaName string, raw []byte) error { value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) if err != nil { diff --git a/internal/contract/local_mock_recording_failure.go b/internal/contract/local_mock_recording_failure.go new file mode 100644 index 0000000..9c9c86f --- /dev/null +++ b/internal/contract/local_mock_recording_failure.go @@ -0,0 +1,32 @@ +package contract + +import ( + "encoding/json" + "fmt" +) + +const LocalMockRecordingFailureVersion = "local-mock-recording-failure.v0.1" +const MaxLocalMockRecordingFailureBytes = 4096 + +// LocalMockRecordingFailure is the strict, Mock-only payload of an Agent +// recording.progress failure fact. It is not a SaaS event or a real-mode API. +type LocalMockRecordingFailure struct { + SchemaVersion string `json:"schema_version"` + UploadID string `json:"upload_id"` + RecordingID string `json:"recording_id"` + ErrorCode string `json:"error_code"` +} + +func DecodeLocalMockRecordingFailure(raw []byte) (LocalMockRecordingFailure, error) { + if len(raw) == 0 || len(raw) > MaxLocalMockRecordingFailureBytes { + return LocalMockRecordingFailure{}, fmt.Errorf("local Mock recording failure payload must be 1–%d bytes", MaxLocalMockRecordingFailureBytes) + } + if err := validateLocalSchema("local-mock-recording-failure-v0.1.schema.json", raw); err != nil { + return LocalMockRecordingFailure{}, err + } + var fact LocalMockRecordingFailure + if err := json.Unmarshal(raw, &fact); err != nil { + return LocalMockRecordingFailure{}, fmt.Errorf("decode local Mock recording failure: %w", err) + } + return fact, nil +} diff --git a/internal/contract/local_mock_recording_failure_test.go b/internal/contract/local_mock_recording_failure_test.go new file mode 100644 index 0000000..6fb46c5 --- /dev/null +++ b/internal/contract/local_mock_recording_failure_test.go @@ -0,0 +1,34 @@ +package contract + +import ( + "strings" + "testing" +) + +func TestLocalMockRecordingFailureSchema(t *testing.T) { + for _, code := range []string{"upload_authorization_failed", "upload_authorization_expired", "upload_failed", "checksum_mismatch"} { + t.Run(code, func(t *testing.T) { + raw := []byte(`{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"` + code + `"}`) + fact, err := DecodeLocalMockRecordingFailure(raw) + if err != nil || fact.UploadID != "upload-a" || fact.RecordingID != "recording-a" || fact.ErrorCode != code { + t.Fatalf("valid local Mock failure: %+v err=%v", fact, err) + } + }) + } + for _, tc := range []struct { + name string + raw string + }{ + {"missing upload", `{"schema_version":"local-mock-recording-failure.v0.1","recording_id":"recording-a","error_code":"upload_failed"}`}, + {"unknown field", `{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_failed","put_url":"https://example.invalid/secret"}`}, + {"timeout owned by Dispatcher", `{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_timeout"}`}, + {"wrong version", `{"schema_version":"v0.2","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_failed"}`}, + {"oversized", `{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_failed"}` + strings.Repeat(" ", 4097)}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := DecodeLocalMockRecordingFailure([]byte(tc.raw)); err == nil { + t.Fatal("unversioned, oversized or incompatible Mock failure accepted") + } + }) + } +} diff --git a/internal/contract/schema.go b/internal/contract/schema.go index 88c84f2..c1ae03e 100644 --- a/internal/contract/schema.go +++ b/internal/contract/schema.go @@ -35,6 +35,72 @@ func (l bundleLoader) Load(address string) (any, error) { return jsonschema.UnmarshalJSON(bytes.NewReader(raw)) } +const projectLocalSchemaBase = "https://go-sip.local/contracts/proposals/" + +type projectBundleLoader struct{} + +func (projectBundleLoader) Load(address string) (any, error) { + upstream := bundleLoader{version: contracts.SourceCommit} + if strings.HasPrefix(address, upstream.base()) { + return upstream.Load(address) + } + if !strings.HasPrefix(address, projectLocalSchemaBase) { + return nil, fmt.Errorf("schema reference outside pinned project bundles: %s", address) + } + name := strings.TrimPrefix(address, projectLocalSchemaBase) + if name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { + return nil, fmt.Errorf("invalid project-local schema resource %q", name) + } + raw, err := contracts.ReadLocal(localSchemaVersion(name), name) + if err != nil { + return nil, fmt.Errorf("read project-local schema %s: %w", name, err) + } + return jsonschema.UnmarshalJSON(bytes.NewReader(raw)) +} + +func localSchemaVersion(name string) string { + switch name { + case "config-read-v0.1.schema.json", "command-next-v0.1-proposal.schema.json", "call-result-v0.1-proposal.schema.json", "local-mock-recording-failure-v0.1.schema.json": + return "v0.1" + case "task-discovery-v0.2-proposal.schema.json": + return "v0.2" + default: + return "" + } +} + +func localSchemaFromBundle(name string) (*jsonschema.Schema, error) { + version := localSchemaVersion(name) + if version == "" { + return nil, fmt.Errorf("unknown project-local schema %q", name) + } + key := "local/" + version + "/" + name + if cached, ok := compiledSchemas.Load(key); ok { + return cached.(*jsonschema.Schema), nil + } + raw, err := contracts.ReadLocal(version, name) + if err != nil { + return nil, err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("decode project-local schema %s: %w", name, err) + } + resource := projectLocalSchemaBase + name + compiler := jsonschema.NewCompiler() + compiler.UseLoader(projectBundleLoader{}) + compiler.AssertFormat() + if err := compiler.AddResource(resource, doc); err != nil { + return nil, fmt.Errorf("register project-local schema %s: %w", name, err) + } + schema, err := compiler.Compile(resource) + if err != nil { + return nil, fmt.Errorf("compile project-local schema %s: %w", name, err) + } + actual, _ := compiledSchemas.LoadOrStore(key, schema) + return actual.(*jsonschema.Schema), nil +} + func schemaFromBundle(version, name string) (*jsonschema.Schema, error) { key := version + "/" + name if cached, ok := compiledSchemas.Load(key); ok { diff --git a/internal/contract/schema_test.go b/internal/contract/schema_test.go index 146180b..7a433e1 100644 --- a/internal/contract/schema_test.go +++ b/internal/contract/schema_test.go @@ -2,6 +2,8 @@ package contract import ( "bytes" + "os" + "path/filepath" "testing" "git.ipao.vip/rogee/go-sip/contracts" @@ -46,3 +48,26 @@ func TestBundleSchemaRejectsInvalidResources(t *testing.T) { } } } + +func TestProjectLocalConfigurationSchemasValidatePositivesAndRejectNegatives(t *testing.T) { + read := func(name string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "..", "docs", "contracts", "examples", name)) + if err != nil { + t.Fatal(err) + } + return body + } + if err := ValidateLocalConfigRead(read("config-read-task-v0.1.json")); err != nil { + t.Fatalf("valid task config: %v", err) + } + if err := ValidateLocalConfigRead(read("config-read-invalid-extra-property-v0.1.json")); err == nil { + t.Fatal("config-read schema accepted an additional property") + } + if err := ValidateLocalTaskDiscovery(read("task-discovery-snapshot-v0.2.json")); err != nil { + t.Fatalf("valid discovery snapshot: %v", err) + } + if err := ValidateLocalTaskDiscovery(read("task-discovery-invalid-queue-v0.2.json")); err == nil { + t.Fatal("task-discovery schema accepted a queue field in the response") + } +} diff --git a/internal/dispatcher/agent.go b/internal/dispatcher/agent.go index 1d58dff..ba7e589 100644 --- a/internal/dispatcher/agent.go +++ b/internal/dispatcher/agent.go @@ -2,23 +2,86 @@ package dispatcher import ( "context" + "crypto/sha256" + "encoding/hex" "errors" "fmt" + "strconv" "sync" "time" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" "google.golang.org/protobuf/proto" ) var ErrRemoteResultUnknown = errors.New("remote Agent result is unknown; reconciliation required") +const ( + AppliedConfigKindSIP = "sip" + AppliedConfigStateApplied = "applied" +) + +type AgentStatusProbe interface { + Probe(ctx context.Context, agentID, cellID string) (*agentv1.AgentStatus, error) +} + +type AgentSIPConfigVerifier struct { + Probe AgentStatusProbe + AgentID string + CellID string +} + +func (v *AgentSIPConfigVerifier) VerifyAppliedSIPConfig(ctx context.Context, snapshot configread.Snapshot) error { + if v == nil || v.Probe == nil || v.AgentID == "" || v.CellID == "" { + return errors.New("Agent status probe, agent ID, and configured cell ID are required") + } + if snapshot.SIPCellID != v.CellID { + return fmt.Errorf("SIP config targets cell %q, configured cell is %q", snapshot.SIPCellID, v.CellID) + } + status, err := v.Probe.Probe(ctx, v.AgentID, v.CellID) + if err != nil { + return fmt.Errorf("probe Agent applied SIP config: %w", err) + } + return verifyAppliedSIPConfigStatus(status, v.AgentID, snapshot) +} + +func verifyAppliedSIPConfigStatus(status *agentv1.AgentStatus, agentID string, snapshot configread.Snapshot) error { + digest, digestErr := hex.DecodeString(snapshot.SIPArtifactConfigSHA256) + if snapshot.SIPCellID == "" || snapshot.SIPArtifactRevision <= 0 || digestErr != nil || len(digest) != sha256.Size { + return errors.New("SIP config artifact identity is incomplete") + } + if status == nil || status.AgentId != agentID || status.CellId != snapshot.SIPCellID || status.BootId == "" { + return errors.New("Agent status identity or boot ID is invalid") + } + var appliedSIP *agentv1.AppliedConfig + for _, applied := range status.AppliedConfigs { + if applied == nil || applied.Kind != AppliedConfigKindSIP { + continue + } + if appliedSIP != nil { + return errors.New("Agent reported multiple SIP applied-config entries") + } + appliedSIP = applied + } + if appliedSIP == nil || appliedSIP.State != AppliedConfigStateApplied || appliedSIP.ObservedAtUnixMs <= 0 { + return errors.New("Agent has not reported one observed, applied SIP config") + } + revision := strconv.FormatInt(snapshot.SIPArtifactRevision, 10) + if appliedSIP.Revision != revision || appliedSIP.ConfigSha256 != snapshot.SIPArtifactConfigSHA256 { + return fmt.Errorf("Agent applied SIP config does not match revision %s and expected SHA-256", revision) + } + return nil +} + type AgentSession struct { AgentID string CellID string BootID string DispatcherEpoch string SessionGeneration uint64 + ExpiresAtUnixMs int64 } type DispatchResult struct { @@ -108,7 +171,7 @@ func (c *AgentCoordinator) Activate(ctx context.Context, agentID, cellID, bootID if response.Session == nil || response.State != agentv1.ActivationState_ACTIVATION_STATE_ACTIVE { return AgentSession{}, errors.New("Agent activation was not active") } - session := AgentSession{AgentID: agentID, CellID: cellID, BootID: bootID, DispatcherEpoch: epoch, SessionGeneration: response.Session.SessionGeneration} + session := AgentSession{AgentID: agentID, CellID: cellID, BootID: bootID, DispatcherEpoch: epoch, SessionGeneration: response.Session.SessionGeneration, ExpiresAtUnixMs: response.Session.ExpiresAtUnixMs} c.mu.Lock() c.sessions[agentID] = session c.mu.Unlock() @@ -187,6 +250,25 @@ func (c *AgentCoordinator) clientAndSession(agentID string) (agentv1.AgentContro return client, session, nil } +// AuthorizeInboundMeta accepts an Agent→Dispatcher fact only from the current +// activated Endpoint session. An old boot's durable fact can be replayed, but +// its request metadata must use the newly activated boot and generation. +func (c *AgentCoordinator) AuthorizeInboundMeta(meta *agentv1.RequestMeta) error { + if c == nil || meta == nil || meta.ProtocolVersion != "agent.v1" || meta.AgentId == "" { + return fmt.Errorf("active Agent session is required: %w", store.ErrCommandConflict) + } + c.mu.Lock() + session, active := c.sessions[meta.AgentId] + client, registered := c.clients[meta.AgentId] + c.mu.Unlock() + if !active || !registered || client == nil || session.ExpiresAtUnixMs <= c.now().UnixMilli() || + meta.CellId != session.CellID || meta.BootId != session.BootID || + meta.DispatcherEpoch != session.DispatcherEpoch || meta.SessionGeneration != session.SessionGeneration { + return fmt.Errorf("Agent report does not match a current activated session: %w", store.ErrCommandConflict) + } + return nil +} + func (c *AgentCoordinator) meta(session AgentSession, operationID, idempotencyKey string) *agentv1.RequestMeta { return &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, IdempotencyKey: idempotencyKey, DispatcherEpoch: session.DispatcherEpoch, AgentId: session.AgentID, CellId: session.CellID, BootId: session.BootID, SessionGeneration: session.SessionGeneration} } diff --git a/internal/dispatcher/agent_inbound_session_test.go b/internal/dispatcher/agent_inbound_session_test.go new file mode 100644 index 0000000..7810ecc --- /dev/null +++ b/internal/dispatcher/agent_inbound_session_test.go @@ -0,0 +1,56 @@ +package dispatcher + +import ( + "errors" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +type inboundSessionTestClient struct { + agentv1.AgentControlServiceClient +} + +func TestInboundAgentFactRequiresCurrentUnexpiredActivatedSession(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + coordinator := NewAgentCoordinator(func() time.Time { return now }) + if err := coordinator.Register("agent-a", &inboundSessionTestClient{}); err != nil { + t.Fatal(err) + } + coordinator.sessions["agent-a"] = AgentSession{ + AgentID: "agent-a", CellID: "cell-a", BootID: "boot-a", DispatcherEpoch: "epoch-a", + SessionGeneration: 3, ExpiresAtUnixMs: now.Add(time.Minute).UnixMilli(), + } + meta := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", + DispatcherEpoch: "epoch-a", SessionGeneration: 3, + } + if err := coordinator.AuthorizeInboundMeta(meta); err != nil { + t.Fatalf("active Agent was refused: %v", err) + } + for _, tc := range []struct { + name string + change func(*agentv1.RequestMeta) + }{ + {"old boot", func(m *agentv1.RequestMeta) { m.BootId = "boot-old" }}, + {"old generation", func(m *agentv1.RequestMeta) { m.SessionGeneration = 2 }}, + {"other cell", func(m *agentv1.RequestMeta) { m.CellId = "cell-other" }}, + {"other epoch", func(m *agentv1.RequestMeta) { m.DispatcherEpoch = "epoch-other" }}, + {"unregistered Agent", func(m *agentv1.RequestMeta) { m.AgentId = "agent-other" }}, + } { + t.Run(tc.name, func(t *testing.T) { + changed := proto.Clone(meta).(*agentv1.RequestMeta) + tc.change(changed) + if err := coordinator.AuthorizeInboundMeta(changed); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("unbound Agent report passed: %v", err) + } + }) + } + now = now.Add(time.Minute) + if err := coordinator.AuthorizeInboundMeta(meta); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("expired Agent report passed: %v", err) + } +} diff --git a/internal/dispatcher/ai_mq_integration_test.go b/internal/dispatcher/ai_mq_integration_test.go deleted file mode 100644 index 0ab84de..0000000 --- a/internal/dispatcher/ai_mq_integration_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package dispatcher - -import ( - "context" - "encoding/json" - "net/url" - "os" - "path/filepath" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" - amqp "github.com/rabbitmq/amqp091-go" -) - -func TestLocalMQAIConfigurationAuthorizationRoundTrip(t *testing.T) { - address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") - if address == "" { - t.Skip("dedicated local RabbitMQ vhost not configured") - } - endpoint, err := url.Parse(address) - if err != nil || (endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "localhost" && endpoint.Hostname() != "::1") { - t.Fatal("loopback RabbitMQ required") - } - id, key := uuid.NewString(), "ai."+uuid.NewString() - broker, err := mq.Open(address, id) - if err != nil { - t.Fatal(err) - } - defer broker.Close() - queue, err := broker.DeclareTenantQueue(key) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, key) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "dispatcher.db") - s, err := store.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { s.Close() }() - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - d, err := New(s, broker, nil) - if err != nil { - t.Fatal(err) - } - connection, err := amqp.Dial(address) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - channel, err := connection.Channel() - if err != nil { - t.Fatal(err) - } - defer channel.Close() - defer channel.QueueDelete(route.InboxQueue, false, false, false) - defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) - defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) - if err := channel.Confirm(false); err != nil { - t.Fatal(err) - } - load := func(name string) map[string]any { - t.Helper() - raw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/" + name + ".json") - if err != nil { - t.Fatal(err) - } - var value map[string]any - if err := json.Unmarshal(raw, &value); err != nil { - t.Fatal(err) - } - return value - } - encode := func(value any) []byte { - t.Helper() - raw, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return raw - } - now := time.Now().UTC() - request := load("ai-config-request") - requestID := uuid.NewString() - request["dispatcher_id"], request["tenant_key"], request["message_id"] = id, key, requestID - request["issued_at"], request["not_after"] = now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) - requestRaw := encode(request) - if err := s.QueueAIConfigRequest(requestRaw); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if count, err := d.FlushOutbox(ctx, 10); err != nil || count != 1 { - t.Fatalf("request publish count=%d error=%v", count, err) - } - delivery, ok, err := channel.Get(mq.SaaSQueue, false) - if err != nil || !ok { - t.Fatalf("missing AI request: %v", err) - } - if string(delivery.Body) != string(requestRaw) || delivery.MessageId != requestID || delivery.DeliveryMode != amqp.Persistent || delivery.RoutingKey != route.OutboundKey { - t.Fatal("AI request lost identity, scope or persistence") - } - if err := delivery.Ack(false); err != nil { - t.Fatal(err) - } - reply := load("ai-config-result") - reply["dispatcher_id"], reply["tenant_key"], reply["correlation_id"] = id, key, requestID - reply["message_id"], reply["issued_at"] = uuid.NewString(), now.Format(time.RFC3339Nano) - payload := reply["payload"].(map[string]any) - auth := payload["authorization"].(map[string]any) - auth["tenant_key"], auth["config_sha256"] = key, payload["snapshot"].(map[string]any)["content_sha256"] - auth["issued_at"], auth["expires_at"] = now.Add(-time.Second).Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) - replyRaw := encode(reply) - for attempt := 0; attempt < 2; attempt++ { - confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, MessageId: reply["message_id"].(string), ContentType: "application/json", Body: replyRaw}) - if err != nil { - t.Fatal(err) - } - if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { - t.Fatalf("reply confirm: %v", err) - } - received, ok, err := channel.Get(queue, false) - if err != nil || !ok { - t.Fatalf("reply absent from D queue: %v", err) - } - if err := d.AcceptMQMessage(received.Body, received.RoutingKey); err != nil { - t.Fatal(err) - } - if err := received.Ack(false); err != nil { - t.Fatal(err) - } - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - s, err = store.Open(path) - if err != nil { - t.Fatal(err) - } - snapshot, authorization, err := s.LoadAuthorizedAI("tenant-a", key, "version-a", "egress-mock") - if err != nil || len(authorization) == 0 || snapshot.Digest != payload["snapshot"].(map[string]any)["content_sha256"] { - t.Fatalf("authorized snapshot recovery failed: %v", err) - } - if _, _, err := s.LoadAuthorizedAI("tenant-a", key, "version-a", "other-egress"); err == nil { - t.Fatal("unauthorized egress accepted") - } - if _, _, err := s.LoadAuthorizedAI("other-tenant", key, "version-a", "egress-mock"); err == nil { - t.Fatal("cross-tenant authorization accepted") - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM ai_mq_requests`).Scan(&count); err != nil || count != 1 { - t.Fatalf("request duplication: count=%d error=%v", count, err) - } -} diff --git a/internal/dispatcher/authorized_agent.go b/internal/dispatcher/authorized_agent.go new file mode 100644 index 0000000..fe82c65 --- /dev/null +++ b/internal/dispatcher/authorized_agent.go @@ -0,0 +1,75 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/protobuf/proto" +) + +const localAuthorizedOriginationVersion = "agent-authorized-origination.v0.1" + +// DispatchAuthorizedLocal is the only local path from an accepted task to +// Agent origination. The Dispatcher claims a bound decision immediately before +// the RPC; neither an uncertain response nor a rejected deadline is retried. +func (d *Dispatcher) DispatchAuthorizedLocal(ctx context.Context, executionID, agentID string, coordinator *AgentCoordinator) (DispatchResult, error) { + result, _, err := d.dispatchAuthorizedLocal(ctx, executionID, agentID, coordinator) + return result, err +} + +func (d *Dispatcher) dispatchAuthorizedLocal(ctx context.Context, executionID, agentID string, coordinator *AgentCoordinator) (DispatchResult, LocalDialAuthorization, error) { + if coordinator == nil || agentID == "" { + return DispatchResult{}, LocalDialAuthorization{}, errors.New("activated Agent coordinator and Agent ID are required") + } + var result DispatchResult + var bound LocalDialAuthorization + err := d.OriginateLocal(ctx, executionID, agentID, func(ctx context.Context, authorized LocalDialAuthorization) error { + bound = authorized + var sendErr error + result, sendErr = coordinator.ExecuteAuthorized(ctx, agentID, authorized) + return sendErr + }) + return result, bound, err +} + +// ExecuteAuthorized sends precisely the previously claimed D decision. It +// does not obtain a new permit, reselect a line, or retry an uncertain RPC. +func (c *AgentCoordinator) ExecuteAuthorized(ctx context.Context, agentID string, authorized LocalDialAuthorization) (DispatchResult, error) { + if authorized.Binding == nil || authorized.Binding.ExecutionId == "" || authorized.SnapshotSHA256 == "" || + authorized.TrunkID == "" || authorized.CallerID == "" || authorized.Callee == "" || authorized.RingTimeoutMS <= 0 || + authorized.MaxCallDurationMS <= 0 || authorized.AllowedUntil.IsZero() { + return DispatchResult{}, errors.New("bound Agent instruction is incomplete") + } + client, session, err := c.clientAndSession(agentID) + if err != nil { + return DispatchResult{}, err + } + meta := c.meta(session, "authorized:"+authorized.Binding.ExecutionId, "authorized:"+authorized.Binding.ExecutionId) + request := &agentv1.ExecuteAuthorizedRequest{ + SchemaVersion: localAuthorizedOriginationVersion, + Meta: meta, Binding: proto.Clone(authorized.Binding).(*agentv1.ExecutionBinding), + SelectedTrunkId: authorized.TrunkID, CallerId: authorized.CallerID, Callee: authorized.Callee, + RingTimeoutMs: authorized.RingTimeoutMS, MaxCallDurationMs: authorized.MaxCallDurationMS, + DialBeforeUnixMs: authorized.AllowedUntil.UnixMilli(), BoundSnapshotSha256: authorized.SnapshotSHA256, + } + response, err := client.ExecuteAuthorized(ctx, request) + if err != nil { + return c.reconcileUnknown(ctx, client, session, authorized.Binding, err) + } + if response == nil || response.Receipt == nil { + return c.reconcileUnknown(ctx, client, session, authorized.Binding, errors.New("Agent omitted authorized execution receipt")) + } + result := DispatchResult{Receipt: response.Receipt, State: response.State} + switch response.Receipt.Result { + case agentv1.ResultCode_RESULT_CODE_APPLIED: + if response.State == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL { + return result, nil + } + case agentv1.ResultCode_RESULT_CODE_UNKNOWN: + result.Unknown = true + return result, fmt.Errorf("%w: Agent authorized execution is unknown", ErrRemoteResultUnknown) + } + return result, fmt.Errorf("Agent authorized execution not applied: result=%s state=%s", response.Receipt.Result, response.State) +} diff --git a/internal/dispatcher/call_result_v01.go b/internal/dispatcher/call_result_v01.go new file mode 100644 index 0000000..d8b794b --- /dev/null +++ b/internal/dispatcher/call_result_v01.go @@ -0,0 +1,151 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +const localCallResultSchemaVersion = "call-result.v0.1-proposal" + +type localCallResultEnvelope struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + OccurredAt string `json:"occurred_at"` + AggregateType string `json:"aggregate_type"` + AggregateID string `json:"aggregate_id"` + AggregateVersion int `json:"aggregate_version"` + Payload json.RawMessage `json:"payload"` +} + +type localCallResultReference struct { + SourceCommandID string `json:"source_command_id"` + ExecutionID string `json:"execution_id"` + CallID string `json:"call_id"` + TaskID string `json:"task_id"` + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at"` + DurationMs int64 `json:"duration_ms"` + Outcome string `json:"outcome"` + ReasonCode *string `json:"reason_code"` + Recording struct { + Status string `json:"status"` + RecordingID *string `json:"recording_id"` + UploadID *string `json:"upload_id"` + Bucket *string `json:"bucket"` + ObjectKey *string `json:"object_key"` + Format *string `json:"format"` + Channels *int `json:"channels"` + SampleRateHz *int `json:"sample_rate_hz"` + DurationMs *int64 `json:"duration_ms"` + SizeBytes *int64 `json:"size_bytes"` + ChecksumSHA256 *string `json:"checksum_sha256"` + ErrorCode *string `json:"error_code"` + } `json:"recording"` +} + +// EnqueueCallResult persists the single final call.result only when it matches +// a previously accepted command and execution. It never initiates a call. +func (d *Dispatcher) EnqueueCallResult(ctx context.Context, body []byte) error { + if err := ctx.Err(); err != nil { + return err + } + if d.dispatcherID == "" || d.store == nil { + return errors.New("v3 Dispatcher identity and store are required") + } + if err := contract.ValidateLocalCallResult(body); err != nil { + return fmt.Errorf("validate local call.result schema: %w", err) + } + var event localCallResultEnvelope + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&event); err != nil { + return fmt.Errorf("decode local call.result: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("local call.result contains multiple JSON values") + } + return fmt.Errorf("decode trailing local call.result data: %w", err) + } + if event.SchemaVersion != localCallResultSchemaVersion || event.EventType != "call.result" || + event.DispatcherID != d.dispatcherID || event.AggregateType != "call" || event.AggregateVersion != 1 || + !validLocalID(event.EventID) || !validLocalID(event.TenantID) || !validLocalID(event.TenantKey) || + !validLocalID(event.TraceID) || !validLocalID(event.AggregateID) { + return errors.New("local call.result envelope does not match the contract") + } + if _, err := time.Parse(time.RFC3339Nano, event.OccurredAt); err != nil { + return fmt.Errorf("invalid call.result occurred_at: %w", err) + } + var reference localCallResultReference + if err := json.Unmarshal(event.Payload, &reference); err != nil { + return fmt.Errorf("decode local call.result references: %w", err) + } + if !validLocalID(reference.SourceCommandID) || !validLocalID(reference.ExecutionID) || + !validLocalID(reference.CallID) || !localTaskIDPattern.MatchString(reference.TaskID) || + event.AggregateID != reference.CallID { + return errors.New("local call.result references do not match the contract") + } + if err := d.store.VerifyLocalAcceptedCommand(event.TenantID, event.TenantKey, reference.SourceCommandID, reference.ExecutionID, reference.TaskID); err != nil { + return fmt.Errorf("call.result does not match an accepted local execution: %w", err) + } + fact, _, err := d.store.LoadLocalCallTerminal(reference.ExecutionID) + if err != nil { + return fmt.Errorf("call.result requires a confirmed call terminal: %w", err) + } + startedAt, startErr := time.Parse(time.RFC3339Nano, reference.StartedAt) + endedAt, endErr := time.Parse(time.RFC3339Nano, reference.EndedAt) + if startErr != nil || endErr != nil { + return fmt.Errorf("call.result timing does not match persisted terminal: %w", errors.Join(startErr, endErr)) + } + reasonCode := "" + if reference.ReasonCode != nil { + reasonCode = *reference.ReasonCode + } + if reference.CallID != fact.CallID || !startedAt.Equal(fact.StartedAt) || !endedAt.Equal(fact.EndedAt) || + reference.DurationMs != fact.EndedAt.Sub(fact.StartedAt).Milliseconds() || + reference.Outcome != fact.Outcome || reasonCode != fact.ReasonCode || + (fact.RecordingExpected && reference.Recording.Status == "not_created") || + (!fact.RecordingExpected && reference.Recording.Status != "not_created") { + return fmt.Errorf("call.result contradicts confirmed terminal: %w", store.ErrCommandConflict) + } + if fact.RecordingExpected { + outcome, exists, err := d.store.LoadLocalRecordingOutcome(reference.ExecutionID) + if err != nil { + return fmt.Errorf("load persisted recording result: %w", err) + } + r := reference.Recording + if !exists || fact.Recording == nil || r.Status != outcome.Status || r.RecordingID == nil || *r.RecordingID != fact.Recording.RecordingID || + r.UploadID == nil || *r.UploadID != fact.Recording.UploadID || r.Format == nil || *r.Format != fact.Recording.Format || + r.Channels == nil || *r.Channels != fact.Recording.Channels || r.SampleRateHz == nil || *r.SampleRateHz != fact.Recording.SampleRateHz || + r.DurationMs == nil || *r.DurationMs != fact.Recording.DurationMs { + return fmt.Errorf("call.result has no matching persisted recording fact: %w", store.ErrCommandConflict) + } + if outcome.Status == "uploaded" && (r.Bucket == nil || *r.Bucket != outcome.Bucket || r.ObjectKey == nil || *r.ObjectKey != outcome.ObjectKey || + r.SizeBytes == nil || *r.SizeBytes != outcome.SizeBytes || r.ChecksumSHA256 == nil || *r.ChecksumSHA256 != outcome.ChecksumSHA256) || + outcome.Status == "unavailable" && (r.ErrorCode == nil || *r.ErrorCode != outcome.ErrorCode) { + return fmt.Errorf("call.result recording outcome disagrees with persisted asset or error: %w", store.ErrCommandConflict) + } + } + err = d.store.EnqueueLocalFinalEvent(reference.ExecutionID, reference.CallID, store.LocalEventRecord{ + EventID: event.EventID, TenantKey: event.TenantKey, + Exchange: mq.ResultsExchangeV3, RoutingKey: "d." + d.dispatcherID + ".out", Body: body, + }) + if err != nil { + return fmt.Errorf("persist final local call.result: %w", err) + } + return nil +} diff --git a/internal/dispatcher/config_read.go b/internal/dispatcher/config_read.go new file mode 100644 index 0000000..9ea373f --- /dev/null +++ b/internal/dispatcher/config_read.go @@ -0,0 +1,183 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +const ( + projectConfigTTL = 60 * time.Second + taskDiscoveryTTL = 30 * time.Second +) + +type projectConfigKey struct { + tenantID string + taskID string +} + +type SIPConfigVerifier interface { + VerifyAppliedSIPConfig(context.Context, configread.Snapshot) error +} + +type SIPConfigVerifierFunc func(context.Context, configread.Snapshot) error + +func (f SIPConfigVerifierFunc) VerifyAppliedSIPConfig(ctx context.Context, snapshot configread.Snapshot) error { + return f(ctx, snapshot) +} + +// LoadProjectConfig fetches a complete response set, persists it atomically, +// then publishes one immutable in-memory snapshot for admission decisions. +func (d *Dispatcher) LoadProjectConfig(ctx context.Context, client *configread.Client, verifier SIPConfigVerifier, taskID, tenantID string) error { + d.configRefreshMu.Lock() + defer d.configRefreshMu.Unlock() + fail := func(err error) error { + d.configMu.Lock() + d.projectConfigs = nil + d.configMu.Unlock() + return err + } + if client == nil || verifier == nil || d.store == nil { + return fail(errors.New("configuration client, applied-SIP verifier, and store are required")) + } + if d.dispatcherID != "" && client.DispatcherID() != d.dispatcherID { + return fail(errors.New("configuration client dispatcher identity mismatch")) + } + snapshot, err := client.ReadTask(ctx, taskID, tenantID) + if err != nil { + return fail(err) + } + if err := verifier.VerifyAppliedSIPConfig(ctx, snapshot); err != nil { + return fail(fmt.Errorf("verify Agent-applied SIP config: %w", err)) + } + now := d.now().UTC() + quotaExpiry := minTime(now.Add(projectConfigTTL), snapshot.QuotaValidUntil) + taskExpiry := minTime(now.Add(projectConfigTTL), snapshot.AgentAuthorizationExpiresAt) + sipExpiry := now.Add(projectConfigTTL) + discoveryExpiry := now.Add(taskDiscoveryTTL) + snapshot.FetchedAt = now + snapshot.ExpiresAt = minTime(quotaExpiry, taskExpiry, sipExpiry, discoveryExpiry) + if !snapshot.ExpiresAt.After(now) { + return fail(errors.New("configuration response is expired; new admission is closed")) + } + if err := d.store.ApplyLocalTaskDiscovery(localTaskDiscoveryFromRead(snapshot.Discovery)); err != nil { + return fail(fmt.Errorf("persist complete task-discovery snapshot: %w", err)) + } + bundle := store.LocalConfigBundle{ + DispatcherID: d.dispatcherID, TenantID: snapshot.TenantID, TenantKey: snapshot.TenantKey, TaskID: snapshot.TaskID, + TenantQuotaScope: store.LocalTenantQuotaScope(d.dispatcherID, snapshot.TenantID), + TaskQuotaScope: store.LocalTaskQuotaScope(d.dispatcherID, snapshot.TenantID, snapshot.TaskID), + TenantQuotaLimit: snapshot.TenantMaxConcurrentCalls, TaskQuotaLimit: snapshot.TaskMaxConcurrentCalls, + Snapshots: []store.LocalConfigSnapshotRecord{ + {Resource: "sip", Revision: snapshot.SIPRevision, Version: strconv.FormatInt(snapshot.SIPRevision, 10), FetchedAt: now, ExpiresAt: sipExpiry, Body: snapshot.SIP}, + {Resource: "tasks", Version: snapshot.DiscoveryCursor, FetchedAt: now, ExpiresAt: discoveryExpiry, Body: snapshot.Tasks}, + {Resource: "task", TenantID: snapshot.TenantID, TaskID: snapshot.TaskID, Revision: snapshot.TaskRevision, Version: strconv.FormatInt(snapshot.TaskRevision, 10), FetchedAt: now, ExpiresAt: taskExpiry, Body: snapshot.Task}, + {Resource: "tenant_quota", TenantID: snapshot.TenantID, Revision: snapshot.QuotaRevision, Version: strconv.FormatInt(snapshot.QuotaRevision, 10), FetchedAt: now, ExpiresAt: quotaExpiry, Body: snapshot.TenantQuota}, + }, + } + if err := d.store.SaveLocalConfigBundle(bundle); err != nil { + return fail(fmt.Errorf("persist configuration response bundle: %w", err)) + } + + d.configMu.Lock() + if d.projectConfigs == nil { + d.projectConfigs = make(map[projectConfigKey]configread.Snapshot) + } + sharedExpiry := minTime(sipExpiry, discoveryExpiry) + for key, current := range d.projectConfigs { + current.SIP = append(current.SIP[:0], snapshot.SIP...) + current.SIPRevision = snapshot.SIPRevision + current.SIPSnapshotSHA256 = snapshot.SIPSnapshotSHA256 + current.SIPCellID = snapshot.SIPCellID + current.SIPArtifactRevision = snapshot.SIPArtifactRevision + current.SIPArtifactConfigSHA256 = snapshot.SIPArtifactConfigSHA256 + current.Tasks = append(current.Tasks[:0], snapshot.Tasks...) + current.DiscoveryCursor = snapshot.DiscoveryCursor + current.ExpiresAt = minTime(current.ExpiresAt, sharedExpiry) + if key.tenantID == snapshot.TenantID { + current.TenantQuota = append(current.TenantQuota[:0], snapshot.TenantQuota...) + current.QuotaRevision = snapshot.QuotaRevision + current.TenantMaxConcurrentCalls = snapshot.TenantMaxConcurrentCalls + current.QuotaValidUntil = snapshot.QuotaValidUntil + current.ExpiresAt = minTime(current.ExpiresAt, quotaExpiry) + } + d.projectConfigs[key] = current + } + key := projectConfigKey{tenantID: snapshot.TenantID, taskID: snapshot.TaskID} + d.projectConfigs[key] = cloneConfigSnapshot(snapshot) + d.configMu.Unlock() + return nil +} + +func (d *Dispatcher) ProjectConfigSnapshot(taskID, tenantID string) (configread.Snapshot, bool) { + d.configMu.RLock() + snapshot, loaded := d.projectConfigs[projectConfigKey{tenantID: tenantID, taskID: taskID}] + d.configMu.RUnlock() + if !loaded { + return configread.Snapshot{}, false + } + now := d.now() + if !now.Before(snapshot.ExpiresAt) || !now.Before(snapshot.QuotaValidUntil) || !now.Before(snapshot.AgentAuthorizationExpiresAt) { + return configread.Snapshot{}, false + } + return cloneConfigSnapshot(snapshot), true +} + +func cloneConfigSnapshot(snapshot configread.Snapshot) configread.Snapshot { + snapshot.Discovery.Body = append(json.RawMessage(nil), snapshot.Discovery.Body...) + snapshot.Discovery.Tasks = append([]configread.DiscoveredTask(nil), snapshot.Discovery.Tasks...) + snapshot.Discovery.Changes = append([]configread.TaskDiscoveryChange(nil), snapshot.Discovery.Changes...) + snapshot.SIP = append(snapshot.SIP[:0:0], snapshot.SIP...) + snapshot.Task = append(snapshot.Task[:0:0], snapshot.Task...) + snapshot.Tasks = append(snapshot.Tasks[:0:0], snapshot.Tasks...) + snapshot.TenantQuota = append(snapshot.TenantQuota[:0:0], snapshot.TenantQuota...) + snapshot.TaskAllowedTrunkIDs = append([]string(nil), snapshot.TaskAllowedTrunkIDs...) + return snapshot +} + +func localTaskDiscoveryFromRead(discovery configread.TaskDiscovery) store.LocalTaskDiscovery { + local := store.LocalTaskDiscovery{ + DispatcherID: discovery.DispatcherID, Mode: discovery.Mode, + Cursor: discovery.Cursor, FromCursor: discovery.FromCursor, + NextCursor: discovery.NextCursor, Body: append([]byte(nil), discovery.Body...), + } + for _, task := range discovery.Tasks { + local.Tasks = append(local.Tasks, store.LocalDiscoveredTask{ + TaskID: task.TaskID, TenantID: task.TenantID, TenantKey: task.TenantKey, + Status: task.Status, TaskRevision: task.TaskRevision, + }) + } + for _, change := range discovery.Changes { + localChange := store.LocalTaskDiscoveryChange{ + Cursor: change.Cursor, Operation: change.Operation, TaskID: change.TaskID, + TenantID: change.TenantID, TenantKey: change.TenantKey, + } + if change.Operation != "removed" { + localChange.Task = store.LocalDiscoveredTask{ + TaskID: change.TaskID, TenantID: change.TenantID, TenantKey: change.TenantKey, + Status: change.Status, TaskRevision: change.TaskRevision, + } + } + local.Changes = append(local.Changes, localChange) + } + return local +} + +func minTime(times ...time.Time) time.Time { + if len(times) == 0 { + return time.Time{} + } + minimum := times[0] + for _, candidate := range times[1:] { + if candidate.Before(minimum) { + minimum = candidate + } + } + return minimum +} diff --git a/internal/dispatcher/consumer.go b/internal/dispatcher/consumer.go deleted file mode 100644 index ac5c9a9..0000000 --- a/internal/dispatcher/consumer.go +++ /dev/null @@ -1,91 +0,0 @@ -package dispatcher - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "strings" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" -) - -// AcceptMQMessage returns only after the input decision and response are durable. -func (d *Dispatcher) AcceptMQMessage(body []byte, routingKey string) error { - var discriminator struct { - MessageType string `json:"message_type"` - CommandType string `json:"command_type"` - } - if err := json.Unmarshal(body, &discriminator); err != nil { - return mq.Permanent(fmt.Errorf("decode MQ message: %w", err)) - } - var err error - switch discriminator.MessageType { - case "ai.config.result": - err = d.store.StoreAIConfigResponse(body, routingKey) - if err == nil { - slog.Info("MQ AI configuration response persisted") - } - case "command.query", "call.query": - var responseID string - var duplicate bool - responseID, duplicate, err = d.store.HandleQuery(body, routingKey) - if err == nil { - slog.Info("MQ query response persisted", "message_type", discriminator.MessageType, "response_id", responseID, "duplicate", duplicate) - } - case "": - switch discriminator.CommandType { - case "task.control": - var responseID string - var duplicate bool - d.executionMu.Lock() - responseID, duplicate, err = d.store.HandleTaskControl(body, routingKey) - d.executionMu.Unlock() - if err == nil { - slog.Info("MQ task control persisted", "response_id", responseID, "duplicate", duplicate) - } - case "call.replay", "command.replay": - var responseID string - var duplicate bool - responseID, duplicate, err = d.store.HandleReplay(body, routingKey) - if err == nil { - slog.Info("MQ replay decision persisted", "command_type", discriminator.CommandType, "response_id", responseID, "duplicate", duplicate) - } - default: - _, err = d.AcceptCommand(body, routingKey) - } - default: - return mq.Permanent(errors.New("unsupported inbound MQ service message")) - } - if err != nil && isPermanentCommandError(err) { - return mq.Permanent(err) - } - return err -} - -func (d *Dispatcher) ConsumeTenant(ctx context.Context, broker *mq.Broker, tenantKey string) error { - if broker == nil { - return errors.New("broker is required") - } - if err := contract.ValidateTenantKey(tenantKey); err != nil { - return err - } - queue, err := broker.DeclareTenantQueue(tenantKey) - if err != nil { - return err - } - return broker.Consume(ctx, queue, func(ctx context.Context, routingKey string, body []byte) error { - return d.AcceptMQMessage(body, routingKey) - }) -} - -func isPermanentCommandError(err error) bool { - if errors.Is(err, contract.ErrInvalidServiceMessage) || errors.Is(err, contract.ErrInvalidTenantKey) || errors.Is(err, store.ErrMessageScope) || errors.Is(err, store.ErrIdempotencyConflict) || errors.Is(err, store.ErrAIConfigResponse) || errors.Is(err, store.ErrCommandConflict) { - return true - } - message := err.Error() - return strings.Contains(message, "schema validation") || strings.Contains(message, "decode json") || strings.Contains(message, "routing mismatch") -} diff --git a/internal/dispatcher/consumer_test.go b/internal/dispatcher/consumer_test.go deleted file mode 100644 index 8e0ea93..0000000 --- a/internal/dispatcher/consumer_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package dispatcher - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestMQCommandQueryPersistsBeforeAcceptance(t *testing.T) { - s, err := store.Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - const id = "c046b893-8628-4589-ae50-619d049248a6" - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - d, err := New(s, nil, nil) - if err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/command-query.json") - if err != nil { - t.Fatal(err) - } - var message map[string]any - if err := json.Unmarshal(raw, &message); err != nil { - t.Fatal(err) - } - message["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) - message["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) - raw, err = json.Marshal(message) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("expected one persistent response before ACK, got %d", count) - } -} - -func TestMQReplayPersistsDecisionBeforeAcknowledgment(t *testing.T) { - for _, name := range []string{"call-replay", "command-replay"} { - t.Run(name, func(t *testing.T) { - s, err := store.Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - const id = "c046b893-8628-4589-ae50-619d049248a6" - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - d, err := New(s, nil, nil) - if err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/" + name + ".json") - if err != nil { - t.Fatal(err) - } - var request map[string]any - if err := json.Unmarshal(raw, &request); err != nil { - t.Fatal(err) - } - request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) - request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) - raw, err = json.Marshal(request) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM mq_command_receipts`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatal("replay decision not durably deduplicated") - } - }) - } -} - -func TestMQCallQueryPersistsResponse(t *testing.T) { - s, err := store.Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - const id = "c046b893-8628-4589-ae50-619d049248a6" - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - d, err := New(s, nil, nil) - if err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/call-query.json") - if err != nil { - t.Fatal(err) - } - var request map[string]any - if err := json.Unmarshal(raw, &request); err != nil { - t.Fatal(err) - } - request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) - request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) - raw, err = json.Marshal(request) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE json_extract(body,'$.message_type')='call.query.result'`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatal("call query was acknowledged without a persisted response") - } -} - -func TestInvalidServiceEncodingIsPermanent(t *testing.T) { - raw, err := contracts.Files.ReadFile("upstream/v1/examples/command-query.json") - if err != nil { - t.Fatal(err) - } - raw = bytes.Replace(raw, []byte("command-a"), []byte{0xff}, 1) - if !bytes.Contains(raw, []byte{0xff}) { - t.Fatal("invalid UTF-8 fixture was not constructed") - } - _, err = contract.DecodeService(raw) - if err == nil || !isPermanentCommandError(err) { - t.Fatalf("invalid encoding would be requeued: %v", err) - } -} - -func TestPermanentCommandClassification(t *testing.T) { - if !isPermanentCommandError(contract.ErrInvalidTenantKey) { - t.Fatal("tenant validation must be permanent") - } - for _, raw := range [][]byte{[]byte(`{}`), []byte(`{"schema_version":"invalid"}`), []byte(`{`)} { - _, _, err := contract.DecodeExecute(raw) - if err == nil || !isPermanentCommandError(err) { - t.Fatalf("actual malformed message must be permanent: %v", err) - } - } - if isPermanentCommandError(errors.New("sqlite busy")) { - t.Fatal("storage failure must be retried") - } - if !mq.IsPermanent(mq.Permanent(contract.ErrInvalidTenantKey)) { - t.Fatal("permanent wrapper was not recognized") - } -} diff --git a/internal/dispatcher/control_mq_integration_test.go b/internal/dispatcher/control_mq_integration_test.go deleted file mode 100644 index 592f9fb..0000000 --- a/internal/dispatcher/control_mq_integration_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package dispatcher - -import ( - "context" - "encoding/json" - "net/url" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "git.ipao.vip/rogee/go-sip/internal/testfixture" - "github.com/google/uuid" - amqp "github.com/rabbitmq/amqp091-go" -) - -func TestLocalMQActiveControlReplyRecovery(t *testing.T) { - address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") - if address == "" { - t.Skip("dedicated local RabbitMQ vhost not configured") - } - endpoint, err := url.Parse(address) - if err != nil || (endpoint.Hostname() != "localhost" && endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "::1") { - t.Fatal("loopback RabbitMQ required") - } - id, key := uuid.NewString(), "control."+uuid.NewString() - broker, err := mq.Open(address, id) - if err != nil { - t.Fatal(err) - } - defer broker.Close() - queue, err := broker.DeclareTenantQueue(key) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, key) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "dispatcher.db") - s, err := store.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { s.Close() }() - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - d, err := New(s, broker, nil) - if err != nil { - t.Fatal(err) - } - connection, err := amqp.Dial(address) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - channel, err := connection.Channel() - if err != nil { - t.Fatal(err) - } - defer channel.Close() - defer channel.QueueDelete(route.InboxQueue, false, false, false) - defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) - defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) - if err := channel.Confirm(false); err != nil { - t.Fatal(err) - } - encode := func(value any) []byte { - t.Helper() - raw, err := json.Marshal(value) - if err != nil { - t.Fatal(err) - } - return raw - } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - var command map[string]any - if err := json.Unmarshal(raw, &command); err != nil { - t.Fatal(err) - } - command["dispatcher_id"], command["tenant_key"] = id, key - raw = encode(command) - if _, err := d.AcceptCommand(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - task, err := d.ReserveTask(key, "reservation-mq-control", []string{"global"}) - if err != nil { - t.Fatal(err) - } - mockNow := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) - coordinator := NewAgentCoordinator(func() time.Time { return mockNow }) - agentStatus := agentStatusForLocalFlow() - client := startMockAgent(t, &agentStatus) - if err := coordinator.Register("agent-a", client); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - if _, err := coordinator.Activate(ctx, "agent-a", "cell-a", "boot-a", "epoch-a", 1); err != nil { - t.Fatal(err) - } - if result, err := d.ExecuteReserved(ctx, coordinator, "agent-a", task, raw, "reservation-mq-control", strings.Repeat("a", 64)); err != nil || result.Unknown { - t.Fatalf("prepare execution: %v", err) - } - controlRaw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/task-control.json") - if err != nil { - t.Fatal(err) - } - var control map[string]any - if err := json.Unmarshal(controlRaw, &control); err != nil { - t.Fatal(err) - } - commandID := uuid.NewString() - control["dispatcher_id"], control["tenant_key"], control["tenant_id"], control["command_id"] = id, key, task.TenantID, commandID - now := time.Now().UTC() - control["issued_at"], control["not_after"] = now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) - payload := control["payload"].(map[string]any) - payload["task_id"], payload["expected_task_revision"] = task.TaskID, task.TaskRevision - controlRaw = encode(control) - send := func() { - t.Helper() - confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, ContentType: "application/json", MessageId: commandID, Body: controlRaw}) - if err != nil { - t.Fatal(err) - } - if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { - t.Fatalf("control confirm: %v", err) - } - delivery, ok, err := channel.Get(queue, false) - if err != nil || !ok { - t.Fatalf("control missing: %v", err) - } - if err := d.AcceptMQMessage(delivery.Body, delivery.RoutingKey); err != nil { - t.Fatal(err) - } - if err := delivery.Ack(false); err != nil { - t.Fatal(err) - } - } - send() - if count, err := d.ProcessTaskControls(ctx, lostControlReply{coordinator}, 10); err == nil || count != 0 { - t.Fatal("lost reply acknowledged as applied") - } - // Dispatcher restart after the Agent applied the control but before its reply - // was persisted must recover the original target and operation, not execute. - if err := s.Close(); err != nil { - t.Fatal(err) - } - s, err = store.Open(path) - if err != nil { - t.Fatal(err) - } - d, err = New(s, broker, nil) - if err != nil { - t.Fatal(err) - } - if count, err := d.ProcessTaskControls(ctx, coordinator, 10); err != nil || count != 1 { - t.Fatalf("control recovery count=%d error=%v", count, err) - } - var finalID string - for attempt := 0; attempt < 2; attempt++ { - if attempt > 0 { - send() - } - if _, err := d.FlushOutbox(ctx, 10); err != nil { - t.Fatal(err) - } - found := false - for { - delivery, ok, err := channel.Get(mq.SaaSQueue, false) - if err != nil { - t.Fatal(err) - } - if !ok { - break - } - var event map[string]any - if err := json.Unmarshal(delivery.Body, &event); err != nil { - t.Fatal(err) - } - if event["aggregate_id"] == commandID { - body := event["payload"].(map[string]any) - if body["status"] == "applied" { - if delivery.DeliveryMode != amqp.Persistent || delivery.RoutingKey != route.OutboundKey || delivery.MessageId != event["event_id"] { - t.Fatal("final control receipt lost identity or persistence") - } - if finalID != "" && finalID != delivery.MessageId { - t.Fatal("duplicate control created a new final receipt") - } - finalID = delivery.MessageId - found = true - } - } - if err := delivery.Ack(false); err != nil { - t.Fatal(err) - } - } - if !found { - t.Fatal("no final applied control receipt reached SaaS queue") - } - } - if count, err := d.ProcessTaskControls(ctx, coordinator, 10); err != nil || count != 0 { - t.Fatal("duplicate control changed Agent again") - } - var tasks, revision int - if err := s.DB().QueryRow(`SELECT COUNT(*),MAX(task_revision) FROM tasks`).Scan(&tasks, &revision); err != nil { - t.Fatal(err) - } - if tasks != 1 || int64(revision) != task.TaskRevision+1 { - t.Fatalf("duplicate execution/revision: tasks=%d revision=%d", tasks, revision) - } -} diff --git a/internal/dispatcher/control_worker.go b/internal/dispatcher/control_worker.go deleted file mode 100644 index e8ffa2a..0000000 --- a/internal/dispatcher/control_worker.go +++ /dev/null @@ -1,83 +0,0 @@ -package dispatcher - -import ( - "context" - "errors" - "fmt" - "log/slog" - "time" - - agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" -) - -type TaskController interface { - Control(context.Context, string, *agentv1.ExecutionBinding, agentv1.ControlAction, agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) -} - -// ProcessTaskControls retries only the original control, never an execution or -// originate. Uncertain acknowledgements leave the durable target pending. -func (d *Dispatcher) ProcessTaskControls(ctx context.Context, controller TaskController, limit int) (int, error) { - if controller == nil { - return 0, errors.New("task controller is required") - } - ctx, cancelBatch := context.WithTimeout(ctx, 10*time.Second) - defer cancelBatch() - // ponytail: one Agent per P1 Dispatcher; serialization orders control against - // last execution admission. Revisit granularity only if this scope expands. - d.executionMu.Lock() - defer d.executionMu.Unlock() - targets, err := d.store.PendingTaskControls(limit) - if err != nil { - return 0, err - } - completed := 0 - var failures []error - for _, target := range targets { - if err := ctx.Err(); err != nil { - return completed, errors.Join(append(failures, err)...) - } - if target.Binding == nil || target.AgentID == "" { - failures = append(failures, fmt.Errorf("control %s execution %s has no durable Agent assignment", target.CommandID, target.ExecutionID)) - continue - } - var action agentv1.ControlAction - switch target.Action { - case "pause": - action = agentv1.ControlAction_CONTROL_ACTION_PAUSE - case "resume": - action = agentv1.ControlAction_CONTROL_ACTION_RESUME - case "stop": - action = agentv1.ControlAction_CONTROL_ACTION_STOP - default: - failures = append(failures, errors.New("invalid persisted control action")) - continue - } - var policy agentv1.ActiveCallPolicy - switch target.Policy { - case "drain": - policy = agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN - case "hangup": - policy = agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP - default: - failures = append(failures, errors.New("invalid persisted active-call policy")) - continue - } - attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - response, err := controller.Control(attemptCtx, target.AgentID, target.Binding, action, policy) - cancel() - if err == nil && (response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_APPLIED || response.GetAppliedTaskRevision() != target.ExpectedRevision+1) { - err = errors.New("Agent has not confirmed the requested applied revision") - } - if err == nil { - err = d.store.CompleteTaskControl(target, response.AppliedTaskRevision) - } - if err != nil { - slog.Error("task control remains pending", "command_id", target.CommandID, "execution_id", target.ExecutionID, "error", err) - failures = append(failures, fmt.Errorf("control %s: %w", target.CommandID, err)) - continue - } - slog.Info("Agent task control application persisted", "command_id", target.CommandID, "execution_id", target.ExecutionID, "revision", response.AppliedTaskRevision) - completed++ - } - return completed, errors.Join(failures...) -} diff --git a/internal/dispatcher/dial_policy.go b/internal/dispatcher/dial_policy.go new file mode 100644 index 0000000..97730aa --- /dev/null +++ b/internal/dispatcher/dial_policy.go @@ -0,0 +1,172 @@ +package dispatcher + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "time" + + "git.ipao.vip/rogee/go-sip/internal/callwindow" + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +type dialDecision struct { + TrunkID string + CallerID string + RingTimeoutMS int64 + MaxCallDurationMS int64 + TrunkCapacity int64 + AllowedUntil time.Time +} + +type dialTaskConfig struct { + Status string `json:"status"` + Schedule callwindow.TaskSchedule `json:"schedule"` + AllowedTrunkIDs []string `json:"allowed_trunk_ids"` + CallerProfileID string `json:"caller_profile_id"` + RoutePolicyID string `json:"route_policy_id"` + RingTimeoutMS int64 `json:"ring_timeout_ms"` + MaxCallDurationMS int64 `json:"max_call_duration_ms"` + Agent struct { + AgentVersionID string `json:"agent_version_id"` + AuthorizationExpiresAt string `json:"authorization_expires_at"` + Config struct { + Conversation struct { + MaxDurationMS int64 `json:"max_duration_ms"` + } `json:"conversation"` + } `json:"config"` + } `json:"agent"` +} + +type dialSIPConfig struct { + Artifact struct { + Mode string `json:"mode"` + AllowedTargets []string `json:"allowed_targets"` + Trunks []struct { + TrunkID string `json:"trunk_id"` + Enabled bool `json:"enabled"` + CallerProfileIDs []string `json:"caller_profile_ids"` + } `json:"trunks"` + } `json:"artifact"` + TrunkDetails []struct { + TrunkID string `json:"trunk_id"` + MaxConcurrentCalls *int64 `json:"max_concurrent_calls"` + CallerProfiles []struct { + CallerProfileID string `json:"caller_profile_id"` + CallerID string `json:"caller_id"` + } `json:"caller_profiles"` + Schedule callwindow.WeeklySchedule `json:"schedule"` + } `json:"trunk_details"` +} + +// A line is chosen once at admission, in the task's listed order. The second +// check before origination must use the bound line, never select a replacement. +func selectDialPolicy(snapshot configread.Snapshot, callee string, at time.Time) (dialDecision, error) { + return evaluateDialPolicy(snapshot, callee, "", at, true) +} + +func checkSelectedDialPolicy(snapshot configread.Snapshot, callee, trunkID string, at time.Time) (dialDecision, error) { + if trunkID == "" { + return dialDecision{}, errors.New("accepted execution has no selected trunk") + } + return evaluateDialPolicy(snapshot, callee, trunkID, at, false) +} + +func evaluateDialPolicy(snapshot configread.Snapshot, callee, selectedTrunk string, at time.Time, newAdmission bool) (dialDecision, error) { + // This project-wide allowlist is never inferred from an artifact or rewritten + // using a provider prefix. Provider targets further restrict this set. + if callee != "15003164745" && callee != "15830461047" { + return dialDecision{}, errors.New("callee is not on the approved outbound whitelist") + } + if newAdmission && (snapshot.ExpiresAt.IsZero() || !at.Before(snapshot.ExpiresAt) || snapshot.QuotaValidUntil.IsZero() || !at.Before(snapshot.QuotaValidUntil)) { + return dialDecision{}, errors.New("configuration or quota is expired or unavailable") + } + if len(snapshot.Task) == 0 || len(snapshot.SIP) == 0 { + return dialDecision{}, errors.New("approved task and SIP snapshots are required") + } + var task dialTaskConfig + if err := json.Unmarshal(snapshot.Task, &task); err != nil { + return dialDecision{}, fmt.Errorf("decode bound task config: %w", err) + } + var sip dialSIPConfig + if err := json.Unmarshal(snapshot.SIP, &sip); err != nil { + return dialDecision{}, fmt.Errorf("decode bound SIP config: %w", err) + } + if task.Status != "running" || len(task.AllowedTrunkIDs) == 0 || task.CallerProfileID == "" { + return dialDecision{}, errors.New("task is not authorized for dialing") + } + if !slices.Contains(sip.Artifact.AllowedTargets, callee) { + return dialDecision{}, errors.New("callee is not in the approved SIP artifact") + } + if task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || task.Agent.Config.Conversation.MaxDurationMS <= 0 { + return dialDecision{}, errors.New("task or AI call duration is missing") + } + authorizationExpiry, err := time.Parse(time.RFC3339, task.Agent.AuthorizationExpiresAt) + if err != nil || !at.Before(authorizationExpiry) { + return dialDecision{}, errors.New("Agent authorization expired or invalid") + } + if !snapshot.AgentAuthorizationExpiresAt.IsZero() && !snapshot.AgentAuthorizationExpiresAt.Equal(authorizationExpiry) { + return dialDecision{}, errors.New("Agent authorization does not match the bound task snapshot") + } + maxDuration := min(task.MaxCallDurationMS, task.Agent.Config.Conversation.MaxDurationMS) + if selectedTrunk != "" && !slices.Contains(task.AllowedTrunkIDs, selectedTrunk) { + return dialDecision{}, errors.New("selected trunk is not permitted by the bound task") + } + var lastReason error + for _, candidate := range task.AllowedTrunkIDs { + if selectedTrunk != "" && candidate != selectedTrunk { + continue + } + var artifactTrunk *struct { + TrunkID string `json:"trunk_id"` + Enabled bool `json:"enabled"` + CallerProfileIDs []string `json:"caller_profile_ids"` + } + for i := range sip.Artifact.Trunks { + if sip.Artifact.Trunks[i].TrunkID == candidate { + artifactTrunk = &sip.Artifact.Trunks[i] + break + } + } + if artifactTrunk == nil || !artifactTrunk.Enabled || !slices.Contains(artifactTrunk.CallerProfileIDs, task.CallerProfileID) { + lastReason = fmt.Errorf("trunk %s is disabled or has no approved caller profile", candidate) + continue + } + for _, detail := range sip.TrunkDetails { + if detail.TrunkID != candidate { + continue + } + if detail.MaxConcurrentCalls == nil || *detail.MaxConcurrentCalls <= 0 { + lastReason = fmt.Errorf("trunk %s has no authorized capacity", candidate) + break + } + var callerID string + for _, profile := range detail.CallerProfiles { + if profile.CallerProfileID == task.CallerProfileID { + callerID = profile.CallerID + break + } + } + if callerID == "" { + lastReason = fmt.Errorf("trunk %s has no matching caller identity", candidate) + break + } + until, err := callwindow.Evaluate(task.Schedule, detail.Schedule, at) + if err != nil { + lastReason = fmt.Errorf("trunk %s calling window: %w", candidate, err) + break + } + if authorizationExpiry.Before(until) { + until = authorizationExpiry + } + return dialDecision{TrunkID: candidate, CallerID: callerID, + RingTimeoutMS: task.RingTimeoutMS, MaxCallDurationMS: maxDuration, + TrunkCapacity: *detail.MaxConcurrentCalls, AllowedUntil: until}, nil + } + } + if lastReason == nil { + lastReason = errors.New("no authorized task trunk is present in the applied SIP snapshot") + } + return dialDecision{}, fmt.Errorf("no eligible trunk: %w", lastReason) +} diff --git a/internal/dispatcher/dial_policy_test.go b/internal/dispatcher/dial_policy_test.go new file mode 100644 index 0000000..afaea7f --- /dev/null +++ b/internal/dispatcher/dial_policy_test.go @@ -0,0 +1,161 @@ +package dispatcher + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +func policyAt(t *testing.T, value string) time.Time { + t.Helper() + at, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatal(err) + } + return at +} + +func policySnapshot(t *testing.T) configread.Snapshot { + t.Helper() + sip := localConfigFixture(t, "config-read-sip-v0.1.json") + var document map[string]any + if err := json.Unmarshal(sip, &document); err != nil { + t.Fatal(err) + } + // Explicit local Mock capacity; the documented null remains an unknown real limit. + document["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 + sip, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + var taskDocument map[string]any + if err := json.Unmarshal(localConfigFixture(t, "config-read-task-v0.1.json"), &taskDocument); err != nil { + t.Fatal(err) + } + taskDocument["ring_timeout_ms"] = 15000 + taskDocument["max_call_duration_ms"] = 240000 + task, err := json.Marshal(taskDocument) + if err != nil { + t.Fatal(err) + } + return configread.Snapshot{ + SIP: sip, Task: task, + TaskID: localTestTaskID, TaskStatus: "running", + TaskAllowedTrunkIDs: []string{"trunk-mock"}, TaskCallerProfileID: "caller-profile-mock", + TaskRingTimeoutMS: 15000, TaskMaxCallDurationMS: 240000, + AgentAuthorizationExpiresAt: policyAt(t, "2030-01-01T00:00:00Z"), + ExpiresAt: policyAt(t, "2026-09-21T12:00:00+08:00"), QuotaValidUntil: policyAt(t, "2026-09-22T12:00:00+08:00"), + } +} + +func TestSelectDialPolicyTaskAndTrunkWindowAndTimeLimits(t *testing.T) { + decision, err := selectDialPolicy(policySnapshot(t), "15003164745", policyAt(t, "2026-09-21T09:30:00+08:00")) + if err != nil { + t.Fatal(err) + } + if decision.TrunkID != "trunk-mock" || decision.CallerID != "BD00000000" || decision.RingTimeoutMS != 15000 || decision.MaxCallDurationMS != 120000 || !decision.AllowedUntil.Equal(policyAt(t, "2026-09-21T11:00:00+08:00")) { + t.Fatalf("bound outbound decision = %+v", decision) + } + for _, when := range []string{"2026-09-21T08:59:59+08:00", "2026-09-21T11:00:00+08:00", "2026-10-01T09:30:00+08:00"} { + if _, err := selectDialPolicy(policySnapshot(t), "15003164745", policyAt(t, when)); err == nil { + t.Errorf("out-of-window or excluded date %s was allowed", when) + } + } +} + +func TestSelectDialPolicyRequiresKnownCapacityCallerAndWhitelist(t *testing.T) { + snapshot := policySnapshot(t) + var sip map[string]any + if err := json.Unmarshal(snapshot.SIP, &sip); err != nil { + t.Fatal(err) + } + at := policyAt(t, "2026-09-21T09:30:00+08:00") + for _, tc := range []struct { + name string + alter func() + }{ + {"unknown trunk capacity", func() { sip["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = nil }}, + {"no matching caller", func() { + sip["artifact"].(map[string]any)["trunks"].([]any)[0].(map[string]any)["caller_profile_ids"] = []string{"other"} + }}, + {"trunk disabled", func() { sip["artifact"].(map[string]any)["trunks"].([]any)[0].(map[string]any)["enabled"] = false }}, + } { + t.Run(tc.name, func(t *testing.T) { + var item map[string]any + body, err := json.Marshal(sip) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &item); err != nil { + t.Fatal(err) + } + previous := sip + sip = item + defer func() { sip = previous }() + tc.alter() + snapshot.SIP, err = json.Marshal(sip) + if err != nil { + t.Fatal(err) + } + if _, err := selectDialPolicy(snapshot, "15003164745", at); err == nil { + t.Fatal("unavailable trunk was selected") + } + }) + } + if _, err := selectDialPolicy(policySnapshot(t), "13900000000", at); err == nil { + t.Fatal("callee outside global whitelist was allowed") + } + snapshot = policySnapshot(t) + snapshot.AgentAuthorizationExpiresAt = at + if _, err := selectDialPolicy(snapshot, "15003164745", at); err == nil { + t.Fatal("expired Agent authorization was allowed") + } +} + +func TestSelectDialPolicyUsesFirstEligibleTrunkWithoutPostSelectionSwitch(t *testing.T) { + snapshot := policySnapshot(t) + var sip map[string]any + if err := json.Unmarshal(snapshot.SIP, &sip); err != nil { + t.Fatal(err) + } + artifact := sip["artifact"].(map[string]any) + first := artifact["trunks"].([]any)[0].(map[string]any) + second := make(map[string]any) + for k, v := range first { + second[k] = v + } + second["trunk_id"] = "trunk-second" + artifact["trunks"] = append(artifact["trunks"].([]any), second) + details := sip["trunk_details"].([]any)[0].(map[string]any) + secondDetails := make(map[string]any) + for k, v := range details { + secondDetails[k] = v + } + secondDetails["trunk_id"] = "trunk-second" + sip["trunk_details"] = append(sip["trunk_details"].([]any), secondDetails) + snapshot.TaskAllowedTrunkIDs = []string{"trunk-mock", "trunk-second"} + var task map[string]any + if err := json.Unmarshal(snapshot.Task, &task); err != nil { + t.Fatal(err) + } + task["allowed_trunk_ids"] = snapshot.TaskAllowedTrunkIDs + snapshot.Task, _ = json.Marshal(task) + firstWindow := details["schedule"].(map[string]any)["weekly_windows"].(map[string]any) + firstWindow["monday"] = []any{map[string]any{"start": "09:00", "end": "10:00"}} + secondDetails["schedule"] = map[string]any{"time_zone": "Asia/Shanghai", "weekly_windows": map[string]any{ + "monday": []any{map[string]any{"start": "10:00", "end": "11:00"}}, + "tuesday": []any{}, "wednesday": []any{}, "thursday": []any{}, "friday": []any{}, "saturday": []any{}, "sunday": []any{}, + }} + snapshot.SIP, _ = json.Marshal(sip) + decision, err := selectDialPolicy(snapshot, "15003164745", policyAt(t, "2026-09-21T10:00:00+08:00")) + if err != nil || decision.TrunkID != "trunk-second" { + t.Fatalf("select first eligible trunk: %+v %v", decision, err) + } + // After selection, evaluating only the chosen trunk must not try another. + if _, err := checkSelectedDialPolicy(snapshot, "15003164745", decision.TrunkID, policyAt(t, "2026-09-21T11:00:00+08:00")); err == nil || !strings.Contains(err.Error(), "window") { + t.Fatalf("selected trunk crossed its window without rejection: %v", err) + } +} diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go index b14e0b9..b16531b 100644 --- a/internal/dispatcher/dispatcher.go +++ b/internal/dispatcher/dispatcher.go @@ -2,23 +2,30 @@ package dispatcher import ( "context" + "crypto/sha256" "database/sql" "errors" "fmt" + "log" "sync" "time" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/mq" "git.ipao.vip/rogee/go-sip/internal/store" ) type Dispatcher struct { - executionMu sync.Mutex - store *store.Store - publisher mq.Publisher - now func() time.Time + executionMu sync.Mutex + configRefreshMu sync.RWMutex + configMu sync.RWMutex + dispatcherID string + projectConfigs map[projectConfigKey]configread.Snapshot + store *store.Store + publisher mq.Publisher + now func() time.Time } func New(s *store.Store, publisher mq.Publisher, now func() time.Time) (*Dispatcher, error) { @@ -44,8 +51,8 @@ func (d *Dispatcher) FlushOutbox(ctx context.Context, limit int) (int, error) { if limit <= 0 { return 0, errors.New("outbox limit must be positive") } - published := 0 - for published < limit { + published, processed := 0, 0 + for processed < limit { if err := ctx.Err(); err != nil { return published, err } @@ -59,6 +66,14 @@ func (d *Dispatcher) FlushOutbox(ctx context.Context, limit int) (int, error) { break } record := records[0] + processed++ + if len(record.Body) > store.MaxOutboxPayloadBytes { + if err := d.store.BlockOutboxPayload(record.ID, len(record.Body)); err != nil { + return published, fmt.Errorf("block oversized outbox %s: %w", record.EventID, err) + } + log.Printf("ERROR outbox blocked_payload_too_large event_id=%s bytes=%d max_bytes=%d sha256=%x", record.EventID, len(record.Body), store.MaxOutboxPayloadBytes, sha256.Sum256(record.Body)) + continue + } if err := d.publisher.Publish(ctx, record.Exchange, record.RoutingKey, record.Body); err != nil { retryErr := d.store.MarkOutboxRetry(record.ID, err) return published, fmt.Errorf("publish outbox %s: %w", record.EventID, errors.Join(err, retryErr)) diff --git a/internal/dispatcher/local_config_read_test.go b/internal/dispatcher/local_config_read_test.go new file mode 100644 index 0000000..e37cd3f --- /dev/null +++ b/internal/dispatcher/local_config_read_test.go @@ -0,0 +1,262 @@ +package dispatcher + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +func TestDispatcherLoadsAndPersistsLocalConfigReadSnapshot(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), client, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err != nil { + t.Fatal(err) + } + snapshot, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID) + if !ok { + t.Fatal("dispatcher did not retain an unexpired config snapshot") + } + if snapshot.TaskID != localTestTaskID || snapshot.TenantID != localTestTenantID || + len(snapshot.SIP) == 0 || len(snapshot.Tasks) == 0 || len(snapshot.Task) == 0 || len(snapshot.TenantQuota) == 0 { + t.Fatalf("incomplete configuration snapshot: %+v", snapshot) + } + persisted, err := st.LocalConfigSnapshot(localTestDispatcherID, "task", localTestTenantID, localTestTaskID) + if err != nil { + t.Fatal(err) + } + if string(persisted.Body) != string(snapshot.Task) || !persisted.ExpiresAt.After(persisted.FetchedAt) { + t.Fatalf("persisted task response mismatch or invalid expiry: %+v", persisted) + } +} + +func TestLoadProjectConfigRequiresAndChecksAppliedSIPStatus(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), client, nil, localTestTaskID, localTestTenantID); err == nil { + t.Fatal("missing applied-SIP verifier was accepted") + } + if _, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID); ok { + t.Fatal("missing verifier did not fail closed") + } + + if err := d.LoadProjectConfig(context.Background(), client, SIPConfigVerifierFunc(func(context.Context, configread.Snapshot) error { + return errors.New("Agent reports an unapplied SIP revision") + }), localTestTaskID, localTestTenantID); err == nil { + t.Fatal("unapplied SIP artifact was accepted") + } + if _, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID); ok { + t.Fatal("unapplied SIP artifact left cached config available") + } +} + +func TestDispatcherDoesNotKeepStaleConfigAfterReadFailure(t *testing.T) { + d, st, validServer := newLocalV01TestDispatcher(t, time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)) + defer validServer.Close() + defer st.Close() + validClient, err := configread.NewClient(validServer.URL, localTestDispatcherID, "test-secret", validServer.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), validClient, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err != nil { + t.Fatalf("initial configuration load: %v", err) + } + if _, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID); !ok { + t.Fatal("initial configuration snapshot was not retained") + } + + calls := 0 + failedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = fmt.Fprint(w, `{"resource":"error","error":{"code":"service_unavailable","message":"unavailable"}}`) + })) + defer failedServer.Close() + failedClient, err := configread.NewClient(failedServer.URL, localTestDispatcherID, "test-secret", failedServer.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), failedClient, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err == nil { + t.Fatal("expected configuration refresh failure") + } + if _, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID); ok { + t.Fatal("stale configuration remained available after failed refresh") + } + if calls != 1 { + t.Fatalf("requests = %d, want fail-fast after first request", calls) + } +} + +func TestDispatcherRejectsExpiredConfigSnapshot(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), client, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err != nil { + t.Fatal(err) + } + d.now = func() time.Time { return now.Add(taskDiscoveryTTL) } + if _, ok := d.ProjectConfigSnapshot(localTestTaskID, localTestTenantID); ok { + t.Fatal("expired task-discovery cache remained available") + } +} + +func TestDispatcherSharesUpdatedTenantQuotaAcrossTasks(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, initialServer := newLocalV01TestDispatcher(t, now) + defer initialServer.Close() + defer st.Close() + + multiServer := newLocalV01MultiTaskConfigServer(t, []string{localTestTaskID, "task-b"}, 2, 1) + defer multiServer.Close() + client, err := configread.NewClient(multiServer.URL, localTestDispatcherID, "test-secret", multiServer.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), client, localTestSIPConfigVerifier(), "task-b", localTestTenantID); err != nil { + t.Fatal(err) + } + for _, taskID := range []string{localTestTaskID, "task-b"} { + snapshot, ok := d.ProjectConfigSnapshot(taskID, localTestTenantID) + if !ok || snapshot.QuotaRevision != 2 || snapshot.TenantMaxConcurrentCalls != 1 { + t.Fatalf("task %s did not observe refreshed shared quota: %+v loaded=%v", taskID, snapshot, ok) + } + } + + commandA := localExecuteTaskCommandBody(t, "multi-command-a", localTestTaskID, now.Add(time.Minute)) + commandB := localExecuteTaskCommandBody(t, "multi-command-b", "task-b", now.Add(time.Minute)) + if err := d.AcceptLocalV01Command(context.Background(), "d."+localTestDispatcherID+".task."+localTestTaskID+".in", commandA); err != nil { + t.Fatal(err) + } + if err := d.AcceptLocalV01Command(context.Background(), "d."+localTestDispatcherID+".task.task-b.in", commandB); err != nil { + t.Fatal(err) + } + taskA, err := st.FindTask(localTestTenantID, localTestTaskID) + if err != nil || taskA.Status != "reserved" { + t.Fatalf("first task was not reserved: %+v err=%v", taskA, err) + } + if _, err := st.FindTask(localTestTenantID, "task-b"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("second task lookup error=%v, want shared-quota rejection", err) + } + receipts, err := st.ClaimOutbox(2) + if err != nil || len(receipts) != 2 { + t.Fatalf("claim results count=%d err=%v", len(receipts), err) + } + results := make(map[string]localTestCommandResult, len(receipts)) + for _, receipt := range receipts { + var result localTestCommandResult + if err := json.Unmarshal(receipt.Body, &result); err != nil { + t.Fatal(err) + } + results[result.Payload.CommandID] = result + } + if results["multi-command-a"].Payload.Status != "accepted" || + results["multi-command-b"].Payload.Status != "rejected" || results["multi-command-b"].Payload.ReasonCode != "quota_exceeded" { + t.Fatalf("unexpected shared-quota results: %+v", results) + } +} + +func TestConfigurationRefreshFailureSerializesCallAdmission(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, initialServer := newLocalV01TestDispatcher(t, now) + defer initialServer.Close() + defer st.Close() + + refreshStarted := make(chan struct{}) + releaseRefresh := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseRefresh) }) } + defer release() + failedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(refreshStarted) + <-releaseRefresh + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = fmt.Fprint(w, `{"resource":"error","error":{"code":"service_unavailable","message":"unavailable"}}`) + })) + defer failedServer.Close() + client, err := configread.NewClient(failedServer.URL, localTestDispatcherID, "test-secret", failedServer.Client()) + if err != nil { + t.Fatal(err) + } + refreshResult := make(chan error, 1) + go func() { + refreshResult <- d.LoadProjectConfig(context.Background(), client, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID) + }() + select { + case <-refreshStarted: + case <-time.After(2 * time.Second): + t.Fatal("configuration refresh did not reach Mock SaaS") + } + + var accepting atomic.Bool + admissionEntered := make(chan struct{}, 1) + d.now = func() time.Time { + if accepting.Load() { + select { + case admissionEntered <- struct{}{}: + default: + } + } + return now + } + acceptResult := make(chan error, 1) + accepting.Store(true) + body := localExecuteCommandBody(t, "refresh-race-command", now.Add(time.Minute)) + go func() { + acceptResult <- d.AcceptLocalV01Command(context.Background(), "d."+localTestDispatcherID+".task."+localTestTaskID+".in", body) + }() + proceededDuringRefresh := false + select { + case <-admissionEntered: + proceededDuringRefresh = true + case <-time.After(50 * time.Millisecond): + } + release() + if err := <-refreshResult; err == nil { + t.Fatal("expected the refresh to fail") + } + if err := <-acceptResult; err != nil { + t.Fatal(err) + } + if proceededDuringRefresh { + t.Fatal("call admission read configuration while a refresh held the write lock") + } + receipts, err := st.ClaimOutbox(1) + if err != nil || len(receipts) != 1 { + t.Fatalf("claim failure receipt count=%d err=%v", len(receipts), err) + } + var result localTestCommandResult + if err := json.Unmarshal(receipts[0].Body, &result); err != nil { + t.Fatal(err) + } + if result.Payload.Status != "rejected" || result.Payload.ReasonCode != "configuration_unavailable" { + t.Fatalf("stale configuration was used during refresh failure: %+v", result) + } +} diff --git a/internal/dispatcher/local_flow_test.go b/internal/dispatcher/local_flow_test.go index 1301ed3..e257de5 100644 --- a/internal/dispatcher/local_flow_test.go +++ b/internal/dispatcher/local_flow_test.go @@ -2,8 +2,6 @@ package dispatcher import ( "context" - "encoding/json" - "errors" "testing" "time" @@ -99,40 +97,6 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { t.Fatalf("local execution result=%+v err=%v", result, err) } - controlRaw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/task-control.json") - if err != nil { - t.Fatal(err) - } - var control map[string]any - if err := json.Unmarshal(controlRaw, &control); err != nil { - t.Fatal(err) - } - control["dispatcher_id"], control["tenant_id"], control["tenant_key"] = testfixture.DispatcherID, task.TenantID, task.TenantKey - controlIssued := time.Now().UTC() - control["issued_at"], control["not_after"] = controlIssued.Format(time.RFC3339Nano), controlIssued.Add(time.Minute).Format(time.RFC3339Nano) - payload := control["payload"].(map[string]any) - payload["task_id"], payload["expected_task_revision"] = task.TaskID, task.TaskRevision - controlRaw, err = json.Marshal(control) - if err != nil { - t.Fatal(err) - } - if err := d.AcceptMQMessage(controlRaw, testfixture.InboundKey(task.TenantKey)); err != nil { - t.Fatal(err) - } - if count, err := d.ProcessTaskControls(context.Background(), lostControlReply{coordinator}, 10); err == nil || count != 0 { - t.Fatal("lost reply incorrectly recorded applied") - } - if count, err := d.ProcessTaskControls(context.Background(), coordinator, 10); err != nil || count != 1 { - t.Fatalf("control recovery: count=%d err=%v", count, err) - } - if count, err := d.ProcessTaskControls(context.Background(), coordinator, 10); err != nil || count != 0 { - t.Fatal("applied target repeated") - } - controlled, err := st.FindTask(task.TenantID, task.TaskID) - if err != nil || controlled.Status != "paused" || controlled.TaskRevision != task.TaskRevision+1 { - t.Fatalf("control result: %+v %v", controlled, err) - } - event, err := (agent.EventWriter{DispatcherID: testfixture.DispatcherID, TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-local"}).TranscriptUpdated(now, "event-local", "call-local", "turn-local", "segment-local", "customer", "hello", 1, true, 0, 100) if err != nil { t.Fatal(err) @@ -142,15 +106,6 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { } } -type lostControlReply struct{ coordinator *AgentCoordinator } - -func (c lostControlReply) Control(ctx context.Context, id string, binding *agentv1.ExecutionBinding, action agentv1.ControlAction, policy agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) { - if _, err := c.coordinator.Control(ctx, id, binding, action, policy); err != nil { - return nil, err - } - return nil, errors.New("injected loss of applied control reply") -} - func agentStatusForLocalFlow() agentv1.AgentStatus { return agentv1.AgentStatus{AgentId: "agent-a", CellId: "cell-a"} } diff --git a/internal/dispatcher/local_mock_dispatcher_deadline.go b/internal/dispatcher/local_mock_dispatcher_deadline.go new file mode 100644 index 0000000..8b1fb0f --- /dev/null +++ b/internal/dispatcher/local_mock_dispatcher_deadline.go @@ -0,0 +1,31 @@ +package dispatcher + +import ( + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +// completeLocalIssuedDeadline records that the Dispatcher claimed an +// instruction but did not invoke the Agent because the bound deadline closed. +// The call ID is the planned execution correlation, not a SIP dialog. +func (d *Dispatcher) completeLocalIssuedDeadline(authorized LocalDialAuthorization, at time.Time) error { + if authorized.Binding == nil || authorized.Binding.AttemptId == "" || at.IsZero() { + return fmt.Errorf("incomplete issued no-Agent deadline: %w", store.ErrCommandConflict) + } + at = at.UTC() + binding := authorized.Binding + fact := store.LocalCallTerminal{ + ExecutionID: binding.ExecutionId, CallID: binding.AttemptId, Source: "dispatcher_deadline", + StartedAt: at, EndedAt: at, Outcome: "failed", ReasonCode: "deadline_expired", + } + event, err := d.localMockFinalEvent(authorized, fact, nil) + if err != nil { + return err + } + if err := d.store.VerifyLocalAcceptedCommand(binding.TenantId, binding.TenantKey, binding.TaskItemId, binding.ExecutionId, binding.TaskId); err != nil { + return fmt.Errorf("verify accepted no-Agent deadline: %w", err) + } + return d.store.RecordLocalCallTerminal(fact, &event) +} diff --git a/internal/dispatcher/local_mock_dispatcher_deadline_test.go b/internal/dispatcher/local_mock_dispatcher_deadline_test.go new file mode 100644 index 0000000..241b59e --- /dev/null +++ b/internal/dispatcher/local_mock_dispatcher_deadline_test.go @@ -0,0 +1,55 @@ +package dispatcher + +import ( + "context" + "errors" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/callwindow" +) + +func TestDispatcherDeadlineAfterClaimClosesAcceptedCallWithoutAgent(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer st.Close() + defer configServer.Close() + body := localExecuteCommandBody(t, "command-dispatcher-deadline", at.Add(2*time.Hour)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id='command-dispatcher-deadline'`).Scan(&executionID); err != nil { + t.Fatal(err) + } + closedAt := at.Add(2 * time.Hour) + d.now = func() time.Time { + decision, _ := st.LocalOriginationDecision(executionID) + if decision == "issued" { + return closedAt + } + return at + } + attempts := 0 + err := d.OriginateLocal(context.Background(), executionID, "agent-1", func(_ context.Context, _ LocalDialAuthorization) error { + attempts++ + return nil + }) + if !errors.Is(err, callwindow.ErrWindowClosed) || attempts != 0 { + t.Fatalf("expired issued call reached Agent: attempts=%d err=%v", attempts, err) + } + decision, err := st.LocalOriginationDecision(executionID) + if err != nil || decision != "issued" { + t.Fatalf("issued decision was not durable: decision=%q err=%v", decision, err) + } + fact, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || fact.Source != "dispatcher_deadline" || fact.Outcome != "failed" || fact.CallID != executionID || + !fact.StartedAt.Equal(closedAt) || !fact.EndedAt.Equal(closedAt) || eventID == "" { + t.Fatalf("no-Agent issued instruction left open: terminal=%+v event=%q err=%v", fact, eventID, err) + } + var reservation string + if err := st.DB().QueryRow(`SELECT state FROM reservations WHERE execution_id=?`, executionID).Scan(&reservation); err != nil || reservation != "released" { + t.Fatalf("no-Agent issued instruction held quota: state=%q err=%v", reservation, err) + } +} diff --git a/internal/dispatcher/local_mock_failure.go b/internal/dispatcher/local_mock_failure.go new file mode 100644 index 0000000..1a7dc28 --- /dev/null +++ b/internal/dispatcher/local_mock_failure.go @@ -0,0 +1,80 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/store" + "github.com/google/uuid" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +// RecordLocalMockRecordingFailure accepts only an explicit, versioned failure +// for a confirmed Mock recording. The fact and unavailable outcome are one +// SQLite transaction; the normal recovery path finishes an outbox write if +// interrupted before the final result is enqueued. +func (d *Dispatcher) RecordLocalMockRecordingFailure(ctx context.Context, fact *agentv1.ExecutionFact) error { + if err := ctx.Err(); err != nil { + return err + } + if d.store == nil || d.dispatcherID == "" || fact == nil || fact.Binding == nil || + fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS || fact.SourceBootId == "" || fact.SourceSequence == 0 { + return fmt.Errorf("Mock recording failure requires a bound execution fact: %w", store.ErrCommandConflict) + } + factID, err := uuid.Parse(fact.FactId) + if err != nil || factID.Version() != 4 { + return fmt.Errorf("Mock recording failure fact ID must be UUID v4: %w", store.ErrCommandConflict) + } + failure, err := contract.DecodeLocalMockRecordingFailure(fact.PayloadJson) + if err != nil { + return fmt.Errorf("invalid versioned Mock recording failure: %w", errors.Join(err, store.ErrCommandConflict)) + } + authorized, err := d.issuedMockAuthorization(fact.Binding.ExecutionId) + if err != nil { + return fmt.Errorf("load issued Mock failure owner: %w", err) + } + if !proto.Equal(fact.Binding, authorized.Binding) { + return fmt.Errorf("Mock failure is not bound to the issued execution: %w", store.ErrCommandConflict) + } + terminal, resultID, err := d.store.LoadLocalCallTerminal(fact.Binding.ExecutionId) + if err != nil { + return fmt.Errorf("load confirmed Mock failure terminal: %w", err) + } + if terminal.Source != "mock_agent" || !terminal.RecordingExpected || terminal.Recording == nil || + terminal.Recording.UploadID != failure.UploadID || terminal.Recording.RecordingID != failure.RecordingID { + return fmt.Errorf("Mock failure does not match confirmed recording: %w", store.ErrCommandConflict) + } + _, exists, err := d.store.LoadLocalRecordingOutcome(terminal.ExecutionID) + if err != nil { + return fmt.Errorf("load immutable Mock recording outcome: %w", err) + } + if !exists && !d.now().UTC().Before(terminal.EndedAt.Add(store.LocalRecordingDeadline)) { + return fmt.Errorf("Mock recording failure arrived after result deadline: %w", store.ErrCommandConflict) + } + bindingJSON, err := protojson.Marshal(fact.Binding) + if err != nil { + return fmt.Errorf("encode Mock failure binding: %w", err) + } + observedAt := time.UnixMilli(fact.ObservedAtUnixMs).UTC() + outcome := store.LocalRecordingOutcome{Status: "unavailable", ErrorCode: failure.ErrorCode, ObservedAt: observedAt} + duplicate, err := d.store.RecordLocalRecordingFailureFact(store.ExecutionFactRecord{ + FactID: fact.FactId, TenantID: fact.Binding.TenantId, TenantKey: fact.Binding.TenantKey, + ExecutionID: fact.Binding.ExecutionId, ContentSHA256: fact.ContentSha256, + Kind: int32(fact.Kind), BindingJSON: bindingJSON, PayloadJSON: fact.PayloadJson, + ObservedAt: observedAt, SourceBootID: fact.SourceBootId, SourceSequence: fact.SourceSequence, + }, outcome) + if err != nil { + return fmt.Errorf("persist Mock recording failure and outcome: %w", err) + } + if err := d.enqueueLocalMockRecordingResult(ctx, authorized, terminal, outcome, resultID); err != nil { + return err + } + log.Printf("Mock recording failure fact persisted execution_id=%s fact_id=%s error_code=%s duplicate=%t", terminal.ExecutionID, fact.FactId, failure.ErrorCode, duplicate) + return nil +} diff --git a/internal/dispatcher/local_mock_final_event.go b/internal/dispatcher/local_mock_final_event.go new file mode 100644 index 0000000..f9edbff --- /dev/null +++ b/internal/dispatcher/local_mock_final_event.go @@ -0,0 +1,116 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "github.com/google/uuid" +) + +// localMockFinalEvent renders persisted facts into the sole V3 call.result. +// The isolated Mock originator creates no speech, opt-out, or user media; +// real media/transcript sources remain closed pending separate authorization. +func (d *Dispatcher) localMockFinalEvent(authorized LocalDialAuthorization, fact store.LocalCallTerminal, outcome *store.LocalRecordingOutcome) (store.LocalEventRecord, error) { + binding := authorized.Binding + if binding == nil || binding.ExecutionId != fact.ExecutionID || binding.AttemptId != fact.CallID || + authorized.TraceID == "" || authorized.Callee == "" || authorized.TrunkID == "" || fact.EndedAt.Before(fact.StartedAt) { + return store.LocalEventRecord{}, errors.New("incomplete confirmed Mock final-result binding") + } + var reason any + if fact.ReasonCode != "" { + reason = fact.ReasonCode + } + recording := map[string]any{ + "recording_id": nil, "upload_id": nil, "bucket": nil, "object_key": nil, + "format": nil, "channels": nil, "sample_rate_hz": nil, "duration_ms": nil, + "size_bytes": nil, "checksum_sha256": nil, + } + if fact.RecordingExpected { + if fact.Recording == nil || outcome == nil { + return store.LocalEventRecord{}, errors.New("expected recording has no immutable manifest or outcome") + } + recording["status"] = outcome.Status + recording["recording_id"] = fact.Recording.RecordingID + recording["upload_id"] = fact.Recording.UploadID + recording["format"] = fact.Recording.Format + recording["channels"] = fact.Recording.Channels + recording["sample_rate_hz"] = fact.Recording.SampleRateHz + recording["duration_ms"] = fact.Recording.DurationMs + switch outcome.Status { + case "uploaded": + recording["bucket"], recording["object_key"] = outcome.Bucket, outcome.ObjectKey + recording["size_bytes"], recording["checksum_sha256"] = outcome.SizeBytes, outcome.ChecksumSHA256 + case "unavailable": + recording["error_code"] = outcome.ErrorCode + default: + return store.LocalEventRecord{}, errors.New("unsupported completed recording outcome") + } + } else { + if outcome != nil { + return store.LocalEventRecord{}, errors.New("unrecorded Mock call cannot have an upload outcome") + } + recording["status"] = "not_created" + recording["reason_code"] = "no_answer" + if fact.Outcome != "no_answer" { + recording["reason_code"] = "not_attempted" + } + } + payload := map[string]any{ + "source_command_id": binding.TaskItemId, "execution_id": binding.ExecutionId, "call_id": fact.CallID, + "task_id": binding.TaskId, "task_revision": binding.TaskRevision, "agent_version_id": binding.AgentVersionId, + "route_policy_id": binding.RoutePolicyId, "caller_profile_id": binding.CallerProfileId, + "callee": authorized.Callee, "trunk_id": authorized.TrunkID, + "started_at": fact.StartedAt.UTC().Format(time.RFC3339Nano), "ended_at": fact.EndedAt.UTC().Format(time.RFC3339Nano), + "duration_ms": fact.EndedAt.Sub(fact.StartedAt).Milliseconds(), "outcome": fact.Outcome, "reason_code": reason, + "transcript": []any{}, "opt_out": false, "recording": recording, + } + occurredAt := fact.EndedAt.UTC() + if outcome != nil { + occurredAt = outcome.ObservedAt.UTC() + } + eventID := uuid.NewString() + body, err := json.Marshal(map[string]any{ + "schema_version": localCallResultSchemaVersion, "event_id": eventID, "event_type": "call.result", + "dispatcher_id": d.dispatcherID, "tenant_id": binding.TenantId, "tenant_key": binding.TenantKey, + "trace_id": authorized.TraceID, "occurred_at": occurredAt.Format(time.RFC3339Nano), + "aggregate_type": "call", "aggregate_id": fact.CallID, "aggregate_version": 1, "payload": payload, + }) + if err != nil { + return store.LocalEventRecord{}, err + } + if err := contract.ValidateLocalCallResult(body); err != nil { + return store.LocalEventRecord{}, fmt.Errorf("validate confirmed Mock call result: %w", err) + } + return store.LocalEventRecord{ + EventID: eventID, TenantKey: binding.TenantKey, + Exchange: mq.ResultsExchangeV3, RoutingKey: "d." + d.dispatcherID + ".out", Body: body, + }, nil +} + +func (d *Dispatcher) enqueueLocalMockRecordingResult(ctx context.Context, authorized LocalDialAuthorization, fact store.LocalCallTerminal, outcome store.LocalRecordingOutcome, resultID string) error { + if resultID != "" { + return nil + } + event, err := d.localMockFinalEvent(authorized, fact, &outcome) + if err != nil { + return fmt.Errorf("build final Mock recording result: %w", err) + } + if err := d.EnqueueCallResult(ctx, event.Body); err != nil { + if !errors.Is(err, store.ErrCommandConflict) { + return fmt.Errorf("persist final Mock recording result: %w", err) + } + // Concurrent callbacks may generate different event IDs. The first + // durable result wins; the other resumes that same result identity. + _, persistedID, loadErr := d.store.LoadLocalCallTerminal(fact.ExecutionID) + if loadErr != nil || persistedID == "" { + return fmt.Errorf("resolve concurrent final Mock result: %w", errors.Join(err, loadErr)) + } + } + return nil +} diff --git a/internal/dispatcher/local_mock_pending_result_test.go b/internal/dispatcher/local_mock_pending_result_test.go new file mode 100644 index 0000000..e64dfa0 --- /dev/null +++ b/internal/dispatcher/local_mock_pending_result_test.go @@ -0,0 +1,295 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "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/rpc" + "git.ipao.vip/rogee/go-sip/internal/store" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +type mockFailureEventForwarder struct { + handler *rpc.DispatcherEventServer + loseReceipt bool + attempts int +} + +func (f *mockFailureEventForwarder) ReportExecutionEvent(ctx context.Context, request *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + response, err := f.handler.ReportExecutionEvent(ctx, request) + f.attempts++ + if err == nil && f.loseReceipt { + return nil, errors.New("injected lost receipt after durable Mock result") + } + return response, err +} + +func TestRecoverPendingMockRecordingResultWithoutAgentOrSecondUpload(t *testing.T) { + for _, tc := range []struct { + name string + withUpload bool + failureCode string + wantStatus string + }{ + {name: "uploaded fact already durable", withUpload: true, wantStatus: "uploaded"}, + {name: "authorization explicitly failed", failureCode: "upload_authorization_failed", wantStatus: "unavailable"}, + {name: "PUT explicitly failed", failureCode: "upload_failed", wantStatus: "unavailable"}, + {name: "missing upload closes at deadline", wantStatus: "unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + current := at + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer st.Close() + defer configServer.Close() + d.now = func() time.Time { return current } + client := &fakeAuthorizedAgentClient{} + const agentID = "agent-recovery" + coordinator := NewAgentCoordinator(func() time.Time { return current }) + if err := coordinator.Register(agentID, client); err != nil { + t.Fatal(err) + } + coordinator.sessions[agentID] = AgentSession{AgentID: agentID, CellID: "cell-1", BootID: "boot-recovery", DispatcherEpoch: "epoch-1", SessionGeneration: 1} + body := localExecuteCommandBody(t, "command-recording-recovery", at.Add(2*time.Hour)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id='command-recording-recovery'`).Scan(&executionID); err != nil { + t.Fatal(err) + } + if err := st.ClaimLocalOrigination(localTestDispatcherID, executionID, "agent-1", localTestExecutionBinding(t, st, executionID), at); err != nil { + t.Fatal(err) + } + endedAt := at.Add(time.Minute) + terminal := store.LocalCallTerminal{ + ExecutionID: executionID, CallID: executionID, Source: "mock_agent", + StartedAt: at, EndedAt: endedAt, Outcome: "answered", RecordingExpected: true, + Recording: &store.LocalRecordingManifest{ + RecordingID: "rec-" + executionID, UploadID: "upload-" + executionID, + Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 60000, + }, + } + if err := st.RecordLocalCallTerminal(terminal, nil); err != nil { + t.Fatal(err) + } + var reservation string + if err := st.DB().QueryRow(`SELECT state FROM reservations WHERE execution_id=?`, executionID).Scan(&reservation); err != nil || reservation != "released" { + t.Fatalf("call end did not release quota before upload: state=%q err=%v", reservation, err) + } + var uploadRequest *agentv1.CompleteUploadRequest + var uploadRecord store.UploadRecord + if tc.withUpload { + current = endedAt.Add(time.Second) + authorized, err := d.issuedMockAuthorization(executionID) + if err != nil { + t.Fatal(err) + } + asset := &agentv1.AssetDescriptor{ + Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: terminal.Recording.RecordingID, + CallId: terminal.CallID, Format: terminal.Recording.Format, Channels: int32(terminal.Recording.Channels), + SampleRateHz: int32(terminal.Recording.SampleRateHz), DurationMs: terminal.Recording.DurationMs, + SizeBytes: 128000, ChecksumSha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + } + grantRequest := &agentv1.RequestUploadRequest{Binding: authorized.Binding, Asset: asset, UploadId: terminal.Recording.UploadID} + if err := d.AuthorizeLocalMockUpload(context.Background(), grantRequest); err != nil { + t.Fatalf("confirmed Mock recording cannot request its first grant: %v", err) + } + unbound := proto.Clone(grantRequest).(*agentv1.RequestUploadRequest) + unbound.UploadId = "unbound-recording" + if err := d.AuthorizeLocalMockUpload(context.Background(), unbound); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("unbound Mock recording received grant authorization: %v", err) + } + uploadRecord = store.UploadRecord{ + UploadID: terminal.Recording.UploadID, Binding: []byte("isolated-binding"), Asset: []byte("isolated-asset"), + Grant: []byte("single-PUT"), Bucket: "isolated-mock", ObjectKey: "tenant/call/rec.wav", + State: "granted", CreatedAt: current, + } + if _, err := st.IssueUploadGrant(uploadRecord, "op-recording", "request-recording"); err != nil { + t.Fatal(err) + } + uploadRequest = &agentv1.CompleteUploadRequest{ + Binding: authorized.Binding, Asset: asset, UploadId: uploadRecord.UploadID, + UploadedSizeBytes: asset.SizeBytes, UploadedChecksumSha256: asset.ChecksumSha256, + } + const concurrentCompletions = 8 + results := make(chan struct { + delivered bool + err error + }, concurrentCompletions) + for i := 0; i < concurrentCompletions; i++ { + go func() { + delivered, err := d.CompleteLocalMockRecording(context.Background(), uploadRequest, uploadRecord) + results <- struct { + delivered bool + err error + }{delivered, err} + }() + } + for i := 0; i < concurrentCompletions; i++ { + result := <-results + if result.err != nil || result.delivered { + t.Fatalf("concurrent Mock completion was treated as MQ delivered: delivered=%t err=%v", result.delivered, result.err) + } + } + loaded, err := st.LoadUpload(uploadRecord.UploadID) + if err != nil || loaded.State != "uploaded" { + t.Fatalf("uploaded fact permitted another PUT: state=%q err=%v", loaded.State, err) + } + if err := d.AuthorizeLocalMockUpload(context.Background(), grantRequest); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("uploaded fact permitted a new grant: %v", err) + } + } else if tc.failureCode != "" { + current = endedAt.Add(time.Second) + authorized, err := d.issuedMockAuthorization(executionID) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + agentSpool, err := agent.NewSpool(root, func() time.Time { return current }) + if err != nil { + t.Fatal(err) + } + if err := agentSpool.ClaimUpload(agent.UploadAttempt{ + UploadID: terminal.Recording.UploadID, Identity: "Mock-" + executionID, + RequestID: uuid.NewString(), State: "attempted", Binding: authorized.Binding, + Asset: &agentv1.AssetDescriptor{AssetId: terminal.Recording.RecordingID}, + }); err != nil { + t.Fatal(err) + } + activeBoot, activeGeneration := "boot-original", uint64(1) + activeMeta := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", AgentId: agentID, CellId: "cell-a", BootId: activeBoot, + DispatcherEpoch: "epoch-a", SessionGeneration: activeGeneration, + } + eventServer, err := rpc.NewDispatcherEventServer(st, rpc.DispatcherEventServerOptions{ + Now: func() time.Time { return current }, LocalV3RecordingFailure: d.RecordLocalMockRecordingFailure, + LocalV3SessionCheck: func(meta *agentv1.RequestMeta) error { + if meta == nil || meta.AgentId != agentID || meta.CellId != "cell-a" || meta.BootId != activeBoot || + meta.DispatcherEpoch != "epoch-a" || meta.SessionGeneration != activeGeneration { + return store.ErrCommandConflict + } + return nil + }, + }) + if err != nil { + t.Fatal(err) + } + forwarder := &mockFailureEventForwarder{handler: eventServer, loseReceipt: true} + putCode := http.StatusBadRequest + if tc.failureCode == "upload_authorization_failed" { + putCode = http.StatusForbidden + } + known, err := agentSpool.ReportMockUploadFailure(context.Background(), forwarder, activeMeta, + terminal.Recording.UploadID, &agent.UploadHTTPError{StatusCode: putCode}) + if !known || err == nil || forwarder.attempts != 1 { + t.Fatalf("first Mock failure fact was not retained after lost receipt: known=%t attempts=%d err=%v", known, forwarder.attempts, err) + } + attempt, err := agentSpool.LoadUploadAttempt(terminal.Recording.UploadID) + if err != nil || attempt.FailureFact == nil || attempt.FailureDelivered { + t.Fatalf("lost receipt erased original failure fact: attempt=%+v err=%v", attempt, err) + } + firstFactID, firstDigest := attempt.FailureFact.FactId, attempt.FailureFact.ContentSha256 + current = endedAt.Add(store.LocalRecordingDeadline + time.Second) + activeBoot, activeGeneration = "boot-restarted", 2 + activeMeta.BootId, activeMeta.SessionGeneration = activeBoot, activeGeneration + agentSpool, err = agent.NewSpool(root, func() time.Time { return current }) + if err != nil { + t.Fatal(err) + } + forwarder.loseReceipt = false + if err := agentSpool.RecoverMockUploadFailures(context.Background(), forwarder, activeMeta); err != nil { + t.Fatalf("original Mock failure was not recovered after restart: %v", err) + } + attempt, err = agentSpool.LoadUploadAttempt(terminal.Recording.UploadID) + if err != nil || !attempt.FailureDelivered || attempt.FailureFact.FactId != firstFactID || + attempt.FailureFact.ContentSha256 != firstDigest || forwarder.attempts != 2 { + t.Fatalf("failure recovery changed identity or replayed upload: attempt=%+v reports=%d err=%v", attempt, forwarder.attempts, err) + } + var recorded int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_facts WHERE fact_id=?`, firstFactID).Scan(&recorded); err != nil || recorded != 1 { + t.Fatalf("failure fact was not durably unique: count=%d err=%v", recorded, err) + } + } else { + current = endedAt.Add(store.LocalRecordingDeadline - time.Nanosecond) + if err := d.RecoverAuthorizedMockResults(context.Background(), agentID, coordinator); err != nil { + t.Fatal(err) + } + _, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || eventID != "" { + t.Fatalf("result emitted before upload deadline: event=%q err=%v", eventID, err) + } + current = endedAt.Add(store.LocalRecordingDeadline) + } + for i := 0; i < 2; i++ { + if err := d.RecoverAuthorizedMockResults(context.Background(), agentID, coordinator); err != nil { + t.Fatalf("pending result recovery %d: %v", i, err) + } + } + if client.attempts != 0 { + t.Fatalf("recording result recovery tried to originate: attempts=%d", client.attempts) + } + _, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || eventID == "" { + t.Fatalf("recording final result missing: event=%q err=%v", eventID, err) + } + var encoded []byte + if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, eventID).Scan(&encoded); err != nil { + t.Fatal(err) + } + var event struct { + EventType string `json:"event_type"` + Payload struct { + Recording struct { + Status string `json:"status"` + ErrorCode *string `json:"error_code"` + } `json:"recording"` + } `json:"payload"` + } + if err := json.Unmarshal(encoded, &event); err != nil || event.EventType != "call.result" || event.Payload.Recording.Status != tc.wantStatus { + t.Fatalf("incorrect final recording result: event=%+v err=%v", event, err) + } + if tc.withUpload { + var outboxID int64 + if err := st.DB().QueryRow(`SELECT id FROM outbox WHERE event_id=?`, eventID).Scan(&outboxID); err != nil { + t.Fatal(err) + } + if err := st.MarkOutboxPublished(outboxID); err != nil { + t.Fatal(err) + } + current = current.Add(time.Hour) + delivered, err := d.CompleteLocalMockRecording(context.Background(), uploadRequest, uploadRecord) + if err != nil || !delivered { + t.Fatalf("confirmed final result did not close original upload: delivered=%t err=%v", delivered, err) + } + } + if !tc.withUpload { + wantCode := tc.failureCode + if wantCode == "" { + wantCode = "upload_timeout" + } + if event.Payload.Recording.ErrorCode == nil || *event.Payload.Recording.ErrorCode != wantCode { + t.Fatalf("unavailable result has wrong failure code: want=%q fact=%+v", wantCode, event.Payload.Recording) + } + late := store.LocalRecordingOutcome{Status: "uploaded", Bucket: "isolated-mock", ObjectKey: "tenant/call/late.wav", + ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ObservedAt: current.Add(time.Second)} + if err := st.RecordLocalRecordingOutcome(executionID, late); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("late PUT replaced terminal result: %v", err) + } + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, eventID).Scan(&count); err != nil || count != 1 { + t.Fatalf("duplicate recording result count=%d err=%v", count, err) + } + }) + } +} diff --git a/internal/dispatcher/local_mock_recovery.go b/internal/dispatcher/local_mock_recovery.go new file mode 100644 index 0000000..828b619 --- /dev/null +++ b/internal/dispatcher/local_mock_recovery.go @@ -0,0 +1,227 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +const localMockReconciliationPageSize = 256 + +// RecoverAuthorizedMockResults never reissues an authorized instruction. It +// reads the immutable accepted/issued decision, observes the Agent journal, +// and only then persists confirmed terminal facts and the unique result. +func (d *Dispatcher) RecoverAuthorizedMockResults(ctx context.Context, agentID string, coordinator *AgentCoordinator) error { + if coordinator == nil || agentID == "" { + return errors.New("activated Mock Agent is required to reconcile issued executions") + } + refusalErr := d.recoverRefusedMockResults(ctx) + var issued []string + for offset := 0; ; offset += localMockReconciliationPageSize { + page, err := d.store.ListLocalIssuedWithoutTerminal(d.dispatcherID, offset, localMockReconciliationPageSize) + if err != nil { + return fmt.Errorf("scan issued Mock executions: %w", err) + } + issued = append(issued, page...) + if len(page) < localMockReconciliationPageSize { + break + } + } + var failures []error + if refusalErr != nil { + failures = append(failures, refusalErr) + } + for _, executionID := range issued { + if err := ctx.Err(); err != nil { + return err + } + authorized, err := d.issuedMockAuthorization(executionID) + if err == nil { + var snapshot *agentv1.ExecutionSnapshot + snapshot, err = coordinator.observeAuthorizedMock(ctx, agentID, &agentv1.ExecutionBinding{ExecutionId: executionID}) + if err == nil { + err = validateMockTerminal(authorized, snapshot) + } + if err == nil { + err = d.completeAuthorizedMockTerminal(authorized, snapshot) + } + } + if err != nil { + failures = append(failures, fmt.Errorf("reconcile issued Mock execution %s (quota retained until confirmed): %w", executionID, err)) + } + } + if err := d.recoverPendingMockResults(ctx); err != nil { + failures = append(failures, err) + } + return errors.Join(failures...) +} + +func (d *Dispatcher) recoverRefusedMockResults(ctx context.Context) error { + var refused []string + for offset := 0; ; offset += localMockReconciliationPageSize { + page, err := d.store.ListLocalRefusedWithoutTerminal(d.dispatcherID, offset, localMockReconciliationPageSize) + if err != nil { + return fmt.Errorf("scan accepted Mock refusals: %w", err) + } + refused = append(refused, page...) + if len(page) < localMockReconciliationPageSize { + break + } + } + var failures []error + for _, executionID := range refused { + if err := ctx.Err(); err != nil { + return err + } + if err := d.completeLocalMockRefusal(executionID); err != nil { + failures = append(failures, fmt.Errorf("restore accepted Mock refusal %s: %w", executionID, err)) + } + } + return errors.Join(failures...) +} + +func (d *Dispatcher) recoverPendingMockResults(ctx context.Context) error { + var pending []string + for offset := 0; ; offset += localMockReconciliationPageSize { + page, err := d.store.ListLocalTerminalsWithoutResult(d.dispatcherID, offset, localMockReconciliationPageSize) + if err != nil { + return fmt.Errorf("scan unfinished Mock results: %w", err) + } + pending = append(pending, page...) + if len(page) < localMockReconciliationPageSize { + break + } + } + var failures []error + for _, executionID := range pending { + if err := ctx.Err(); err != nil { + return err + } + fact, eventID, err := d.store.LoadLocalCallTerminal(executionID) + if err == nil && eventID != "" { + continue + } + if err != nil { + failures = append(failures, fmt.Errorf("load pending Mock terminal %s: %w", executionID, err)) + continue + } + var outcome *store.LocalRecordingOutcome + if fact.RecordingExpected { + stored, exists, err := d.store.LoadLocalRecordingOutcome(executionID) + if err != nil { + failures = append(failures, fmt.Errorf("load pending recording outcome %s: %w", executionID, err)) + continue + } + if !exists { + at := d.now().UTC() + if at.Before(fact.EndedAt.Add(store.LocalRecordingDeadline)) { + continue + } + timeout := store.LocalRecordingOutcome{Status: "unavailable", ErrorCode: "upload_timeout", ObservedAt: at} + if err := d.store.RecordLocalRecordingOutcome(executionID, timeout); err != nil { + if !errors.Is(err, store.ErrCommandConflict) { + failures = append(failures, fmt.Errorf("persist recording timeout %s: %w", executionID, err)) + continue + } + // A concurrently persisted upload wins. Never replace it with a + // timeout or initiate another PUT; reload its immutable fact. + var loadErr error + stored, exists, loadErr = d.store.LoadLocalRecordingOutcome(executionID) + if loadErr != nil || !exists { + failures = append(failures, fmt.Errorf("close missing recording %s: %w", executionID, errors.Join(err, loadErr))) + continue + } + } else { + stored = timeout + } + } + outcome = &stored + } + var authorized LocalDialAuthorization + if fact.Source == "dispatcher_refusal" { + var reason string + authorized, reason, err = d.refusedMockAuthorization(executionID) + if err == nil && reason != fact.ReasonCode { + err = fmt.Errorf("refused terminal changed reason: %w", store.ErrCommandConflict) + } + } else { + authorized, err = d.issuedMockAuthorization(executionID) + } + if err == nil { + var event store.LocalEventRecord + event, err = d.localMockFinalEvent(authorized, fact, outcome) + if err == nil { + err = d.EnqueueCallResult(ctx, event.Body) + } + } + if err != nil { + failures = append(failures, fmt.Errorf("restore final Mock result %s: %w", executionID, err)) + } + } + return errors.Join(failures...) +} + +func (d *Dispatcher) issuedMockAuthorization(executionID string) (LocalDialAuthorization, error) { + candidate, at, err := d.store.LoadLocalIssuedOrigination(d.dispatcherID, executionID) + if err != nil { + return LocalDialAuthorization{}, err + } + return d.mockAuthorizationFromCandidate(candidate, at) +} + +func (d *Dispatcher) refusedMockAuthorization(executionID string) (LocalDialAuthorization, string, error) { + candidate, at, reason, err := d.store.LoadLocalRefusedOrigination(d.dispatcherID, executionID) + if err != nil { + return LocalDialAuthorization{}, "", err + } + authorized, err := d.mockAuthorizationFromCandidate(candidate, at) + return authorized, reason, err +} + +func (d *Dispatcher) mockAuthorizationFromCandidate(candidate store.LocalOriginationCandidate, at time.Time) (LocalDialAuthorization, error) { + var command localExecuteCommand + if err := json.Unmarshal(candidate.CommandBody, &command); err != nil { + return LocalDialAuthorization{}, fmt.Errorf("decode accepted issued command: %w", err) + } + if command.CommandID != candidate.CommandID || command.DispatcherID != candidate.DispatcherID || + command.TenantID != candidate.TenantID || command.TenantKey != candidate.TenantKey || + command.Payload.TaskID != candidate.TaskID || command.Payload.Callee != candidate.Callee || !validLocalID(command.TraceID) { + return LocalDialAuthorization{}, fmt.Errorf("%w: issued command changed identity", store.ErrCommandConflict) + } + var task dialTaskConfig + if err := json.Unmarshal(candidate.Snapshot.Task, &task); err != nil { + return LocalDialAuthorization{}, fmt.Errorf("decode bound issued task: %w", err) + } + if candidate.Snapshot.TaskRevision < 1 || task.Agent.AgentVersionID == "" || task.RoutePolicyID == "" || task.CallerProfileID == "" || + candidate.Snapshot.SelectedTrunkID == "" || at.IsZero() { + return LocalDialAuthorization{}, fmt.Errorf("%w: incomplete issued Agent/route/caller binding", store.ErrCommandConflict) + } + return LocalDialAuthorization{ + dialDecision: dialDecision{TrunkID: candidate.Snapshot.SelectedTrunkID}, + Binding: &agentv1.ExecutionBinding{ + ExecutionId: candidate.ExecutionID, TaskItemId: candidate.CommandID, TenantId: candidate.TenantID, + TenantKey: candidate.TenantKey, TaskId: candidate.TaskID, TaskRevision: candidate.Snapshot.TaskRevision, + AgentVersionId: task.Agent.AgentVersionID, RoutePolicyId: task.RoutePolicyID, + CallerProfileId: task.CallerProfileID, AttemptId: candidate.ExecutionID, + }, + Callee: candidate.Callee, TraceID: command.TraceID, AuthorizedAt: at, + SnapshotSHA256: candidate.SnapshotSHA256, + }, nil +} + +func validateMockTerminal(authorized LocalDialAuthorization, snapshot *agentv1.ExecutionSnapshot) error { + if authorized.Binding == nil || snapshot == nil || !proto.Equal(snapshot.Binding, authorized.Binding) || + snapshot.AttemptId != authorized.Binding.AttemptId || + snapshot.State != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL || snapshot.Unknown || + (snapshot.CallState != "mock_no_answer" && snapshot.CallState != "mock_deadline_closed_without_dial") { + return fmt.Errorf("%w: authorized Mock execution %s has no confirmed terminal fact (state=%s call_state=%q unknown=%t)", + ErrRemoteResultUnknown, authorized.Binding.GetExecutionId(), snapshot.GetState(), snapshot.GetCallState(), snapshot.GetUnknown()) + } + return nil +} diff --git a/internal/dispatcher/local_mock_recovery_test.go b/internal/dispatcher/local_mock_recovery_test.go new file mode 100644 index 0000000..17e02df --- /dev/null +++ b/internal/dispatcher/local_mock_recovery_test.go @@ -0,0 +1,59 @@ +package dispatcher + +import ( + "context" + "database/sql" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func TestRecoverIssuedMockTerminalWithoutReorigination(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer configServer.Close() + defer st.Close() + client := &fakeAuthorizedAgentClient{} + coordinator := NewAgentCoordinator(func() time.Time { return at }) + const agentID = "agent-recovery" + if err := coordinator.Register(agentID, client); err != nil { + t.Fatal(err) + } + coordinator.sessions[agentID] = AgentSession{AgentID: agentID, CellID: "cell-1", BootID: "boot-recovery", DispatcherEpoch: "epoch-1", SessionGeneration: 1} + body := localExecuteCommandBody(t, "command-recovery", at.Add(2*time.Hour)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id='command-recovery'`).Scan(&executionID); err != nil { + t.Fatal(err) + } + if result, err := d.DispatchAuthorizedLocal(context.Background(), executionID, agentID, coordinator); err != nil || result.State != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL || client.attempts != 1 { + t.Fatalf("initial issued instruction: result=%+v attempts=%d err=%v", result, client.attempts, err) + } + if _, _, err := st.LoadLocalCallTerminal(executionID); err != sql.ErrNoRows { + t.Fatalf("terminal fact appeared before Dispatcher reconciliation: %v", err) + } + for i := 0; i < 2; i++ { + if err := d.RecoverAuthorizedMockResults(context.Background(), agentID, coordinator); err != nil { + t.Fatalf("recovery iteration %d: %v", i, err) + } + } + if client.attempts != 1 { + t.Fatalf("recovery re-originated a previously issued call: %d attempts", client.attempts) + } + fact, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || fact.Outcome != "no_answer" || eventID == "" { + t.Fatalf("confirmed result lost: terminal=%+v event=%q err=%v", fact, eventID, err) + } + var reservation, taskStatus string + if err := st.DB().QueryRow(`SELECT r.state,t.status FROM reservations r JOIN tasks t ON t.execution_id=r.execution_id WHERE r.execution_id=?`, executionID).Scan(&reservation, &taskStatus); err != nil || reservation != "released" || taskStatus != "finished" { + t.Fatalf("confirmed terminal held capacity: reservation=%s task=%s err=%v", reservation, taskStatus, err) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, eventID).Scan(&count); err != nil || count != 1 { + t.Fatalf("duplicate/lost final event count=%d err=%v", count, err) + } +} diff --git a/internal/dispatcher/local_mock_refusal.go b/internal/dispatcher/local_mock_refusal.go new file mode 100644 index 0000000..018c647 --- /dev/null +++ b/internal/dispatcher/local_mock_refusal.go @@ -0,0 +1,46 @@ +package dispatcher + +import ( + "database/sql" + "errors" + "fmt" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +// completeLocalMockRefusal closes an already-accepted execution that was +// denied before any Agent instruction. Its call ID is the reserved execution +// correlation ID, NOT a SIP dialog or a claim that dialing occurred. +func (d *Dispatcher) completeLocalMockRefusal(executionID string) error { + authorized, reason, err := d.refusedMockAuthorization(executionID) + if err != nil { + return err + } + at := authorized.AuthorizedAt.UTC() + fact := store.LocalCallTerminal{ + ExecutionID: executionID, CallID: authorized.Binding.AttemptId, + Source: "dispatcher_refusal", StartedAt: at, EndedAt: at, + Outcome: "failed", ReasonCode: reason, + } + persisted, eventID, err := d.store.LoadLocalCallTerminal(executionID) + if err == nil { + if persisted.Source != fact.Source || persisted.CallID != fact.CallID || !persisted.StartedAt.Equal(at) || + !persisted.EndedAt.Equal(at) || persisted.Outcome != fact.Outcome || persisted.ReasonCode != fact.ReasonCode || persisted.RecordingExpected { + return fmt.Errorf("refused execution terminal changed identity: %w", store.ErrCommandConflict) + } + if eventID != "" { + return nil + } + } else if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("load refused execution terminal: %w", err) + } + event, err := d.localMockFinalEvent(authorized, fact, nil) + if err != nil { + return err + } + binding := authorized.Binding + if err := d.store.VerifyLocalAcceptedCommand(binding.TenantId, binding.TenantKey, binding.TaskItemId, binding.ExecutionId, binding.TaskId); err != nil { + return fmt.Errorf("confirm persisted accepted refusal: %w", err) + } + return d.store.RecordLocalCallTerminal(fact, &event) +} diff --git a/internal/dispatcher/local_mock_refusal_pending_test.go b/internal/dispatcher/local_mock_refusal_pending_test.go new file mode 100644 index 0000000..d93755c --- /dev/null +++ b/internal/dispatcher/local_mock_refusal_pending_test.go @@ -0,0 +1,51 @@ +package dispatcher + +import ( + "context" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestRecoverFinalResultAfterAcceptedMockRefusalReleasedQuota(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer st.Close() + defer configServer.Close() + body := localExecuteCommandBody(t, "command-refusal-pending-result", at.Add(2*time.Hour)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id='command-refusal-pending-result'`).Scan(&executionID); err != nil { + t.Fatal(err) + } + if err := st.RefuseLocalOrigination(localTestDispatcherID, executionID, "policy_denied", at); err != nil { + t.Fatal(err) + } + fact := store.LocalCallTerminal{ + ExecutionID: executionID, CallID: executionID, Source: "dispatcher_refusal", + StartedAt: at, EndedAt: at, Outcome: "failed", ReasonCode: "policy_denied", + } + if err := st.RecordLocalCallTerminal(fact, nil); err != nil { + t.Fatal(err) + } + _, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || eventID != "" { + t.Fatalf("result unexpectedly emitted before recovery: event=%q err=%v", eventID, err) + } + coordinator := NewAgentCoordinator(func() time.Time { return at }) + if err := d.RecoverAuthorizedMockResults(context.Background(), "absent-agent", coordinator); err != nil { + t.Fatal(err) + } + terminal, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || terminal.Source != "dispatcher_refusal" || eventID == "" { + t.Fatalf("released refused execution lost result: terminal=%+v event=%q err=%v", terminal, eventID, err) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, eventID).Scan(&count); err != nil || count != 1 { + t.Fatalf("pending refusal created %d results: %v", count, err) + } +} diff --git a/internal/dispatcher/local_mock_refusal_test.go b/internal/dispatcher/local_mock_refusal_test.go new file mode 100644 index 0000000..a94c359 --- /dev/null +++ b/internal/dispatcher/local_mock_refusal_test.go @@ -0,0 +1,50 @@ +package dispatcher + +import ( + "context" + "testing" + "time" +) + +func TestRecoverAcceptedMockRefusalWithoutAgentSession(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer st.Close() + defer configServer.Close() + body := localExecuteCommandBody(t, "command-refusal-recovery", at.Add(2*time.Hour)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id='command-refusal-recovery'`).Scan(&executionID); err != nil { + t.Fatal(err) + } + if err := st.RefuseLocalOrigination(localTestDispatcherID, executionID, "policy_denied", at); err != nil { + t.Fatal(err) + } + // Simulate a Dispatcher crash after the durable refusal but before the + // final result transaction; no Agent session or dial is available. + coordinator := NewAgentCoordinator(func() time.Time { return at }) + for i := 0; i < 2; i++ { + if err := d.RecoverAuthorizedMockResults(context.Background(), "absent-agent", coordinator); err != nil { + t.Fatalf("refusal recovery iteration %d: %v", i, err) + } + } + fact, eventID, err := st.LoadLocalCallTerminal(executionID) + if err != nil || fact.Source != "dispatcher_refusal" || fact.Outcome != "failed" || fact.ReasonCode != "policy_denied" || eventID == "" { + t.Fatalf("accepted no-dial refusal lost: fact=%+v event=%q err=%v", fact, eventID, err) + } + var reservation string + if err := st.DB().QueryRow(`SELECT state FROM reservations WHERE execution_id=?`, executionID).Scan(&reservation); err != nil || reservation != "released" { + t.Fatalf("refused execution held quota: state=%q err=%v", reservation, err) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, eventID).Scan(&count); err != nil || count != 1 { + t.Fatalf("refusal produced %d final events: %v", count, err) + } + ids, err := st.ListLocalRefusedWithoutTerminal(localTestDispatcherID, 0, 256) + if err != nil || len(ids) != 0 { + t.Fatalf("closed refusal left behind: ids=%v err=%v", ids, err) + } +} diff --git a/internal/dispatcher/local_mock_result.go b/internal/dispatcher/local_mock_result.go new file mode 100644 index 0000000..261ed72 --- /dev/null +++ b/internal/dispatcher/local_mock_result.go @@ -0,0 +1,91 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +// DispatchAndFinalizeAuthorizedMock is the isolated Mock execution path. It +// confirms the Agent's terminal observation before releasing capacity and +// inserting the unique final result in the same SQLite transaction. Neither a +// missing response nor a missing observation authorizes another originate. +func (d *Dispatcher) DispatchAndFinalizeAuthorizedMock(ctx context.Context, executionID, agentID string, coordinator *AgentCoordinator) (DispatchResult, error) { + result, authorized, dispatchErr := d.dispatchAuthorizedLocal(ctx, executionID, agentID, coordinator) + if authorized.Binding == nil { + return result, dispatchErr + } + snapshot, err := coordinator.observeAuthorizedMock(ctx, agentID, authorized.Binding) + if err != nil { + return result, errors.Join(dispatchErr, fmt.Errorf("confirm Mock Agent terminal observation: %w", err)) + } + if err := validateMockTerminal(authorized, snapshot); err != nil { + return result, errors.Join(dispatchErr, err) + } + if err := d.completeAuthorizedMockTerminal(authorized, snapshot); err != nil { + return result, errors.Join(dispatchErr, fmt.Errorf("persist confirmed Mock terminal and result: %w", err)) + } + if dispatchErr != nil { + slog.Info("reconciled uncertain Mock Agent reply from confirmed terminal observation", "execution_id", executionID) + } + result.Unknown = false + result.State = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL + return result, nil +} + +func (c *AgentCoordinator) observeAuthorizedMock(ctx context.Context, agentID string, binding *agentv1.ExecutionBinding) (*agentv1.ExecutionSnapshot, error) { + client, session, err := c.clientAndSession(agentID) + if err != nil { + return nil, err + } + request := &agentv1.QueryExecutionRequest{ + Meta: c.meta(session, "observe:"+binding.ExecutionId, "observe:"+binding.ExecutionId), + Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), + } + response, err := client.QueryExecution(ctx, request) + if err != nil { + return nil, err + } + if response == nil || response.Snapshot == nil || response.Failure != nil { + return nil, fmt.Errorf("Agent returned no confirmed execution snapshot: failure=%v", response.GetFailure()) + } + return response.Snapshot, nil +} + +func (d *Dispatcher) completeAuthorizedMockTerminal(authorized LocalDialAuthorization, snapshot *agentv1.ExecutionSnapshot) error { + if snapshot.ObservedAtUnixMs <= 0 || authorized.AuthorizedAt.IsZero() || authorized.TraceID == "" || + authorized.Binding == nil || authorized.Binding.AttemptId == "" || authorized.Callee == "" || authorized.TrunkID == "" { + return errors.New("incomplete bound Mock terminal observation") + } + startedAt := authorized.AuthorizedAt.UTC() + endedAt := time.UnixMilli(snapshot.ObservedAtUnixMs).UTC() + if endedAt.Before(startedAt) { + return fmt.Errorf("Mock terminal observation precedes authorized attempt: %s < %s", endedAt, startedAt) + } + binding := authorized.Binding + callID := binding.AttemptId + fact := store.LocalCallTerminal{ + ExecutionID: binding.ExecutionId, CallID: callID, Source: "mock_agent", + StartedAt: startedAt, EndedAt: endedAt, Outcome: "no_answer", ReasonCode: "no_answer", + RecordingExpected: false, + } + if snapshot.CallState == "mock_deadline_closed_without_dial" { + fact.Source, fact.Outcome, fact.ReasonCode = "mock_agent_deadline", "failed", "deadline_expired" + } else if snapshot.CallState != "mock_no_answer" { + return fmt.Errorf("%w: unsupported Mock terminal state %q", ErrRemoteResultUnknown, snapshot.CallState) + } + event, err := d.localMockFinalEvent(authorized, fact, nil) + if err != nil { + return err + } + if err := d.store.VerifyLocalAcceptedCommand(binding.TenantId, binding.TenantKey, binding.TaskItemId, binding.ExecutionId, binding.TaskId); err != nil { + return fmt.Errorf("confirm persisted accepted Mock command: %w", err) + } + return d.store.RecordLocalCallTerminal(fact, &event) +} diff --git a/internal/dispatcher/local_mock_upload.go b/internal/dispatcher/local_mock_upload.go new file mode 100644 index 0000000..b5dac45 --- /dev/null +++ b/internal/dispatcher/local_mock_upload.go @@ -0,0 +1,126 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +// AuthorizeLocalMockUpload rejects grants not bound to a confirmed Mock +// recording. This check runs before creating an OSS PUT grant, not just after +// a PUT has already happened. +func (d *Dispatcher) AuthorizeLocalMockUpload(ctx context.Context, request *agentv1.RequestUploadRequest) error { + if request == nil { + return errors.New("local Mock upload request is required") + } + _, fact, resultID, err := d.localMockUploadOwner(ctx, request.Binding, request.Asset, request.UploadId) + if err != nil { + return err + } + if resultID != "" || !d.now().UTC().Before(fact.EndedAt.Add(store.LocalRecordingDeadline)) { + return fmt.Errorf("Mock recording grant is past its result deadline: %w", store.ErrCommandConflict) + } + _, exists, err := d.store.LoadLocalRecordingOutcome(fact.ExecutionID) + if err != nil { + return fmt.Errorf("load immutable Mock upload outcome: %w", err) + } + if exists { + return fmt.Errorf("Mock recording already has a durable outcome: %w", store.ErrCommandConflict) + } + return nil +} + +func (d *Dispatcher) localMockUploadOwner(ctx context.Context, binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor, uploadID string) (LocalDialAuthorization, store.LocalCallTerminal, string, error) { + if err := ctx.Err(); err != nil { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", err + } + if d.store == nil || d.dispatcherID == "" || binding == nil || asset == nil { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", errors.New("local Mock upload requires Dispatcher, execution binding and asset") + } + authorized, err := d.issuedMockAuthorization(binding.ExecutionId) + if err != nil { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", fmt.Errorf("load issued Mock upload owner: %w", err) + } + if !proto.Equal(binding, authorized.Binding) { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", fmt.Errorf("Mock upload is not bound to the issued execution: %w", store.ErrCommandConflict) + } + fact, resultID, err := d.store.LoadLocalCallTerminal(binding.ExecutionId) + if err != nil { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", fmt.Errorf("load confirmed Mock call terminal: %w", err) + } + manifest := fact.Recording + if fact.Source != "mock_agent" || !fact.RecordingExpected || manifest == nil || + asset.Kind != agentv1.AssetKind_ASSET_KIND_RECORDING || asset.AssetId != manifest.RecordingID || + asset.CallId != fact.CallID || asset.Format != manifest.Format || int(asset.Channels) != manifest.Channels || + int(asset.SampleRateHz) != manifest.SampleRateHz || asset.DurationMs != manifest.DurationMs || + uploadID == "" || uploadID != manifest.UploadID { + return LocalDialAuthorization{}, store.LocalCallTerminal{}, "", fmt.Errorf("Mock upload does not match confirmed recording: %w", store.ErrCommandConflict) + } + return authorized, fact, resultID, nil +} + +// CompleteLocalMockRecording binds an Agent's finished PUT to the already +// confirmed Mock call. The sole outward notification is the immutable V3 +// call.result; a locally stored result never counts as MQ delivery. +func (d *Dispatcher) CompleteLocalMockRecording(ctx context.Context, request *agentv1.CompleteUploadRequest, grant store.UploadRecord) (bool, error) { + if request == nil { + return false, errors.New("local Mock upload completion is required") + } + authorized, fact, resultID, err := d.localMockUploadOwner(ctx, request.Binding, request.Asset, request.UploadId) + if err != nil { + return false, err + } + if grant.UploadID != request.UploadId || grant.Bucket == "" || grant.ObjectKey == "" || + request.Asset.SizeBytes != request.UploadedSizeBytes || request.Asset.ChecksumSha256 != request.UploadedChecksumSha256 { + return false, fmt.Errorf("Mock completion does not match granted asset: %w", store.ErrCommandConflict) + } + outcome, exists, err := d.store.LoadLocalRecordingOutcome(fact.ExecutionID) + if err != nil { + return false, fmt.Errorf("load immutable Mock upload outcome: %w", err) + } + matches := func(recorded store.LocalRecordingOutcome) bool { + return recorded.Status == "uploaded" && recorded.Bucket == grant.Bucket && recorded.ObjectKey == grant.ObjectKey && + recorded.SizeBytes == request.UploadedSizeBytes && recorded.ChecksumSHA256 == request.UploadedChecksumSha256 + } + if exists && !matches(outcome) { + return false, fmt.Errorf("Mock upload contradicts persisted recording outcome: %w", store.ErrCommandConflict) + } + if !exists { + outcome = store.LocalRecordingOutcome{ + Status: "uploaded", Bucket: grant.Bucket, ObjectKey: grant.ObjectKey, + SizeBytes: request.UploadedSizeBytes, ChecksumSHA256: request.UploadedChecksumSha256, + ObservedAt: d.now().UTC(), + } + if err := d.store.RecordLocalRecordingOutcome(fact.ExecutionID, outcome); err != nil { + // Another completion or deadline may have won. Only the exact same + // durable fact may be resumed; never replace an unavailable result. + if !errors.Is(err, store.ErrCommandConflict) { + return false, fmt.Errorf("persist Mock recording upload: %w", err) + } + stored, found, loadErr := d.store.LoadLocalRecordingOutcome(fact.ExecutionID) + if loadErr != nil || !found || !matches(stored) { + return false, fmt.Errorf("concurrent Mock recording outcome changed: %w", errors.Join(err, loadErr)) + } + outcome = stored + } + } + if err := d.enqueueLocalMockRecordingResult(ctx, authorized, fact, outcome, resultID); err != nil { + return false, err + } + persisted, err := d.store.LoadUpload(grant.UploadID) + if err != nil { + return false, fmt.Errorf("load confirmed Mock upload delivery: %w", err) + } + switch persisted.State { + case "completed": + return true, nil + case "uploaded": + return false, nil + default: + return false, fmt.Errorf("unexpected Mock upload delivery state %q: %w", persisted.State, store.ErrCommandConflict) + } +} diff --git a/internal/dispatcher/local_origination.go b/internal/dispatcher/local_origination.go new file mode 100644 index 0000000..c3bd22e --- /dev/null +++ b/internal/dispatcher/local_origination.go @@ -0,0 +1,170 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/callwindow" + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +// LocalDialAuthorization binds an immutable, one-shot Dispatcher decision to +// its persisted command and config. The Agent executes it without reselecting +// a line or recalculating outbound business policy. +type LocalDialAuthorization struct { + dialDecision + Binding *agentv1.ExecutionBinding + TraceID string + AuthorizedAt time.Time + Callee string + SnapshotSHA256 string +} + +// OriginateLocal keeps the final check adjacent to the Agent call. A failed or +// uncertain call is never retried under the same execution identity. +func (d *Dispatcher) OriginateLocal(ctx context.Context, executionID, agentID string, originate func(context.Context, LocalDialAuthorization) error) error { + if originate == nil { + return fmt.Errorf("Agent originate callback is missing") + } + decision, err := d.AuthorizeLocalOrigination(executionID, agentID) + if err != nil { + return err + } + if at := d.now().UTC(); !at.Before(decision.AllowedUntil) { + deadlineErr := fmt.Errorf("%w: dial deadline passed before Agent invocation", callwindow.ErrWindowClosed) + if closeErr := d.completeLocalIssuedDeadline(decision, at); closeErr != nil { + return errors.Join(deadlineErr, fmt.Errorf("persist confirmed no-Agent deadline: %w", closeErr)) + } + return deadlineErr + } + if err := originate(ctx, decision); err != nil { + slog.Error("Agent originate failed or is uncertain; manual reconciliation required", "execution_id", executionID, "trunk_id", decision.TrunkID, "error", err) + return fmt.Errorf("Agent originate for execution %s: %w", executionID, err) + } + return nil +} + +// AuthorizeLocalOrigination is the Dispatcher-side last business decision +// before issuing an Agent originate. It never reselects a trunk or retries an +// uncertain execution; the one-shot claim prevents duplicate authorization. +func (d *Dispatcher) AuthorizeLocalOrigination(executionID, agentID string) (authorization LocalDialAuthorization, err error) { + if d.dispatcherID == "" || executionID == "" || agentID == "" { + return LocalDialAuthorization{}, fmt.Errorf("Dispatcher, execution or Agent identity is missing") + } + defer func() { + if err == nil { + return + } + reason := "policy_denied" + if errors.Is(err, store.ErrLocalOriginationNotAuthorized) { + reason = "control_closed" + } + if refuseErr := d.store.RefuseLocalOrigination(d.dispatcherID, executionID, reason, d.now().UTC()); refuseErr != nil { + if !errors.Is(refuseErr, store.ErrLocalOriginationNotAuthorized) { + err = errors.Join(err, fmt.Errorf("persist final origination refusal: %w", refuseErr)) + } + } else if closeErr := d.completeLocalMockRefusal(executionID); closeErr != nil { + err = errors.Join(err, fmt.Errorf("close accepted no-dial refusal: %w", closeErr)) + } + }() + candidate, err := d.store.LoadLocalOrigination(d.dispatcherID, executionID) + if err != nil { + return LocalDialAuthorization{}, err + } + var command localExecuteCommand + if err := json.Unmarshal(candidate.CommandBody, &command); err != nil { + return LocalDialAuthorization{}, fmt.Errorf("decode bound call.execute: %w", err) + } + if command.CommandType != "call.execute" || command.CommandID != candidate.CommandID || command.DispatcherID != d.dispatcherID || + command.TenantID != candidate.TenantID || command.TenantKey != candidate.TenantKey || + command.Payload.TaskID != candidate.TaskID || command.Payload.Callee != candidate.Callee { + return LocalDialAuthorization{}, fmt.Errorf("bound call.execute identity does not match accepted execution") + } + notAfter, err := time.Parse(time.RFC3339, command.NotAfter) + if err != nil { + return LocalDialAuthorization{}, fmt.Errorf("invalid bound command expiry: %w", err) + } + at := d.now() + if !at.Before(notAfter) { + return LocalDialAuthorization{}, fmt.Errorf("%w: bound call.execute expired before origination", callwindow.ErrWindowClosed) + } + // The accepted snapshot is immutable. Cache expiry prevents NEW admissions; + // it does not silently replace an already accepted call's routing policy. + bound := configread.Snapshot{SIP: candidate.Snapshot.SIP, Task: candidate.Snapshot.Task} + decision, err := checkSelectedDialPolicy(bound, candidate.Callee, candidate.Snapshot.SelectedTrunkID, at) + if err != nil { + return LocalDialAuthorization{}, fmt.Errorf("bound pre-origination policy: %w", err) + } + var identity struct { + RoutePolicyID string `json:"route_policy_id"` + CallerProfileID string `json:"caller_profile_id"` + Agent struct { + AgentVersionID string `json:"agent_version_id"` + } `json:"agent"` + } + if err := json.Unmarshal(candidate.Snapshot.Task, &identity); err != nil { + return LocalDialAuthorization{}, fmt.Errorf("decode bound Agent identity: %w", err) + } + if identity.RoutePolicyID == "" || identity.CallerProfileID == "" || identity.Agent.AgentVersionID == "" || + candidate.Snapshot.TaskRevision <= 0 || decision.RingTimeoutMS <= 0 || decision.MaxCallDurationMS <= 0 { + return LocalDialAuthorization{}, fmt.Errorf("bound execution has incomplete Agent identity or dial limits") + } + quotaUntil, err := boundQuotaDeadline(candidate.Snapshot.TenantQuota, at) + if err != nil { + return LocalDialAuthorization{}, fmt.Errorf("bound tenant quota: %w", err) + } + if quotaUntil.Before(decision.AllowedUntil) { + decision.AllowedUntil = quotaUntil + } + if notAfter.Before(decision.AllowedUntil) { + decision.AllowedUntil = notAfter + } + if current := d.now(); !current.Before(decision.AllowedUntil) { + return LocalDialAuthorization{}, fmt.Errorf("%w: bound dial deadline passed before claim", callwindow.ErrWindowClosed) + } + if !validLocalID(command.TraceID) { + return LocalDialAuthorization{}, fmt.Errorf("accepted call trace is missing: %w", store.ErrCommandConflict) + } + binding := &agentv1.ExecutionBinding{ + TenantId: candidate.TenantID, TenantKey: candidate.TenantKey, + ExecutionId: executionID, TaskId: candidate.TaskID, TaskItemId: candidate.CommandID, + TaskRevision: candidate.Snapshot.TaskRevision, AgentVersionId: identity.Agent.AgentVersionID, + RoutePolicyId: identity.RoutePolicyID, CallerProfileId: identity.CallerProfileID, AttemptId: executionID, + } + if err := d.store.ClaimLocalOrigination(d.dispatcherID, executionID, agentID, binding, at); err != nil { + return LocalDialAuthorization{}, err + } + slog.Info("authorized local origination", "dispatcher_id", d.dispatcherID, "task_id", candidate.TaskID, + "execution_id", executionID, "agent_id", agentID, "trunk_id", decision.TrunkID, + "allowed_until", decision.AllowedUntil.Format(time.RFC3339)) + return LocalDialAuthorization{ + dialDecision: decision, Callee: candidate.Callee, SnapshotSHA256: candidate.SnapshotSHA256, + TraceID: command.TraceID, AuthorizedAt: at, Binding: binding, + }, nil +} + +func boundQuotaDeadline(raw json.RawMessage, at time.Time) (time.Time, error) { + var response struct { + ValidUntil string `json:"valid_until"` + } + if err := json.Unmarshal(raw, &response); err != nil { + return time.Time{}, fmt.Errorf("invalid bound quota: %w", err) + } + if response.ValidUntil == "" { + return time.Time{}, fmt.Errorf("%w: bound quota validity missing", callwindow.ErrWindowClosed) + } + until, err := time.Parse(time.RFC3339, response.ValidUntil) + if err != nil { + return time.Time{}, fmt.Errorf("invalid bound quota validity: %w", err) + } + if !at.Before(until) { + return time.Time{}, fmt.Errorf("%w: bound quota expired", callwindow.ErrWindowClosed) + } + return until, nil +} diff --git a/internal/dispatcher/local_origination_quota_test.go b/internal/dispatcher/local_origination_quota_test.go new file mode 100644 index 0000000..c1ac622 --- /dev/null +++ b/internal/dispatcher/local_origination_quota_test.go @@ -0,0 +1,25 @@ +package dispatcher + +import ( + "encoding/json" + "testing" + "time" +) + +func TestBoundQuotaDeadlineIsFailClosed(t *testing.T) { + quota := json.RawMessage(`{"valid_until":"2026-09-21T10:00:00+08:00"}`) + at := time.Date(2026, 9, 21, 1, 59, 59, 0, time.UTC) // Shanghai 09:59:59. + until, err := boundQuotaDeadline(quota, at) + if err != nil || !until.Equal(time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)) { + t.Fatalf("quota deadline=%s err=%v", until, err) + } + if _, err := boundQuotaDeadline(quota, until); err == nil { + t.Fatal("expired tenant quota was accepted at its exclusive boundary") + } + if _, err := boundQuotaDeadline(json.RawMessage(`{}`), at); err == nil { + t.Fatal("missing quota validity was accepted") + } + if _, err := boundQuotaDeadline(json.RawMessage(`{"valid_until":"invalid"}`), at); err == nil { + t.Fatal("invalid quota validity was accepted") + } +} diff --git a/internal/dispatcher/local_origination_rpc_test.go b/internal/dispatcher/local_origination_rpc_test.go new file mode 100644 index 0000000..035af4b --- /dev/null +++ b/internal/dispatcher/local_origination_rpc_test.go @@ -0,0 +1,65 @@ +package dispatcher + +import ( + "context" + "net" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + rpcserver "git.ipao.vip/rogee/go-sip/internal/rpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestLocalAuthorizedOriginationReachesOnlyMockAgentAfterFinalGate(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer configServer.Close() + defer st.Close() + attempts := 0 + current := at.Add(time.Minute) + agent := rpcserver.NewServer(rpcserver.ServerOptions{Mode: "mock", Now: func() time.Time { return current }, MockAuthorizedOriginate: func(_ context.Context, req *agentv1.ExecuteAuthorizedRequest) error { + attempts++ + if req.SelectedTrunkId != "trunk-mock" || req.CallerId != "BD00000000" || req.Callee != "15003164745" || req.MaxCallDurationMs != 120000 || req.BoundSnapshotSha256 == "" { + t.Errorf("Agent received an unbound/changed decision: %+v", req) + } + return nil + }}) + listener := bufconn.Listen(1024 * 1024) + grpcServer := grpc.NewServer() + agentv1.RegisterAgentControlServiceServer(grpcServer, agent) + go func() { _ = grpcServer.Serve(listener) }() + defer grpcServer.Stop() + connection, err := grpc.NewClient("passthrough:///authorized-mock", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + coordinator := NewAgentCoordinator(func() time.Time { return current }) + if err := coordinator.Register("agent-1", agentv1.NewAgentControlServiceClient(connection)); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-1", "cell-1", "boot-1", "epoch-1", 1); err != nil { + t.Fatal(err) + } + executionID := acceptedLocalExecutionID(t, d, st, at, "command-agent-bridge") + d.now = func() time.Time { return current } + result, err := d.DispatchAuthorizedLocal(context.Background(), executionID, "agent-1", coordinator) + if err != nil || result.Unknown || result.Receipt.GetResult() != agentv1.ResultCode_RESULT_CODE_APPLIED || attempts != 1 { + t.Fatalf("authorized mock call: %+v attempts=%d err=%v", result, attempts, err) + } + if _, err := d.DispatchAuthorizedLocal(context.Background(), executionID, "agent-1", coordinator); err == nil || attempts != 1 { + t.Fatalf("duplicate execution redialed: attempts=%d err=%v", attempts, err) + } + outsideD, outsideStore, outsideConfig := newLocalV01TestDispatcher(t, at) + defer outsideConfig.Close() + defer outsideStore.Close() + outsideID := acceptedLocalExecutionID(t, outsideD, outsideStore, at, "command-window-closed-bridge") + current = at.Add(90 * time.Minute) // Shanghai 11:00, right-open. + outsideD.now = func() time.Time { return current } + if _, err := outsideD.DispatchAuthorizedLocal(context.Background(), outsideID, "agent-1", coordinator); err == nil || attempts != 1 { + t.Fatalf("out-of-window command reached Agent: attempts=%d err=%v", attempts, err) + } +} diff --git a/internal/dispatcher/local_sqlite_full_test.go b/internal/dispatcher/local_sqlite_full_test.go new file mode 100644 index 0000000..3aca83f --- /dev/null +++ b/internal/dispatcher/local_sqlite_full_test.go @@ -0,0 +1,47 @@ +package dispatcher + +import ( + "context" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +// A deterministic SQLite write failure models the admission boundary under +// disk exhaustion without filling the developer's filesystem. The MQ handler +// must leave the original command available for redelivery, not ACK a refusal. +func TestLocalSQLiteWriteFailureKeepsOriginalCommandUnaccepted(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer configServer.Close() + defer st.Close() + const commandID = "command-sqlite-full" + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + body := localExecuteCommandBody(t, commandID, at.Add(2*time.Hour)) + if _, err := st.DB().Exec(`CREATE TRIGGER injected_sqlite_full BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'database or disk is full'); END`); err != nil { + t.Fatal(err) + } + if err := d.AcceptLocalV01Command(context.Background(), route, body); err == nil || mq.IsPermanent(err) { + t.Fatalf("SQLite write failure was ACKable or classified as a permanent command error: %v", err) + } + var tasks, reservations int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_item_id=?`, commandID).Scan(&tasks); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM reservations`).Scan(&reservations); err != nil { + t.Fatal(err) + } + if tasks != 0 || reservations != 0 { + t.Fatalf("failed SQLite admission persisted a partial command: tasks=%d reservations=%d", tasks, reservations) + } + if _, err := st.DB().Exec(`DROP TRIGGER injected_sqlite_full`); err != nil { + t.Fatal(err) + } + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatalf("original command was not accepted after storage recovered: %v", err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM tasks WHERE task_item_id=?`, commandID).Scan(&tasks); err != nil || tasks != 1 { + t.Fatalf("recovered command identity changed or duplicated: tasks=%d err=%v", tasks, err) + } +} diff --git a/internal/dispatcher/local_v01.go b/internal/dispatcher/local_v01.go new file mode 100644 index 0000000..b4872e0 --- /dev/null +++ b/internal/dispatcher/local_v01.go @@ -0,0 +1,276 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "regexp" + "time" + "unicode" + "unicode/utf8" + + "git.ipao.vip/rogee/go-sip/internal/callwindow" + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" +) + +const localCommandSchemaVersion = "command-next.v0.1-proposal" + +var localTaskIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) + +type localExecuteCommand struct { + SchemaVersion string `json:"schema_version"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + IssuedAt string `json:"issued_at"` + NotAfter string `json:"not_after"` + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + Payload struct { + TaskID string `json:"task_id"` + Callee string `json:"callee"` + } `json:"payload"` +} + +type localCommandResultEvent struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + OccurredAt string `json:"occurred_at"` + AggregateType string `json:"aggregate_type"` + AggregateID string `json:"aggregate_id"` + AggregateVersion int `json:"aggregate_version"` + Payload localCommandResultPayload `json:"payload"` +} + +type localCommandResultPayload struct { + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + Status string `json:"status"` + ReasonCode string `json:"reason_code"` + ExecutionID string `json:"execution_id,omitempty"` +} + +func NewV3(dispatcherID string, st *store.Store, publisher mq.Publisher, now func() time.Time) (*Dispatcher, error) { + if err := tenant.ValidateDispatcherID(dispatcherID); err != nil { + return nil, err + } + d, err := New(st, publisher, now) + if err != nil { + return nil, err + } + d.dispatcherID = dispatcherID + return d, nil +} + +func (d *Dispatcher) ConsumeV3TaskQueue(ctx context.Context, broker *mq.V3Broker, queue string) error { + if broker == nil { + return errors.New("v3 broker is required") + } + return broker.ConsumePredeclared(ctx, queue, d.AcceptLocalV01Command) +} + +func (d *Dispatcher) AcceptLocalV01Command(ctx context.Context, routingKey string, body []byte) error { + command, err := decodeLocalExecuteCommand(body) + if err != nil { + return mq.Permanent(fmt.Errorf("invalid local command: %w", err)) + } + if d.dispatcherID == "" || command.DispatcherID != d.dispatcherID { + return mq.Permanent(errors.New("command Dispatcher identity mismatch")) + } + if err := tenant.ValidateDispatcherID(command.DispatcherID); err != nil { + return mq.Permanent(err) + } + if command.CommandType != "call.execute" { + return mq.Permanent(fmt.Errorf("unsupported local command type %q", command.CommandType)) + } + if !validLocalID(command.CommandID) || !validLocalID(command.TraceID) { + return mq.Permanent(errors.New("command ID and trace ID do not match the contract ID format")) + } + if command.TenantID == "" || command.TenantKey == "" || !localTaskIDPattern.MatchString(command.Payload.TaskID) || command.Payload.Callee == "" { + return mq.Permanent(errors.New("command tenant, task, or callee fields are invalid")) + } + wantRoutingKey := "d." + d.dispatcherID + ".task." + command.Payload.TaskID + ".in" + if routingKey != wantRoutingKey { + return mq.Permanent(fmt.Errorf("command routing key mismatch: got %q", routingKey)) + } + issuedAt, err := time.Parse(time.RFC3339Nano, command.IssuedAt) + if err != nil { + return mq.Permanent(fmt.Errorf("invalid issued_at: %w", err)) + } + notAfter, err := time.Parse(time.RFC3339Nano, command.NotAfter) + if err != nil || !notAfter.After(issuedAt) { + return mq.Permanent(errors.New("not_after must be later than issued_at")) + } + + d.configRefreshMu.RLock() + defer d.configRefreshMu.RUnlock() + + now := d.now().UTC() + reason := "" + var projectConfig configread.Snapshot + var dial dialDecision + if !now.Before(notAfter) { + reason = "command_expired" + } else if command.Payload.Callee != "15003164745" && command.Payload.Callee != "15830461047" { + reason = "callee_not_allowed" + } else { + projectConfig, reason = d.localConfigSnapshot(command) + if reason == "" { + dial, err = selectDialPolicy(projectConfig, command.Payload.Callee, now) + if err != nil { + reason = "configuration_unavailable" + if errors.Is(err, callwindow.ErrWindowClosed) { + reason = "outside_call_window" + } + slog.Warn("outbound policy rejected new execution", "dispatcher_id", d.dispatcherID, "task_id", command.Payload.TaskID, "reason", reason, "error", err) + } + } + } + + status := "accepted" + inboxStatus := "persisted" + executionID := "" + if reason != "" { + status = "rejected" + inboxStatus = "rejected" + } else { + executionID = uuid.NewString() + reason = "accepted" + } + event := localCommandResultEvent{ + SchemaVersion: localCommandSchemaVersion, + EventID: uuid.NewString(), + EventType: "command.result", + DispatcherID: d.dispatcherID, + TenantID: command.TenantID, + TenantKey: command.TenantKey, + TraceID: command.TraceID, + OccurredAt: now.Format(time.RFC3339Nano), + AggregateType: "command", + AggregateID: command.CommandID, + AggregateVersion: 1, + Payload: localCommandResultPayload{ + CommandID: command.CommandID, CommandType: command.CommandType, + Status: status, ReasonCode: reason, ExecutionID: executionID, + }, + } + responseBody, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal local command result: %w", err) + } + var admission *store.LocalCallAdmission + var capacityRejectedBody []byte + if status == "accepted" { + admission = &store.LocalCallAdmission{ + DispatcherID: d.dispatcherID, + Task: store.Task{ + ExecutionID: executionID, TenantKey: command.TenantKey, TenantID: command.TenantID, + TaskID: projectConfig.TaskID, TaskItemID: command.CommandID, TaskRevision: projectConfig.TaskRevision, + TraceID: command.TraceID, Callee: command.Payload.Callee, + RoutePolicyID: projectConfig.TaskRoutePolicyID, CallerProfileID: projectConfig.TaskCallerProfileID, + AgentVersionID: projectConfig.AgentVersionID, Variables: map[string]any{}, + RingTimeoutMS: dial.RingTimeoutMS, MaxCallDurationMS: dial.MaxCallDurationMS, + Status: "accepted", + }, + ReservationID: executionID, + QuotaScopes: []string{ + store.LocalTenantQuotaScope(d.dispatcherID, command.TenantID), + store.LocalTaskQuotaScope(d.dispatcherID, command.TenantID, projectConfig.TaskID), + }, + Snapshot: store.LocalExecutionConfigSnapshot{ + SchemaVersion: "execution-config-snapshot.v0.2", DispatcherID: d.dispatcherID, + TenantID: projectConfig.TenantID, TenantKey: projectConfig.TenantKey, TaskID: projectConfig.TaskID, SelectedTrunkID: dial.TrunkID, + SIPRevision: projectConfig.SIPRevision, TaskRevision: projectConfig.TaskRevision, QuotaRevision: projectConfig.QuotaRevision, + SIP: projectConfig.SIP, Tasks: projectConfig.Tasks, Task: projectConfig.Task, TenantQuota: projectConfig.TenantQuota, + }, + } + capacityRejected := event + capacityRejected.Payload.Status = "rejected" + capacityRejected.Payload.ReasonCode = "quota_exceeded" + capacityRejected.Payload.ExecutionID = "" + capacityRejectedBody, err = json.Marshal(capacityRejected) + if err != nil { + return fmt.Errorf("marshal quota rejection result: %w", err) + } + } + _, err = d.store.PersistLocalCommand(store.LocalCommandRecord{ + DispatcherID: d.dispatcherID, TaskID: command.Payload.TaskID, + TenantID: command.TenantID, TenantKey: command.TenantKey, + CommandID: command.CommandID, CommandType: command.CommandType, Body: body, + ReceiptID: event.EventID, Exchange: mq.ResultsExchangeV3, + RoutingKey: "d." + d.dispatcherID + ".out", ReceiptBody: responseBody, + CapacityRejectedReceiptBody: capacityRejectedBody, Status: inboxStatus, Admission: admission, + }) + if errors.Is(err, store.ErrCommandConflict) { + return mq.Permanent(err) + } + if err != nil { + return fmt.Errorf("persist local command and receipt: %w", err) + } + return nil +} + +func (d *Dispatcher) localConfigSnapshot(command localExecuteCommand) (configread.Snapshot, string) { + snapshot, loaded := d.ProjectConfigSnapshot(command.Payload.TaskID, command.TenantID) + if !loaded { + return configread.Snapshot{}, "configuration_unavailable" + } + if snapshot.TaskID != command.Payload.TaskID || snapshot.TenantID != command.TenantID || snapshot.TenantKey != command.TenantKey { + return configread.Snapshot{}, "configuration_mismatch" + } + if snapshot.TaskStatus != "running" { + return configread.Snapshot{}, "task_not_running" + } + if snapshot.TaskMaxConcurrentCalls <= 0 { + return configread.Snapshot{}, "task_quota_unavailable" + } + if snapshot.TenantMaxConcurrentCalls <= 0 || !d.now().Before(snapshot.QuotaValidUntil) { + return configread.Snapshot{}, "tenant_quota_unavailable" + } + return snapshot, "" +} + +func decodeLocalExecuteCommand(body []byte) (localExecuteCommand, error) { + var command localExecuteCommand + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&command); err != nil { + return localExecuteCommand{}, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return localExecuteCommand{}, errors.New("multiple JSON values") + } + return localExecuteCommand{}, err + } + if command.SchemaVersion != localCommandSchemaVersion || command.CommandType != "call.execute" { + return localExecuteCommand{}, errors.New("unsupported command schema version or type") + } + return command, nil +} + +func validLocalID(value string) bool { + if !utf8.ValidString(value) || utf8.RuneCountInString(value) == 0 || utf8.RuneCountInString(value) > 128 { + return false + } + for _, r := range value { + if unicode.IsSpace(r) || r == '/' || r == '\\' { + return false + } + } + return true +} diff --git a/internal/dispatcher/local_v01_integration_test.go b/internal/dispatcher/local_v01_integration_test.go new file mode 100644 index 0000000..30e815b --- /dev/null +++ b/internal/dispatcher/local_v01_integration_test.go @@ -0,0 +1,304 @@ +//go:build integration + +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + amqp "github.com/rabbitmq/amqp091-go" +) + +func TestLocalDispatcherMockSaaSEndToEndWithOutboxRecovery(t *testing.T) { + brokerURL := os.Getenv("RABBITMQ_URL") + if brokerURL == "" { + t.Skip("RABBITMQ_URL not set") + } + provisionerURL := os.Getenv("RABBITMQ_PROVISIONER_URL") + if provisionerURL == "" { + provisionerURL = brokerURL + } + // The published example intentionally leaves supplier capacity unknown; + // only this isolated Mock grants a positive, explicit trunk limit. + fixtures := map[string][]byte{ + "/internal/v1/dispatcher/sip": localConfigFixture(t, "config-read-sip-v0.1.json"), + "/internal/v1/dispatcher/tasks": readLocalFixture(t, "task-discovery-snapshot-v0.2.json"), + "/internal/v1/dispatcher/task/" + localTestTaskID: readLocalFixture(t, "config-read-task-v0.1.json"), + "/internal/v1/dispatcher/tenant/" + localTestTenantID + "/quota": readLocalFixture(t, "config-read-tenant-quota-v0.1.json"), + } + var requestsMu sync.Mutex + var requests []string + mockHTTP := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestsMu.Lock() + requests = append(requests, r.Method+" "+r.URL.RequestURI()) + requestsMu.Unlock() + if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-id") != localTestDispatcherID || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "mock-test-secret" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprint(w, `{"schema_version":"config-read.v0.1","resource":"error","error":{"code":"unauthorized","message":"unauthorized"}}`) + return + } + body, ok := fixtures[r.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprint(w, `{"schema_version":"config-read.v0.1","resource":"error","error":{"code":"resource_not_found","message":"not found"}}`) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer mockHTTP.Close() + + conn, err := amqp.Dial(provisionerURL) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + channel, err := conn.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + for _, exchange := range []string{mq.CommandsExchangeV3, mq.ResultsExchangeV3, mq.DeadLetterExchangeV3} { + if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + t.Fatalf("Mock SaaS declare %s: %v", exchange, err) + } + } + taskQueue := "agent-call.d." + localTestDispatcherID + ".task." + localTestTaskID + ".v3" + controlQueue := mq.V3ControlQueueName(localTestDispatcherID) + resultQueue := "agent-call.saas.d." + localTestDispatcherID + ".v3" + deadQueue := "agent-call.d." + localTestDispatcherID + ".dead-letter.v3" + taskRoute := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + controlRoute := "d." + localTestDispatcherID + ".control.in" + resultRoute := "d." + localTestDispatcherID + ".out" + deadRoute := "d." + localTestDispatcherID + ".dead-letter" + if _, err := channel.QueueDeclare(deadQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(deadQueue, deadRoute, mq.DeadLetterExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if _, err := channel.QueueDeclare(taskQueue, true, false, false, false, amqp.Table{ + "x-dead-letter-exchange": mq.DeadLetterExchangeV3, "x-dead-letter-routing-key": deadRoute, + }); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(taskQueue, taskRoute, mq.CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if _, err := channel.QueueDeclare(controlQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(controlQueue, controlRoute, mq.CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + declareResultQueue := func() { + t.Helper() + if _, err := channel.QueueDeclare(resultQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(resultQueue, resultRoute, mq.ResultsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + } + declareResultQueue() + for _, queue := range []string{taskQueue, controlQueue, resultQueue, deadQueue} { + if _, err := channel.QueuePurge(queue, false); err != nil { + t.Fatal(err) + } + defer func(queue string) { _, _ = channel.QueueDelete(queue, false, false, false) }(queue) + } + + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + broker, err := mq.OpenV3(brokerURL, localTestDispatcherID, mq.DefaultPrefetch) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + d, err := NewV3(localTestDispatcherID, st, broker, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + configClient, err := configread.NewClient(mockHTTP.URL, localTestDispatcherID, "mock-test-secret", mockHTTP.Client()) + if err != nil { + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), configClient, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err != nil { + t.Fatal(err) + } + requestsMu.Lock() + gotRequests := append([]string(nil), requests...) + requestsMu.Unlock() + wantRequests := []string{ + "GET /internal/v1/dispatcher/sip", + "GET /internal/v1/dispatcher/tasks", + "GET /internal/v1/dispatcher/task/" + localTestTaskID, + "GET /internal/v1/dispatcher/tenant/" + localTestTenantID + "/quota", + } + if !reflect.DeepEqual(gotRequests, wantRequests) { + t.Fatalf("Mock SaaS GET flow = %#v, want %#v", gotRequests, wantRequests) + } + + ctx, cancel := context.WithCancel(context.Background()) + consumeErr := make(chan error, 1) + go func() { consumeErr <- d.ConsumeV3TaskQueue(ctx, broker, taskQueue) }() + commandBody := localExecuteCommandBody(t, "command-a", now.Add(time.Minute)) + if err := channel.PublishWithContext(context.Background(), mq.CommandsExchangeV3, taskRoute, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: commandBody, + }); err != nil { + cancel() + t.Fatal(err) + } + + if _, err := channel.QueueDelete(resultQueue, false, false, false); err != nil { + cancel() + t.Fatal(err) + } + deadline := time.Now().Add(8 * time.Second) + var attempted int + for time.Now().Before(deadline) { + n, flushErr := d.FlushOutbox(ctx, 1) + if flushErr != nil { + if n != 0 || !strings.Contains(flushErr.Error(), "required SaaS result queue unavailable") { + cancel() + t.Fatalf("expected the missing SaaS queue to force an outbox retry: count=%d err=%v", n, flushErr) + } + attempted = 1 + break + } + if n > 0 { + cancel() + t.Fatal("publication succeeded while the SaaS-owned result queue was absent") + } + time.Sleep(10 * time.Millisecond) + } + if attempted != 1 { + cancel() + t.Fatal("timed out waiting for the command receipt to enter the durable outbox") + } + declareResultQueue() + resultDeliveries, err := channel.Consume(resultQueue, "", true, false, false, false, nil) + if err != nil { + cancel() + t.Fatal(err) + } + if n, err := d.FlushOutbox(ctx, 1); err != nil || n != 1 { + cancel() + t.Fatalf("recover command.result outbox: count=%d err=%v", n, err) + } + commandReceipt := receiveLocalV3Event(t, resultDeliveries) + var receipt localTestCommandResult + if err := json.Unmarshal(commandReceipt, &receipt); err != nil { + cancel() + t.Fatal(err) + } + if receipt.EventType != "command.result" || receipt.Payload.CommandID != "command-a" || receipt.Payload.Status != "accepted" || receipt.Payload.ExecutionID == "" { + cancel() + t.Fatalf("Mock SaaS received unexpected command result: %+v", receipt) + } + + callResult := localCallResultFixture(t, receipt.Payload.ExecutionID) + if err := st.ClaimLocalOrigination(localTestDispatcherID, receipt.Payload.ExecutionID, "agent-1", localTestExecutionBinding(t, st, receipt.Payload.ExecutionID), now); err != nil { + cancel() + t.Fatal(err) + } + var uploaded struct { + Payload struct { + Recording struct { + RecordingID string `json:"recording_id"` + UploadID string `json:"upload_id"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + Format string `json:"format"` + ChecksumSHA256 string `json:"checksum_sha256"` + Channels int `json:"channels"` + SampleRateHz int `json:"sample_rate_hz"` + DurationMs int64 `json:"duration_ms"` + SizeBytes int64 `json:"size_bytes"` + } `json:"recording"` + } `json:"payload"` + } + if err := json.Unmarshal(callResult, &uploaded); err != nil { + cancel() + t.Fatal(err) + } + recording := uploaded.Payload.Recording + if err := st.RecordLocalCallTerminal(store.LocalCallTerminal{ + ExecutionID: receipt.Payload.ExecutionID, CallID: "call-a", Source: "mock_agent", + StartedAt: now, EndedAt: now.Add(10 * time.Minute), Outcome: "answered", RecordingExpected: true, + Recording: &store.LocalRecordingManifest{ + RecordingID: recording.RecordingID, UploadID: recording.UploadID, Format: recording.Format, + Channels: recording.Channels, SampleRateHz: recording.SampleRateHz, DurationMs: recording.DurationMs, + }, + }, nil); err != nil { + cancel() + t.Fatal(err) + } + if err := st.RecordLocalRecordingOutcome(receipt.Payload.ExecutionID, store.LocalRecordingOutcome{ + Status: "uploaded", Bucket: recording.Bucket, ObjectKey: recording.ObjectKey, + SizeBytes: recording.SizeBytes, ChecksumSHA256: recording.ChecksumSHA256, + ObservedAt: now.Add(10*time.Minute + time.Second), + }); err != nil { + cancel() + t.Fatal(err) + } + if err := d.EnqueueCallResult(ctx, callResult); err != nil { + cancel() + t.Fatal(err) + } + if n, err := d.FlushOutbox(ctx, 1); err != nil || n != 1 { + cancel() + t.Fatalf("publish final call.result: count=%d err=%v", n, err) + } + finalResult := receiveLocalV3Event(t, resultDeliveries) + if string(finalResult) != string(callResult) { + cancel() + t.Fatalf("Mock SaaS call.result = %s, want %s", finalResult, callResult) + } + cancel() + select { + case <-consumeErr: + case <-time.After(5 * time.Second): + t.Fatal("Dispatcher task consumer did not stop") + } +} + +func readLocalFixture(t *testing.T, name string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "..", "docs", "contracts", "examples", name)) + if err != nil { + t.Fatal(err) + } + return body +} + +func receiveLocalV3Event(t *testing.T, deliveries <-chan amqp.Delivery) []byte { + t.Helper() + select { + case delivery, ok := <-deliveries: + if !ok { + t.Fatal("Mock SaaS result queue consumer closed") + } + return append([]byte(nil), delivery.Body...) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for Dispatcher event") + return nil + } +} diff --git a/internal/dispatcher/local_v01_test.go b/internal/dispatcher/local_v01_test.go new file mode 100644 index 0000000..a41dd71 --- /dev/null +++ b/internal/dispatcher/local_v01_test.go @@ -0,0 +1,802 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +const ( + localTestDispatcherID = "c046b893-8628-4589-ae50-619d049248a6" + localTestTenantID = "tenant-id-mock" + localTestTenantKey = "tenant-mock" + localTestTaskID = "task-mock" +) + +func localTestExecutionBinding(t *testing.T, st *store.Store, executionID string) *agentv1.ExecutionBinding { + t.Helper() + binding := &agentv1.ExecutionBinding{ExecutionId: executionID, AttemptId: executionID} + if err := st.DB().QueryRow(`SELECT tenant_id,tenant_key,task_id,task_item_id,task_revision,agent_version_id,route_policy_id,caller_profile_id FROM tasks WHERE execution_id=?`, executionID).Scan( + &binding.TenantId, &binding.TenantKey, &binding.TaskId, &binding.TaskItemId, &binding.TaskRevision, + &binding.AgentVersionId, &binding.RoutePolicyId, &binding.CallerProfileId, + ); err != nil { + t.Fatal(err) + } + return binding +} + +func TestValidLocalIDMatchesContractLengthAndWhitespace(t *testing.T) { + valid := []string{"command-a", strings.Repeat("x", 128), strings.Repeat("中", 128)} + for _, value := range valid { + if !validLocalID(value) { + t.Errorf("validLocalID(%q) = false", value) + } + } + invalid := []string{"", strings.Repeat("x", 129), "a b", "a/b", `a\\b`, "a\u00a0b"} + for _, value := range invalid { + if validLocalID(value) { + t.Errorf("validLocalID(%q) = true", value) + } + } +} + +func TestAcceptLocalV01CommandPersistsReceiptAndRejectsExpiredCommand(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + + acceptedBody := localExecuteCommandBody(t, "command-a", now.Add(time.Minute)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, acceptedBody); err != nil { + t.Fatal(err) + } + accepted, err := st.ClaimOutbox(10) + if err != nil || len(accepted) != 1 { + t.Fatalf("claim accepted receipt: count=%d err=%v", len(accepted), err) + } + var acceptedReceipt localTestCommandResult + if err := json.Unmarshal(accepted[0].Body, &acceptedReceipt); err != nil { + t.Fatal(err) + } + if acceptedReceipt.EventType != "command.result" || acceptedReceipt.Payload.Status != "accepted" || acceptedReceipt.Payload.ExecutionID == "" { + t.Fatalf("unexpected accepted receipt: %+v", acceptedReceipt) + } + var executionBody []byte + if err := st.DB().QueryRow(`SELECT body FROM local_v01_execution_configs WHERE execution_id=?`, acceptedReceipt.Payload.ExecutionID).Scan(&executionBody); err != nil { + t.Fatal(err) + } + var bound store.LocalExecutionConfigSnapshot + if err := json.Unmarshal(executionBody, &bound); err != nil || bound.SelectedTrunkID != "trunk-mock" || bound.SchemaVersion != "execution-config-snapshot.v0.2" { + t.Fatalf("accepted call did not bind its selected trunk: %+v err=%v", bound, err) + } + if err := st.MarkOutboxPublished(accepted[0].ID); err != nil { + t.Fatal(err) + } + + if err := d.AcceptLocalV01Command(context.Background(), route, acceptedBody); err != nil { + t.Fatalf("redeliver accepted command: %v", err) + } + duplicateReceipt, err := st.ClaimOutbox(10) + if err != nil || len(duplicateReceipt) != 1 { + t.Fatalf("claim redelivered receipt: count=%d err=%v", len(duplicateReceipt), err) + } + if duplicateReceipt[0].EventID != accepted[0].EventID || string(duplicateReceipt[0].Body) != string(accepted[0].Body) { + t.Fatalf("redelivery changed receipt: first=%+v second=%+v", accepted[0], duplicateReceipt[0]) + } + if err := st.MarkOutboxPublished(duplicateReceipt[0].ID); err != nil { + t.Fatal(err) + } + + expiredBody := localExecuteCommandBody(t, "command-expired", now.Add(-time.Second)) + if err := d.AcceptLocalV01Command(context.Background(), route, expiredBody); err != nil { + t.Fatal(err) + } + expired, err := st.ClaimOutbox(10) + if err != nil || len(expired) != 1 { + t.Fatalf("claim expired receipt: count=%d err=%v", len(expired), err) + } + var expiredReceipt localTestCommandResult + if err := json.Unmarshal(expired[0].Body, &expiredReceipt); err != nil { + t.Fatal(err) + } + if expiredReceipt.Payload.Status != "rejected" || expiredReceipt.Payload.ReasonCode != "command_expired" || expiredReceipt.Payload.ExecutionID != "" { + t.Fatalf("unexpected expired receipt: %+v", expiredReceipt) + } +} + +type localTestCommandResult struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + AggregateID string `json:"aggregate_id"` + Payload struct { + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + Status string `json:"status"` + ReasonCode string `json:"reason_code"` + ExecutionID string `json:"execution_id"` + } `json:"payload"` +} + +func TestAuthorizeLocalOriginationRechecksBoundWindowAndClaimsOnlyOnce(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) // Shanghai 09:30. + d, st, server := newLocalV01TestDispatcher(t, at) + defer server.Close() + defer st.Close() + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, localExecuteCommandBody(t, "command-final-gate", at.Add(2*time.Hour))); err != nil { + t.Fatal(err) + } + outbox, err := st.ClaimOutbox(1) + if err != nil || len(outbox) != 1 { + t.Fatalf("accepted receipt: count=%d err=%v", len(outbox), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(outbox[0].Body, &receipt); err != nil || receipt.Payload.ExecutionID == "" { + t.Fatalf("execution ID: %+v err=%v", receipt, err) + } + // A newer cached schedule must not extend an already accepted execution's + // immutable schedule. Only its bound task/SIP snapshot decides origination. + d.configMu.Lock() + key := projectConfigKey{tenantID: localTestTenantID, taskID: localTestTaskID} + newer := d.projectConfigs[key] + var changedTask map[string]any + if err := json.Unmarshal(newer.Task, &changedTask); err != nil { + d.configMu.Unlock() + t.Fatal(err) + } + changedTask["schedule"].(map[string]any)["weekly_windows"].(map[string]any)["monday"] = []any{map[string]any{"start": "09:00", "end": "12:00"}} + newer.Task, err = json.Marshal(changedTask) + if err != nil { + d.configMu.Unlock() + t.Fatal(err) + } + d.projectConfigs[key] = newer + d.configMu.Unlock() + current := at.Add(90 * time.Minute) // Shanghai 11:00; bound window is right-open. + d.now = func() time.Time { return current } + if _, err := d.AuthorizeLocalOrigination(receipt.Payload.ExecutionID, "agent-1"); err == nil { + t.Fatal("accepted call crossed its bound task window and was authorized to dial") + } + var status string + if err := st.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id=?`, receipt.Payload.ExecutionID).Scan(&status); err != nil || status != "finished" { + t.Fatalf("rejected originate was not closed after its durable refusal: status=%q err=%v", status, err) + } + var finalCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM local_v01_call_terminals WHERE execution_id=? AND result_event_id IS NOT NULL`, receipt.Payload.ExecutionID).Scan(&finalCount); err != nil || finalCount != 1 { + t.Fatalf("rejected originate must have one final result: count=%d err=%v", finalCount, err) + } + current = at.Add(time.Minute) + if _, err := d.AuthorizeLocalOrigination(receipt.Payload.ExecutionID, "agent-1"); err == nil { + t.Fatal("a refused execution was revived when the clock moved back inside the window") + } + var refused string + if err := st.DB().QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, receipt.Payload.ExecutionID).Scan(&refused); err != nil || refused != "refused" { + t.Fatalf("bound-window refusal not durable: decision=%q err=%v", refused, err) + } + allowedD, allowedStore, allowedServer := newLocalV01TestDispatcher(t, at) + defer allowedServer.Close() + defer allowedStore.Close() + allowedID := acceptedLocalExecutionID(t, allowedD, allowedStore, at, "command-final-gate-valid") + allowedD.now = func() time.Time { return current } + decision, err := allowedD.AuthorizeLocalOrigination(allowedID, "agent-1") + if err != nil || decision.TrunkID != "trunk-mock" { + t.Fatalf("valid pre-origination check: %+v err=%v", decision, err) + } + if decision.Binding == nil || decision.Binding.ExecutionId != allowedID || decision.Binding.TaskItemId != "command-final-gate-valid" || + decision.Binding.AgentVersionId != "agent-version-mock" || decision.Binding.CallerProfileId != "caller-profile-mock" || + decision.Callee != "15003164745" || decision.RingTimeoutMS <= 0 || len(decision.SnapshotSHA256) != 64 { + t.Fatalf("authorized Agent instruction lost immutable execution/config identity: %+v", decision) + } + if _, err := allowedD.AuthorizeLocalOrigination(allowedID, "agent-1"); err == nil { + t.Fatal("the same accepted call obtained a second origination authorization") + } + var issued int + if err := allowedStore.DB().QueryRow(`SELECT COUNT(*) FROM local_v02_origination_decisions WHERE execution_id=? AND decision='issued'`, allowedID).Scan(&issued); err != nil || issued != 1 { + t.Fatalf("expected one durable issued decision, got %d: %v", issued, err) + } +} + +func TestAuthorizeLocalOriginationRejectsPauseAndExpiredCommand(t *testing.T) { + for _, tc := range []struct { + name string + barrier string + advance time.Duration + }{ + {"pause before origination", "paused", time.Minute}, + {"stop before origination", "stopped", time.Minute}, + {"command expires before origination", "", 2 * time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, at) + defer server.Close() + defer st.Close() + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, localExecuteCommandBody(t, "command-control-gate", at.Add(90*time.Second))); err != nil { + t.Fatal(err) + } + outbox, err := st.ClaimOutbox(1) + if err != nil || len(outbox) != 1 { + t.Fatalf("accepted receipt: count=%d err=%v", len(outbox), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(outbox[0].Body, &receipt); err != nil || receipt.Payload.ExecutionID == "" { + t.Fatalf("execution ID: %+v err=%v", receipt, err) + } + if tc.barrier != "" { + if _, err := st.SetLocalTaskAdmissionBarrier(localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey, tc.barrier); err != nil { + t.Fatal(err) + } + } + d.now = func() time.Time { return at.Add(tc.advance) } + if _, err := d.AuthorizeLocalOrigination(receipt.Payload.ExecutionID, "agent-1"); err == nil { + t.Fatal("a paused task or expired command was authorized to dial") + } + var status string + if err := st.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id=?`, receipt.Payload.ExecutionID).Scan(&status); err != nil || status != "finished" { + t.Fatalf("rejected originate was not closed after its durable refusal: status=%q err=%v", status, err) + } + var finalCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM local_v01_call_terminals WHERE execution_id=? AND result_event_id IS NOT NULL`, receipt.Payload.ExecutionID).Scan(&finalCount); err != nil || finalCount != 1 { + t.Fatalf("rejected originate must have one final result: count=%d err=%v", finalCount, err) + } + var refused string + if err := st.DB().QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, receipt.Payload.ExecutionID).Scan(&refused); err != nil || refused != "refused" { + t.Fatalf("rejected originate lacked durable refusal: decision=%q err=%v", refused, err) + } + }) + } +} + +func TestFinalPolicyRefusalIsDurableAndCannotBeRevived(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, at) + defer server.Close() + defer st.Close() + executionID := acceptedLocalExecutionID(t, d, st, at, "command-final-refusal") + current := at.Add(90 * time.Minute) // Shanghai 11:00, task window closed. + d.now = func() time.Time { return current } + if _, err := d.AuthorizeLocalOrigination(executionID, "agent-1"); err == nil { + t.Fatal("out-of-window task authorized to dial") + } + var decision string + if err := st.DB().QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, executionID).Scan(&decision); err != nil || decision != "refused" { + t.Fatalf("closed-window refusal not durable: decision=%q err=%v", decision, err) + } + current = at.Add(time.Minute) // Even a restored clock cannot revive this execution. + if _, err := d.AuthorizeLocalOrigination(executionID, "agent-1"); err == nil { + t.Fatal("previously refused execution was automatically retried") + } +} + +func TestAuthorizeLocalOriginationControlWinsBetweenPolicyAndClaim(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, at) + defer server.Close() + defer st.Close() + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, localExecuteCommandBody(t, "command-claim-race", at.Add(2*time.Hour))); err != nil { + t.Fatal(err) + } + outbox, err := st.ClaimOutbox(1) + if err != nil || len(outbox) != 1 { + t.Fatalf("accepted receipt: count=%d err=%v", len(outbox), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(outbox[0].Body, &receipt); err != nil || receipt.Payload.ExecutionID == "" { + t.Fatalf("execution ID: %+v err=%v", receipt, err) + } + clockReads := 0 + d.now = func() time.Time { + clockReads++ + if clockReads == 2 { // Control crosses between the bound policy check and the atomic claim. + if _, err := st.SetLocalTaskAdmissionBarrier(localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey, "paused"); err != nil { + t.Fatal(err) + } + } + return at.Add(time.Minute) + } + if _, err := d.AuthorizeLocalOrigination(receipt.Payload.ExecutionID, "agent-1"); err == nil { + t.Fatal("control barrier crossed the final origination claim") + } + var refused string + if err := st.DB().QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, receipt.Payload.ExecutionID).Scan(&refused); err != nil || refused != "refused" { + t.Fatalf("control race did not durably refuse: decision=%q err=%v", refused, err) + } +} + +func TestOriginateLocalCallsAgentOnlyWithFreshAuthorization(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + for _, tc := range []struct { + name string + clock []time.Time + wantIssued int + wantRefused int + wantAttempts int + }{ + {"outside bound task window", []time.Time{at.Add(90 * time.Minute)}, 0, 1, 0}, + {"window closes after one-shot claim", []time.Time{at.Add(time.Minute), at.Add(time.Minute), at.Add(90 * time.Minute)}, 1, 0, 0}, + {"inside bound task window", []time.Time{at.Add(time.Minute)}, 1, 0, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + d, st, server := newLocalV01TestDispatcher(t, at) + defer server.Close() + defer st.Close() + executionID := acceptedLocalExecutionID(t, d, st, at, "command-originator") + reads := 0 + d.now = func() time.Time { + idx := reads + reads++ + if idx >= len(tc.clock) { + idx = len(tc.clock) - 1 + } + return tc.clock[idx] + } + attempts := 0 + originator := func(_ context.Context, decision LocalDialAuthorization) error { + attempts++ + if decision.TrunkID != "trunk-mock" { + t.Errorf("unexpected bound trunk: %s", decision.TrunkID) + } + return nil + } + err := d.OriginateLocal(context.Background(), executionID, "agent-1", originator) + if (err == nil) != (tc.wantAttempts == 1) { + t.Fatalf("origination error=%v attempts=%d", err, attempts) + } + if attempts != tc.wantAttempts { + t.Fatalf("Agent called %d times; want %d", attempts, tc.wantAttempts) + } + var issued, refused int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM local_v02_origination_decisions WHERE execution_id=? AND decision='issued'`, executionID).Scan(&issued); err != nil || issued != tc.wantIssued { + t.Fatalf("issued decisions=%d want=%d err=%v", issued, tc.wantIssued, err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM local_v02_origination_decisions WHERE execution_id=? AND decision='refused'`, executionID).Scan(&refused); err != nil || refused != tc.wantRefused { + t.Fatalf("refused decisions=%d want=%d err=%v", refused, tc.wantRefused, err) + } + if issued+refused == 1 { + _ = d.OriginateLocal(context.Background(), executionID, "agent-1", originator) + if attempts != tc.wantAttempts { + t.Fatal("claimed execution retried the Agent originate") + } + } + }) + } +} + +func acceptedLocalExecutionID(t *testing.T, d *Dispatcher, st *store.Store, at time.Time, commandID string) string { + t.Helper() + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, localExecuteCommandBody(t, commandID, at.Add(2*time.Hour))); err != nil { + t.Fatal(err) + } + outbox, err := st.ClaimOutbox(1) + if err != nil || len(outbox) != 1 { + t.Fatalf("accepted receipt: count=%d err=%v", len(outbox), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(outbox[0].Body, &receipt); err != nil || receipt.Payload.ExecutionID == "" { + t.Fatalf("execution ID: %+v err=%v", receipt, err) + } + return receipt.Payload.ExecutionID +} + +func TestAcceptLocalV01CommandRejectsOutsideTaskScheduleInsideOldGlobalWindow(t *testing.T) { + now := time.Date(2026, 9, 21, 4, 0, 0, 0, time.UTC) // 12:00 Shanghai; task ends 11:00. + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + body := localExecuteCommandBody(t, "command-task-window", now.Add(time.Minute)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + records, err := st.ClaimOutbox(1) + if err != nil || len(records) != 1 { + t.Fatalf("claim schedule rejection: count=%d err=%v", len(records), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(records[0].Body, &receipt); err != nil { + t.Fatal(err) + } + if receipt.Payload.Status != "rejected" || receipt.Payload.ReasonCode != "outside_call_window" || receipt.Payload.ExecutionID != "" { + t.Fatalf("task schedule outside window did not reject: %+v", receipt) + } +} + +func TestAcceptLocalV01CommandRejectsOutsideCurrentCallWindow(t *testing.T) { + now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC) // 20:00 Asia/Shanghai, right-open boundary. + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + + body := localExecuteCommandBody(t, "command-window", now.Add(time.Minute)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + records, err := st.ClaimOutbox(1) + if err != nil || len(records) != 1 { + t.Fatalf("claim window rejection: count=%d err=%v", len(records), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(records[0].Body, &receipt); err != nil { + t.Fatal(err) + } + if receipt.Payload.Status != "rejected" || receipt.Payload.ReasonCode != "outside_call_window" || receipt.Payload.ExecutionID != "" { + t.Fatalf("unexpected out-of-window receipt: %+v", receipt) + } +} + +func TestAcceptLocalV01CommandRejectsUnlistedCallee(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + + var command localExecuteCommand + if err := json.Unmarshal(localExecuteCommandBody(t, "command-unlisted", now.Add(time.Minute)), &command); err != nil { + t.Fatal(err) + } + command.Payload.Callee = "15000000000" + body, err := json.Marshal(command) + if err != nil { + t.Fatal(err) + } + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, body); err != nil { + t.Fatal(err) + } + records, err := st.ClaimOutbox(1) + if err != nil || len(records) != 1 { + t.Fatalf("claim rejected receipt: count=%d err=%v", len(records), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(records[0].Body, &receipt); err != nil { + t.Fatal(err) + } + if receipt.Payload.Status != "rejected" || receipt.Payload.ReasonCode != "callee_not_allowed" || receipt.Payload.ExecutionID != "" { + t.Fatalf("unexpected unlisted-callee receipt: %+v", receipt) + } +} + +func localExecuteCommandBody(t *testing.T, commandID string, expiresAt time.Time) []byte { + return localExecuteTaskCommandBody(t, commandID, localTestTaskID, expiresAt) +} + +func localExecuteTaskCommandBody(t *testing.T, commandID, taskID string, expiresAt time.Time) []byte { + t.Helper() + body, err := json.Marshal(struct { + SchemaVersion string `json:"schema_version"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + IssuedAt string `json:"issued_at"` + NotAfter string `json:"not_after"` + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + Payload struct { + TaskID string `json:"task_id"` + Callee string `json:"callee"` + } `json:"payload"` + }{ + SchemaVersion: "command-next.v0.1-proposal", DispatcherID: localTestDispatcherID, + TenantID: localTestTenantID, TenantKey: localTestTenantKey, + TraceID: "trace-v2", + IssuedAt: expiresAt.Add(-time.Hour).UTC().Format(time.RFC3339Nano), NotAfter: expiresAt.UTC().Format(time.RFC3339Nano), + CommandID: commandID, CommandType: "call.execute", + Payload: struct { + TaskID string `json:"task_id"` + Callee string `json:"callee"` + }{TaskID: taskID, Callee: "15003164745"}, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func newLocalV01TestDispatcher(t *testing.T, now time.Time) (*Dispatcher, *store.Store, *httptest.Server) { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.1.json")))) + mux.HandleFunc("/internal/v1/dispatcher/tasks", localConfigResponse(string(localConfigFixture(t, "task-discovery-snapshot-v0.2.json")))) + mux.HandleFunc("/internal/v1/dispatcher/task/"+localTestTaskID, localConfigResponse(string(localConfigFixture(t, "config-read-task-v0.1.json")))) + mux.HandleFunc("/internal/v1/dispatcher/tenant/"+localTestTenantID+"/quota", localConfigResponse(string(localConfigFixture(t, "config-read-tenant-quota-v0.1.json")))) + server := httptest.NewServer(mux) + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + server.Close() + t.Fatal(err) + } + d, err := NewV3(localTestDispatcherID, st, nil, func() time.Time { return now }) + if err != nil { + st.Close() + server.Close() + t.Fatal(err) + } + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + st.Close() + server.Close() + t.Fatal(err) + } + if err := d.LoadProjectConfig(context.Background(), client, localTestSIPConfigVerifier(), localTestTaskID, localTestTenantID); err != nil { + st.Close() + server.Close() + t.Fatal(err) + } + return d, st, server +} + +func newLocalV01MultiTaskConfigServer(t *testing.T, taskIDs []string, quotaRevision, quotaLimit int64) *httptest.Server { + t.Helper() + if len(taskIDs) == 0 { + t.Fatal("at least one task is required") + } + discovery := map[string]any{} + if err := json.Unmarshal(localConfigFixture(t, "task-discovery-snapshot-v0.2.json"), &discovery); err != nil { + t.Fatal(err) + } + discovery["cursor"] = fmt.Sprintf("cursor-multi-%d", quotaRevision) + discoveredTasks := make([]map[string]any, 0, len(taskIDs)) + taskBodies := make(map[string]string, len(taskIDs)) + for _, taskID := range taskIDs { + discoveredTasks = append(discoveredTasks, map[string]any{ + "task_id": taskID, "tenant_id": localTestTenantID, "tenant_key": localTestTenantKey, + "status": "running", "task_revision": 2, + }) + taskConfig := map[string]any{} + if err := json.Unmarshal(localConfigFixture(t, "config-read-task-v0.1.json"), &taskConfig); err != nil { + t.Fatal(err) + } + taskConfig["task_id"] = taskID + encoded, err := json.Marshal(taskConfig) + if err != nil { + t.Fatal(err) + } + taskBodies[taskID] = string(encoded) + } + discovery["tasks"] = discoveredTasks + discoveryBody, err := json.Marshal(discovery) + if err != nil { + t.Fatal(err) + } + quota := map[string]any{} + if err := json.Unmarshal(localConfigFixture(t, "config-read-tenant-quota-v0.1.json"), "a); err != nil { + t.Fatal(err) + } + quota["quota_revision"] = quotaRevision + quota["max_concurrent_calls"] = quotaLimit + quotaBody, err := json.Marshal(quota) + if err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.1.json")))) + mux.HandleFunc("/internal/v1/dispatcher/tasks", localConfigResponse(string(discoveryBody))) + mux.HandleFunc("/internal/v1/dispatcher/task/", func(w http.ResponseWriter, r *http.Request) { + taskID := r.URL.Path[len("/internal/v1/dispatcher/task/"):] + body, ok := taskBodies[taskID] + if !ok { + http.NotFound(w, r) + return + } + localConfigResponse(body)(w, r) + }) + mux.HandleFunc("/internal/v1/dispatcher/tenant/"+localTestTenantID+"/quota", localConfigResponse(string(quotaBody))) + return httptest.NewServer(mux) +} + +func localTestSIPConfigVerifier() SIPConfigVerifier { + return SIPConfigVerifierFunc(func(_ context.Context, snapshot configread.Snapshot) error { + status := &agentv1.AgentStatus{ + AgentId: "agent-mock", CellId: snapshot.SIPCellID, BootId: "boot-mock", + AppliedConfigs: []*agentv1.AppliedConfig{{ + Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPArtifactRevision), + ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, ObservedAtUnixMs: 1, + }}, + } + return verifyAppliedSIPConfigStatus(status, "agent-mock", snapshot) + }) +} + +func localConfigFixture(t *testing.T, name string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join("..", "..", "docs", "contracts", "examples", name)) + if err != nil { + t.Fatal(err) + } + if name == "config-read-sip-v0.1.json" { + var response map[string]any + if err := json.Unmarshal(body, &response); err != nil { + t.Fatal(err) + } + // Only the isolated Mock is granted explicit capacity; the documented null + // remains unknown and must not authorize a real supplier trunk. + response["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 + body, err = json.Marshal(response) + if err != nil { + t.Fatal(err) + } + } + if name == "config-read-task-v0.1.json" || name == "config-read-tenant-quota-v0.1.json" { + var response map[string]any + if err := json.Unmarshal(body, &response); err != nil { + t.Fatal(err) + } + if name == "config-read-task-v0.1.json" { + agent, ok := response["agent"].(map[string]any) + if !ok { + t.Fatal("task fixture has no agent object") + } + agent["authorization_expires_at"] = "2030-01-01T00:00:00Z" + } else { + response["valid_until"] = "2030-01-01T00:00:00Z" + } + body, err = json.Marshal(response) + if err != nil { + t.Fatal(err) + } + } + return body +} + +func localConfigResponse(body string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, body) + } +} + +func TestEnqueueLocalCallResultRequiresMatchingAcceptedExecution(t *testing.T) { + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + + commandBody := localExecuteCommandBody(t, "command-a", now.Add(time.Minute)) + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + if err := d.AcceptLocalV01Command(context.Background(), route, commandBody); err != nil { + t.Fatal(err) + } + receipts, err := st.ClaimOutbox(1) + if err != nil || len(receipts) != 1 { + t.Fatalf("claim command receipt: count=%d err=%v", len(receipts), err) + } + var receipt localTestCommandResult + if err := json.Unmarshal(receipts[0].Body, &receipt); err != nil { + t.Fatal(err) + } + if err := st.MarkOutboxPublished(receipts[0].ID); err != nil { + t.Fatal(err) + } + + resultBody := localCallResultFixture(t, receipt.Payload.ExecutionID) + if err := d.EnqueueCallResult(context.Background(), resultBody); err == nil { + t.Fatal("unconfirmed uploaded result was published without an issued Agent call") + } + if err := st.ClaimLocalOrigination(localTestDispatcherID, receipt.Payload.ExecutionID, "agent-1", localTestExecutionBinding(t, st, receipt.Payload.ExecutionID), now); err != nil { + t.Fatal(err) + } + var uploaded struct { + Payload struct { + Recording struct { + RecordingID string `json:"recording_id"` + UploadID string `json:"upload_id"` + Bucket string `json:"bucket"` + ObjectKey string `json:"object_key"` + Format string `json:"format"` + ChecksumSHA256 string `json:"checksum_sha256"` + Channels int `json:"channels"` + SampleRateHz int `json:"sample_rate_hz"` + DurationMs int64 `json:"duration_ms"` + SizeBytes int64 `json:"size_bytes"` + } `json:"recording"` + } `json:"payload"` + } + if err := json.Unmarshal(resultBody, &uploaded); err != nil { + t.Fatal(err) + } + recording := uploaded.Payload.Recording + if err := st.RecordLocalCallTerminal(store.LocalCallTerminal{ + ExecutionID: receipt.Payload.ExecutionID, CallID: "call-a", Source: "mock_agent", + StartedAt: now, EndedAt: now.Add(10 * time.Minute), Outcome: "answered", RecordingExpected: true, + Recording: &store.LocalRecordingManifest{ + RecordingID: recording.RecordingID, UploadID: recording.UploadID, Format: recording.Format, + Channels: recording.Channels, SampleRateHz: recording.SampleRateHz, DurationMs: recording.DurationMs, + }, + }, nil); err != nil { + t.Fatal(err) + } + if err := d.EnqueueCallResult(context.Background(), resultBody); err == nil { + t.Fatal("uploaded result was published without a persisted upload fact") + } + if err := st.RecordLocalRecordingOutcome(receipt.Payload.ExecutionID, store.LocalRecordingOutcome{ + Status: "uploaded", Bucket: recording.Bucket, ObjectKey: recording.ObjectKey, + SizeBytes: recording.SizeBytes, ChecksumSHA256: recording.ChecksumSHA256, + ObservedAt: now.Add(10*time.Minute + time.Second), + }); err != nil { + t.Fatal(err) + } + if err := d.EnqueueCallResult(context.Background(), resultBody); err != nil { + t.Fatal(err) + } + results, err := st.ClaimOutbox(1) + if err != nil || len(results) != 1 { + t.Fatalf("claim final result: count=%d err=%v", len(results), err) + } + if results[0].EventID != "call-result-001" || string(results[0].Body) != string(resultBody) { + t.Fatalf("unexpected final result outbox record: %+v", results[0]) + } + if err := st.MarkOutboxPublished(results[0].ID); err != nil { + t.Fatal(err) + } + if err := d.EnqueueCallResult(context.Background(), resultBody); err != nil { + t.Fatalf("repeat identical final result: %v", err) + } + if records, err := st.ClaimOutbox(1); err != nil || len(records) != 0 { + t.Fatalf("identical final result created another outbox row: count=%d err=%v", len(records), err) + } + + wrongExecution := localCallResultFixture(t, "execution-wrong") + if err := d.EnqueueCallResult(context.Background(), wrongExecution); err == nil { + t.Fatal("final result with unrelated execution ID was accepted") + } +} + +func localCallResultFixture(t *testing.T, executionID string) []byte { + t.Helper() + path := filepath.Join("..", "..", "docs", "contracts", "examples", "call-result-uploaded-v0.1.json") + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var event map[string]any + if err := json.Unmarshal(body, &event); err != nil { + t.Fatal(err) + } + event["dispatcher_id"] = localTestDispatcherID + event["tenant_id"] = localTestTenantID + event["tenant_key"] = localTestTenantKey + event["trace_id"] = "trace-v2" + event["occurred_at"] = "2026-09-21T02:10:15Z" + event["aggregate_id"] = "call-a" + payload, ok := event["payload"].(map[string]any) + if !ok { + t.Fatal("call result fixture has no payload object") + } + payload["source_command_id"] = "command-a" + payload["execution_id"] = executionID + payload["task_id"] = localTestTaskID + payload["task_revision"] = 2 + payload["started_at"] = "2026-09-21T02:00:00Z" + payload["ended_at"] = "2026-09-21T02:10:00Z" + return mustJSON(t, event) +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return body +} diff --git a/internal/dispatcher/mq_integration_test.go b/internal/dispatcher/mq_integration_test.go deleted file mode 100644 index 4dea88d..0000000 --- a/internal/dispatcher/mq_integration_test.go +++ /dev/null @@ -1,137 +0,0 @@ -//go:build integration - -package dispatcher - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "git.ipao.vip/rogee/go-sip/internal/testfixture" - amqp "github.com/rabbitmq/amqp091-go" -) - -func TestLocalDispatcherConsumesCommandIntoSQLiteAndPublishesOutbox(t *testing.T) { - url := os.Getenv("RABBITMQ_URL") - if url == "" { - t.Skip("RABBITMQ_URL is not configured") - } - broker, err := mq.OpenWithPrefetch(url, testfixture.DispatcherID, 1) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := broker.Close(); err != nil { - t.Error(err) - } - }) - st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := st.Close(); err != nil { - t.Error(err) - } - }) - if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { - t.Fatal(err) - } - d, err := New(st, broker, time.Now) - if err != nil { - t.Fatal(err) - } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - const tenantKey = "tenant-demo-key" - route, err := tenant.NewDispatcherRoute(testfixture.DispatcherID, tenantKey) - if err != nil { - t.Fatal(err) - } - if _, err := broker.DeclareTenantQueue(tenantKey); err != nil { - t.Fatal(err) - } - routingKey := route.InboundKey - ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) - defer cancel() - consumeDone := make(chan error, 1) - go func() { consumeDone <- d.ConsumeTenant(ctx, broker, tenantKey) }() - connection, err := amqp.Dial(url) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - publishChannel, err := connection.Channel() - if err != nil { - t.Fatal(err) - } - defer publishChannel.Close() - if err := publishChannel.Confirm(false); err != nil { - t.Fatal(err) - } - returned := publishChannel.NotifyReturn(make(chan amqp.Return, 1)) - confirmation, err := publishChannel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, routingKey, true, false, amqp.Publishing{ - ContentType: "application/json", - DeliveryMode: amqp.Persistent, - Body: raw, - }) - if err != nil { - t.Fatal(err) - } - if confirmation == nil { - t.Fatal("command publication confirmation unavailable") - } - acked, err := confirmation.WaitContext(ctx) - if err != nil || !acked { - t.Fatalf("command publication was not confirmed: %v", err) - } - select { - case result := <-returned: - t.Fatalf("command publication returned: code=%d", result.ReplyCode) - default: - } - deadline := time.Now().Add(10 * time.Second) - for { - var taskCount, outboxCount int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount); err != nil { - t.Fatal(err) - } - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil { - t.Fatal(err) - } - if taskCount == 1 && outboxCount >= 1 { - break - } - if time.Now().After(deadline) { - t.Fatalf("command was not persisted: tasks=%d outbox=%d", taskCount, outboxCount) - } - time.Sleep(50 * time.Millisecond) - } - published, err := d.FlushOutbox(ctx, 1) - if err != nil || published != 1 { - t.Fatalf("published=%d err=%v", published, err) - } - cancel() - select { - case <-consumeDone: - case <-time.After(3 * time.Second): - t.Fatal("tenant consumer did not stop") - } - var taskStatus, outboxStatus string - if err := st.DB().QueryRow(`SELECT status FROM tasks LIMIT 1`).Scan(&taskStatus); err != nil { - t.Fatal(err) - } - if err := st.DB().QueryRow(`SELECT status FROM outbox LIMIT 1`).Scan(&outboxStatus); err != nil { - t.Fatal(err) - } - if taskStatus != "accepted" || outboxStatus != "published" { - t.Fatalf("unexpected persisted statuses: task=%q outbox=%q", taskStatus, outboxStatus) - } -} diff --git a/internal/dispatcher/outbox_payload_limit_test.go b/internal/dispatcher/outbox_payload_limit_test.go new file mode 100644 index 0000000..aa89171 --- /dev/null +++ b/internal/dispatcher/outbox_payload_limit_test.go @@ -0,0 +1,66 @@ +package dispatcher + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestFlushOutboxDurablyBlocksOversizedPayloadWithoutPoisoningLaterMessages(t *testing.T) { + path := filepath.Join(t.TempDir(), "dispatcher.db") + st, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + big := bytes.Repeat([]byte{'x'}, store.MaxOutboxPayloadBytes+1) + small := []byte(`{"message":"after blocked"}`) + for _, message := range []struct { + id string + body []byte + }{{"oversized", big}, {"following", small}} { + if _, err := st.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) + VALUES(?,'tenant-a','local-mock','outbound',?,'pending','2026-09-21T00:00:00Z')`, message.id, message.body); err != nil { + t.Fatal(err) + } + } + publisher := &fakePublisher{} + d, err := New(st, publisher, nil) + if err != nil { + t.Fatal(err) + } + count, err := d.FlushOutbox(context.Background(), 2) + if err != nil || count != 1 || len(publisher.bodies) != 1 || !bytes.Equal(publisher.bodies[0], small) { + t.Fatalf("oversized message blocked following publication: count=%d sent=%d err=%v", count, len(publisher.bodies), err) + } + var blockedID int64 + var status, reason string + var persisted []byte + if err := st.DB().QueryRow(`SELECT id,status,last_error,body FROM outbox WHERE event_id='oversized'`).Scan(&blockedID, &status, &reason, &persisted); err != nil { + t.Fatal(err) + } + if reason != "blocked_payload_too_large" || !bytes.Equal(persisted, big) { + t.Fatalf("oversized payload was not durably blocked in full: status=%q reason=%q bytes=%d", status, reason, len(persisted)) + } + if err := st.MarkOutboxRetry(blockedID, errors.New("transient broker fault")); !errors.Is(err, store.ErrCommandConflict) { + t.Fatalf("transient retry silently unblocked oversized payload: %v", err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + reopened, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if err := reopened.RecoverOutbox(); err != nil { + t.Fatal(err) + } + claimed, err := reopened.ClaimOutbox(2) + if err != nil || len(claimed) != 0 { + t.Fatalf("blocked payload retried after restart: count=%d err=%v", len(claimed), err) + } +} diff --git a/internal/dispatcher/query_mq_integration_test.go b/internal/dispatcher/query_mq_integration_test.go deleted file mode 100644 index 6d3df92..0000000 --- a/internal/dispatcher/query_mq_integration_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package dispatcher - -import ( - "context" - "encoding/json" - "net/url" - "os" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/store" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" - amqp "github.com/rabbitmq/amqp091-go" -) - -// A dedicated local vhost prevents other package tests from consuming these -// messages from the contract's fixed SaaS queue. -func TestLocalMQRequestRoundTrip(t *testing.T) { - address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") - if address == "" { - t.Skip("dedicated local RabbitMQ vhost not configured") - } - u, err := url.Parse(address) - if err != nil || (u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" && u.Hostname() != "localhost") { - t.Fatal("test requires loopback RabbitMQ") - } - for _, fixture := range []string{"command-query", "call-query", "call-replay", "command-replay", "call-execute"} { - t.Run(fixture, func(t *testing.T) { runLocalMQRequest(t, address, fixture) }) - } -} - -func runLocalMQRequest(t *testing.T, address, fixture string) { - t.Helper() - id, key := uuid.NewString(), "request."+uuid.NewString() - broker, err := mq.Open(address, id) - if err != nil { - t.Fatal(err) - } - defer broker.Close() - s, err := store.Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(id); err != nil { - t.Fatal(err) - } - d, err := New(s, broker, nil) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(id, key) - if err != nil { - t.Fatal(err) - } - if _, err := broker.DeclareTenantQueue(key); err != nil { - t.Fatal(err) - } - connection, err := amqp.Dial(address) - if err != nil { - t.Fatal(err) - } - defer connection.Close() - channel, err := connection.Channel() - if err != nil { - t.Fatal(err) - } - defer channel.Close() - defer channel.QueueDelete(route.InboxQueue, false, false, false) - defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) - defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) - if err := channel.Confirm(false); err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/" + fixture + ".json") - if err != nil { - t.Fatal(err) - } - var request map[string]any - if err := json.Unmarshal(raw, &request); err != nil { - t.Fatal(err) - } - requestID := uuid.NewString() - request["dispatcher_id"], request["tenant_key"] = id, key - _, isCommand := request["command_type"] - identityField := "message_id" - if isCommand { - identityField = "command_id" - } - request[identityField] = requestID - request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) - request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) - raw, err = json.Marshal(request) - if err != nil { - t.Fatal(err) - } - isExecute := fixture == "call-execute" - isReplay := isCommand && !isExecute - var originalFact []byte - if isReplay { - originalFact = seedReplayMQFact(t, s, id, key, route.OutboundKey) - } - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - finished := make(chan error, 1) - go func() { finished <- d.ConsumeTenant(ctx, broker, key) }() - defer func() { cancel(); <-finished }() - originalResponseID := "" - factDeliveries := 0 - for attempt := 0; attempt < 2; attempt++ { - confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: raw}) - if err != nil { - t.Fatal(err) - } - if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { - t.Fatalf("input not queued: %v", err) - } - ticker := time.NewTicker(10 * time.Millisecond) - delivered := false - for !delivered { - select { - case <-ctx.Done(): - ticker.Stop() - t.Fatal("MQ response timed out") - case <-ticker.C: - n, err := d.FlushOutbox(ctx, 1) - if err != nil { - ticker.Stop() - t.Fatal(err) - } - if n == 0 { - continue - } - message, ok, err := channel.Get(mq.SaaSQueue, true) - if err != nil || !ok { - ticker.Stop() - t.Fatalf("confirmed output absent: %v", err) - } - if err := contract.ValidateMQMessage(message.Body); err != nil { - ticker.Stop() - t.Fatal(err) - } - var response struct { - DispatcherID string `json:"dispatcher_id"` - TenantKey string `json:"tenant_key"` - MessageID string `json:"message_id"` - CorrelationID string `json:"correlation_id"` - EventID string `json:"event_id"` - EventType string `json:"event_type"` - Payload struct { - CommandID string `json:"command_id"` - Status string `json:"status"` - } `json:"payload"` - } - if err := json.Unmarshal(message.Body, &response); err != nil { - ticker.Stop() - t.Fatal(err) - } - if response.DispatcherID != id || response.TenantKey != key || message.DeliveryMode != amqp.Persistent || message.RoutingKey != route.OutboundKey { - ticker.Stop() - t.Fatal("output scope or durability mismatch") - } - if isReplay && response.EventType != "command.result" { - if string(message.Body) != string(originalFact) { - ticker.Stop() - t.Fatal("replay rewrote the original business fact") - } - factDeliveries++ - continue - } - responseID := response.MessageID - if isCommand { - responseID = response.EventID - wantStatus := "applied" - if isExecute { - wantStatus = "accepted" - } - if response.Payload.CommandID != requestID || response.Payload.Status != wantStatus { - ticker.Stop() - t.Fatal("incorrect replay receipt") - } - } else if response.CorrelationID != requestID { - ticker.Stop() - t.Fatal("incorrect query correlation") - } - if attempt == 0 { - originalResponseID = responseID - } else if responseID != originalResponseID { - ticker.Stop() - t.Fatal("duplicate created another response identity") - } - delivered = true - } - } - ticker.Stop() - } - if isReplay && factDeliveries != 1 { - t.Fatalf("original fact delivered %d times; duplicate re-executed replay", factDeliveries) - } - var tasks int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&tasks); err != nil { - t.Fatal(err) - } - wantTasks := 0 - if isExecute { - wantTasks = 1 - } - if tasks != wantTasks { - t.Fatalf("created %d tasks, want %d", tasks, wantTasks) - } -} - -func seedReplayMQFact(t *testing.T, s *store.Store, id, key, routingKey string) []byte { - t.Helper() - raw, err := contracts.Files.ReadFile("upstream/v1/examples/call-execute.json") - if err != nil { - t.Fatal(err) - } - var command map[string]any - if err := json.Unmarshal(raw, &command); err != nil { - t.Fatal(err) - } - command["dispatcher_id"], command["tenant_key"], command["command_id"] = id, key, "command-a" - command["payload"].(map[string]any)["execution_id"] = "execution-1" - raw, err = json.Marshal(command) - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at) VALUES('tenant-a','command-a',?,'call.execute','fixture',?,'persisted','2026-09-21T00:00:00Z')`, key, raw); err != nil { - t.Fatal(err) - } - raw, err = contracts.Files.ReadFile("upstream/v1/examples/event-call-status.json") - if err != nil { - t.Fatal(err) - } - var event map[string]any - if err := json.Unmarshal(raw, &event); err != nil { - t.Fatal(err) - } - event["dispatcher_id"], event["tenant_key"] = id, key - raw, err = json.Marshal(event) - if err != nil { - t.Fatal(err) - } - if err := contract.ValidateMQMessage(raw); err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,'agent-call.saas.v2',?,?,'published',?)`, event["event_id"], key, routingKey, raw, event["occurred_at"]); err != nil { - t.Fatal(err) - } - return raw -} diff --git a/internal/dispatcher/sip_applied_config_test.go b/internal/dispatcher/sip_applied_config_test.go new file mode 100644 index 0000000..00aade8 --- /dev/null +++ b/internal/dispatcher/sip_applied_config_test.go @@ -0,0 +1,106 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "testing" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +type staticAgentStatusProbe struct { + status *agentv1.AgentStatus + err error + gotAgentID string + gotCellID string +} + +func (p *staticAgentStatusProbe) Probe(_ context.Context, agentID, cellID string) (*agentv1.AgentStatus, error) { + p.gotAgentID, p.gotCellID = agentID, cellID + return p.status, p.err +} + +func TestAgentSIPConfigVerifierRequiresExactAppliedArtifact(t *testing.T) { + snapshot := configread.Snapshot{ + SIPCellID: "cell-a", SIPArtifactRevision: 7, + SIPArtifactConfigSHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + } + newStatus := func() *agentv1.AgentStatus { + return &agentv1.AgentStatus{ + AgentId: "agent-a", CellId: snapshot.SIPCellID, BootId: "boot-a", + AppliedConfigs: []*agentv1.AppliedConfig{{ + Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPArtifactRevision), + ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, + ObservedAtUnixMs: 1000, + }}, + } + } + probe := &staticAgentStatusProbe{status: newStatus()} + verifier := &AgentSIPConfigVerifier{Probe: probe, AgentID: "agent-a", CellID: "cell-a"} + if err := verifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err != nil { + t.Fatalf("matching applied artifact rejected: %v", err) + } + if probe.gotAgentID != "agent-a" || probe.gotCellID != "cell-a" { + t.Fatalf("probe target agent=%q cell=%q", probe.gotAgentID, probe.gotCellID) + } + wrongCellProbe := &staticAgentStatusProbe{status: newStatus()} + wrongCellVerifier := &AgentSIPConfigVerifier{Probe: wrongCellProbe, AgentID: "agent-a", CellID: "cell-b"} + if err := wrongCellVerifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err == nil || wrongCellProbe.gotCellID != "" { + t.Fatal("SIP config for a different configured cell was probed or accepted") + } + var nilVerifier *AgentSIPConfigVerifier + if err := nilVerifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err == nil { + t.Fatal("nil Agent verifier was accepted") + } + + cases := []struct { + name string + status *agentv1.AgentStatus + err error + }{ + {name: "missing status"}, + {name: "wrong agent", status: newStatus()}, + {name: "wrong cell", status: newStatus()}, + {name: "missing boot ID", status: newStatus()}, + {name: "missing applied config", status: &agentv1.AgentStatus{AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a"}}, + {name: "multiple SIP applied configs", status: newStatus()}, + {name: "wrong kind", status: newStatus()}, + {name: "not applied", status: newStatus()}, + {name: "wrong revision", status: newStatus()}, + {name: "wrong digest", status: newStatus()}, + {name: "missing observation time", status: newStatus()}, + {name: "probe failure", status: newStatus(), err: errors.New("status unavailable")}, + } + for i := range cases { + c := &cases[i] + switch c.name { + case "wrong agent": + c.status.AgentId = "agent-b" + case "wrong cell": + c.status.CellId = "cell-b" + case "missing boot ID": + c.status.BootId = "" + case "multiple SIP applied configs": + c.status.AppliedConfigs = append(c.status.AppliedConfigs, &agentv1.AppliedConfig{Kind: AppliedConfigKindSIP, Revision: "7", ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, ObservedAtUnixMs: 2000}) + case "wrong kind": + c.status.AppliedConfigs[0].Kind = "media" + case "not applied": + c.status.AppliedConfigs[0].State = "pending" + case "wrong revision": + c.status.AppliedConfigs[0].Revision = "6" + case "wrong digest": + c.status.AppliedConfigs[0].ConfigSha256 = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + case "missing observation time": + c.status.AppliedConfigs[0].ObservedAtUnixMs = 0 + } + t.Run(c.name, func(t *testing.T) { + probe := &staticAgentStatusProbe{status: c.status, err: c.err} + verifier := &AgentSIPConfigVerifier{Probe: probe, AgentID: "agent-a", CellID: "cell-a"} + if err := verifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err == nil { + t.Fatal("invalid or missing applied-config status was accepted") + } + }) + } +} diff --git a/internal/dispatcher/task_control_v3.go b/internal/dispatcher/task_control_v3.go new file mode 100644 index 0000000..a71fc6b --- /dev/null +++ b/internal/dispatcher/task_control_v3.go @@ -0,0 +1,349 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" +) + +type taskControlStatusReader interface { + ReadTaskStatus(context.Context, string, string, string) (configread.TaskStatus, error) +} + +type taskQueueController interface { + StartTask(context.Context, store.LocalTaskAssignment) error + StopTask(context.Context, store.LocalTaskAssignment) error + DrainTask(context.Context, store.LocalTaskAssignment) error + ApplyActiveCallPolicy(context.Context, store.LocalTaskAssignment, string, string) error +} + +type localTaskControlProcessor struct { + dispatcher *Dispatcher + statusReader taskControlStatusReader + queueControl taskQueueController + controlMu sync.Mutex +} + +type localTaskControlCommand struct { + SchemaVersion string `json:"schema_version"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + IssuedAt string `json:"issued_at"` + NotAfter string `json:"not_after"` + CommandType string `json:"command_type"` + Payload struct { + TaskID string `json:"task_id"` + Action string `json:"action"` + ActiveCallPolicy string `json:"active_call_policy"` + Reason string `json:"reason"` + } `json:"payload"` +} + +type localTaskControlResultEvent struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + OccurredAt string `json:"occurred_at"` + AggregateType string `json:"aggregate_type"` + AggregateID string `json:"aggregate_id"` + AggregateVersion int64 `json:"aggregate_version"` + Payload localTaskControlResultPayload `json:"payload"` +} + +type localTaskControlResultPayload struct { + CommandType string `json:"command_type"` + TaskID string `json:"task_id"` + Action string `json:"action"` + Status string `json:"status"` + ReasonCode string `json:"reason_code"` + TaskState string `json:"task_state"` +} + +func newLocalTaskControlProcessor(d *Dispatcher, reader taskControlStatusReader, queues taskQueueController) *localTaskControlProcessor { + return &localTaskControlProcessor{dispatcher: d, statusReader: reader, queueControl: queues} +} + +// Handle processes one SaaS-owned control delivery. State barriers and receipts +// are durable before the consumer ACKs the message. +func (p *localTaskControlProcessor) Handle(ctx context.Context, routingKey string, body []byte) error { + if p == nil || p.dispatcher == nil || p.statusReader == nil || p.queueControl == nil { + return errors.New("task control processor dependencies are required") + } + if err := contract.ValidateLocalCommandNext(body); err != nil { + return mq.Permanent(fmt.Errorf("invalid local task-control message: %w", err)) + } + var command localTaskControlCommand + if err := json.Unmarshal(body, &command); err != nil { + return mq.Permanent(fmt.Errorf("decode local task-control message: %w", err)) + } + if command.SchemaVersion != localCommandSchemaVersion || command.CommandType != "task.control" { + return mq.Permanent(errors.New("message is not a local task-control command")) + } + if command.DispatcherID != p.dispatcher.dispatcherID || tenant.ValidateDispatcherID(command.DispatcherID) != nil { + return mq.Permanent(errors.New("task-control Dispatcher identity mismatch")) + } + if routingKey != "d."+p.dispatcher.dispatcherID+".control.in" { + return mq.Permanent(fmt.Errorf("task-control routing key mismatch: got %q", routingKey)) + } + if !validLocalID(command.TraceID) || command.TenantID == "" || command.TenantKey == "" || !localTaskIDPattern.MatchString(command.Payload.TaskID) { + return mq.Permanent(errors.New("task-control identity fields are invalid")) + } + issuedAt, err := time.Parse(time.RFC3339Nano, command.IssuedAt) + if err != nil { + return mq.Permanent(fmt.Errorf("invalid task-control issued_at: %w", err)) + } + notAfter, err := time.Parse(time.RFC3339Nano, command.NotAfter) + if err != nil || !notAfter.After(issuedAt) { + return mq.Permanent(errors.New("task-control not_after must be later than issued_at")) + } + + // The single control consumer already serializes deliveries; this mutex also + // protects direct/test callers and preserves task-transition ordering. + p.controlMu.Lock() + defer p.controlMu.Unlock() + + assignments, err := p.dispatcher.store.LocalTaskAssignments(p.dispatcher.dispatcherID) + if err != nil { + return err + } + var assignment store.LocalTaskAssignment + found := false + for _, candidate := range assignments { + if candidate.TaskID == command.Payload.TaskID { + assignment, found = candidate, true + break + } + } + if !found { + return p.persistUnknownTaskResult(command, "task_unavailable", "stopped") + } + if assignment.TenantID != command.TenantID || assignment.TenantKey != command.TenantKey { + return mq.Permanent(errors.New("task-control tenant binding mismatch")) + } + + now := p.dispatcher.now().UTC() + if !now.Before(notAfter) { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "command_expired", publicTaskAdmissionState(assignment.AdmissionState)) + } + if now.Before(issuedAt) { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "command_not_yet_valid", publicTaskAdmissionState(assignment.AdmissionState)) + } + + switch command.Payload.Action { + case "pause": + return p.pause(ctx, command, assignment) + case "resume": + return p.resume(ctx, command, assignment) + case "stop": + return p.stop(ctx, command, assignment) + default: + return mq.Permanent(fmt.Errorf("unsupported task-control action %q", command.Payload.Action)) + } +} + +func (p *localTaskControlProcessor) pause(ctx context.Context, command localTaskControlCommand, assignment store.LocalTaskAssignment) error { + assignment, err := p.dispatcher.store.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "paused") + if err != nil { + return err + } + if err := p.queueControl.StopTask(ctx, assignment); err != nil { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "queue_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + status, statusErr := p.readAuthoritativeStatus(ctx, command) + if statusErr != nil { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "task_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + if assignment.AdmissionState == "stopped" || assignment.AdmissionState == "finished" || assignment.AdmissionState == "removed" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "task_stopped", publicTaskAdmissionState(assignment.AdmissionState)) + } + if status.Status != "paused" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "state_mismatch", publicTaskAdmissionState(assignment.AdmissionState)) + } + if err := p.queueControl.ApplyActiveCallPolicy(ctx, assignment, "pause", command.Payload.ActiveCallPolicy); err != nil { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "active_call_policy_failed", publicTaskAdmissionState(assignment.AdmissionState)) + } + return p.persistControlResult(ctx, command, assignment, &status, "applied", "applied", "paused") +} + +func (p *localTaskControlProcessor) resume(ctx context.Context, command localTaskControlCommand, assignment store.LocalTaskAssignment) error { + status, statusErr := p.readAuthoritativeStatus(ctx, command) + if statusErr != nil { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "task_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + if assignment.Removed || assignment.AdmissionState == "stopped" || assignment.AdmissionState == "finished" || assignment.AdmissionState == "removed" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "task_stopped", publicTaskAdmissionState(assignment.AdmissionState)) + } + if status.Status != "running" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "state_mismatch", publicTaskAdmissionState(assignment.AdmissionState)) + } + assignment, err := p.dispatcher.store.ResumeLocalTaskAdmission(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, status.Status, status.TaskRevision) + if err != nil { + if errors.Is(err, store.ErrLocalTaskStopped) { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "task_stopped", "stopped") + } + if errors.Is(err, store.ErrConfigRevisionConflict) || errors.Is(err, store.ErrConfigRevisionRollback) { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "state_mismatch", publicTaskAdmissionState(assignment.AdmissionState)) + } + return err + } + if err := p.queueControl.StartTask(ctx, assignment); err != nil { + assignment, barrierErr := p.dispatcher.store.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "paused") + if barrierErr != nil { + return errors.Join(err, barrierErr) + } + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "queue_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + return p.persistControlResult(ctx, command, assignment, &status, "applied", "applied", "running") +} + +func (p *localTaskControlProcessor) stop(ctx context.Context, command localTaskControlCommand, assignment store.LocalTaskAssignment) error { + assignment, err := p.dispatcher.store.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "stopped") + if err != nil { + return err + } + if err := p.queueControl.StopTask(ctx, assignment); err != nil { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "queue_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + status, statusErr := p.readAuthoritativeStatus(ctx, command) + if statusErr != nil { + return p.persistControlResult(ctx, command, assignment, nil, "rejected", "task_unavailable", publicTaskAdmissionState(assignment.AdmissionState)) + } + if status.Status != "stopped" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "state_mismatch", publicTaskAdmissionState(assignment.AdmissionState)) + } + if assignment.AdmissionState == "finished" || assignment.AdmissionState == "removed" { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "task_stopped", publicTaskAdmissionState(assignment.AdmissionState)) + } + if err := p.queueControl.ApplyActiveCallPolicy(ctx, assignment, "stop", command.Payload.ActiveCallPolicy); err != nil { + return p.persistControlResult(ctx, command, assignment, &status, "rejected", "active_call_policy_failed", publicTaskAdmissionState(assignment.AdmissionState)) + } + if err := p.dispatcher.store.RecordLocalTaskStatusObservation( + assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, status.Status, status.TaskRevision, + ); err != nil { + return fmt.Errorf("persist authoritative stopped task status before queue drain: %w", err) + } + if err := p.queueControl.DrainTask(ctx, assignment); err != nil { + return err + } + return p.persistControlResult(ctx, command, assignment, &status, "applied", "applied", "stopped") +} + +func (p *localTaskControlProcessor) readAuthoritativeStatus(ctx context.Context, command localTaskControlCommand) (configread.TaskStatus, error) { + status, err := p.statusReader.ReadTaskStatus(ctx, command.Payload.TaskID, command.TenantID, command.TenantKey) + if err != nil { + return configread.TaskStatus{}, err + } + if status.DispatcherID != command.DispatcherID || status.TaskID != command.Payload.TaskID || status.TenantID != command.TenantID || + status.TenantKey != command.TenantKey || status.TaskRevision <= 0 { + return configread.TaskStatus{}, errors.New("authoritative task status identity mismatch") + } + return status, nil +} + +func (p *localTaskControlProcessor) persistControlResult(_ context.Context, command localTaskControlCommand, assignment store.LocalTaskAssignment, observed *configread.TaskStatus, status, reason, admissionState string) error { + revision := assignment.TaskRevision + observedStatus := "" + if observed != nil { + observedStatus = observed.Status + revision = observed.TaskRevision + } + if revision <= 0 { + revision = 1 + } + state := publicTaskAdmissionState(admissionState) + now := p.dispatcher.now().UTC() + event := localTaskControlResultEvent{ + SchemaVersion: localCommandSchemaVersion, + EventID: uuid.NewString(), + EventType: "command.result", + DispatcherID: command.DispatcherID, + TenantID: command.TenantID, + TenantKey: command.TenantKey, + TraceID: command.TraceID, + OccurredAt: now.Format(time.RFC3339Nano), + AggregateType: "task", + AggregateID: command.Payload.TaskID, + AggregateVersion: revision, + Payload: localTaskControlResultPayload{ + CommandType: "task.control", TaskID: command.Payload.TaskID, + Action: command.Payload.Action, Status: status, ReasonCode: reason, TaskState: state, + }, + } + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal task-control result: %w", err) + } + if err := contract.ValidateLocalCommandNext(body); err != nil { + return fmt.Errorf("validate task-control result: %w", err) + } + outbox := store.LocalEventRecord{ + EventID: event.EventID, TenantKey: command.TenantKey, Exchange: mq.ResultsExchangeV3, + RoutingKey: "d." + command.DispatcherID + ".out", Body: body, + } + _, _, err = p.dispatcher.store.PersistLocalTaskControlResult(store.LocalTaskControlResultRecord{ + DispatcherID: command.DispatcherID, TaskID: command.Payload.TaskID, TenantID: command.TenantID, + TenantKey: command.TenantKey, Action: command.Payload.Action, ResultStatus: status, + ReasonCode: reason, ObservedStatus: observedStatus, TaskRevision: func() int64 { + if observed != nil { + return observed.TaskRevision + } + return 0 + }(), AdmissionState: state, Event: outbox, + }) + if err != nil { + return fmt.Errorf("persist task-control result: %w", err) + } + return nil +} + +func (p *localTaskControlProcessor) persistUnknownTaskResult(command localTaskControlCommand, reason, taskState string) error { + revision := int64(1) + now := p.dispatcher.now().UTC() + event := localTaskControlResultEvent{ + SchemaVersion: localCommandSchemaVersion, EventID: uuid.NewString(), EventType: "command.result", + DispatcherID: command.DispatcherID, TenantID: command.TenantID, TenantKey: command.TenantKey, + TraceID: command.TraceID, OccurredAt: now.Format(time.RFC3339Nano), AggregateType: "task", + AggregateID: command.Payload.TaskID, AggregateVersion: revision, + Payload: localTaskControlResultPayload{CommandType: "task.control", TaskID: command.Payload.TaskID, + Action: command.Payload.Action, Status: "rejected", ReasonCode: reason, TaskState: taskState}, + } + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal unassigned task-control result: %w", err) + } + if err := contract.ValidateLocalCommandNext(body); err != nil { + return fmt.Errorf("validate unassigned task-control result: %w", err) + } + _, err = p.dispatcher.store.EnqueueLocalEvent(store.LocalEventRecord{ + EventID: event.EventID, TenantKey: command.TenantKey, Exchange: mq.ResultsExchangeV3, + RoutingKey: "d." + command.DispatcherID + ".out", Body: body, + }) + return err +} + +func publicTaskAdmissionState(state string) string { + switch state { + case "running", "paused", "stopped", "finished": + return state + case "removed": + return "stopped" + default: + return "stopped" + } +} diff --git a/internal/dispatcher/task_control_v3_test.go b/internal/dispatcher/task_control_v3_test.go new file mode 100644 index 0000000..e69775e --- /dev/null +++ b/internal/dispatcher/task_control_v3_test.go @@ -0,0 +1,323 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +type fakeTaskControlStatusReader struct { + status configread.TaskStatus + err error + calls int +} + +func (f *fakeTaskControlStatusReader) ReadTaskStatus(context.Context, string, string, string) (configread.TaskStatus, error) { + f.calls++ + return f.status, f.err +} + +type fakeTaskQueueController struct { + events []string + err error + store *store.Store + drainErr error +} + +func (f *fakeTaskQueueController) StartTask(context.Context, store.LocalTaskAssignment) error { + f.events = append(f.events, "start") + return f.err +} + +func (f *fakeTaskQueueController) StopTask(context.Context, store.LocalTaskAssignment) error { + f.events = append(f.events, "stop") + return f.err +} + +func (f *fakeTaskQueueController) DrainTask(_ context.Context, assignment store.LocalTaskAssignment) error { + if assignment.AdmissionState != "stopped" { + return errUnexpectedTaskState + } + if f.store != nil { + current, err := f.store.LocalTaskAssignment(assignment.DispatcherID, assignment.TaskID) + if err != nil { + return err + } + if current.Status != "stopped" { + return errors.New("authoritative stopped status was not persisted before queue drain") + } + var outboxCount int + if err := f.store.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil { + return err + } + if outboxCount != 0 { + return errors.New("task-control receipt was persisted before queue drain") + } + } + f.events = append(f.events, "drain") + return f.drainErr +} + +func (f *fakeTaskQueueController) ApplyActiveCallPolicy(_ context.Context, _ store.LocalTaskAssignment, _ string, policy string) error { + if policy == "hangup" { + f.events = append(f.events, "hangup") + } + return f.err +} + +func TestTaskControlPauseClosesAdmissionBeforeStoppingConsumer(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + reader := &fakeTaskControlStatusReader{status: localControlStatus("paused", 3)} + queues := &fakeTaskQueueController{} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "pause")); err != nil { + t.Fatal(err) + } + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 || assignments[0].AdmissionState != "paused" { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + if len(queues.events) != 1 || queues.events[0] != "stop" || reader.calls != 1 { + t.Fatalf("queue events=%v status calls=%d", queues.events, reader.calls) + } + assertTaskControlReceipt(t, st, "pause", "applied", "applied", "paused") +} + +func TestTaskControlStopDrainsOnlyAfterAuthoritativeStop(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + reader := &fakeTaskControlStatusReader{status: localControlStatus("stopped", 3)} + queues := &fakeTaskQueueController{store: st} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "stop")); err != nil { + t.Fatal(err) + } + if len(queues.events) != 2 || queues.events[0] != "stop" || queues.events[1] != "drain" { + t.Fatalf("stop/drain ordering = %v", queues.events) + } + assertTaskControlReceipt(t, st, "stop", "applied", "applied", "stopped") +} + +func TestTaskControlStopPersistsReceiptOnlyAfterSuccessfulDrain(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + reader := &fakeTaskControlStatusReader{status: localControlStatus("stopped", 3)} + queues := &fakeTaskQueueController{store: st, drainErr: errors.New("injected drain failure")} + processor := newLocalTaskControlProcessor(d, reader, queues) + body := localTaskControlBody(t, now, "stop") + if err := processor.Handle(context.Background(), localControlRoutingKey(), body); !errors.Is(err, queues.drainErr) { + t.Fatalf("first stop error=%v, want drain failure", err) + } + assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil || assignment.Status != "stopped" || assignment.AdmissionState != "stopped" { + t.Fatalf("authoritative stop was not retained for recovery: %+v err=%v", assignment, err) + } + var outboxCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil || outboxCount != 0 { + t.Fatalf("receipt exists before successful drain: count=%d err=%v", outboxCount, err) + } + + queues.drainErr = nil + if err := processor.Handle(context.Background(), localControlRoutingKey(), body); err != nil { + t.Fatalf("retry stop after drain recovery: %v", err) + } + assertTaskControlReceipt(t, st, "stop", "applied", "applied", "stopped") +} + +func TestTaskControlUnassignedTaskEmitsRejectedReceipt(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + body := localTaskControlBody(t, now, "stop") + var command map[string]any + if err := json.Unmarshal(body, &command); err != nil { + t.Fatal(err) + } + command["payload"].(map[string]any)["task_id"] = "task-not-assigned" + body, err := json.Marshal(command) + if err != nil { + t.Fatal(err) + } + processor := newLocalTaskControlProcessor(d, &fakeTaskControlStatusReader{}, &fakeTaskQueueController{}) + if err := processor.Handle(context.Background(), localControlRoutingKey(), body); err != nil { + t.Fatal(err) + } + assertTaskControlReceipt(t, st, "stop", "rejected", "task_unavailable", "stopped") +} + +func TestTaskControlResumeKeepsAdmissionPausedWhenQueueCannotStart(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignment, err := st.SetLocalTaskAdmissionBarrier(localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey, "paused") + if err != nil { + t.Fatal(err) + } + reader := &fakeTaskControlStatusReader{status: localControlStatus("running", assignment.TaskRevision+1)} + queues := &fakeTaskQueueController{err: errors.New("queue unavailable")} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "resume")); err != nil { + t.Fatal(err) + } + current, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil || current.AdmissionState != "paused" { + t.Fatalf("failed resume opened admission: %+v err=%v", current, err) + } + assertTaskControlReceipt(t, st, "resume", "rejected", "queue_unavailable", "paused") +} + +func TestTaskControlPauseMismatchKeepsAdmissionClosed(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + reader := &fakeTaskControlStatusReader{status: localControlStatus("running", 2)} + queues := &fakeTaskQueueController{} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "pause")); err != nil { + t.Fatal(err) + } + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || assignments[0].AdmissionState != "paused" { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + assertTaskControlReceipt(t, st, "pause", "rejected", "state_mismatch", "paused") +} + +func TestTaskControlResumeReopensPausedTaskAfterFreshRunningStatus(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignment, err := st.SetLocalTaskAdmissionBarrier(localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey, "paused") + if err != nil { + t.Fatal(err) + } + reader := &fakeTaskControlStatusReader{status: localControlStatus("running", assignment.TaskRevision+1)} + queues := &fakeTaskQueueController{} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "resume")); err != nil { + t.Fatal(err) + } + current, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil || current.Status != "running" || current.AdmissionState != "running" || current.TaskRevision != assignment.TaskRevision+1 { + t.Fatalf("resumed assignment=%+v err=%v", current, err) + } + if len(queues.events) != 1 || queues.events[0] != "start" { + t.Fatalf("resume queue events=%v", queues.events) + } + assertTaskControlReceipt(t, st, "resume", "applied", "applied", "running") +} + +func TestTaskControlResumeCannotReopenStoppedTask(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + if _, err := st.SetLocalTaskAdmissionBarrier(localTestDispatcherID, localTestTaskID, localTestTenantID, localTestTenantKey, "stopped"); err != nil { + t.Fatal(err) + } + reader := &fakeTaskControlStatusReader{status: localControlStatus("running", 2)} + queues := &fakeTaskQueueController{} + processor := newLocalTaskControlProcessor(d, reader, queues) + if err := processor.Handle(context.Background(), localControlRoutingKey(), localTaskControlBody(t, now, "resume")); err != nil { + t.Fatal(err) + } + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || assignments[0].AdmissionState != "stopped" { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + if len(queues.events) != 0 { + t.Fatalf("stopped task started queues: %v", queues.events) + } + assertTaskControlReceipt(t, st, "resume", "rejected", "task_stopped", "stopped") +} + +var errUnexpectedTaskState = taskStateError("task queue drain attempted before stopped barrier") + +type taskStateError string + +func (e taskStateError) Error() string { return string(e) } + +func localControlStatus(status string, revision int64) configread.TaskStatus { + return configread.TaskStatus{ + DispatcherID: localTestDispatcherID, + TaskID: localTestTaskID, + TenantID: localTestTenantID, + TenantKey: localTestTenantKey, + Status: status, + TaskRevision: revision, + } +} + +func localControlRoutingKey() string { + return "d." + localTestDispatcherID + ".control.in" +} + +func localTaskControlBody(t *testing.T, now time.Time, action string) []byte { + t.Helper() + payload := map[string]any{ + "task_id": localTestTaskID, + "action": action, + "reason": "local test control", + } + if action == "pause" || action == "stop" { + payload["active_call_policy"] = "drain" + } + body, err := json.Marshal(map[string]any{ + "schema_version": "command-next.v0.1-proposal", + "dispatcher_id": localTestDispatcherID, + "tenant_id": localTestTenantID, + "tenant_key": localTestTenantKey, + "trace_id": "11111111-1111-4111-8111-111111111111", + "issued_at": now.Add(-time.Minute).Format(time.RFC3339Nano), + "not_after": now.Add(time.Minute).Format(time.RFC3339Nano), + "command_type": "task.control", + "payload": payload, + }) + if err != nil { + t.Fatal(err) + } + return body +} + +func assertTaskControlReceipt(t *testing.T, st *store.Store, action, status, reason, taskState string) { + t.Helper() + records, err := st.ClaimOutbox(8) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("outbox records = %d, want one", len(records)) + } + var event struct { + EventType string `json:"event_type"` + Payload struct { + Action string `json:"action"` + Status string `json:"status"` + ReasonCode string `json:"reason_code"` + TaskState string `json:"task_state"` + } `json:"payload"` + } + if err := json.Unmarshal(records[0].Body, &event); err != nil { + t.Fatal(err) + } + if event.EventType != "command.result" || event.Payload.Action != action || event.Payload.Status != status || + event.Payload.ReasonCode != reason || event.Payload.TaskState != taskState { + t.Fatalf("control receipt = %+v", event) + } +} diff --git a/internal/dispatcher/task_controller.go b/internal/dispatcher/task_controller.go new file mode 100644 index 0000000..e9dd99a --- /dev/null +++ b/internal/dispatcher/task_controller.go @@ -0,0 +1,11 @@ +package dispatcher + +import ( + "context" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +type TaskController interface { + Control(context.Context, string, *agentv1.ExecutionBinding, agentv1.ControlAction, agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) +} diff --git a/internal/dispatcher/task_queue_v3.go b/internal/dispatcher/task_queue_v3.go new file mode 100644 index 0000000..f68e064 --- /dev/null +++ b/internal/dispatcher/task_queue_v3.go @@ -0,0 +1,273 @@ +package dispatcher + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "sync" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +type taskQueueConsumer interface { + Wait(context.Context) error + Stop(context.Context) error +} + +type taskQueueBroker interface { + StartPredeclaredConsumer(context.Context, string, mq.MessageHandler) (taskQueueConsumer, error) + DrainPredeclared(context.Context, string) (int, error) +} + +type v3TaskBrokerAdapter struct{ broker *mq.V3Broker } + +func (a v3TaskBrokerAdapter) StartPredeclaredConsumer(ctx context.Context, queue string, handler mq.MessageHandler) (taskQueueConsumer, error) { + return a.broker.StartPredeclaredConsumer(ctx, queue, handler) +} + +func (a v3TaskBrokerAdapter) DrainPredeclared(ctx context.Context, queue string) (int, error) { + return a.broker.DrainPredeclared(ctx, queue) +} + +type v3TaskQueueController struct { + dispatcher *Dispatcher + broker taskQueueBroker + taskController TaskController + mockAuthorizedAgentID string + mockAuthorizedAgents *AgentCoordinator + mu sync.Mutex + consumers map[string]taskQueueConsumer + errors chan error +} + +func newV3TaskQueueController(d *Dispatcher, broker *mq.V3Broker, taskController TaskController) *v3TaskQueueController { + return newTaskQueueController(d, v3TaskBrokerAdapter{broker: broker}, taskController) +} + +func newTaskQueueController(d *Dispatcher, broker taskQueueBroker, taskController TaskController) *v3TaskQueueController { + return &v3TaskQueueController{ + dispatcher: d, broker: broker, taskController: taskController, + consumers: make(map[string]taskQueueConsumer), errors: make(chan error, 64), + } +} + +// EnableMockAuthorizedOrigination must run before any task consumer starts. +// Non-mock modes never call this setter and cannot originate by this path. +func (q *v3TaskQueueController) EnableMockAuthorizedOrigination(agentID string, agents *AgentCoordinator) error { + if q == nil || agentID == "" || agents == nil { + return errors.New("mock authorized origination requires one activated Agent") + } + q.mu.Lock() + defer q.mu.Unlock() + if len(q.consumers) != 0 || q.mockAuthorizedAgentID != "" { + return errors.New("mock authorized origination must be configured once before consumption") + } + q.mockAuthorizedAgentID, q.mockAuthorizedAgents = agentID, agents + return nil +} + +func (q *v3TaskQueueController) handleTaskCommand(ctx context.Context, routingKey string, body []byte) error { + if err := q.dispatcher.AcceptLocalV01Command(ctx, routingKey, body); err != nil { + return err + } + if q.mockAuthorizedAgentID == "" { + return nil + } + executionID, err := q.dispatcher.store.LocalPendingOriginationForCommand(body) + if errors.Is(err, sql.ErrNoRows) { // Rejected command or already-issued/refused execution. + return nil + } + if err != nil { + return fmt.Errorf("read pending local execution after durable admission: %w", err) + } + result, dispatchErr := q.dispatcher.DispatchAndFinalizeAuthorizedMock(ctx, executionID, q.mockAuthorizedAgentID, q.mockAuthorizedAgents) + decision, err := q.dispatcher.store.LocalOriginationDecision(executionID) + if err != nil { + return errors.Join(dispatchErr, fmt.Errorf("verify durable final dial decision before ACK: %w", err)) + } + if dispatchErr != nil { + slog.Error("Mock Agent instruction refused or uncertain; no automatic retry", "execution_id", executionID, + "decision", decision, "unknown", result.Unknown, "error", dispatchErr) + } else if decision != "issued" { + return fmt.Errorf("Agent applied execution %s without an issued dial decision", executionID) + } + // The command was durably admitted and its final decision was persisted. + // An Agent error is never passed to MQ as a requeue/re-originate signal. + return nil +} + +func (q *v3TaskQueueController) StartTask(ctx context.Context, assignment store.LocalTaskAssignment) error { + if q == nil || q.dispatcher == nil || q.broker == nil { + return errors.New("v3 task queue controller is not configured") + } + q.mu.Lock() + defer q.mu.Unlock() + if _, exists := q.consumers[assignment.TaskID]; exists { + return nil + } + current, err := q.dispatcher.store.LocalTaskAssignment(assignment.DispatcherID, assignment.TaskID) + if err != nil { + return err + } + if current.TenantID != assignment.TenantID || current.TenantKey != assignment.TenantKey { + return store.ErrTenantBindingConflict + } + if current.Removed || current.AdmissionState != "running" || current.Status != "running" { + return fmt.Errorf("task %s is not eligible for queue consumption", assignment.TaskID) + } + if current.Queue.QueueName == "" { + return fmt.Errorf("task %s has no SaaS-owned queue name", assignment.TaskID) + } + consumer, err := q.broker.StartPredeclaredConsumer(ctx, current.Queue.QueueName, q.handleTaskCommand) + if err != nil { + return err + } + q.consumers[current.TaskID] = consumer + go q.watchConsumer(current.TaskID, consumer) + return nil +} + +func (q *v3TaskQueueController) StopTask(ctx context.Context, assignment store.LocalTaskAssignment) error { + if q == nil || q.dispatcher == nil { + return errors.New("v3 task queue controller is not configured") + } + q.mu.Lock() + defer q.mu.Unlock() + current, err := q.dispatcher.store.LocalTaskAssignment(assignment.DispatcherID, assignment.TaskID) + if err != nil { + return err + } + if current.TenantID != assignment.TenantID || current.TenantKey != assignment.TenantKey { + return store.ErrTenantBindingConflict + } + if current.AdmissionState == "running" { + return errors.New("refuse to stop task consumer before durable admission barrier") + } + consumer, exists := q.consumers[assignment.TaskID] + if !exists { + return nil + } + if err := consumer.Stop(ctx); err != nil { + return err + } + delete(q.consumers, assignment.TaskID) + return nil +} + +// StopConsumer cancels consumption without changing the durable task state. It +// is used during process shutdown and when a fresh execution snapshot is absent. +func (q *v3TaskQueueController) StopConsumer(ctx context.Context, taskID string) error { + if q == nil { + return errors.New("v3 task queue controller is not configured") + } + q.mu.Lock() + defer q.mu.Unlock() + consumer, exists := q.consumers[taskID] + if !exists { + return nil + } + if err := consumer.Stop(ctx); err != nil { + return err + } + delete(q.consumers, taskID) + return nil +} + +func (q *v3TaskQueueController) StopAll(ctx context.Context) error { + q.mu.Lock() + taskIDs := make([]string, 0, len(q.consumers)) + for taskID := range q.consumers { + taskIDs = append(taskIDs, taskID) + } + q.mu.Unlock() + var failures []error + for _, taskID := range taskIDs { + if err := q.StopConsumer(ctx, taskID); err != nil { + failures = append(failures, fmt.Errorf("stop task %s consumer: %w", taskID, err)) + } + } + return errors.Join(failures...) +} + +func (q *v3TaskQueueController) DrainTask(ctx context.Context, assignment store.LocalTaskAssignment) error { + if q == nil || q.dispatcher == nil || q.broker == nil { + return errors.New("v3 task queue controller is not configured") + } + current, err := q.dispatcher.store.LocalTaskAssignment(assignment.DispatcherID, assignment.TaskID) + if err != nil { + return err + } + if current.TenantID != assignment.TenantID || current.TenantKey != assignment.TenantKey { + return store.ErrTenantBindingConflict + } + if current.Removed || current.AdmissionState != "stopped" || current.Status != "stopped" { + return errors.New("refuse to drain task queue before authoritative stop") + } + if current.Queue.QueueName == "" { + return errors.New("stopped task has no SaaS-owned queue name") + } + _, err = q.broker.DrainPredeclared(ctx, current.Queue.QueueName) + return err +} + +func (q *v3TaskQueueController) ApplyActiveCallPolicy(ctx context.Context, assignment store.LocalTaskAssignment, action, policy string) error { + if policy == "drain" { + return nil + } + if policy != "hangup" { + return fmt.Errorf("unsupported active-call policy %q", policy) + } + var controlAction agentv1.ControlAction + switch action { + case "pause": + controlAction = agentv1.ControlAction_CONTROL_ACTION_PAUSE + case "stop": + controlAction = agentv1.ControlAction_CONTROL_ACTION_STOP + default: + return fmt.Errorf("active-call hangup is not valid for %q", action) + } + + q.dispatcher.executionMu.Lock() + defer q.dispatcher.executionMu.Unlock() + targets, err := q.dispatcher.store.ActiveTaskExecutionControls(assignment.TenantID, assignment.TenantKey, assignment.TaskID) + if err != nil { + return err + } + if len(targets) == 0 { + return nil + } + if q.taskController == nil { + return errors.New("active-call hangup requires the configured Agent task controller") + } + for _, target := range targets { + if target.Binding == nil || target.AgentID == "" { + return fmt.Errorf("execution %s has no durable Agent control binding", target.ExecutionID) + } + response, err := q.taskController.Control(ctx, target.AgentID, target.Binding, controlAction, agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP) + if err != nil { + return fmt.Errorf("hang up active execution %s: %w", target.ExecutionID, err) + } + if response == nil || response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_APPLIED || + response.GetAppliedTaskRevision() != target.Binding.TaskRevision+1 { + return fmt.Errorf("Agent did not confirm hangup for execution %s", target.ExecutionID) + } + } + return nil +} + +func (q *v3TaskQueueController) Errors() <-chan error { return q.errors } + +func (q *v3TaskQueueController) watchConsumer(taskID string, consumer taskQueueConsumer) { + if err := consumer.Wait(context.Background()); err != nil { + q.mu.Lock() + if q.consumers[taskID] == consumer { + delete(q.consumers, taskID) + } + q.mu.Unlock() + q.errors <- fmt.Errorf("task queue consumer %s: %w", taskID, err) + } +} diff --git a/internal/dispatcher/task_queue_v3_origination_test.go b/internal/dispatcher/task_queue_v3_origination_test.go new file mode 100644 index 0000000..3b1079b --- /dev/null +++ b/internal/dispatcher/task_queue_v3_origination_test.go @@ -0,0 +1,166 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc" +) + +type fakeAuthorizedAgentClient struct { + agentv1.AgentControlServiceClient + attempts int + mockOrigins int + deadlineClosed bool + last *agentv1.ExecuteAuthorizedRequest +} + +func (f *fakeAuthorizedAgentClient) ExecuteAuthorized(_ context.Context, request *agentv1.ExecuteAuthorizedRequest, _ ...grpc.CallOption) (*agentv1.ExecuteAuthorizedResponse, error) { + f.attempts++ + f.last = request + if f.deadlineClosed { + return &agentv1.ExecuteAuthorizedResponse{ + Receipt: &agentv1.OperationReceipt{Result: agentv1.ResultCode_RESULT_CODE_REJECTED}, + State: agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, + }, nil + } + f.mockOrigins++ + return &agentv1.ExecuteAuthorizedResponse{ + Receipt: &agentv1.OperationReceipt{Result: agentv1.ResultCode_RESULT_CODE_APPLIED}, + State: agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, + }, nil +} + +func (f *fakeAuthorizedAgentClient) QueryExecution(_ context.Context, request *agentv1.QueryExecutionRequest, _ ...grpc.CallOption) (*agentv1.QueryExecutionResponse, error) { + binding := request.Binding + if f.last != nil && f.last.Binding.GetExecutionId() == request.Binding.GetExecutionId() { + binding = f.last.Binding + } + callState := "mock_no_answer" + if f.deadlineClosed { + callState = "mock_deadline_closed_without_dial" + } + return &agentv1.QueryExecutionResponse{Snapshot: &agentv1.ExecutionSnapshot{ + Binding: binding, State: agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, + CallState: callState, AttemptId: binding.AttemptId, + ObservedAtUnixMs: time.Date(2026, 9, 21, 1, 30, 1, 0, time.UTC).UnixMilli(), + }}, nil +} + +func TestTaskConsumerIssuesOnlyOneAuthorizedMockInstruction(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + client := &fakeAuthorizedAgentClient{} + coordinator := NewAgentCoordinator(func() time.Time { return at }) + if err := coordinator.Register("agent-1", client); err != nil { + t.Fatal(err) + } + coordinator.sessions["agent-1"] = AgentSession{AgentID: "agent-1", CellID: "cell-1", BootID: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1} + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + for _, tc := range []struct { + name string + refused bool + agentDeadline bool + wantInstructions int + wantMockOrigins int + }{ + {"approved call", false, false, 1, 1}, + {"refused call", true, false, 0, 0}, + {"Agent deadline closed without dialing", false, true, 1, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + client.deadlineClosed = tc.agentDeadline + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer configServer.Close() + defer st.Close() + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + if err := queues.EnableMockAuthorizedOrigination("agent-1", coordinator); err != nil { + t.Fatal(err) + } + assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := queues.StartTask(ctx, assignment); err != nil || broker.handler == nil { + t.Fatalf("task consumer: handler=%v err=%v", broker.handler != nil, err) + } + body := localExecuteCommandBody(t, "command-queue-authorized", at.Add(2*time.Hour)) + if tc.refused { + if err := d.AcceptLocalV01Command(ctx, route, body); err != nil { + t.Fatal(err) + } + d.now = func() time.Time { return at.Add(90 * time.Minute) } // Shanghai 11:00, right-open. + } + before := client.attempts + beforeOrigins := client.mockOrigins + if err := broker.handler(ctx, route, body); err != nil { + t.Fatalf("accepted command must ACK after durable final decision: %v", err) + } + if client.attempts-before != tc.wantInstructions || client.mockOrigins-beforeOrigins != tc.wantMockOrigins { + t.Fatalf("Agent instructions/originations=%d/%d want=%d/%d", client.attempts-before, client.mockOrigins-beforeOrigins, tc.wantInstructions, tc.wantMockOrigins) + } + if tc.refused { + d.now = func() time.Time { return at.Add(time.Minute) } + } + if err := broker.handler(ctx, route, body); err != nil { + t.Fatalf("duplicate delivery must ACK: %v", err) + } + if client.attempts-before != tc.wantInstructions || client.mockOrigins-beforeOrigins != tc.wantMockOrigins { + t.Fatal("duplicate message retried or revived an Agent originate") + } + var decision string + if err := st.DB().QueryRow(`SELECT decision FROM local_v02_origination_decisions LIMIT 1`).Scan(&decision); err != nil { + t.Fatal(err) + } + wantDecision := "issued" + if tc.refused { + wantDecision = "refused" + } + if decision != wantDecision { + t.Fatalf("decision=%q want=%q", decision, wantDecision) + } + var taskStatus, reservationState string + if err := st.DB().QueryRow(`SELECT t.status,r.state FROM tasks t JOIN reservations r ON r.execution_id=t.execution_id WHERE t.task_item_id='command-queue-authorized'`).Scan(&taskStatus, &reservationState); err != nil || taskStatus != "finished" || reservationState != "released" { + t.Fatalf("ended or refused Mock execution held quota: task=%q reservation=%q err=%v", taskStatus, reservationState, err) + } + messages, err := st.ClaimOutbox(10) + if err != nil { + t.Fatal(err) + } + results := 0 + for _, message := range messages { + var event struct { + EventType string `json:"event_type"` + Payload struct { + Outcome string `json:"outcome"` + Recording struct { + Status string `json:"status"` + ReasonCode string `json:"reason_code"` + } `json:"recording"` + } `json:"payload"` + } + if err := json.Unmarshal(message.Body, &event); err != nil { + t.Fatal(err) + } + if event.EventType == "call.result" { + results++ + wantOutcome, wantReason := "no_answer", "no_answer" + if tc.refused || tc.agentDeadline { + wantOutcome, wantReason = "failed", "not_attempted" + } + if event.Payload.Outcome != wantOutcome || event.Payload.Recording.Status != "not_created" || event.Payload.Recording.ReasonCode != wantReason { + t.Fatalf("incorrect Mock final outcome: %+v", event.Payload) + } + } + } + if results != 1 { + t.Fatalf("one accepted execution produced %d final results", results) + } + }) + } +} diff --git a/internal/dispatcher/task_queue_v3_stop_active_test.go b/internal/dispatcher/task_queue_v3_stop_active_test.go new file mode 100644 index 0000000..9cb6478 --- /dev/null +++ b/internal/dispatcher/task_queue_v3_stop_active_test.go @@ -0,0 +1,131 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +type mockActiveStopRecorder struct { + calls []struct { + agentID string + executionID string + action agentv1.ControlAction + policy agentv1.ActiveCallPolicy + } +} + +func (r *mockActiveStopRecorder) Control(_ context.Context, agentID string, binding *agentv1.ExecutionBinding, action agentv1.ControlAction, policy agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) { + r.calls = append(r.calls, struct { + agentID string + executionID string + action agentv1.ControlAction + policy agentv1.ActiveCallPolicy + }{agentID, binding.ExecutionId, action, policy}) + return &agentv1.ApplyTaskControlResponse{ + Receipt: &agentv1.OperationReceipt{Result: agentv1.ResultCode_RESULT_CODE_APPLIED}, + AppliedTaskRevision: binding.TaskRevision + 1, + }, nil +} + +func TestAuthorizedMockInFlightExecutionsRemainAddressableForStop(t *testing.T) { + at := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + d, st, configServer := newLocalV01TestDispatcher(t, at) + defer configServer.Close() + defer st.Close() + client := &fakeAuthorizedAgentClient{} + coordinator := NewAgentCoordinator(func() time.Time { return at }) + if err := coordinator.Register("agent-1", client); err != nil { + t.Fatal(err) + } + coordinator.sessions["agent-1"] = AgentSession{ + AgentID: "agent-1", CellID: "cell-1", BootID: "boot-1", + DispatcherEpoch: "epoch-1", SessionGeneration: 1, + } + ctx := context.Background() + route := "d." + localTestDispatcherID + ".task." + localTestTaskID + ".in" + issued := make(map[string]bool, 2) + for _, commandID := range []string{"command-active-a", "command-active-b"} { + if err := d.AcceptLocalV01Command(ctx, route, localExecuteCommandBody(t, commandID, at.Add(2*time.Hour))); err != nil { + t.Fatalf("accept %s: %v", commandID, err) + } + var executionID string + if err := st.DB().QueryRow(`SELECT execution_id FROM tasks WHERE task_item_id=?`, commandID).Scan(&executionID); err != nil { + t.Fatal(err) + } + if _, err := d.DispatchAuthorizedLocal(ctx, executionID, "agent-1", coordinator); err != nil { + t.Fatalf("issue %s: %v", commandID, err) + } + issued[executionID] = true // D has not yet persisted either terminal observation. + } + if client.attempts != 2 { + t.Fatalf("Agent received %d instructions, want two", client.attempts) + } + targets, err := st.ActiveTaskExecutionControls(localTestTenantID, localTestTenantKey, localTestTaskID) + if err != nil || len(targets) != 2 { + t.Fatalf("stop cannot address both in-flight Agent calls: targets=%+v err=%v", targets, err) + } + for _, target := range targets { + if target.AgentID != "agent-1" || target.Binding == nil || !issued[target.Binding.ExecutionId] { + t.Fatalf("stop selected an unrelated or unbound call: %+v", target) + } + } + control := &mockActiveStopRecorder{} + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, control) + reader := &fakeTaskControlStatusReader{status: localControlStatus("stopped", 3)} + processor := newLocalTaskControlProcessor(d, reader, queues) + var stop map[string]any + if err := json.Unmarshal(localTaskControlBody(t, at, "stop"), &stop); err != nil { + t.Fatal(err) + } + stop["payload"].(map[string]any)["active_call_policy"] = "hangup" + body, err := json.Marshal(stop) + if err != nil { + t.Fatal(err) + } + if err := processor.Handle(ctx, localControlRoutingKey(), body); err != nil { + t.Fatalf("persist stop, hang up both calls and drain the owned queue: %v", err) + } + if len(control.calls) != 2 || len(broker.drained) != 1 { + t.Fatalf("stop skipped an active call or the backlog: controls=%+v drained=%v", control.calls, broker.drained) + } + for _, call := range control.calls { + if call.agentID != "agent-1" || !issued[call.executionID] || + call.action != agentv1.ControlAction_CONTROL_ACTION_STOP || call.policy != agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP { + t.Fatalf("stop sent an unbound control: %+v", call) + } + } + // Already-issued calls can still report their original terminal facts after + // the stop barrier; neither a new instruction nor a second PUT is allowed. + for _, target := range targets { + authorized, err := d.issuedMockAuthorization(target.ExecutionID) + if err != nil { + t.Fatal(err) + } + terminal := &agentv1.ExecutionSnapshot{ + Binding: authorized.Binding, State: agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, + CallState: "mock_no_answer", AttemptId: authorized.Binding.AttemptId, + ObservedAtUnixMs: at.Add(time.Second).UnixMilli(), + } + if err := d.completeAuthorizedMockTerminal(authorized, terminal); err != nil { + t.Fatalf("stopped task discarded an existing call result: %v", err) + } + } + if client.attempts != 2 { + t.Fatalf("stop redialed an existing call: %d attempts", client.attempts) + } + var results, held int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE json_extract(CAST(body AS TEXT), '$.event_type')='call.result'`).Scan(&results); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM reservations WHERE state='held'`).Scan(&held); err != nil { + t.Fatal(err) + } + if results != 2 || held != 0 { + t.Fatalf("stop lost the two in-flight final results or kept quota: results=%d held=%d", results, held) + } +} diff --git a/internal/dispatcher/task_queue_v3_test.go b/internal/dispatcher/task_queue_v3_test.go new file mode 100644 index 0000000..1850e32 --- /dev/null +++ b/internal/dispatcher/task_queue_v3_test.go @@ -0,0 +1,259 @@ +package dispatcher + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +type fakeQueueBroker struct { + started []string + drained []string + consumers []*fakeQueueConsumer + consumerWaitErr error + handler mq.MessageHandler +} + +func (f *fakeQueueBroker) StartPredeclaredConsumer(_ context.Context, queue string, handler mq.MessageHandler) (taskQueueConsumer, error) { + f.started = append(f.started, queue) + f.handler = handler + consumer := &fakeQueueConsumer{done: make(chan struct{}), waitErr: f.consumerWaitErr} + f.consumers = append(f.consumers, consumer) + return consumer, nil +} + +func (f *fakeQueueBroker) DrainPredeclared(_ context.Context, queue string) (int, error) { + f.drained = append(f.drained, queue) + return 3, nil +} + +type fakeQueueConsumer struct { + done chan struct{} + once sync.Once + waitErr error +} + +func (f *fakeQueueConsumer) Wait(ctx context.Context) error { + select { + case <-f.done: + return f.waitErr + case <-ctx.Done(): + return ctx.Err() + } +} + +func (f *fakeQueueConsumer) Stop(context.Context) error { + f.finish() + return nil +} + +func (f *fakeQueueConsumer) finish() { + f.once.Do(func() { close(f.done) }) +} + +func TestV3TaskQueueControllerPauseResumeAndStopUsesOnlyPredeclaredQueue(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + assignment := assignments[0] + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + ctx := context.Background() + + if err := queues.StartTask(ctx, assignment); err != nil { + t.Fatal(err) + } + if len(broker.started) != 1 || broker.started[0] != assignment.Queue.QueueName { + t.Fatalf("started queues=%v, want only %q", broker.started, assignment.Queue.QueueName) + } + + paused, err := st.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "paused") + if err != nil { + t.Fatal(err) + } + if err := queues.StopTask(ctx, paused); err != nil { + t.Fatal(err) + } + if len(broker.drained) != 0 { + t.Fatalf("pause discarded queued messages: drained=%v", broker.drained) + } + + resumed, err := st.ResumeLocalTaskAdmission(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "running", assignment.TaskRevision+1) + if err != nil { + t.Fatal(err) + } + if err := queues.StartTask(ctx, resumed); err != nil { + t.Fatal(err) + } + if len(broker.started) != 2 || broker.started[1] != assignment.Queue.QueueName { + t.Fatalf("resume did not consume the same backlog queue: %v", broker.started) + } + + stopped, err := st.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "stopped") + if err != nil { + t.Fatal(err) + } + if err := queues.StopTask(ctx, stopped); err != nil { + t.Fatal(err) + } + if err := queues.DrainTask(ctx, stopped); err == nil || len(broker.drained) != 0 { + t.Fatalf("drain before authoritative stopped status: err=%v drained=%v", err, broker.drained) + } + if _, err := st.DB().Exec(`UPDATE local_v01_task_assignments SET saas_status='stopped', task_revision=task_revision+1 WHERE dispatcher_id=? AND task_id=?`, assignment.DispatcherID, assignment.TaskID); err != nil { + t.Fatal(err) + } + if err := queues.DrainTask(ctx, stopped); err != nil { + t.Fatal(err) + } + if len(broker.drained) != 1 || broker.drained[0] != assignment.Queue.QueueName { + t.Fatalf("stop did not drain the stopped task queue: %v", broker.drained) + } +} + +func TestV3TaskQueueControllerReportsUnexpectedConsumerExit(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + brokerErr := errors.New("broker consumer exited") + broker := &fakeQueueBroker{consumerWaitErr: brokerErr} + queues := newTaskQueueController(d, broker, nil) + if err := queues.StartTask(context.Background(), assignments[0]); err != nil { + t.Fatal(err) + } + broker.consumers[0].finish() + select { + case err := <-queues.Errors(): + if !errors.Is(err, brokerErr) { + t.Fatalf("consumer error=%v, want wrapped broker error", err) + } + case <-time.After(time.Second): + t.Fatal("unexpected consumer exit was not reported") + } + queues.mu.Lock() + remaining := len(queues.consumers) + queues.mu.Unlock() + if remaining != 0 { + t.Fatalf("unexpectedly exited consumer remains registered: %d", remaining) + } +} + +func TestV3TaskQueueApplyActiveCallPolicyValidation(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + queues := newTaskQueueController(d, &fakeQueueBroker{}, nil) + for _, tc := range []struct { + action, policy string + wantErr bool + }{ + {action: "stop", policy: "drain", wantErr: false}, + {action: "pause", policy: "hangup", wantErr: false}, + {action: "resume", policy: "hangup", wantErr: true}, + {action: "stop", policy: "invalid", wantErr: true}, + } { + err := queues.ApplyActiveCallPolicy(context.Background(), assignments[0], tc.action, tc.policy) + if (err != nil) != tc.wantErr { + t.Fatalf("ApplyActiveCallPolicy(%q,%q) error=%v, wantErr=%v", tc.action, tc.policy, err, tc.wantErr) + } + } +} + +func TestV3TaskQueueControllerCannotStartWhenAdmissionIsClosed(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + assignment, err := st.SetLocalTaskAdmissionBarrier(assignments[0].DispatcherID, assignments[0].TaskID, assignments[0].TenantID, assignments[0].TenantKey, "stopped") + if err != nil { + t.Fatal(err) + } + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + if err := queues.StartTask(context.Background(), assignment); err == nil { + t.Fatal("started a stopped task queue") + } + if len(broker.started) != 0 { + t.Fatalf("broker subscribed to queues despite closed admission: %v", broker.started) + } +} + +func TestV3TaskQueueControllerStopAllDoesNotChangeDurableAdmission(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + if err := queues.StartTask(context.Background(), assignments[0]); err != nil { + t.Fatal(err) + } + if err := queues.StopAll(context.Background()); err != nil { + t.Fatal(err) + } + current, err := st.LocalTaskAssignment(assignments[0].DispatcherID, assignments[0].TaskID) + if err != nil { + t.Fatal(err) + } + if current.AdmissionState != "running" { + t.Fatalf("shutdown changed durable admission to %q", current.AdmissionState) + } +} + +func TestV3TaskQueueControllerPreservesBrokerDrainErrors(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + stopped, err := st.SetLocalTaskAdmissionBarrier(assignments[0].DispatcherID, assignments[0].TaskID, assignments[0].TenantID, assignments[0].TenantKey, "stopped") + if err != nil { + t.Fatal(err) + } + if _, err := st.DB().Exec(`UPDATE local_v01_task_assignments SET saas_status='stopped' WHERE dispatcher_id=? AND task_id=?`, stopped.DispatcherID, stopped.TaskID); err != nil { + t.Fatal(err) + } + broker := &errorDrainBroker{fakeQueueBroker: fakeQueueBroker{}, err: errors.New("drain failed")} + queues := newTaskQueueController(d, broker, nil) + if err := queues.DrainTask(context.Background(), stopped); !errors.Is(err, broker.err) { + t.Fatalf("DrainTask error=%v, want %v", err, broker.err) + } +} + +type errorDrainBroker struct { + fakeQueueBroker + err error +} + +func (f *errorDrainBroker) DrainPredeclared(context.Context, string) (int, error) { + return 0, f.err +} diff --git a/internal/dispatcher/task_runtime_v3.go b/internal/dispatcher/task_runtime_v3.go new file mode 100644 index 0000000..6cba4c1 --- /dev/null +++ b/internal/dispatcher/task_runtime_v3.go @@ -0,0 +1,225 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +const ( + taskDiscoveryPollInterval = 30 * time.Second + outboxFlushInterval = 250 * time.Millisecond + outboxFlushBatchSize = 64 +) + +// LocalV01Runtime owns the V3 control and task consumers plus durable discovery +// polling. Broker queues remain SaaS-owned; this runtime only passively consumes +// them and publishes Dispatcher outbox records. +type LocalV01Runtime struct { + dispatcher *Dispatcher + broker *mq.V3Broker + client *configread.Client + verifier SIPConfigVerifier + queues *v3TaskQueueController + controls *localTaskControlProcessor +} + +func NewLocalV01Runtime(d *Dispatcher, broker *mq.V3Broker, client *configread.Client, verifier SIPConfigVerifier, taskController TaskController) (*LocalV01Runtime, error) { + if d == nil || broker == nil || client == nil || verifier == nil { + return nil, errors.New("dispatcher, V3 broker, config-read client, and SIP verifier are required") + } + if d.dispatcherID == "" || client.DispatcherID() != d.dispatcherID || broker.ControlQueue() != mq.V3ControlQueueName(d.dispatcherID) { + return nil, errors.New("V3 runtime Dispatcher identity mismatch") + } + queues := newV3TaskQueueController(d, broker, taskController) + return &LocalV01Runtime{ + dispatcher: d, broker: broker, client: client, verifier: verifier, queues: queues, + controls: newLocalTaskControlProcessor(d, client, queues), + }, nil +} + +// EnableMockAuthorizedOrigination binds the single active Agent before task +// consumption starts. Other modes have no authorized execution adapter. +func (r *LocalV01Runtime) EnableMockAuthorizedOrigination(agentID string, agents *AgentCoordinator) error { + if r == nil || r.queues == nil { + return errors.New("local task runtime is not configured") + } + return r.queues.EnableMockAuthorizedOrigination(agentID, agents) +} + +// Run keeps control processing available when config discovery is temporarily +// unavailable, while withholding task-queue consumption until a fresh discovery +// and valid execution snapshot have been persisted. +func (r *LocalV01Runtime) Run(ctx context.Context) error { + if r == nil || r.dispatcher == nil || r.broker == nil || r.client == nil || r.verifier == nil || r.queues == nil || r.controls == nil { + return errors.New("local v0.1 runtime is not configured") + } + if err := r.dispatcher.store.CloseLocalTaskDiscoveryAdmission(r.dispatcher.dispatcherID); err != nil { + return fmt.Errorf("close admission before initial discovery: %w", err) + } + initialDiscoveryErr := r.refreshDiscoveryState(ctx) + if initialDiscoveryErr != nil && !errors.Is(initialDiscoveryErr, context.Canceled) { + slog.Error("initial task discovery failed; task admission remains closed", "dispatcher_id", r.dispatcher.dispatcherID, "error", initialDiscoveryErr) + } + controlConsumer, err := r.broker.StartPredeclaredConsumer(ctx, r.broker.ControlQueue(), r.controls.Handle) + if err != nil { + return fmt.Errorf("start SaaS-predeclared control consumer: %w", err) + } + controlDone := make(chan error, 1) + go func() { controlDone <- controlConsumer.Wait(context.Background()) }() + + cleanup := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := r.queues.StopAll(shutdownCtx); err != nil { + slog.Error("stop task consumers during runtime shutdown", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + if err := controlConsumer.Stop(shutdownCtx); err != nil { + slog.Error("stop control consumer during runtime shutdown", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + } + defer cleanup() + + // Results and issued-call reconciliation must keep running even when + // discovery is unavailable and new task consumption remains closed. + r.recoverMockResults(ctx) + if initialDiscoveryErr == nil { + if err := r.reconcileTaskQueues(ctx); err != nil { + slog.Error("initial task queue reconciliation failed; admission remains closed", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + } + + discoveryTicker := time.NewTicker(taskDiscoveryPollInterval) + defer discoveryTicker.Stop() + resultTicker := time.NewTicker(time.Second) + defer resultTicker.Stop() + outboxTicker := time.NewTicker(outboxFlushInterval) + defer outboxTicker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case err := <-controlDone: + if ctx.Err() != nil { + return nil + } + if err == nil { + return errors.New("control queue consumer exited unexpectedly") + } + return fmt.Errorf("control queue consumer stopped: %w", err) + case err := <-r.queues.Errors(): + return fmt.Errorf("task queue consumer stopped: %w", err) + case <-discoveryTicker.C: + r.recoverMockResults(ctx) + if err := r.refreshDiscoveryState(ctx); err != nil { + slog.Error("task discovery refresh failed; task admission closed", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + continue + } + if err := r.reconcileTaskQueues(ctx); err != nil { + slog.Error("task queue reconciliation failed; admission remains fail-closed", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + case <-resultTicker.C: + if r.queues.mockAuthorizedAgents != nil { + if err := r.dispatcher.recoverPendingMockResults(ctx); err != nil && ctx.Err() == nil { + slog.Error("restore pending Mock call results", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + } + case <-outboxTicker.C: + if _, err := r.dispatcher.FlushOutbox(ctx, outboxFlushBatchSize); err != nil && ctx.Err() == nil { + slog.Error("Dispatcher outbox flush failed; records remain retryable", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } + } + } +} + +func (r *LocalV01Runtime) recoverMockResults(ctx context.Context) { + if r.queues.mockAuthorizedAgents == nil { + return + } + if err := r.dispatcher.RecoverAuthorizedMockResults(ctx, r.queues.mockAuthorizedAgentID, r.queues.mockAuthorizedAgents); err != nil && ctx.Err() == nil { + slog.Error("reconcile issued and final Mock call facts; unresolved calls retain quota", "dispatcher_id", r.dispatcher.dispatcherID, "error", err) + } +} + +func (r *LocalV01Runtime) refreshDiscoveryState(ctx context.Context) error { + cursor, exists, err := r.dispatcher.store.LocalTaskDiscoveryCursor(r.dispatcher.dispatcherID) + if err != nil { + return r.failDiscovery(fmt.Errorf("read durable task-discovery cursor: %w", err)) + } + if !exists { + cursor = "" + } + discovery, err := r.client.ReadTaskDiscovery(ctx, cursor) + if err != nil { + return r.failDiscovery(fmt.Errorf("read task discovery: %w", err)) + } + if err := r.dispatcher.store.ApplyLocalTaskDiscovery(localTaskDiscoveryFromRead(discovery)); err != nil { + return r.failDiscovery(fmt.Errorf("persist complete task-discovery response: %w", err)) + } + return nil +} + +func (r *LocalV01Runtime) failDiscovery(cause error) error { + if err := r.dispatcher.store.CloseLocalTaskDiscoveryAdmission(r.dispatcher.dispatcherID); err != nil { + cause = errors.Join(cause, fmt.Errorf("close admission after task discovery failure: %w", err)) + } + if r.queues != nil { + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := r.queues.StopAll(stopCtx); err != nil { + cause = errors.Join(cause, fmt.Errorf("stop task consumers after discovery failure: %w", err)) + } + } + return cause +} + +func (r *LocalV01Runtime) reconcileTaskQueues(ctx context.Context) error { + assignments, err := r.dispatcher.store.LocalTaskAssignments(r.dispatcher.dispatcherID) + if err != nil { + return fmt.Errorf("list durable task assignments: %w", err) + } + var failures []error + for _, assignment := range assignments { + if !taskAssignmentCanConsume(assignment) { + if err := r.queues.StopTask(ctx, assignment); err != nil { + failures = append(failures, fmt.Errorf("stop task %s consumer: %w", assignment.TaskID, err)) + } + continue + } + if _, valid := r.dispatcher.ProjectConfigSnapshot(assignment.TaskID, assignment.TenantID); !valid { + if err := r.dispatcher.LoadProjectConfig(ctx, r.client, r.verifier, assignment.TaskID, assignment.TenantID); err != nil { + if stopErr := r.queues.StopConsumer(ctx, assignment.TaskID); stopErr != nil { + failures = append(failures, fmt.Errorf("stop task %s consumer after config-read failure: %w", assignment.TaskID, stopErr)) + } + failures = append(failures, fmt.Errorf("refresh task %s execution snapshot: %w", assignment.TaskID, err)) + continue + } + assignment, err = r.dispatcher.store.LocalTaskAssignment(assignment.DispatcherID, assignment.TaskID) + if err != nil { + failures = append(failures, fmt.Errorf("reload task %s assignment after config refresh: %w", assignment.TaskID, err)) + continue + } + if !taskAssignmentCanConsume(assignment) { + if err := r.queues.StopTask(ctx, assignment); err != nil { + failures = append(failures, fmt.Errorf("stop task %s consumer after config refresh: %w", assignment.TaskID, err)) + } + continue + } + } + if err := r.queues.StartTask(ctx, assignment); err != nil { + failures = append(failures, fmt.Errorf("start predeclared task %s consumer: %w", assignment.TaskID, err)) + } + } + return errors.Join(failures...) +} + +func taskAssignmentCanConsume(assignment store.LocalTaskAssignment) bool { + return !assignment.Removed && assignment.Status == "running" && assignment.AdmissionState == "running" && assignment.Queue.QueueName != "" +} diff --git a/internal/dispatcher/task_runtime_v3_test.go b/internal/dispatcher/task_runtime_v3_test.go new file mode 100644 index 0000000..cf49ae3 --- /dev/null +++ b/internal/dispatcher/task_runtime_v3_test.go @@ -0,0 +1,299 @@ +package dispatcher + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestLocalV01RuntimeRefreshDiscoveryPersistsCompleteSnapshotAndCursor(t *testing.T) { + body := localConfigFixture(t, "task-discovery-snapshot-v0.2.json") + var afterValues []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/internal/v1/dispatcher/tasks" { + t.Errorf("discovery request = %s %s", r.Method, r.URL.RequestURI()) + } + afterValues = append(afterValues, r.URL.Query().Get("after")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer server.Close() + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + d, err := NewV3(localTestDispatcherID, st, nil, time.Now) + if err != nil { + t.Fatal(err) + } + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + runtime := &LocalV01Runtime{dispatcher: d, client: client} + if err := runtime.refreshDiscoveryState(context.Background()); err != nil { + t.Fatal(err) + } + if len(afterValues) != 1 || afterValues[0] != "" { + t.Fatalf("initial discovery cursors = %v, want one full-snapshot request", afterValues) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor(localTestDispatcherID) + if err != nil || !exists || cursor == "" { + t.Fatalf("persisted cursor=%q exists=%v err=%v", cursor, exists, err) + } + assignments, err := st.LocalTaskAssignments(localTestDispatcherID) + if err != nil || len(assignments) != 1 || assignments[0].TaskID != localTestTaskID { + t.Fatalf("persisted assignments=%+v err=%v", assignments, err) + } +} + +func TestLocalV01RuntimeDiscoveryFailureClosesAdmissionAndStopsTaskConsumers(t *testing.T) { + var phase atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v1/dispatcher/tasks" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch phase.Load() { + case 0: + _, _ = w.Write(localConfigFixture(t, "task-discovery-snapshot-v0.2.json")) + case 1: + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"schema_version":"task-discovery.v0.2-proposal","resource":"error","error":{"code":"service_unavailable","message":"mock outage"}}`)) + default: + _, _ = w.Write([]byte(`{"schema_version":"task-discovery.v0.2-proposal","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","next_cursor":"opaque-watermark-001","changes":[]}`)) + } + })) + defer server.Close() + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + d, err := NewV3(localTestDispatcherID, st, nil, time.Now) + if err != nil { + t.Fatal(err) + } + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + runtime := &LocalV01Runtime{dispatcher: d, client: client, queues: queues} + if err := runtime.refreshDiscoveryState(context.Background()); err != nil { + t.Fatal(err) + } + assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil { + t.Fatal(err) + } + if err := queues.StartTask(context.Background(), assignment); err != nil { + t.Fatal(err) + } + phase.Store(1) + if err := runtime.refreshDiscoveryState(context.Background()); err == nil { + t.Fatal("outage accepted as fresh discovery") + } + var ready int + if err := st.DB().QueryRow(`SELECT ready FROM local_v02_task_discovery_state WHERE dispatcher_id=?`, localTestDispatcherID).Scan(&ready); err != nil || ready != 0 { + t.Fatalf("discovery gate after outage=%d err=%v", ready, err) + } + select { + case <-broker.consumers[0].done: + default: + t.Fatal("stale task consumer remained active after discovery outage") + } + phase.Store(2) + if err := runtime.refreshDiscoveryState(context.Background()); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT ready FROM local_v02_task_discovery_state WHERE dispatcher_id=?`, localTestDispatcherID).Scan(&ready); err != nil || ready != 1 { + t.Fatalf("discovery gate after fresh no-change=%d err=%v", ready, err) + } +} + +func TestLocalV01RuntimeExpiredCursorRecoversFullSnapshotAndRetiresTask(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v1/dispatcher/tasks" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + switch calls.Add(1) { + case 1: + if r.URL.Query().Has("after") { + t.Error("initial read must be a full snapshot") + } + _, _ = w.Write(localConfigFixture(t, "task-discovery-snapshot-v0.2.json")) + case 2: + if r.URL.Query().Get("after") != "opaque-watermark-001" { + t.Errorf("incremental cursor = %s", r.URL.RawQuery) + } + w.WriteHeader(http.StatusGone) + _, _ = w.Write([]byte(`{"schema_version":"task-discovery.v0.2-proposal","resource":"error","error":{"code":"cursor_expired","message":"mock expired"}}`)) + case 3: + if r.URL.Query().Has("after") { + t.Errorf("recovery must be a full snapshot: %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"schema_version":"task-discovery.v0.2-proposal","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-watermark-002","tasks":[]}`)) + default: + t.Error("unexpected fourth request") + } + })) + defer server.Close() + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + d, err := NewV3(localTestDispatcherID, st, nil, time.Now) + if err != nil { + t.Fatal(err) + } + client, err := configread.NewClient(server.URL, localTestDispatcherID, "test-secret", server.Client()) + if err != nil { + t.Fatal(err) + } + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + runtime := &LocalV01Runtime{dispatcher: d, client: client, queues: queues} + if err := runtime.refreshDiscoveryState(context.Background()); err != nil { + t.Fatal(err) + } + assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil { + t.Fatal(err) + } + if err := queues.StartTask(context.Background(), assignment); err != nil { + t.Fatal(err) + } + if err := runtime.refreshDiscoveryState(context.Background()); err != nil { + t.Fatal(err) + } + if err := runtime.reconcileTaskQueues(context.Background()); err != nil { + t.Fatal(err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor(localTestDispatcherID) + if err != nil || !exists || cursor != "opaque-watermark-002" || calls.Load() != 3 { + t.Fatalf("recovered cursor=%q exists=%v requests=%d err=%v", cursor, exists, calls.Load(), err) + } + assignment, err = st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil || !assignment.Removed || assignment.AdmissionState != "removed" { + t.Fatalf("retired assignment=%+v err=%v", assignment, err) + } + select { + case <-broker.consumers[0].done: + default: + t.Fatal("removed task remained subscribed") + } +} + +func TestLocalV01RuntimeReconcilesOnlyEligibleAssignments(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + runtime := &LocalV01Runtime{dispatcher: d, queues: queues} + if err := runtime.reconcileTaskQueues(context.Background()); err != nil { + t.Fatal(err) + } + if len(broker.started) != 1 || broker.started[0] == "" { + t.Fatalf("eligible task queue subscriptions=%v", broker.started) + } + if err := queues.StopAll(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestLocalV01RuntimeReconcileDoesNotConsumePausedAssignment(t *testing.T) { + now := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + d, st, server := newLocalV01TestDispatcher(t, now) + defer server.Close() + defer st.Close() + assignment, err := st.LocalTaskAssignment(localTestDispatcherID, localTestTaskID) + if err != nil { + t.Fatal(err) + } + assignment, err = st.SetLocalTaskAdmissionBarrier(assignment.DispatcherID, assignment.TaskID, assignment.TenantID, assignment.TenantKey, "paused") + if err != nil { + t.Fatal(err) + } + broker := &fakeQueueBroker{} + queues := newTaskQueueController(d, broker, nil) + runtime := &LocalV01Runtime{dispatcher: d, queues: queues} + if err := runtime.reconcileTaskQueues(context.Background()); err != nil { + t.Fatal(err) + } + if len(broker.started) != 0 || len(broker.drained) != 0 { + t.Fatalf("paused task consumed or drained its backlog: started=%v drained=%v", broker.started, broker.drained) + } +} + +func TestTaskAssignmentCanConsumeRequiresRunningAssignedQueue(t *testing.T) { + base := store.LocalTaskAssignment{ + Status: "running", AdmissionState: "running", + Queue: store.LocalTaskQueue{QueueName: "saas-owned-queue"}, + } + cases := []struct { + name string + assignment store.LocalTaskAssignment + want bool + }{ + {name: "eligible", assignment: base, want: true}, + {name: "paused status", assignment: withTaskStatus(base, "paused"), want: false}, + {name: "closed admission", assignment: withAdmissionState(base, "paused"), want: false}, + {name: "removed", assignment: withRemoved(base), want: false}, + {name: "queue missing", assignment: withQueueName(base, ""), want: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := taskAssignmentCanConsume(tc.assignment); got != tc.want { + t.Fatalf("taskAssignmentCanConsume()=%v, want %v", got, tc.want) + } + }) + } +} + +func TestNewLocalV01RuntimeRequiresDependencies(t *testing.T) { + if _, err := NewLocalV01Runtime(nil, nil, nil, nil, nil); err == nil { + t.Fatal("runtime accepted missing dependencies") + } +} + +func TestLocalV01RuntimeRunFailsClosedWhenUnconfigured(t *testing.T) { + var runtime LocalV01Runtime + if err := runtime.Run(context.Background()); err == nil { + t.Fatal("unconfigured runtime started") + } +} + +func withTaskStatus(assignment store.LocalTaskAssignment, status string) store.LocalTaskAssignment { + assignment.Status = status + return assignment +} + +func withAdmissionState(assignment store.LocalTaskAssignment, state string) store.LocalTaskAssignment { + assignment.AdmissionState = state + return assignment +} + +func withRemoved(assignment store.LocalTaskAssignment) store.LocalTaskAssignment { + assignment.Removed = true + return assignment +} + +func withQueueName(assignment store.LocalTaskAssignment, name string) store.LocalTaskAssignment { + assignment.Queue.QueueName = name + return assignment +} diff --git a/internal/mq/amqp_v3.go b/internal/mq/amqp_v3.go new file mode 100644 index 0000000..0b61949 --- /dev/null +++ b/internal/mq/amqp_v3.go @@ -0,0 +1,351 @@ +package mq + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "git.ipao.vip/rogee/go-sip/internal/tenant" + amqp "github.com/rabbitmq/amqp091-go" +) + +const ( + CommandsExchangeV3 = "agent-call.dispatchers.v3" + ResultsExchangeV3 = "agent-call.saas.v3" + DeadLetterExchangeV3 = "agent-call.dead-letter.v3" + MaxV3MessageBytes = 8 << 20 +) + +type V3Broker struct { + conn *amqp.Connection + dispatcherID string + prefetch int + controlQueue string + resultQueue string + resultRoute string + closed <-chan *amqp.Error + mu sync.Mutex +} + +// OpenV3 verifies SaaS-provisioned topology passively. It never declares, +// binds, or deletes exchanges or queues. +func V3ControlQueueName(dispatcherID string) string { + return "agent-call.d." + dispatcherID + ".control.v3" +} + +func OpenV3(url, dispatcherID string, prefetch int) (*V3Broker, error) { + if strings.TrimSpace(url) == "" { + return nil, errors.New("rabbitmq URL is required") + } + if prefetch <= 0 { + return nil, errors.New("prefetch must be positive") + } + if err := tenant.ValidateDispatcherID(dispatcherID); err != nil { + return nil, err + } + conn, err := amqp.Dial(url) + if err != nil { + return nil, fmt.Errorf("dial rabbitmq: %w", err) + } + channel, err := conn.Channel() + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("open rabbitmq v3 channel: %w", err) + } + for _, exchange := range []string{CommandsExchangeV3, ResultsExchangeV3, DeadLetterExchangeV3} { + if err := channel.ExchangeDeclarePassive(exchange, "topic", true, false, false, false, nil); err != nil { + _ = channel.Close() + _ = conn.Close() + return nil, fmt.Errorf("required SaaS exchange %s unavailable: %w", exchange, err) + } + } + controlQueue := V3ControlQueueName(dispatcherID) + if _, err := channel.QueueDeclarePassive(controlQueue, true, false, false, false, nil); err != nil { + _ = channel.Close() + _ = conn.Close() + return nil, fmt.Errorf("required SaaS control queue unavailable: %w", err) + } + resultQueue := "agent-call.saas.d." + dispatcherID + ".v3" + if _, err := channel.QueueDeclarePassive(resultQueue, true, false, false, false, nil); err != nil { + _ = channel.Close() + _ = conn.Close() + return nil, fmt.Errorf("required SaaS result queue unavailable: %w", err) + } + _ = channel.Close() + return &V3Broker{ + conn: conn, + dispatcherID: dispatcherID, + prefetch: prefetch, + controlQueue: controlQueue, + resultQueue: resultQueue, + resultRoute: "d." + dispatcherID + ".out", + closed: conn.NotifyClose(make(chan *amqp.Error, 1)), + }, nil +} + +func (b *V3Broker) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.conn == nil { + return nil + } + err := b.conn.Close() + b.conn = nil + return err +} + +func (b *V3Broker) Done() <-chan *amqp.Error { return b.closed } + +func (b *V3Broker) Publish(ctx context.Context, exchange, routingKey string, body []byte) error { + if exchange != ResultsExchangeV3 || routingKey != b.resultRoute || len(body) == 0 || len(body) > MaxV3MessageBytes { + return fmt.Errorf("v3 publish requires result exchange, Dispatcher route and 1..%d body bytes", MaxV3MessageBytes) + } + var identity struct { + EventID string `json:"event_id"` + MessageID string `json:"message_id"` + } + if err := json.Unmarshal(body, &identity); err != nil { + return fmt.Errorf("decode outbound message identity: %w", err) + } + if (identity.EventID == "") == (identity.MessageID == "") { + return errors.New("outbound message requires exactly one event_id or message_id") + } + messageID := identity.EventID + identity.MessageID + if len(messageID) > 255 { + return errors.New("outbound message identity exceeds AMQP limit") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + b.mu.Lock() + defer b.mu.Unlock() + if err := ctx.Err(); err != nil { + return err + } + if b.conn == nil || b.conn.IsClosed() { + return errors.New("rabbitmq v3 connection is closed") + } + channel, err := b.conn.Channel() + if err != nil { + return fmt.Errorf("open v3 publication channel: %w", err) + } + defer channel.Close() + if _, err := channel.QueueDeclarePassive(b.resultQueue, true, false, false, false, nil); err != nil { + return fmt.Errorf("required SaaS result queue unavailable: %w", err) + } + if err := channel.Confirm(false); err != nil { + return fmt.Errorf("enable v3 publisher confirms: %w", err) + } + returned := channel.NotifyReturn(make(chan amqp.Return, 1)) + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, exchange, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, MessageId: messageID, + }) + if err != nil { + return fmt.Errorf("publish v3 result: %w", err) + } + if confirmation == nil { + return errors.New("rabbitmq v3 publisher confirmation unavailable") + } + acked, err := confirmation.WaitContext(ctx) + if err != nil { + return fmt.Errorf("wait for v3 publisher confirmation: %w", err) + } + select { + case result, ok := <-returned: + if !ok { + return errors.New("v3 publication channel closed before routing was established") + } + return fmt.Errorf("v3 publication returned: code=%d", result.ReplyCode) + default: + } + if !acked { + return errors.New("rabbitmq v3 publisher was negatively acknowledged") + } + return nil +} + +type V3Consumer struct { + cancel context.CancelFunc + done chan struct{} + mu sync.Mutex + err error +} + +func (c *V3Consumer) Wait(ctx context.Context) error { + select { + case <-c.done: + c.mu.Lock() + defer c.mu.Unlock() + return c.err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *V3Consumer) Stop(ctx context.Context) error { + c.cancel() + err := c.Wait(ctx) + if errors.Is(err, context.Canceled) { + return nil + } + return err +} + +func (b *V3Broker) ConsumePredeclared(ctx context.Context, queue string, handler MessageHandler) error { + consumer, err := b.StartPredeclaredConsumer(ctx, queue, handler) + if err != nil { + return err + } + err = consumer.Wait(context.Background()) + if err != nil { + return err + } + return ctx.Err() +} + +// StartPredeclaredConsumer subscribes only to an existing SaaS-owned queue. +// Stopping it closes the channel so every unacknowledged delivery is requeued. +func (b *V3Broker) StartPredeclaredConsumer(ctx context.Context, queue string, handler MessageHandler) (*V3Consumer, error) { + if queue == "" || handler == nil { + return nil, errors.New("predeclared queue and handler are required") + } + if len(queue) > 255 { + return nil, errors.New("queue name exceeds AMQP limit") + } + if err := ctx.Err(); err != nil { + return nil, err + } + b.mu.Lock() + conn, prefetch, dispatcherID := b.conn, b.prefetch, b.dispatcherID + closed := conn == nil || conn.IsClosed() + b.mu.Unlock() + if closed { + return nil, errors.New("rabbitmq v3 connection is closed") + } + channel, err := conn.Channel() + if err != nil { + return nil, fmt.Errorf("open v3 consumer channel: %w", err) + } + if _, err := channel.QueueDeclarePassive(queue, true, false, false, false, nil); err != nil { + _ = channel.Close() + return nil, fmt.Errorf("required SaaS-owned queue %s unavailable: %w", queue, err) + } + if err := channel.Qos(prefetch, 0, false); err != nil { + _ = channel.Close() + return nil, fmt.Errorf("set v3 consumer prefetch: %w", err) + } + consumerTag := fmt.Sprintf("sip-go-agent-v3-%d", consumerSequence.Add(1)) + deliveries, err := channel.Consume(queue, consumerTag, false, false, false, false, nil) + if err != nil { + _ = channel.Close() + return nil, fmt.Errorf("consume SaaS-owned v3 queue: %w", err) + } + consumerCtx, cancel := context.WithCancel(ctx) + consumer := &V3Consumer{cancel: cancel, done: make(chan struct{})} + go func() { + consumeErr := b.consumeV3Deliveries(consumerCtx, dispatcherID, deliveries, handler) + if errors.Is(consumeErr, context.Canceled) && consumerCtx.Err() != nil { + consumeErr = nil + } + if err := channel.Cancel(consumerTag, false); err != nil { + consumeErr = errors.Join(consumeErr, fmt.Errorf("cancel v3 consumer: %w", err)) + } + if err := channel.Close(); err != nil { + consumeErr = errors.Join(consumeErr, fmt.Errorf("close v3 consumer channel: %w", err)) + } + consumer.mu.Lock() + consumer.err = consumeErr + consumer.mu.Unlock() + close(consumer.done) + }() + return consumer, nil +} + +func (b *V3Broker) DrainPredeclared(ctx context.Context, queue string) (int, error) { + if queue == "" || len(queue) > 255 { + return 0, errors.New("valid SaaS-owned queue name is required") + } + if err := ctx.Err(); err != nil { + return 0, err + } + b.mu.Lock() + conn := b.conn + closed := conn == nil || conn.IsClosed() + b.mu.Unlock() + if closed { + return 0, errors.New("rabbitmq v3 connection is closed") + } + channel, err := conn.Channel() + if err != nil { + return 0, fmt.Errorf("open v3 drain channel: %w", err) + } + defer channel.Close() + if _, err := channel.QueueDeclarePassive(queue, true, false, false, false, nil); err != nil { + return 0, fmt.Errorf("required SaaS-owned queue %s unavailable: %w", queue, err) + } + drained := 0 + for { + if err := ctx.Err(); err != nil { + return drained, err + } + delivery, ok, err := channel.Get(queue, false) + if err != nil { + return drained, fmt.Errorf("read SaaS-owned queue %s for stop drain: %w", queue, err) + } + if !ok { + return drained, nil + } + if err := delivery.Ack(false); err != nil { + return drained, fmt.Errorf("ack stopped task backlog: %w", err) + } + drained++ + } +} + +func (b *V3Broker) ControlQueue() string { return b.controlQueue } + +func (b *V3Broker) consumeV3Deliveries(ctx context.Context, dispatcherID string, deliveries <-chan amqp.Delivery, handler MessageHandler) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case delivery, ok := <-deliveries: + if !ok { + return errors.New("rabbitmq v3 delivery channel closed") + } + if len(delivery.Body) == 0 || len(delivery.Body) > MaxV3MessageBytes { + if err := delivery.Reject(false); err != nil { + return fmt.Errorf("reject invalid v3 message size: %w", err) + } + slog.Warn("MQ v3 message rejected", "dispatcher_id", dispatcherID, "delivery_tag", delivery.DeliveryTag, "reason", "invalid_message_size", "bytes", len(delivery.Body)) + continue + } + if !json.Valid(delivery.Body) { + if err := delivery.Reject(false); err != nil { + return fmt.Errorf("reject invalid v3 JSON: %w", err) + } + slog.Warn("MQ v3 message rejected", "dispatcher_id", dispatcherID, "delivery_tag", delivery.DeliveryTag, "reason", "invalid_json") + continue + } + if err := handler(ctx, delivery.RoutingKey, delivery.Body); err != nil { + if IsPermanent(err) { + if rejectErr := delivery.Reject(false); rejectErr != nil { + return fmt.Errorf("permanent v3 handler error %v; reject: %w", err, rejectErr) + } + continue + } + if nackErr := delivery.Nack(false, true); nackErr != nil { + return fmt.Errorf("v3 handler error %v; nack: %w", err, nackErr) + } + continue + } + if err := delivery.Ack(false); err != nil { + return fmt.Errorf("ack v3 delivery: %w", err) + } + } + } +} diff --git a/internal/mq/amqp_v3_integration_test.go b/internal/mq/amqp_v3_integration_test.go new file mode 100644 index 0000000..d8e5af2 --- /dev/null +++ b/internal/mq/amqp_v3_integration_test.go @@ -0,0 +1,400 @@ +//go:build integration + +package mq + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +func TestV3BrokerConsumesSaaSOwnedQueueAndPublishesConfirmedResult(t *testing.T) { + url := os.Getenv("RABBITMQ_URL") + if url == "" { + t.Skip("RABBITMQ_URL not set") + } + provisionerURL := os.Getenv("RABBITMQ_PROVISIONER_URL") + if provisionerURL == "" { + provisionerURL = url + } + const dispatcherID = "550e8400-e29b-41d4-a716-446655440000" + const taskID = "task-1" + const routingKey = "d." + dispatcherID + ".task." + taskID + ".in" + const taskQueue = "agent-call.d." + dispatcherID + ".task." + taskID + ".v3" + const controlQueue = "agent-call.d." + dispatcherID + ".control.v3" + const controlRoute = "d." + dispatcherID + ".control.in" + const resultRoute = "d." + dispatcherID + ".out" + const resultQueue = "agent-call.saas.d." + dispatcherID + ".v3" + const deadRoute = "d." + dispatcherID + ".dead-letter" + const deadQueue = "agent-call.d." + dispatcherID + ".dead-letter.v3" + + mockConn, err := amqp.Dial(provisionerURL) + if err != nil { + t.Fatal(err) + } + defer mockConn.Close() + mockChannel, err := mockConn.Channel() + if err != nil { + t.Fatal(err) + } + defer mockChannel.Close() + for _, exchange := range []string{CommandsExchangeV3, ResultsExchangeV3, DeadLetterExchangeV3} { + if err := mockChannel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + t.Fatalf("Mock SaaS declare exchange %s: %v", exchange, err) + } + } + if _, err := mockChannel.QueueDeclare(deadQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := mockChannel.QueueBind(deadQueue, deadRoute, DeadLetterExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if _, err := mockChannel.QueueDeclare(taskQueue, true, false, false, false, amqp.Table{ + "x-dead-letter-exchange": DeadLetterExchangeV3, + "x-dead-letter-routing-key": deadRoute, + }); err != nil { + t.Fatal(err) + } + if err := mockChannel.QueueBind(taskQueue, routingKey, CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if _, err := mockChannel.QueueDeclare(controlQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := mockChannel.QueueBind(controlQueue, controlRoute, CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if _, err := mockChannel.QueueDeclare(resultQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + if err := mockChannel.QueueBind(resultQueue, resultRoute, ResultsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + for _, queue := range []string{taskQueue, controlQueue, resultQueue, deadQueue} { + if _, err := mockChannel.QueuePurge(queue, false); err != nil { + t.Fatalf("purge Mock-owned queue %s: %v", queue, err) + } + defer func(queue string) { _, _ = mockChannel.QueueDelete(queue, false, false, false) }(queue) + } + + if os.Getenv("RABBITMQ_EXPECT_NO_CONFIG") == "1" { + forbiddenQueue := fmt.Sprintf("agent-call.d.%s.task.forbidden-%d.v3", dispatcherID, time.Now().UnixNano()) + dispatcherConn, err := amqp.Dial(url) + if err != nil { + t.Fatal(err) + } + dispatcherChannel, err := dispatcherConn.Channel() + if err != nil { + _ = dispatcherConn.Close() + t.Fatal(err) + } + if _, err := dispatcherChannel.QueueDeclare(forbiddenQueue, true, false, false, false, nil); err == nil { + _, _ = mockChannel.QueueDelete(forbiddenQueue, false, false, false) + _ = dispatcherChannel.Close() + _ = dispatcherConn.Close() + t.Fatal("Dispatcher identity unexpectedly has queue configure permission") + } + _ = dispatcherChannel.Close() + _ = dispatcherConn.Close() + checkChannel, err := mockConn.Channel() + if err != nil { + t.Fatal(err) + } + if _, err := checkChannel.QueueDeclarePassive(forbiddenQueue, true, false, false, false, nil); err == nil { + _ = checkChannel.Close() + _, _ = mockChannel.QueueDelete(forbiddenQueue, false, false, false) + t.Fatal("unauthorized Dispatcher queue declaration created a queue") + } + _ = checkChannel.Close() + } + + broker, err := OpenV3(url, dispatcherID, DefaultPrefetch) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + missingQueue := "agent-call.d." + dispatcherID + ".task.missing.v3" + missingCtx, missingCancel := context.WithTimeout(context.Background(), time.Second) + if err := broker.ConsumePredeclared(missingCtx, missingQueue, func(context.Context, string, []byte) error { return nil }); err == nil { + missingCancel() + t.Fatal("missing task queue should fail passive declaration") + } + missingCancel() + checkChannel, err := mockConn.Channel() + if err != nil { + t.Fatal(err) + } + if _, err := checkChannel.QueueDeclarePassive(missingQueue, true, false, false, false, nil); err == nil { + _ = checkChannel.Close() + t.Fatal("Dispatcher created a missing SaaS-owned task queue") + } + _ = checkChannel.Close() + + commandBody := []byte(`{"event_id":"command-event-1"}`) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: commandBody, + }); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + consumed := make(chan []byte, 1) + consumeErr := make(chan error, 1) + go func() { + consumeErr <- broker.ConsumePredeclared(ctx, taskQueue, func(_ context.Context, key string, body []byte) error { + if key != routingKey { + return fmt.Errorf("routing key = %q, want %q", key, routingKey) + } + consumed <- append([]byte(nil), body...) + return nil + }) + }() + select { + case got := <-consumed: + if string(got) != string(commandBody) { + t.Fatalf("consumed body = %s, want %s", got, commandBody) + } + case err := <-consumeErr: + cancel() + t.Fatalf("consume task queue: %v", err) + case <-time.After(5 * time.Second): + cancel() + t.Fatal("timed out waiting for command delivery") + } + cancel() + select { + case <-consumeErr: + case <-time.After(5 * time.Second): + t.Fatal("consumer did not stop after cancellation") + } + if leftover, ok, err := mockChannel.Get(taskQueue, false); err != nil { + t.Fatal(err) + } else if ok { + if string(leftover.Body) != string(commandBody) { + t.Fatalf("unexpected command after first consumer stopped: %s", leftover.Body) + } + if err := leftover.Ack(false); err != nil { + t.Fatal(err) + } + } + + // A transient SQLite write error must NACK the same original delivery; + // prefetch=1 and a later barrier prove the retry was ACKed only after + // the handler recovered. + transientBody := []byte(`{"event_id":"sqlite-write-failed-original"}`) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: transientBody, + }); err != nil { + t.Fatal(err) + } + retryCtx, retryCancel := context.WithCancel(context.Background()) + failed := make(chan struct{}, 1) + retryEntered := make(chan struct{}, 1) + releaseRetry := make(chan struct{}) + barrierSeen := make(chan struct{}, 1) + retryBarrierBody := []byte(`{"event_id":"sqlite-recovery-barrier"}`) + attempts := 0 + retryConsumer, err := broker.StartPredeclaredConsumer(retryCtx, taskQueue, func(_ context.Context, _ string, body []byte) error { + if string(body) == string(retryBarrierBody) { + barrierSeen <- struct{}{} + return nil + } + if string(body) != string(transientBody) { + return fmt.Errorf("unexpected redelivery: %s", body) + } + attempts++ + if attempts == 1 { + failed <- struct{}{} + return fmt.Errorf("injected SQLite write failure") + } + if attempts != 2 { + return fmt.Errorf("original command delivered %d times", attempts) + } + retryEntered <- struct{}{} + <-releaseRetry + return nil + }) + if err != nil { + retryCancel() + t.Fatal(err) + } + for _, signal := range []<-chan struct{}{failed, retryEntered} { + select { + case <-signal: + case <-time.After(5 * time.Second): + t.Fatal("transient SQLite write failure did not redeliver the original task") + } + } + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: retryBarrierBody, + }); err != nil { + t.Fatal(err) + } + close(releaseRetry) + select { + case <-barrierSeen: + case <-time.After(5 * time.Second): + t.Fatal("recovered command was not ACKed before the barrier") + } + if err := retryConsumer.Stop(context.Background()); err != nil { + t.Fatal(err) + } + retryCancel() + if leftover, ok, err := mockChannel.Get(taskQueue, false); err != nil { + t.Fatal(err) + } else if ok { + if string(leftover.Body) != string(retryBarrierBody) { + t.Fatalf("original command was requeued after its ACK: %s", leftover.Body) + } + if err := leftover.Ack(false); err != nil { + t.Fatal(err) + } + } + if attempts != 2 { + t.Fatalf("expected one failed and one successful delivery, got %d", attempts) + } + + pauseBody := []byte(`{"event_id":"pause-requeue"}`) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: pauseBody, + }); err != nil { + t.Fatal(err) + } + for i := 1; i < 100; i++ { + body := []byte(fmt.Sprintf(`{"event_id":"pause-backlog-%d"}`, i)) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, + }); err != nil { + t.Fatal(err) + } + } + pauseCtx, pauseCancel := context.WithCancel(context.Background()) + delivered := make(chan struct{}, 1) + consumer, err := broker.StartPredeclaredConsumer(pauseCtx, taskQueue, func(ctx context.Context, _ string, _ []byte) error { + delivered <- struct{}{} + <-ctx.Done() + return ctx.Err() + }) + if err != nil { + pauseCancel() + t.Fatal(err) + } + select { + case <-delivered: + case <-time.After(5 * time.Second): + _ = consumer.Stop(context.Background()) + pauseCancel() + t.Fatal("timed out waiting for pause-test delivery") + } + if err := consumer.Stop(context.Background()); err != nil { + pauseCancel() + t.Fatalf("stop paused consumer: %v", err) + } + pauseCancel() + queued, err := mockChannel.QueueInspect(taskQueue) + if err != nil || queued.Messages != 100 { + t.Fatalf("pause did not preserve all 100 task commands: count=%d err=%v", queued.Messages, err) + } + // Prefetch=1 means seeing this last barrier proves all 100 preceding + // deliveries were ACKed before the resumed consumer can be stopped. + barrierBody := []byte(`{"event_id":"resume-barrier"}`) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: barrierBody, + }); err != nil { + t.Fatal(err) + } + resumeCtx, resumeCancel := context.WithCancel(context.Background()) + resumed := make(chan string, 100) + barrier := make(chan struct{}, 1) + resumedConsumer, err := broker.StartPredeclaredConsumer(resumeCtx, taskQueue, func(_ context.Context, _ string, body []byte) error { + if string(body) == string(barrierBody) { + barrier <- struct{}{} + return nil + } + resumed <- string(body) + return nil + }) + if err != nil { + resumeCancel() + t.Fatal(err) + } + seen := make(map[string]bool, 100) + for len(seen) < 100 { + select { + case body := <-resumed: + if seen[body] { + t.Fatalf("resumed task command delivered twice: %s", body) + } + seen[body] = true + case <-time.After(15 * time.Second): + t.Fatalf("resumed only %d of 100 original task commands", len(seen)) + } + } + if !seen[string(pauseBody)] { + t.Fatal("paused in-flight task was not resumed from its original queue") + } + select { + case <-barrier: + case <-time.After(5 * time.Second): + t.Fatal("resumed task ACK barrier was not reached") + } + if err := resumedConsumer.Stop(context.Background()); err != nil { + resumeCancel() + t.Fatal(err) + } + resumeCancel() + // Cancellation can requeue the barrier itself; it is not one of the 100 + // task commands and cannot cause another task execution. + if last, ok, err := mockChannel.Get(taskQueue, false); err != nil { + t.Fatal(err) + } else if ok { + if string(last.Body) != string(barrierBody) { + t.Fatalf("resumed task remained after barrier: %s", last.Body) + } + if err := last.Ack(false); err != nil { + t.Fatal(err) + } + } + + for i := 0; i < 100; i++ { + body := []byte(fmt.Sprintf(`{"event_id":"stop-backlog-%d"}`, i)) + if err := mockChannel.PublishWithContext(context.Background(), CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, + }); err != nil { + t.Fatal(err) + } + } + drained, err := broker.DrainPredeclared(context.Background(), taskQueue) + if err != nil || drained != 100 { + t.Fatalf("stop drain count=%d err=%v, want 100", drained, err) + } + if _, ok, err := mockChannel.Get(taskQueue, true); err != nil || ok { + t.Fatalf("stop drain left task messages: ok=%v err=%v", ok, err) + } + if _, ok, err := mockChannel.Get(resultQueue, true); err != nil || ok { + t.Fatalf("stop drain emitted per-call results: ok=%v err=%v", ok, err) + } + + resultBody := []byte(`{"event_id":"result-event-1"}`) + if err := broker.Publish(context.Background(), ResultsExchangeV3, resultRoute, resultBody); err != nil { + t.Fatal(err) + } + resultDeliveries, err := mockChannel.Consume(resultQueue, "", true, false, false, false, nil) + if err != nil { + t.Fatal(err) + } + select { + case delivery := <-resultDeliveries: + if string(delivery.Body) != string(resultBody) { + t.Fatalf("published result = %s, want %s", delivery.Body, resultBody) + } + if delivery.DeliveryMode != amqp.Persistent { + t.Fatalf("result delivery mode = %d, want persistent", delivery.DeliveryMode) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for confirmed result") + } +} diff --git a/internal/mq/amqp_v3_queue_full_integration_test.go b/internal/mq/amqp_v3_queue_full_integration_test.go new file mode 100644 index 0000000..e7a5ede --- /dev/null +++ b/internal/mq/amqp_v3_queue_full_integration_test.go @@ -0,0 +1,129 @@ +//go:build integration + +package mq + +import ( + "context" + "os" + "testing" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// A full task queue is SaaS-owned: the publisher must observe the rejection +// and retain its command, rather than expecting Dispatcher to create a queue +// or invent the missing message after restart. +func TestV3FullSaaSOwnedTaskQueueRejectsSecondCommand(t *testing.T) { + url := os.Getenv("RABBITMQ_URL") + if url == "" { + t.Skip("RABBITMQ_URL not set") + } + provisionerURL := os.Getenv("RABBITMQ_PROVISIONER_URL") + if provisionerURL == "" { + provisionerURL = url + } + const dispatcherID = "550e8400-e29b-41d4-a716-446655440099" + const taskQueue = "agent-call.d." + dispatcherID + ".task.queue-full-1.v3" + const routingKey = "d." + dispatcherID + ".task.queue-full-1.in" + provisioner, err := amqp.Dial(provisionerURL) + if err != nil { + t.Fatal(err) + } + defer provisioner.Close() + channel, err := provisioner.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + for _, exchange := range []string{CommandsExchangeV3, ResultsExchangeV3, DeadLetterExchangeV3} { + if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + t.Fatal(err) + } + } + if _, err := channel.QueueDeclare(taskQueue, true, false, false, false, amqp.Table{ + "x-max-length": int32(1), "x-overflow": "reject-publish", + }); err != nil { + t.Fatal(err) + } + defer channel.QueueDelete(taskQueue, false, false, false) + if err := channel.QueueBind(taskQueue, routingKey, CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + controlQueue := V3ControlQueueName(dispatcherID) + resultQueue := "agent-call.saas.d." + dispatcherID + ".v3" + for _, queue := range []string{controlQueue, resultQueue} { + if _, err := channel.QueueDeclare(queue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + defer channel.QueueDelete(queue, false, false, false) + } + if err := channel.QueueBind(controlQueue, "d."+dispatcherID+".control.in", CommandsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(resultQueue, "d."+dispatcherID+".out", ResultsExchangeV3, false, nil); err != nil { + t.Fatal(err) + } + if err := channel.Confirm(false); err != nil { + t.Fatal(err) + } + confirms := channel.NotifyPublish(make(chan amqp.Confirmation, 2)) + returned := channel.NotifyReturn(make(chan amqp.Return, 2)) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + publish := func(body string) bool { + t.Helper() + if err := channel.PublishWithContext(ctx, CommandsExchangeV3, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: []byte(body), + }); err != nil { + t.Fatal(err) + } + select { + case confirmation := <-confirms: + if !confirmation.Ack { + return false + } + select { + case <-returned: + return false + default: + return true + } + case <-ctx.Done(): + t.Fatal("SaaS publisher received no RabbitMQ confirmation") + return false + } + } + const first = `{"event_id":"original-task-command"}` + if !publish(first) || publish(`{"event_id":"rejected-task-command"}`) { + t.Fatal("full SaaS-owned queue did not confirm the first command and reject the second") + } + queued, err := channel.QueueInspect(taskQueue) + if err != nil || queued.Messages != 1 { + t.Fatalf("queue-full publication invented or discarded the original command: count=%d err=%v", queued.Messages, err) + } + broker, err := OpenV3(url, dispatcherID, 1) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + got := make(chan string, 1) + consumer, err := broker.StartPredeclaredConsumer(ctx, taskQueue, func(_ context.Context, _ string, body []byte) error { + got <- string(body) + return nil + }) + if err != nil { + t.Fatal(err) + } + select { + case body := <-got: + if body != first { + t.Fatalf("Dispatcher consumed an unpublished command: %s", body) + } + case <-ctx.Done(): + t.Fatal("Dispatcher did not consume the SaaS-retained original command") + } + if err := consumer.Stop(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/mq/integration_test.go b/internal/mq/integration_test.go index 8d129e8..fe36d6b 100644 --- a/internal/mq/integration_test.go +++ b/internal/mq/integration_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "strings" "testing" "time" @@ -170,3 +171,69 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { t.Fatal("dead-letter consumer did not stop") } } + +func TestLocalRabbitMQV2PublishesOnlyConfirmedRoutedMessages(t *testing.T) { + url := os.Getenv("RABBITMQ_URL") + if url == "" { + t.Skip("RABBITMQ_URL is not configured") + } + broker, err := OpenWithPrefetch(url, uuid.NewString(), 1) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + if _, err := broker.DeclareTenantQueue("publish-" + uuid.NewString()); err != nil { + t.Fatal(err) + } + var outbound string + for route := range broker.outbound { + outbound = route + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + reject := func(exchange, route string, body []byte, reason string) { + t.Helper() + if err := broker.Publish(ctx, exchange, route, body); err == nil || !strings.Contains(err.Error(), reason) { + t.Fatalf("publish exchange=%q route=%q body-size=%d: got %v, want %q", exchange, route, len(body), err, reason) + } + } + reject(DefaultExchange, outbound, []byte(`{"event_id":"wrong-exchange"}`), "SaaS exchange") + reject(EventExchange, "", []byte(`{"event_id":"empty-route"}`), "declared route") + reject(EventExchange, outbound, nil, "body bytes") + reject(EventExchange, outbound, make([]byte, MaxMessageBytes+1), "body bytes") + reject(EventExchange, outbound, []byte(`{`), "decode outbound message identity") + reject(EventExchange, outbound, []byte(`{"event_id":"a","message_id":"b"}`), "exactly one") + reject(EventExchange, outbound, []byte(`{}`), "exactly one") + reject(EventExchange, outbound, []byte(fmt.Sprintf(`{"event_id":"%s"}`, strings.Repeat("x", 256))), "identity exceeds") + reject(EventExchange, outbound+".other", []byte(`{"event_id":"unknown-route"}`), "does not belong") + + channel, err := broker.conn.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + for _, idField := range []string{"event_id", "message_id"} { + id := uuid.NewString() + body := []byte(fmt.Sprintf(`{"%s":"%s"}`, idField, id)) + if err := broker.Publish(ctx, EventExchange, outbound, body); err != nil { + t.Fatalf("confirmed %s publication: %v", idField, err) + } + delivery, ok, err := channel.Get(SaaSQueue, true) + if err != nil || !ok || string(delivery.Body) != string(body) || delivery.MessageId != id || delivery.DeliveryMode != amqp.Persistent { + t.Fatalf("confirmed message lost identity or durability: delivery=%+v ok=%t err=%v", delivery, ok, err) + } + } + + if err := channel.QueueUnbind(SaaSQueue, outbound, EventExchange, nil); err != nil { + t.Fatal(err) + } + reject(EventExchange, outbound, []byte(`{"event_id":"unroutable"}`), "publication returned") + if err := channel.QueueBind(SaaSQueue, outbound, EventExchange, false, nil); err != nil { + t.Fatal(err) + } + if err := broker.Close(); err != nil { + t.Fatal(err) + } + reject(EventExchange, outbound, []byte(`{"event_id":"after-close"}`), "closed") +} diff --git a/internal/rpc/authorized_execution.go b/internal/rpc/authorized_execution.go new file mode 100644 index 0000000..1f7decd --- /dev/null +++ b/internal/rpc/authorized_execution.go @@ -0,0 +1,123 @@ +package rpc + +import ( + "context" + "encoding/hex" + "log/slog" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const authorizedOriginationVersion = "agent-authorized-origination.v0.1" + +// ExecuteAuthorized only runs an explicit mock adapter. D has already made and +// durably claimed the final dial decision. A enforces the D-issued exclusive +// deadline and session identity, but never recalculates business policy. +func (s *Server) ExecuteAuthorized(ctx context.Context, req *agentv1.ExecuteAuthorizedRequest) (*agentv1.ExecuteAuthorizedResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and execution binding are required") + } + if s.mode != "mock" || s.mockAuthorizedOriginate == nil { + return nil, status.Error(codes.FailedPrecondition, "authorized origination requires an explicit mock adapter") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + binding := req.Binding + if req.SchemaVersion != authorizedOriginationVersion || binding.ExecutionId == "" || binding.TenantId == "" || binding.TenantKey == "" || + binding.TaskId == "" || binding.TaskItemId == "" || req.SelectedTrunkId == "" || req.CallerId == "" || req.Callee == "" || + req.RingTimeoutMs <= 0 || req.MaxCallDurationMs <= 0 || req.DialBeforeUnixMs <= 0 || !validLowerSHA256(req.BoundSnapshotSha256) { + return nil, status.Error(codes.InvalidArgument, "invalid authorized origination version, binding or dial decision") + } + digest := messageDigest(req) + key := s.operationKey(req.Meta) + s.mu.Lock() + if s.executionErr != nil { + s.mu.Unlock() + return nil, status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + if previous, exists := s.operations[key]; exists { + if previous.digest != digest { + s.mu.Unlock() + return &agentv1.ExecuteAuthorizedResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "idempotency key content conflict", false)}, nil + } + entry := s.executions[binding.ExecutionId] + if entry == nil { + s.mu.Unlock() + return nil, status.Error(codes.Internal, "persisted operation lacks execution state") + } + response := &agentv1.ExecuteAuthorizedResponse{Receipt: proto.Clone(previous.receipt).(*agentv1.OperationReceipt), State: entry.state} + s.mu.Unlock() + return response, nil + } + if _, exists := s.executions[binding.ExecutionId]; exists { + s.mu.Unlock() + return &agentv1.ExecuteAuthorizedResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "execution already exists", false)}, nil + } + if s.now().UnixMilli() >= req.DialBeforeUnixMs { + s.mu.Unlock() + return nil, status.Error(codes.FailedPrecondition, "Dispatcher-issued dial deadline expired") + } + // Persist an unknown one-shot attempt BEFORE contacting the mock adapter. + // After a crash or lost response, replay/query cannot cause a second dial. + s.executions[binding.ExecutionId] = &executionRecord{ + executeDigest: digest, binding: proto.Clone(binding).(*agentv1.ExecutionBinding), + state: agentv1.ExecutionState_EXECUTION_STATE_UNKNOWN, taskRevision: binding.TaskRevision, + unknown: true, callState: "mock_originating_unknown", + } + pending := s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_UNKNOWN, agentv1.FailureCode_FAILURE_CODE_UNAVAILABLE, "mock originate pending; do not retry", false) + s.operations[key] = operationRecord{digest: digest, receipt: pending} + if err := s.persistExecutionJournalLocked(); err != nil { + s.mu.Unlock() + return nil, err + } + s.mu.Unlock() + + // A newly closed deadline between durable claim and adapter invocation must + // not dial. This is a technical check of D's instruction, not a new policy. + expired := s.now().UnixMilli() >= req.DialBeforeUnixMs + var dialErr error + if !expired { + dialErr = s.mockAuthorizedOriginate(ctx, proto.Clone(req).(*agentv1.ExecuteAuthorizedRequest)) + } + s.mu.Lock() + defer s.mu.Unlock() + record := s.executions[binding.ExecutionId] + var result agentv1.ResultCode + var failure agentv1.FailureCode + var detail string + switch { + case expired: + record.state, record.unknown, record.callState = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, false, "mock_deadline_closed_without_dial" + record.terminalObservedAtUnixMs = s.now().UnixMilli() + result, failure, detail = agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "dial deadline expired before mock adapter" + case dialErr != nil: + slog.Error("mock originate failed; execution remains unknown and cannot be retried", "execution_id", binding.ExecutionId, "error", dialErr) + record.state, record.unknown, record.callState = agentv1.ExecutionState_EXECUTION_STATE_UNKNOWN, true, "mock_originating_unknown" + result, failure, detail = agentv1.ResultCode_RESULT_CODE_UNKNOWN, agentv1.FailureCode_FAILURE_CODE_UNAVAILABLE, "mock originate outcome unknown; reconcile instead of retry" + default: + record.state, record.unknown, record.callState = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, false, "mock_no_answer" + record.terminalObservedAtUnixMs = s.now().UnixMilli() + result, failure, detail = agentv1.ResultCode_RESULT_CODE_APPLIED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "" + } + receipt := s.receipt(req.Meta, result, failure, detail, false) + s.operations[key] = operationRecord{digest: digest, receipt: receipt} + if err := s.persistExecutionJournalLocked(); err != nil { + return nil, err + } + return &agentv1.ExecuteAuthorizedResponse{Receipt: receipt, State: record.state}, nil +} + +func validLowerSHA256(value string) bool { + if len(value) != 64 { + return false + } + decoded, err := hex.DecodeString(value) + return err == nil && hex.EncodeToString(decoded) == value +} diff --git a/internal/rpc/authorized_execution_test.go b/internal/rpc/authorized_execution_test.go new file mode 100644 index 0000000..b8419bf --- /dev/null +++ b/internal/rpc/authorized_execution_test.go @@ -0,0 +1,201 @@ +package rpc + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func authorizedMockRequest(now time.Time) *agentv1.ExecuteAuthorizedRequest { + return &agentv1.ExecuteAuthorizedRequest{ + SchemaVersion: "agent-authorized-origination.v0.1", + Meta: testMeta("authorized-1", "authorized-key-1", 1), + Binding: &agentv1.ExecutionBinding{ + TenantId: "tenant-1", TenantKey: "tenant-original", ExecutionId: "execution-authorized-1", + TaskId: "task-1", TaskItemId: "command-1", TaskRevision: 2, + AgentVersionId: "agent-version-1", RoutePolicyId: "route-1", CallerProfileId: "caller-1", + }, + SelectedTrunkId: "trunk-1", + CallerId: "BD93205882", + Callee: "15003164745", + RingTimeoutMs: 12000, + MaxCallDurationMs: 30000, + DialBeforeUnixMs: now.Add(time.Minute).UnixMilli(), + BoundSnapshotSha256: strings.Repeat("a", 64), + } +} + +func TestExecuteAuthorizedMockOneShotAndIdentity(t *testing.T) { + now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + attempts := 0 + server := NewServer(ServerOptions{Mode: "mock", Now: func() time.Time { return now }, MockAuthorizedOriginate: func(_ context.Context, req *agentv1.ExecuteAuthorizedRequest) error { + attempts++ + if req.SelectedTrunkId != "trunk-1" || req.CallerId != "BD93205882" || req.MaxCallDurationMs != 30000 { + t.Fatalf("Agent received modified Dispatcher decision: %+v", req) + } + return nil + }}) + activateTestServer(t, server) + req := authorizedMockRequest(now) + first, err := server.ExecuteAuthorized(context.Background(), req) + if err != nil || first.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_APPLIED || first.GetState() != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL || attempts != 1 { + t.Fatalf("first mock execution=%+v attempts=%d err=%v", first, attempts, err) + } + replay, err := server.ExecuteAuthorized(context.Background(), req) + if err != nil || replay.GetReceipt().GetMeta().GetOperationId() != first.GetReceipt().GetMeta().GetOperationId() || attempts != 1 { + t.Fatalf("replay caused duplicate mock dial: %+v attempts=%d err=%v", replay, attempts, err) + } + changed := proto.Clone(req).(*agentv1.ExecuteAuthorizedRequest) + changed.Callee = "15830461047" + conflict, err := server.ExecuteAuthorized(context.Background(), changed) + if err != nil || conflict.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_CONFLICT || attempts != 1 { + t.Fatalf("same key/different body: %+v attempts=%d err=%v", conflict, attempts, err) + } + newKey := proto.Clone(req).(*agentv1.ExecuteAuthorizedRequest) + newKey.Meta = testMeta("authorized-2", "authorized-key-2", 1) + conflict, err = server.ExecuteAuthorized(context.Background(), newKey) + if err != nil || conflict.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_CONFLICT || attempts != 1 { + t.Fatalf("same execution/new operation: %+v attempts=%d err=%v", conflict, attempts, err) + } +} + +func TestExecuteAuthorizedRejectsExpiredAndInvalidMock(t *testing.T) { + now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + attempts := 0 + server := NewServer(ServerOptions{Mode: "mock", Now: func() time.Time { return now }, MockAuthorizedOriginate: func(context.Context, *agentv1.ExecuteAuthorizedRequest) error { + attempts++ + return nil + }}) + activateTestServer(t, server) + for _, tc := range []struct { + name string + change func(*agentv1.ExecuteAuthorizedRequest) + }{ + {"expired at Agent", func(r *agentv1.ExecuteAuthorizedRequest) { r.DialBeforeUnixMs = now.UnixMilli() }}, + {"missing selected trunk", func(r *agentv1.ExecuteAuthorizedRequest) { r.SelectedTrunkId = "" }}, + {"invalid snapshot digest", func(r *agentv1.ExecuteAuthorizedRequest) { r.BoundSnapshotSha256 = "bad" }}, + {"unknown contract version", func(r *agentv1.ExecuteAuthorizedRequest) { r.SchemaVersion = "other" }}, + } { + t.Run(tc.name, func(t *testing.T) { + req := authorizedMockRequest(now) + tc.change(req) + response, err := server.ExecuteAuthorized(context.Background(), req) + if response != nil || status.Code(err) != codes.InvalidArgument && status.Code(err) != codes.FailedPrecondition { + t.Fatalf("invalid decision response=%+v err=%v", response, err) + } + if attempts != 0 { + t.Fatalf("invalid decision reached mock dial %d times", attempts) + } + }) + } +} + +func TestExecuteAuthorizedMockFailureIsUnknownAndNeverRetried(t *testing.T) { + now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + attempts := 0 + server := NewServer(ServerOptions{Mode: "mock", Now: func() time.Time { return now }, MockAuthorizedOriginate: func(context.Context, *agentv1.ExecuteAuthorizedRequest) error { + attempts++ + return errors.New("mock transport failed") + }}) + activateTestServer(t, server) + req := authorizedMockRequest(now) + first, err := server.ExecuteAuthorized(context.Background(), req) + if err != nil || first.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_UNKNOWN || attempts != 1 { + t.Fatalf("uncertain mock execution=%+v attempts=%d err=%v", first, attempts, err) + } + replay, err := server.ExecuteAuthorized(context.Background(), req) + if err != nil || replay.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_UNKNOWN || attempts != 1 { + t.Fatalf("uncertain execution redialed: %+v attempts=%d err=%v", replay, attempts, err) + } +} + +func TestExecuteAuthorizedJournalPreventsRedialAfterAgentRestart(t *testing.T) { + for _, tc := range []struct { + name string + fail bool + }{ + {"mock completed", false}, + {"mock outcome unknown", true}, + } { + t.Run(tc.name, func(t *testing.T) { + now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + path := filepath.Join(t.TempDir(), "agent-session.json") + attempts := 0 + start := func(generation uint64, bootID string) *Server { + server := NewServer(ServerOptions{Mode: "mock", Now: func() time.Time { return now }, StatePath: path, + Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: bootID}, + MockAuthorizedOriginate: func(context.Context, *agentv1.ExecuteAuthorizedRequest) error { + attempts++ + if tc.fail { + return errors.New("mock adapter failed") + } + return nil + }, + }) + meta := testMeta("activate", "", 0) + meta.BootId = bootID + if _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: meta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: bootID, + DispatcherEpoch: "epoch-1", SessionGeneration: generation}, ActivationOperationId: "activate", + }); err != nil { + t.Fatal(err) + } + return server + } + first := start(1, "boot-1") + req := authorizedMockRequest(now) + response, err := first.ExecuteAuthorized(context.Background(), req) + if err != nil || response == nil || attempts != 1 { + t.Fatalf("first mock call: response=%+v attempts=%d err=%v", response, attempts, err) + } + terminalObservedAt := now.UnixMilli() + now = now.Add(time.Hour) + recovered := start(2, "boot-2") + replay := proto.Clone(req).(*agentv1.ExecuteAuthorizedRequest) + replay.Meta = testMeta("authorized-new-boot", "authorized-key-new-boot", 2) + replay.Meta.BootId = "boot-2" + retry, err := recovered.ExecuteAuthorized(context.Background(), replay) + if err != nil || retry.GetReceipt().GetResult() == agentv1.ResultCode_RESULT_CODE_APPLIED || attempts != 1 { + t.Fatalf("restart redialed execution: response=%+v attempts=%d err=%v", retry, attempts, err) + } + queryMeta := testMeta("query-recovered", "query-recovered-key", 2) + queryMeta.BootId = "boot-2" + query, err := recovered.QueryExecution(context.Background(), &agentv1.QueryExecutionRequest{Meta: queryMeta, Binding: req.Binding}) + if err != nil || query.Snapshot == nil || query.Snapshot.Unknown != tc.fail { + t.Fatalf("recovered state failed to retain uncertainty: %+v err=%v", query, err) + } + if !tc.fail && query.Snapshot.ObservedAtUnixMs != terminalObservedAt { + t.Fatalf("terminal observation drifted after restart: got=%d want=%d", query.Snapshot.ObservedAtUnixMs, terminalObservedAt) + } + }) + } +} + +func TestExecuteAuthorizedCannotDialOutsideMockOrWithoutAdapter(t *testing.T) { + now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC) + for _, tc := range []struct { + name string + mode string + }{ + {"mixed disabled", "mixed"}, + {"real disabled", "real"}, + {"mock adapter missing", "mock"}, + } { + t.Run(tc.name, func(t *testing.T) { + server := NewServer(ServerOptions{Mode: tc.mode, Now: func() time.Time { return now }}) + activateTestServer(t, server) + response, err := server.ExecuteAuthorized(context.Background(), authorizedMockRequest(now)) + if response != nil || status.Code(err) != codes.FailedPrecondition { + t.Fatalf("unavailable mode/adapter response=%+v err=%v", response, err) + } + }) + } +} diff --git a/internal/rpc/dispatcher_events.go b/internal/rpc/dispatcher_events.go index 05da852..6af40ce 100644 --- a/internal/rpc/dispatcher_events.go +++ b/internal/rpc/dispatcher_events.go @@ -22,14 +22,22 @@ type DispatcherEventServerOptions struct { PeerCertificateFingerprints map[string]struct{} AllowedAgentIDs map[string]struct{} Now func() time.Time + // LocalV3RecordingFailure accepts only a versioned Mock failure fact; + // when configured, the older split execution events are rejected. + LocalV3RecordingFailure func(context.Context, *agentv1.ExecutionFact) error + LocalV3SessionCheck func(*agentv1.RequestMeta) error } // DispatcherEventServer owns the Dispatcher side of R11. It persists the // received fact and the derived authoritative MQ event through the same // SQLite transaction; it never accepts an Agent-chosen aggregate version. +// Mock V3 instead accepts only a versioned recording-failure fact, whose +// transaction and unique final result are owned by the Dispatcher. type DispatcherEventServer struct { store *store.Store now func() time.Time + localV3RecordingFailure func(context.Context, *agentv1.ExecutionFact) error + localV3SessionCheck func(*agentv1.RequestMeta) error requirePeer bool peerCertificateFingerprints map[string]struct{} allowedAgentIDs map[string]struct{} @@ -39,12 +47,17 @@ func NewDispatcherEventServer(st *store.Store, options DispatcherEventServerOpti if st == nil { return nil, errors.New("Dispatcher event server requires store") } + if (options.LocalV3RecordingFailure == nil) != (options.LocalV3SessionCheck == nil) { + return nil, errors.New("Mock V3 failure handler and active session check must be configured together") + } now := options.Now if now == nil { now = time.Now } return &DispatcherEventServer{ store: st, now: now, requirePeer: options.RequirePeer, + localV3RecordingFailure: options.LocalV3RecordingFailure, + localV3SessionCheck: options.LocalV3SessionCheck, peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints), allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs), }, nil @@ -78,6 +91,27 @@ func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *a if err := json.Unmarshal(fact.PayloadJson, &payload); err != nil || payload == nil { return nil, status.Error(codes.InvalidArgument, "fact payload must be a JSON object") } + if s.localV3RecordingFailure != nil { + if err := s.localV3SessionCheck(req.Meta); err != nil { + if errors.Is(err, store.ErrCommandConflict) { + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_ABORTED, err.Error(), fact.FactId, fact.ContentSha256)}, nil + } + return nil, status.Errorf(codes.Internal, "check active Agent session: %v", err) + } + if fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS || fact.SourceSequence == 0 { + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_ABORTED, "Mock V3 accepts only a recording failure with a valid source sequence", fact.FactId, fact.ContentSha256)}, nil + } + if err := s.localV3RecordingFailure(ctx, fact); err != nil { + if errors.Is(err, store.ErrFactConflict) { + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, err.Error(), fact.FactId, fact.ContentSha256)}, nil + } + if errors.Is(err, store.ErrCommandConflict) { + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_ABORTED, err.Error(), fact.FactId, fact.ContentSha256)}, nil + } + return nil, status.Errorf(codes.Internal, "persist Mock recording failure and final result: %v", err) + } + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "Mock recording failure and final result persisted", fact.FactId, fact.ContentSha256)}, nil + } eventType, aggregateType, aggregateID, eventPayload, err := deriveFactEvent(fact.Kind, binding, payload) if err != nil { diff --git a/internal/rpc/dispatcher_events_local_v3_test.go b/internal/rpc/dispatcher_events_local_v3_test.go new file mode 100644 index 0000000..f5b77b3 --- /dev/null +++ b/internal/rpc/dispatcher_events_local_v3_test.go @@ -0,0 +1,115 @@ +package rpc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/store" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func TestLocalV3EventServerAcceptsOnlyVersionedMockRecordingFailure(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.BindDispatcherID("11111111-1111-4111-8111-111111111111"); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + called := 0 + activeBoot, activeGeneration := "boot-a", uint64(1) + var callbackErr error + server, err := NewDispatcherEventServer(st, DispatcherEventServerOptions{ + Now: func() time.Time { return now }, + LocalV3SessionCheck: func(meta *agentv1.RequestMeta) error { + if meta.BootId != activeBoot || meta.SessionGeneration != activeGeneration || meta.DispatcherEpoch != "epoch-a" { + return store.ErrCommandConflict + } + return nil + }, + LocalV3RecordingFailure: func(_ context.Context, fact *agentv1.ExecutionFact) error { + called++ + if fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS { + t.Fatalf("legacy event passed Mock failure handler: %v", fact.Kind) + } + return callbackErr + }, + }) + if err != nil { + t.Fatal(err) + } + meta := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", RequestId: "request-a", OperationId: "operation-a", IdempotencyKey: "idempotency-a", + AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", DispatcherEpoch: "epoch-a", SessionGeneration: 1, TraceId: "trace-a", + } + payload := []byte(`{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"upload-a","recording_id":"recording-a","error_code":"upload_failed"}`) + sum := sha256.Sum256(payload) + fact := &agentv1.ExecutionFact{ + FactId: uuid.NewString(), ContentSha256: hex.EncodeToString(sum[:]), Kind: agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS, + Binding: &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a"}, + PayloadJson: payload, ObservedAtUnixMs: now.UnixMilli(), SourceBootId: meta.BootId, SourceSequence: 1, + } + request := &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: fact} + receipt, err := server.ReportExecutionEvent(context.Background(), request) + if err != nil || receipt.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || called != 1 { + t.Fatalf("Mock failure was not routed: receipt=%+v calls=%d err=%v", receipt, called, err) + } + legacy := proto.Clone(fact).(*agentv1.ExecutionFact) + legacy.Kind = agentv1.FactKind_FACT_KIND_CALL_STATUS + legacy.FactId = uuid.NewString() + legacy.PayloadJson = []byte(`{"call_id":"call-a","state":"finished"}`) + if got, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: legacy}); err != nil || got.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_REJECTED || called != 1 { + t.Fatalf("legacy split call event leaked into V3: receipt=%+v calls=%d err=%v", got, called, err) + } + restartedMeta := proto.Clone(meta).(*agentv1.RequestMeta) + restartedMeta.BootId = "new-active-boot" + restartedMeta.SessionGeneration = 2 + activeBoot, activeGeneration = restartedMeta.BootId, restartedMeta.SessionGeneration + if got, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: restartedMeta, Fact: fact}); err != nil || got.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || called != 2 { + t.Fatalf("durable fact from the previous boot was rejected after restart: receipt=%+v calls=%d err=%v", got, called, err) + } + if got, err := server.ReportExecutionEvent(context.Background(), request); err != nil || got.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_REJECTED || called != 2 { + t.Fatalf("fenced old Agent session reached Mock handler: receipt=%+v calls=%d err=%v", got, called, err) + } + missingSource := proto.Clone(fact).(*agentv1.ExecutionFact) + missingSource.SourceBootId = "" + if _, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: missingSource}); status.Code(err) != codes.InvalidArgument || called != 2 { + t.Fatalf("missing source boot reached handler: calls=%d err=%v", called, err) + } + var outbox int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outbox); err != nil || outbox != 0 { + t.Fatalf("Mock failure generated legacy split MQ events: outbox=%d err=%v", outbox, err) + } + activeBoot, activeGeneration = meta.BootId, meta.SessionGeneration + for _, tc := range []struct { + name string + cause error + result agentv1.ResultCode + }{ + {"fact conflict", store.ErrFactConflict, agentv1.ResultCode_RESULT_CODE_CONFLICT}, + {"recording conflict", store.ErrCommandConflict, agentv1.ResultCode_RESULT_CODE_REJECTED}, + } { + t.Run(tc.name, func(t *testing.T) { + callbackErr = tc.cause + got, err := server.ReportExecutionEvent(context.Background(), request) + if err != nil || got.GetReceipt().GetResult() != tc.result { + t.Fatalf("Mock failure rejection: receipt=%+v err=%v", got, err) + } + }) + } + callbackErr = errors.New("durable store unavailable") + if _, err := server.ReportExecutionEvent(context.Background(), request); status.Code(err) != codes.Internal { + t.Fatalf("store outage was falsely acknowledged: %v", err) + } +} diff --git a/internal/rpc/dispatcher_upload.go b/internal/rpc/dispatcher_upload.go index 1164997..d72d4ac 100644 --- a/internal/rpc/dispatcher_upload.go +++ b/internal/rpc/dispatcher_upload.go @@ -30,6 +30,8 @@ type DispatcherUploadServer struct { store *store.Store oss *ossclient.Client now func() time.Time + localV3Authorize func(context.Context, *agentv1.RequestUploadRequest) error + localV3Complete func(context.Context, *agentv1.CompleteUploadRequest, store.UploadRecord) (bool, error) requirePeer bool maxAssetBytes int64 peerCertificateFingerprints map[string]struct{} @@ -40,6 +42,12 @@ type DispatcherUploadOptions struct { RequirePeer bool PeerCertificateFingerprints map[string]struct{} AllowedAgentIDs map[string]struct{} + // LocalV3Authorize binds an OSS grant to a confirmed Mock recording. + LocalV3Authorize func(context.Context, *agentv1.RequestUploadRequest) error + // LocalV3Complete is the sole Mock-mode upload outcome path. A successful + // local write is not delivery: true requires the final call.result to + // have reached the durable MQ queue with publisher confirmation. + LocalV3Complete func(context.Context, *agentv1.CompleteUploadRequest, store.UploadRecord) (bool, error) } func NewDispatcherUploadServer(st *store.Store, client *ossclient.Client, now func() time.Time, requirePeer bool) (*DispatcherUploadServer, error) { @@ -53,11 +61,16 @@ func NewDispatcherUploadServerWithOptions(st *store.Store, client *ossclient.Cli if client == nil { return nil, errors.New("upload server requires OSS client") } + if (options.LocalV3Authorize == nil) != (options.LocalV3Complete == nil) { + return nil, errors.New("local V3 upload authorization and completion must be configured together") + } if now == nil { now = time.Now } return &DispatcherUploadServer{ store: st, oss: client, now: now, requirePeer: options.RequirePeer, + localV3Authorize: options.LocalV3Authorize, + localV3Complete: options.LocalV3Complete, maxAssetBytes: client.Config().MaxAssetBytes, peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints), allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs), @@ -74,6 +87,11 @@ func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1 if s.maxAssetBytes > 0 && req.Asset.SizeBytes > s.maxAssetBytes { return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_RESOURCE_EXHAUSTED, "asset exceeds Dispatcher OSS limit", false), nil } + if s.localV3Authorize != nil { + if err := s.localV3Authorize(ctx, req); err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "local V3 recording grant refused: %v", err) + } + } record, err := s.store.LoadUpload(req.UploadId) if err == nil { @@ -85,15 +103,22 @@ func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1 return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload ID is bound to a different execution or asset", false), nil } if record.State == "uploaded" { - return nil, status.Error(codes.Unavailable, "upload already reported; retry completion notification, not PUT") + detail := "upload already reported; retry completion notification, not PUT" + if s.localV3Complete != nil { + detail = "upload already reported; retry final call.result completion, not PUT" + } + return nil, status.Error(codes.Unavailable, detail) } if record.State == "completed" { - return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload notification delivered to MQ", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil + detail := "upload notification delivered to MQ" + if s.localV3Complete != nil { + detail = "final call.result delivered to MQ" + } + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, detail, false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil } } else if !errors.Is(err, sql.ErrNoRows) { return nil, status.Errorf(codes.Internal, "load upload: %v", err) } - if req.Meta.OperationId == "" || req.Meta.IdempotencyKey == "" { return nil, status.Error(codes.InvalidArgument, "upload request operation and idempotency identities are required") } @@ -181,15 +206,28 @@ func (s *DispatcherUploadServer) CompleteUpload(ctx context.Context, req *agentv if asset.SizeBytes != req.UploadedSizeBytes || !strings.EqualFold(asset.ChecksumSha256, req.UploadedChecksumSha256) || req.UploadedSizeBytes > grant.MaxBytes { return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded asset does not match grant", false), nil } - if record.State == "completed" { + if record.State == "completed" && s.localV3Complete == nil { return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload notification delivered to MQ", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil } - if record.State == "uploaded" { + if record.State == "uploaded" && s.localV3Complete == nil { return nil, status.Error(codes.Unavailable, "upload fact retained; original notification awaits MQ delivery") } if asset.Kind != agentv1.AssetKind_ASSET_KIND_RECORDING { return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "only recording upload facts are supported", false), nil } + if s.localV3Complete != nil { + if record.State != "granted" && record.State != "uploaded" && record.State != "completed" { + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload already closed without a completed asset", false), nil + } + delivered, err := s.localV3Complete(ctx, req, record) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "persist local V3 upload result: %v", err) + } + if !delivered { + return nil, status.Error(codes.Unavailable, "upload fact retained; final call.result awaits MQ delivery") + } + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "final call.result delivered to MQ", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil + } dispatcherID, err := s.store.DispatcherID() if err != nil { return nil, status.Errorf(codes.FailedPrecondition, "upload requires bound Dispatcher identity: %v", err) diff --git a/internal/rpc/dispatcher_upload_local_v3_test.go b/internal/rpc/dispatcher_upload_local_v3_test.go new file mode 100644 index 0000000..38ca1a2 --- /dev/null +++ b/internal/rpc/dispatcher_upload_local_v3_test.go @@ -0,0 +1,102 @@ +package rpc + +import ( + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestLocalV3UploadCompletionDoesNotWriteSplitNotification(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.BindDispatcherID("11111111-1111-4111-8111-111111111111"); err != nil { + t.Fatal(err) + } + client, err := ossclient.NewClient(ossclient.Config{ + Endpoint: "https://oss.invalid", Region: "cn-beijing", Bucket: "local-test", + AccessKeyID: "local-test", AccessKeySecret: "local-test", GrantTTL: 15 * time.Minute, MaxAssetBytes: 1024, + }) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + calls, delivered, inactive := 0, false, false + server, err := NewDispatcherUploadServerWithOptions(st, client, func() time.Time { return now }, DispatcherUploadOptions{ + LocalV3Authorize: func(_ context.Context, request *agentv1.RequestUploadRequest) error { + if inactive { + return errors.New("inactive Mock Agent session") + } + if request.UploadId != "upload-a" { + return errors.New("unbound Mock recording") + } + return nil + }, + LocalV3Complete: func(_ context.Context, request *agentv1.CompleteUploadRequest, record store.UploadRecord) (bool, error) { + calls++ + if request.UploadId != record.UploadID { + t.Fatalf("upload identity changed: %q != %q", request.UploadId, record.UploadID) + } + if delivered { + if _, err := st.DB().Exec(`UPDATE uploads SET state='completed',completed_at=? WHERE upload_id=?`, now.Format(time.RFC3339Nano), record.UploadID); err != nil { + return false, err + } + } + return delivered, nil + }, + }) + if err != nil { + t.Fatal(err) + } + meta := &agentv1.RequestMeta{AgentId: "agent-a", CellId: "cell-a", OperationId: "op-a", IdempotencyKey: "key-a", TraceId: "trace-a"} + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", TaskId: "task-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-a", CallId: "call-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 10, SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64)} + if _, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "forged"}); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("unbound Mock recording received a PUT grant: %v", err) + } + var issued int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM uploads`).Scan(&issued); err != nil || issued != 0 { + t.Fatalf("refused recording persisted a grant: count=%d err=%v", issued, err) + } + if grant, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}); err != nil || grant.GetGrant() == nil { + t.Fatalf("first grant: %+v err=%v", grant, err) + } + request := &agentv1.CompleteUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a", UploadedSizeBytes: 4, UploadedChecksumSha256: asset.ChecksumSha256} + if _, err := server.CompleteUpload(context.Background(), request); status.Code(err) != codes.Unavailable { + t.Fatalf("unpublished final call.result cannot complete upload: %v", err) + } + var notifications, outbox int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM upload_notifications`).Scan(¬ifications); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outbox); err != nil { + t.Fatal(err) + } + if notifications != 0 || outbox != 0 || calls != 1 { + t.Fatalf("v3 completion wrote a split event: notifications=%d outbox=%d callback=%d", notifications, outbox, calls) + } + delivered = true + completed, err := server.CompleteUpload(context.Background(), request) + if err != nil || completed.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED || calls != 2 { + t.Fatalf("confirmed final-result delivery: response=%+v calls=%d err=%v", completed, calls, err) + } + grant, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}) + if err != nil || grant.GetGrant() != nil || grant.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED { + t.Fatalf("completed upload reauthorized a PUT: grant=%+v err=%v", grant, err) + } + inactive = true + if _, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("inactive session read a completed Mock upload without authorization: %v", err) + } +} diff --git a/internal/rpc/execution_journal.go b/internal/rpc/execution_journal.go index 42ccb21..5f0c3fc 100644 --- a/internal/rpc/execution_journal.go +++ b/internal/rpc/execution_journal.go @@ -21,12 +21,13 @@ type savedOperation struct { Control *agentv1.ApplyTaskControlResponse `json:"control,omitempty"` } type savedExecution struct { - Binding *agentv1.ExecutionBinding `json:"binding"` - Digest string `json:"digest"` - State agentv1.ExecutionState `json:"state"` - Revision int64 `json:"revision"` - CallState string `json:"call_state"` - ControlAction agentv1.ControlAction `json:"control_action"` + Binding *agentv1.ExecutionBinding `json:"binding"` + Digest string `json:"digest"` + State agentv1.ExecutionState `json:"state"` + Revision int64 `json:"revision"` + CallState string `json:"call_state"` + TerminalObservedAtUnixMs int64 `json:"terminal_observed_at_unix_ms,omitempty"` + ControlAction agentv1.ControlAction `json:"control_action"` } type executionJournal struct { Version int `json:"version"` @@ -86,7 +87,13 @@ func (s *Server) loadExecutionJournal() error { if _, ok := agentv1.ControlAction_name[int32(record.ControlAction)]; !ok { return errors.New("invalid persisted control action") } - execution := &executionRecord{binding: record.Binding, executeDigest: record.Digest, taskRevision: record.Revision, callState: record.CallState, controlAction: record.ControlAction, state: record.State} + mockTerminal := record.CallState == "mock_no_answer" || record.CallState == "mock_deadline_closed_without_dial" + if mockTerminal && (record.TerminalObservedAtUnixMs <= 0 || record.State != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL) || + !mockTerminal && record.TerminalObservedAtUnixMs != 0 { + return errors.New("invalid durable Mock terminal observation") + } + execution := &executionRecord{binding: record.Binding, executeDigest: record.Digest, taskRevision: record.Revision, callState: record.CallState, + terminalObservedAtUnixMs: record.TerminalObservedAtUnixMs, controlAction: record.ControlAction, state: record.State} // A new process cannot infer Asterisk's state from an old local snapshot. // Never restore permits or clear unknown occupancy because a process restarted. if execution.state != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL { @@ -112,7 +119,8 @@ func (s *Server) persistExecutionJournalLocked() error { journal.Operations[key] = savedOperation{Digest: record.digest, Receipt: record.receipt, Control: record.control} } for id, record := range s.executions { - journal.Executions[id] = savedExecution{Binding: record.binding, Digest: record.executeDigest, State: record.state, Revision: record.taskRevision, CallState: record.callState, ControlAction: record.controlAction} + journal.Executions[id] = savedExecution{Binding: record.binding, Digest: record.executeDigest, State: record.state, Revision: record.taskRevision, + CallState: record.callState, TerminalObservedAtUnixMs: record.terminalObservedAtUnixMs, ControlAction: record.controlAction} } if err := writeRPCJournal(s.executionPath, journal); err != nil { s.executionErr = err diff --git a/internal/rpc/server.go b/internal/rpc/server.go index 89311a0..825c423 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -44,6 +44,9 @@ type ServerOptions struct { PeerCertificateFingerprints map[string]struct{} StatePath string CallLogger *calllog.Logger + // MockAuthorizedOriginate is explicitly supplied only in isolated mock mode. + // Real/mixed dialing is not authorized through the new local contract. + MockAuthorizedOriginate func(context.Context, *agentv1.ExecuteAuthorizedRequest) error } // Server is the local Unary gRPC state boundary. It owns session/fencing and @@ -68,6 +71,7 @@ type Server struct { peerAgentIDs map[string]string peerCertificateFingerprints map[string]struct{} callLogger *calllog.Logger + mockAuthorizedOriginate func(context.Context, *agentv1.ExecuteAuthorizedRequest) error sessions *SessionRegistry executionPath string @@ -92,15 +96,16 @@ type admissionRecord struct { } type executionRecord struct { - executeDigest string - binding *agentv1.ExecutionBinding - state agentv1.ExecutionState - taskRevision int64 - callState string - controlAction agentv1.ControlAction - permit *agentv1.ExecutionPermit - unknown bool - phone calllog.Identity + executeDigest string + binding *agentv1.ExecutionBinding + state agentv1.ExecutionState + taskRevision int64 + callState string + terminalObservedAtUnixMs int64 + controlAction agentv1.ControlAction + permit *agentv1.ExecutionPermit + unknown bool + phone calllog.Identity } type uploadRecord struct { @@ -165,6 +170,7 @@ func NewServer(options ServerOptions) *Server { peerAgentIDs: cloneStringMap(options.PeerAgentIDs), peerCertificateFingerprints: cloneSet(options.PeerCertificateFingerprints), callLogger: options.CallLogger, + mockAuthorizedOriginate: options.MockAuthorizedOriginate, sessions: NewSessionRegistry(options.StatePath), operations: make(map[string]operationRecord), admissions: make(map[string]admissionRecord), @@ -334,6 +340,36 @@ func (r *SessionRegistry) Authorize(meta *agentv1.RequestMeta, now time.Time) er return nil } +// CurrentMeta returns only the active, unexpired session identity. It does +// not expose the session credential; callers use it for Agent→Dispatcher +// reports and never invent a new boot identity after restart. +func (r *SessionRegistry) CurrentMeta(agentID string, now time.Time) (*agentv1.RequestMeta, error) { + if agentID == "" { + return nil, status.Error(codes.InvalidArgument, "Agent ID is required") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.loadErr != nil { + return nil, status.Errorf(codes.Internal, "load session journal: %v", r.loadErr) + } + existing, ok := r.sessions[agentID] + if !ok || existing.session.ExpiresAtUnixMs <= now.UnixMilli() { + return nil, status.Error(codes.Unauthenticated, "Agent session is not active") + } + return &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", AgentId: agentID, CellId: existing.binding.CellId, + BootId: existing.binding.ExpectedBootId, DispatcherEpoch: existing.binding.DispatcherEpoch, + SessionGeneration: existing.binding.SessionGeneration, + }, nil +} + +func (s *Server) ActiveSessionMeta() (*agentv1.RequestMeta, error) { + if s.status == nil { + return nil, status.Error(codes.FailedPrecondition, "Agent status is unavailable") + } + return s.sessions.CurrentMeta(s.status.AgentId, s.now()) +} + func (s *Server) GetAgentStatus(ctx context.Context, req *agentv1.GetAgentStatusRequest) (*agentv1.GetAgentStatusResponse, error) { if req == nil || req.Meta == nil || req.Meta.AgentId == "" || req.Meta.CellId == "" { return nil, status.Error(codes.InvalidArgument, "status metadata with agent and cell is required") @@ -711,7 +747,15 @@ func (s *Server) QueryExecution(ctx context.Context, req *agentv1.QueryExecution s.mu.Unlock() return &agentv1.QueryExecutionResponse{Meta: s.responseMeta(req.Meta), Failure: s.failure(agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "execution not found", false)}, nil } - snapshot := &agentv1.ExecutionSnapshot{Binding: proto.Clone(execution.binding).(*agentv1.ExecutionBinding), State: execution.state, CallState: execution.callState, AttemptId: execution.binding.AttemptId, ObservedAtUnixMs: s.now().UnixMilli(), Unknown: execution.unknown} + observedAt := s.now().UnixMilli() + if execution.callState == "mock_no_answer" || execution.callState == "mock_deadline_closed_without_dial" { + if execution.terminalObservedAtUnixMs <= 0 { + s.mu.Unlock() + return nil, status.Error(codes.Internal, "missing durable Mock terminal observation time") + } + observedAt = execution.terminalObservedAtUnixMs + } + snapshot := &agentv1.ExecutionSnapshot{Binding: proto.Clone(execution.binding).(*agentv1.ExecutionBinding), State: execution.state, CallState: execution.callState, AttemptId: execution.binding.AttemptId, ObservedAtUnixMs: observedAt, Unknown: execution.unknown} s.mu.Unlock() return &agentv1.QueryExecutionResponse{Meta: s.responseMeta(req.Meta), Snapshot: snapshot}, nil } diff --git a/internal/rpc/session_meta_test.go b/internal/rpc/session_meta_test.go new file mode 100644 index 0000000..856545b --- /dev/null +++ b/internal/rpc/session_meta_test.go @@ -0,0 +1,34 @@ +package rpc + +import ( + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestCurrentSessionMetaUsesOnlyActivatedUnexpiredAgentBinding(t *testing.T) { + registry := NewSessionRegistry("") + now := time.Unix(100, 0).UTC() + if _, err := registry.CurrentMeta("agent-a", now); status.Code(err) != codes.Unauthenticated { + t.Fatalf("unactivated Agent fabricated a session: %v", err) + } + binding := &agentv1.AgentBinding{AgentId: "agent-a", CellId: "cell-a", ExpectedBootId: "boot-a", DispatcherEpoch: "epoch-a", SessionGeneration: 1} + session, _, err := registry.Activate(binding, "activate-a", "digest-a", now) + if err != nil { + t.Fatal(err) + } + meta, err := registry.CurrentMeta("agent-a", now.Add(time.Second)) + if err != nil || meta.ProtocolVersion != "agent.v1" || meta.AgentId != binding.AgentId || meta.CellId != binding.CellId || + meta.BootId != binding.ExpectedBootId || meta.DispatcherEpoch != binding.DispatcherEpoch || meta.SessionGeneration != session.SessionGeneration { + t.Fatalf("active session metadata differs from bound Agent: meta=%+v err=%v", meta, err) + } + if err := registry.Authorize(meta, now.Add(time.Second)); err != nil { + t.Fatalf("derived metadata is not authorized: %v", err) + } + if _, err := registry.CurrentMeta("agent-a", time.UnixMilli(session.ExpiresAtUnixMs)); status.Code(err) != codes.Unauthenticated { + t.Fatalf("expired session metadata was still returned: %v", err) + } +} diff --git a/internal/store/active_task_controls.go b/internal/store/active_task_controls.go new file mode 100644 index 0000000..b69346f --- /dev/null +++ b/internal/store/active_task_controls.go @@ -0,0 +1,55 @@ +package store + +import ( + "errors" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/protobuf/proto" +) + +type ActiveTaskExecutionControl struct { + ExecutionID string + Status string + AgentID string + Binding *agentv1.ExecutionBinding +} + +// ActiveTaskExecutionControls returns only reserved or in-flight executions +// that already have a durable Agent assignment. The Store is Dispatcher-local. +func (s *Store) ActiveTaskExecutionControls(tenantID, tenantKey, taskID string) ([]ActiveTaskExecutionControl, error) { + if tenantID == "" || tenantKey == "" || taskID == "" { + return nil, errors.New("tenant and task identity are required") + } + s.mu.Lock() + defer s.mu.Unlock() + rows, err := s.db.Query(`SELECT t.execution_id,t.status,a.agent_id,a.binding + FROM tasks t JOIN execution_agents a ON a.execution_id=t.execution_id + WHERE t.tenant_id=? AND t.tenant_key=? AND t.task_id=? + AND t.status IN ('reserved','running','draining','paused','unknown') + ORDER BY t.execution_id`, tenantID, tenantKey, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + targets := make([]ActiveTaskExecutionControl, 0) + for rows.Next() { + var target ActiveTaskExecutionControl + var encoded []byte + if err := rows.Scan(&target.ExecutionID, &target.Status, &target.AgentID, &encoded); err != nil { + return nil, err + } + binding := &agentv1.ExecutionBinding{} + if err := proto.Unmarshal(encoded, binding); err != nil { + return nil, err + } + if target.ExecutionID != binding.ExecutionId || tenantID != binding.TenantId || tenantKey != binding.TenantKey || taskID != binding.TaskId { + return nil, errors.New("persisted Agent binding does not match task execution") + } + target.Binding = binding + targets = append(targets, target) + } + if err := rows.Err(); err != nil { + return nil, err + } + return targets, nil +} diff --git a/internal/store/control_routes.go b/internal/store/control_routes.go index 2532d2f..7693c20 100644 --- a/internal/store/control_routes.go +++ b/internal/store/control_routes.go @@ -12,11 +12,7 @@ import ( // BindExecutionAgent records the controlled endpoint identity before any remote // execution. An execution may not be reassigned after a lost response. func (s *Store) BindExecutionAgent(agentID string, binding *agentv1.ExecutionBinding) error { - if agentID == "" || binding == nil { - return errors.New("agent and execution binding are required") - } - encoded, err := (proto.MarshalOptions{Deterministic: true}).Marshal(binding) - if err != nil { + if err := validateExecutionAgentBinding(agentID, binding); err != nil { return err } s.mu.Lock() @@ -26,6 +22,26 @@ func (s *Store) BindExecutionAgent(agentID string, binding *agentv1.ExecutionBin return err } defer tx.Rollback() + if err := bindExecutionAgentTx(tx, agentID, binding); err != nil { + return err + } + return tx.Commit() +} + +func validateExecutionAgentBinding(agentID string, binding *agentv1.ExecutionBinding) error { + if agentID == "" || binding == nil { + return errors.New("agent and execution binding are required") + } + return nil +} + +// bindExecutionAgentTx is shared by the legacy control route and the local +// Mock origination claim, which must commit its Agent assignment atomically. +func bindExecutionAgentTx(tx *sql.Tx, agentID string, binding *agentv1.ExecutionBinding) error { + encoded, err := (proto.MarshalOptions{Deterministic: true}).Marshal(binding) + if err != nil { + return err + } var count int if err := tx.QueryRow(`SELECT COUNT(*) FROM tasks WHERE execution_id=? AND tenant_id=? AND tenant_key=? AND task_id=? AND task_item_id=? AND task_revision=? AND agent_version_id=? AND route_policy_id=? AND caller_profile_id=? AND status='reserved'`, binding.ExecutionId, binding.TenantId, binding.TenantKey, binding.TaskId, binding.TaskItemId, binding.TaskRevision, binding.AgentVersionId, binding.RoutePolicyId, binding.CallerProfileId).Scan(&count); err != nil { return err @@ -45,8 +61,6 @@ func (s *Store) BindExecutionAgent(agentID string, binding *agentv1.ExecutionBin if !errors.Is(err, sql.ErrNoRows) { return err } - if _, err := tx.Exec(`INSERT INTO execution_agents(execution_id,agent_id,binding) VALUES(?,?,?)`, binding.ExecutionId, agentID, encoded); err != nil { - return err - } - return tx.Commit() + _, err = tx.Exec(`INSERT INTO execution_agents(execution_id,agent_id,binding) VALUES(?,?,?)`, binding.ExecutionId, agentID, encoded) + return err } diff --git a/internal/store/control_routes_test.go b/internal/store/control_routes_test.go index 61b60d2..f7b3de5 100644 --- a/internal/store/control_routes_test.go +++ b/internal/store/control_routes_test.go @@ -8,6 +8,42 @@ import ( "git.ipao.vip/rogee/go-sip/internal/testfixture" ) +func TestActiveTaskExecutionControlsReturnsOnlyBoundNonterminalExecutions(t *testing.T) { + s := testStore(t) + raw, err := testfixture.Execute() + if err != nil { + t.Fatal(err) + } + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + t.Fatal(err) + } + if _, err := s.IngestCommand(raw, testfixture.InboundKey(envelope.TenantKey)); err != nil { + t.Fatal(err) + } + binding := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} + if err := s.SetQuota("global", 1); err != nil { + t.Fatal(err) + } + if err := s.Reserve("reservation-a", binding.ExecutionId, binding.TenantKey, []string{"global"}); err != nil { + t.Fatal(err) + } + if err := s.BindExecutionAgent("agent-a", binding); err != nil { + t.Fatal(err) + } + targets, err := s.ActiveTaskExecutionControls(binding.TenantId, binding.TenantKey, binding.TaskId) + if err != nil || len(targets) != 1 || targets[0].ExecutionID != binding.ExecutionId || targets[0].AgentID != "agent-a" { + t.Fatalf("active targets=%+v err=%v", targets, err) + } + if _, err := s.DB().Exec(`UPDATE tasks SET status='finished' WHERE execution_id=?`, binding.ExecutionId); err != nil { + t.Fatal(err) + } + targets, err = s.ActiveTaskExecutionControls(binding.TenantId, binding.TenantKey, binding.TaskId) + if err != nil || len(targets) != 0 { + t.Fatalf("terminal task remained active: %+v err=%v", targets, err) + } +} + func TestExecutionRouteRequiresReservedMatchingTask(t *testing.T) { s := testStore(t) raw, err := testfixture.Execute() diff --git a/internal/store/local_call_terminal.go b/internal/store/local_call_terminal.go new file mode 100644 index 0000000..03bcc53 --- /dev/null +++ b/internal/store/local_call_terminal.go @@ -0,0 +1,257 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +// LocalCallTerminal is an immutable, confirmed Agent observation. Recording +// delivery is deliberately separate: a completed call stops consuming quota +// even when its final result is waiting for an upload outcome. +type LocalRecordingManifest struct { + RecordingID string + UploadID string + Format string + Channels int + SampleRateHz int + DurationMs int64 +} + +// LocalRecordingOutcome contains only a finished upload fact or a terminal +// failure. It contains no OSS authorization or media bytes. +type LocalRecordingOutcome struct { + Status string + ErrorCode string + Bucket string + ObjectKey string + SizeBytes int64 + ChecksumSHA256 string + ObservedAt time.Time +} + +type LocalCallTerminal struct { + ExecutionID string + CallID string + Source string + StartedAt time.Time + EndedAt time.Time + Outcome string + ReasonCode string + RecordingExpected bool + Recording *LocalRecordingManifest +} + +// RecordLocalCallTerminal atomically records an issued call's terminal fact and +// releases its reservation. A known final result can be enqueued in the same +// transaction; otherwise the durable fact remains available for recovery. +func (s *Store) RecordLocalCallTerminal(fact LocalCallTerminal, event *LocalEventRecord) error { + if fact.ExecutionID == "" || fact.CallID == "" || fact.Outcome == "" || + fact.StartedAt.IsZero() || fact.EndedAt.IsZero() || fact.EndedAt.Before(fact.StartedAt) { + return errors.New("incomplete or reversed local call terminal fact") + } + expectedDecision := "issued" + switch fact.Source { + case "mock_agent": + case "dispatcher_refusal", "mock_agent_deadline", "dispatcher_deadline": + if fact.Source == "dispatcher_refusal" { + expectedDecision = "refused" + } + if fact.Outcome != "failed" || fact.RecordingExpected { + return errors.New("no-dial terminal cannot claim a call or recording") + } + default: + return errors.New("unknown local terminal source") + } + fact.StartedAt = fact.StartedAt.UTC() + fact.EndedAt = fact.EndedAt.UTC() + if fact.RecordingExpected != (fact.Recording != nil) { + return errors.New("expected recording requires a manifest; no recording must not have one") + } + var manifestJSON []byte + if fact.Recording != nil { + if fact.Recording.RecordingID == "" || fact.Recording.UploadID == "" || fact.Recording.Format == "" || + fact.Recording.Channels < 1 || fact.Recording.SampleRateHz < 1 || fact.Recording.DurationMs < 0 { + return errors.New("incomplete local recording manifest") + } + var err error + manifestJSON, err = json.Marshal(fact.Recording) + if err != nil { + return err + } + } + digest, err := localTerminalDigest(fact) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var oldDigest string + err = tx.QueryRow(`SELECT fact_sha256 FROM local_v01_call_terminals WHERE execution_id=?`, fact.ExecutionID).Scan(&oldDigest) + if err == nil { + if oldDigest != digest { + return ErrCommandConflict + } + } else if errors.Is(err, sql.ErrNoRows) { + var reservationID, status, decision string + if err := tx.QueryRow(`SELECT r.reservation_id,t.status,d.decision FROM tasks t + JOIN reservations r ON r.execution_id=t.execution_id + JOIN local_v02_origination_decisions d ON d.execution_id=t.execution_id + WHERE t.execution_id=?`, fact.ExecutionID).Scan(&reservationID, &status, &decision); err != nil { + return fmt.Errorf("confirm local call terminal ownership: %w", err) + } + if decision != expectedDecision || status == "finished" { + return ErrCommandConflict + } + expected := 0 + if fact.RecordingExpected { + expected = 1 + } + var recordingID, uploadID any + if fact.Recording != nil { + recordingID, uploadID = fact.Recording.RecordingID, fact.Recording.UploadID + } + if _, err := tx.Exec(`INSERT INTO local_v01_call_terminals + (execution_id,call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_id,upload_id,recording_manifest,fact_sha256) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, fact.ExecutionID, fact.CallID, fact.Source, fact.StartedAt.Format(time.RFC3339Nano), + fact.EndedAt.Format(time.RFC3339Nano), fact.Outcome, fact.ReasonCode, expected, recordingID, uploadID, manifestJSON, digest); err != nil { + return fmt.Errorf("persist local terminal fact: %w", err) + } + if err := releaseReservationTx(tx, reservationID, false, nil, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return fmt.Errorf("release terminated local call quota: %w", err) + } + result, err := tx.Exec(`UPDATE tasks SET status='finished', updated_at=? WHERE execution_id=? AND status!='finished'`, + s.now().UTC().Format(time.RFC3339Nano), fact.ExecutionID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil || rows != 1 { + return fmt.Errorf("finish local execution (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) + } + } else { + return err + } + if event != nil { + if err := enqueueLocalFinalEventTx(tx, fact.ExecutionID, fact.CallID, *event, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + } + return tx.Commit() +} + +// EnqueueLocalFinalEvent links a unique final result to the persisted terminal +// fact. A duplicate with the same event and bytes is harmless; a different +// event ID or body for the same execution is rejected. +func (s *Store) EnqueueLocalFinalEvent(executionID, callID string, event LocalEventRecord) error { + if executionID == "" || callID == "" { + return errors.New("final result requires execution and call identity") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := enqueueLocalFinalEventTx(tx, executionID, callID, event, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +// LoadLocalCallTerminal returns only persisted Agent facts. An empty result ID +// means recording or final-result delivery is still pending; it does not +// authorize another originate or upload. +func (s *Store) LoadLocalCallTerminal(executionID string) (LocalCallTerminal, string, error) { + var fact LocalCallTerminal + var startedAt, endedAt string + var expected int + var manifestJSON []byte + var eventID sql.NullString + fact.ExecutionID = executionID + err := s.db.QueryRow(`SELECT call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_manifest,result_event_id + FROM local_v01_call_terminals WHERE execution_id=?`, executionID). + Scan(&fact.CallID, &fact.Source, &startedAt, &endedAt, &fact.Outcome, &fact.ReasonCode, &expected, &manifestJSON, &eventID) + if err != nil { + return LocalCallTerminal{}, "", err + } + fact.StartedAt, err = time.Parse(time.RFC3339Nano, startedAt) + if err != nil { + return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call start: %w", err) + } + fact.EndedAt, err = time.Parse(time.RFC3339Nano, endedAt) + if err != nil { + return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call end: %w", err) + } + if expected != 0 && expected != 1 { + return LocalCallTerminal{}, "", ErrCommandConflict + } + fact.RecordingExpected = expected == 1 + if fact.RecordingExpected != (manifestJSON != nil) { + return LocalCallTerminal{}, "", ErrCommandConflict + } + if fact.RecordingExpected { + fact.Recording = &LocalRecordingManifest{} + if err := json.Unmarshal(manifestJSON, fact.Recording); err != nil { + return LocalCallTerminal{}, "", fmt.Errorf("decode persisted recording manifest: %w", err) + } + } + return fact, eventID.String, nil +} + +func enqueueLocalFinalEventTx(tx *sql.Tx, executionID, callID string, event LocalEventRecord, now string) error { + if event.EventID == "" || event.TenantKey == "" || event.Exchange == "" || event.RoutingKey == "" || len(event.Body) == 0 { + return errors.New("complete local final event is required") + } + var persistedCallID string + var expected int + var recordingOutcome []byte + var priorEventID, priorDigest sql.NullString + if err := tx.QueryRow(`SELECT call_id,recording_expected,recording_outcome,result_event_id,result_sha256 + FROM local_v01_call_terminals WHERE execution_id=?`, executionID). + Scan(&persistedCallID, &expected, &recordingOutcome, &priorEventID, &priorDigest); err != nil { + return fmt.Errorf("load confirmed terminal before final result: %w", err) + } + if expected != 0 && expected != 1 || expected == 1 && recordingOutcome == nil || persistedCallID != callID { + return ErrCommandConflict + } + bodyDigest := sha256.Sum256(event.Body) + sha := hex.EncodeToString(bodyDigest[:]) + if priorEventID.Valid && (priorEventID.String != event.EventID || !priorDigest.Valid || priorDigest.String != sha) { + return ErrCommandConflict + } + if _, err := enqueueLocalEventTx(tx, event, now); err != nil { + return err + } + if !priorEventID.Valid { + result, err := tx.Exec(`UPDATE local_v01_call_terminals SET result_event_id=?,result_sha256=? + WHERE execution_id=? AND result_event_id IS NULL`, event.EventID, sha, executionID) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil || rows != 1 { + return fmt.Errorf("bind unique local final event (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) + } + } + return nil +} + +func localTerminalDigest(fact LocalCallTerminal) (string, error) { + encoded, err := json.Marshal(fact) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/store/local_call_terminal_test.go b/internal/store/local_call_terminal_test.go new file mode 100644 index 0000000..b575a19 --- /dev/null +++ b/internal/store/local_call_terminal_test.go @@ -0,0 +1,153 @@ +package store + +import ( + "database/sql" + "errors" + "testing" + "time" +) + +func localTerminalFixture() LocalCallTerminal { + return LocalCallTerminal{ + ExecutionID: "execution-a", CallID: "call-a", Source: "mock_agent", + StartedAt: time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC), + EndedAt: time.Date(2026, 9, 21, 2, 1, 0, 0, time.UTC), + Outcome: "no_answer", ReasonCode: "no_answer", + } +} + +func localFinalEventFixture() LocalEventRecord { + return LocalEventRecord{ + EventID: "result-a", TenantKey: "tenant-key-a", + Exchange: "sip.results.v3", RoutingKey: "d.d-1.out", Body: []byte(`{"event_type":"call.result"}`), + } +} + +func assertLocalTerminalQuota(t *testing.T, st *Store, wantReserved, wantUnknown int, wantState string) { + t.Helper() + var reserved, unknown int + var state string + if err := st.db.QueryRow(`SELECT q.reserved_value,q.unknown_value,r.state FROM reservations r + JOIN quotas q ON q.scope=? WHERE r.execution_id='execution-a'`, + LocalTenantQuotaScope("d-1", "tenant-a")).Scan(&reserved, &unknown, &state); err != nil { + t.Fatal(err) + } + if reserved != wantReserved || unknown != wantUnknown || state != wantState { + t.Fatalf("quota reserved=%d unknown=%d reservation=%s; want %d/%d/%s", reserved, unknown, state, wantReserved, wantUnknown, wantState) + } +} + +func TestLocalTerminalReleaseAndFinalEventAreAtomicAndUnique(t *testing.T) { + st, _ := readyLocalOrigination(t) + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { + t.Fatal(err) + } + fact, event := localTerminalFixture(), localFinalEventFixture() + if err := st.RecordLocalCallTerminal(fact, &event); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") + var status, eventID string + if err := st.db.QueryRow(`SELECT t.status,f.result_event_id FROM tasks t JOIN local_v01_call_terminals f ON f.execution_id=t.execution_id WHERE t.execution_id='execution-a'`).Scan(&status, &eventID); err != nil || status != "finished" || eventID != event.EventID { + t.Fatalf("finished terminal status=%q event=%q err=%v", status, eventID, err) + } + if err := st.RecordLocalCallTerminal(fact, &event); err != nil { + t.Fatalf("identical confirmed terminal replay: %v", err) + } + if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); err != nil { + t.Fatalf("identical final event replay: %v", err) + } + changed := event + changed.EventID = "result-b" + if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, changed); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("second final event=%v", err) + } + changed = event + changed.Body = []byte(`{"event_type":"different"}`) + if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, changed); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("conflicting final body=%v", err) + } + changedFact := fact + changedFact.EndedAt = changedFact.EndedAt.Add(time.Second) + if err := st.RecordLocalCallTerminal(changedFact, &event); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("conflicting terminal fact=%v", err) + } + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id='result-a'`).Scan(&count); err != nil || count != 1 { + t.Fatalf("unique outbox row count=%d err=%v", count, err) + } +} + +func TestLocalTerminalOutboxFailureRollsBackQuotaAndTerminal(t *testing.T) { + st, _ := readyLocalOrigination(t) + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { + t.Fatal(err) + } + event := localFinalEventFixture() + prior := event + prior.Body = []byte(`{"event_type":"unrelated"}`) + if _, err := st.EnqueueLocalEvent(prior); err != nil { + t.Fatal(err) + } + if err := st.RecordLocalCallTerminal(localTerminalFixture(), &event); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("conflicting result did not roll back terminal: %v", err) + } + assertLocalTerminalQuota(t, st, 1, 0, "held") + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM local_v01_call_terminals WHERE execution_id='execution-a'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("terminal persisted despite outbox conflict: count=%d err=%v", count, err) + } + event.EventID = "result-b" + if err := st.RecordLocalCallTerminal(localTerminalFixture(), &event); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") +} + +func TestLocalTerminalReleasesUnknownQuotaBeforeRecordingAndFinalResult(t *testing.T) { + st, record := readyLocalOrigination(t) + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { + t.Fatal(err) + } + if err := st.ReleaseReservation(record.Admission.ReservationID, true); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 1, "unknown") + fact := localTerminalFixture() + fact.RecordingExpected = true + fact.Recording = &LocalRecordingManifest{ + RecordingID: "rec-a", UploadID: "upload-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 60000, + } + if err := st.RecordLocalCallTerminal(fact, nil); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") + var resultID sql.NullString + if err := st.db.QueryRow(`SELECT result_event_id FROM local_v01_call_terminals WHERE execution_id='execution-a'`).Scan(&resultID); err != nil || resultID.Valid { + t.Fatalf("recording-pending call already had a result: event=%v err=%v", resultID, err) + } + event := localFinalEventFixture() + if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("pending recording allowed final result without an outcome: %v", err) + } + if err := st.RecordLocalRecordingOutcome(fact.ExecutionID, LocalRecordingOutcome{ + Status: "unavailable", ErrorCode: "upload_timeout", ObservedAt: fact.EndedAt.Add(LocalRecordingDeadline), + }); err != nil { + t.Fatal(err) + } + if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") +} + +func TestLocalTerminalRequiresIssuedOrigination(t *testing.T) { + st, _ := readyLocalOrigination(t) + if err := st.RefuseLocalOrigination("d-1", "execution-a", "policy_denied", localTerminalFixture().StartedAt); err != nil { + t.Fatal(err) + } + if err := st.RecordLocalCallTerminal(localTerminalFixture(), nil); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("refused origination recorded a call: %v", err) + } + assertLocalTerminalQuota(t, st, 1, 0, "held") +} diff --git a/internal/store/local_grant_after_outcome_test.go b/internal/store/local_grant_after_outcome_test.go new file mode 100644 index 0000000..4547dda --- /dev/null +++ b/internal/store/local_grant_after_outcome_test.go @@ -0,0 +1,29 @@ +package store + +import ( + "errors" + "testing" +) + +func TestLocalRecordingOutcomePreventsFirstGrantRacingWithCompletion(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, LocalRecordingOutcome{ + Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", SizeBytes: 4, + ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ObservedAt: terminal.EndedAt, + }); err != nil { + t.Fatal(err) + } + grant := UploadRecord{ + UploadID: terminal.Recording.UploadID, Binding: []byte("bound-agent"), Asset: []byte("bound-recording"), + Grant: []byte("one-put-only"), Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", + State: "granted", CreatedAt: terminal.EndedAt, + } + if _, err := st.IssueUploadGrant(grant, "op-a", "request-a"); !errors.Is(err, ErrUploadMismatch) { + t.Fatalf("completed Mock recording received its first late PUT grant: %v", err) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM uploads WHERE upload_id=?`, grant.UploadID).Scan(&count); err != nil || count != 0 { + t.Fatalf("late PUT grant survived: rows=%d err=%v", count, err) + } +} diff --git a/internal/store/local_origination.go b/internal/store/local_origination.go new file mode 100644 index 0000000..da7f6c4 --- /dev/null +++ b/internal/store/local_origination.go @@ -0,0 +1,164 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +var ErrLocalOriginationNotAuthorized = errors.New("local execution is not authorized for origination") + +type LocalOriginationCandidate struct { + DispatcherID string + ExecutionID string + TenantID string + TenantKey string + TaskID string + CommandID string + Callee string + CommandBody json.RawMessage + Snapshot LocalExecutionConfigSnapshot + SnapshotSHA256 string +} + +// LoadLocalOrigination checks current task, reservation, control, discovery and +// assignment state. Its caller must still make the final time/policy decision +// against the immutable bound snapshot and atomically claim the task below. +func (s *Store) LoadLocalOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, error) { + s.mu.Lock() + defer s.mu.Unlock() + if dispatcherID == "" || executionID == "" { + return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized + } + candidate := LocalOriginationCandidate{DispatcherID: dispatcherID, ExecutionID: executionID} + var taskStatus, inboxStatus, inboxHash, reservationState, admissionState string + var removed, ready int + var snapshotBody []byte + err := s.db.QueryRow(`SELECT t.tenant_id,t.tenant_key,t.task_id,t.task_item_id,t.callee,t.status, + i.body,i.body_hash,i.status,c.body,c.content_sha256,r.state,a.admission_state,a.removed,d.ready + FROM tasks t + JOIN inbox i ON i.command_id=t.task_item_id AND i.command_type='call.execute' + JOIN local_v01_execution_configs c ON c.execution_id=t.execution_id + JOIN reservations r ON r.execution_id=t.execution_id + JOIN local_v01_task_assignments a ON a.dispatcher_id=? AND a.task_id=t.task_id + JOIN local_v02_task_discovery_state d ON d.dispatcher_id=a.dispatcher_id + WHERE t.execution_id=?`, dispatcherID, executionID).Scan(&candidate.TenantID, &candidate.TenantKey, &candidate.TaskID, &candidate.CommandID, &candidate.Callee, &taskStatus, + &candidate.CommandBody, &inboxHash, &inboxStatus, &snapshotBody, &candidate.SnapshotSHA256, &reservationState, &admissionState, &removed, &ready) + if errors.Is(err, sql.ErrNoRows) { + return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized + } + if err != nil { + return LocalOriginationCandidate{}, fmt.Errorf("load local origination facts: %w", err) + } + if taskStatus != "reserved" || inboxStatus != "persisted" || reservationState != "held" || admissionState != "running" || removed != 0 || ready != 1 { + return LocalOriginationCandidate{}, fmt.Errorf("%w: task=%s inbox=%s reservation=%s admission=%s removed=%d discovery_ready=%d", ErrLocalOriginationNotAuthorized, + taskStatus, inboxStatus, reservationState, admissionState, removed, ready) + } + if err := validateLocalOriginationCandidate(&candidate, snapshotBody, inboxHash); err != nil { + return LocalOriginationCandidate{}, err + } + return candidate, nil +} + +func validateLocalOriginationCandidate(candidate *LocalOriginationCandidate, snapshotBody []byte, inboxHash string) error { + commandDigest := sha256.Sum256(candidate.CommandBody) + snapshotDigest := sha256.Sum256(snapshotBody) + if hex.EncodeToString(commandDigest[:]) != inboxHash || hex.EncodeToString(snapshotDigest[:]) != candidate.SnapshotSHA256 { + return fmt.Errorf("%w: persisted command or snapshot digest mismatch", ErrLocalOriginationNotAuthorized) + } + if err := json.Unmarshal(snapshotBody, &candidate.Snapshot); err != nil { + return fmt.Errorf("decode bound execution snapshot: %w", err) + } + if candidate.Snapshot.SchemaVersion != "execution-config-snapshot.v0.2" || candidate.Snapshot.DispatcherID != candidate.DispatcherID || + candidate.Snapshot.TaskID != candidate.TaskID || candidate.Snapshot.TenantID != candidate.TenantID || candidate.Snapshot.TenantKey != candidate.TenantKey || + candidate.Snapshot.SelectedTrunkID == "" { + return fmt.Errorf("%w: execution snapshot identity or selected trunk mismatch", ErrLocalOriginationNotAuthorized) + } + return nil +} + +// ClaimLocalOrigination commits the one-shot dial decision and its Agent +// control route together. A crash between them must not leave an unaddressable +// in-flight call. The admission reservation is not debited a second time. +func (s *Store) ClaimLocalOrigination(dispatcherID, executionID, agentID string, binding *agentv1.ExecutionBinding, decidedAt time.Time) error { + if decidedAt.IsZero() || binding == nil || binding.ExecutionId != executionID || binding.AttemptId != executionID || agentID == "" { + return ErrLocalOriginationNotAuthorized + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if err := bindExecutionAgentTx(tx, agentID, binding); err != nil { + return fmt.Errorf("bind local origination Agent: %w", err) + } + result, err := tx.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at) + SELECT t.execution_id,?,'issued','',? FROM tasks t WHERE t.execution_id=? AND t.status='reserved' + AND EXISTS (SELECT 1 FROM reservations r WHERE r.execution_id=t.execution_id AND r.state='held') + AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key AND a.removed=0 AND a.admission_state='running') + AND EXISTS (SELECT 1 FROM local_v02_task_discovery_state d WHERE d.dispatcher_id=? AND d.ready=1) + AND NOT EXISTS (SELECT 1 FROM controls c WHERE c.execution_id=t.execution_id AND c.action IN ('pause','drain','stop','hangup')) + AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`, + dispatcherID, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID, dispatcherID) + if err != nil { + return fmt.Errorf("claim local origination: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return ErrLocalOriginationNotAuthorized + } + return tx.Commit() +} + +// RefuseLocalOrigination records a failed final gate, not just a transient +// return value. A duplicate command or a clock change cannot revive it. It +// never changes an already issued decision. +func (s *Store) RefuseLocalOrigination(dispatcherID, executionID, reason string, decidedAt time.Time) error { + switch reason { + case "policy_denied", "control_closed", "invalid_binding": + default: + return fmt.Errorf("invalid local origination refusal reason %q", reason) + } + if dispatcherID == "" || executionID == "" || decidedAt.IsZero() { + return ErrLocalOriginationNotAuthorized + } + s.mu.Lock() + defer s.mu.Unlock() + result, err := s.db.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at) + SELECT t.execution_id,?,'refused',?,? FROM tasks t WHERE t.execution_id=? AND t.status='reserved' + AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key) + AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`, + dispatcherID, reason, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID) + if err != nil { + return fmt.Errorf("persist local origination refusal: %w", err) + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count == 1 { + return nil + } + var previous string + if err := s.db.QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=? AND dispatcher_id=?`, executionID, dispatcherID).Scan(&previous); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrLocalOriginationNotAuthorized + } + return fmt.Errorf("read local origination decision: %w", err) + } + if previous == "refused" { + return nil + } + return ErrLocalOriginationNotAuthorized +} diff --git a/internal/store/local_origination_queue.go b/internal/store/local_origination_queue.go new file mode 100644 index 0000000..9a2c2f4 --- /dev/null +++ b/internal/store/local_origination_queue.go @@ -0,0 +1,45 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" +) + +// LocalPendingOriginationForCommand resolves only a durably accepted, held +// execution for the exact delivered command bytes. Missing rows include +// rejected/terminal commands and already issued or refused decisions. +func (s *Store) LocalPendingOriginationForCommand(body []byte) (string, error) { + if len(body) == 0 { + return "", sql.ErrNoRows + } + hash := sha256.Sum256(body) + s.mu.Lock() + defer s.mu.Unlock() + var executionID string + err := s.db.QueryRow(`SELECT t.execution_id FROM tasks t + JOIN inbox i ON i.command_id=t.task_item_id AND i.tenant_id=t.tenant_id AND i.tenant_key=t.tenant_key + JOIN reservations r ON r.execution_id=t.execution_id AND r.state='held' + WHERE i.command_type='call.execute' AND i.status='persisted' AND i.body_hash=? AND i.body=? + AND t.status='reserved' + AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions d WHERE d.execution_id=t.execution_id)`, + hex.EncodeToString(hash[:]), body).Scan(&executionID) + if err != nil { + return "", err + } + return executionID, nil +} + +func (s *Store) LocalOriginationDecision(executionID string) (string, error) { + if executionID == "" { + return "", ErrLocalOriginationNotAuthorized + } + s.mu.Lock() + defer s.mu.Unlock() + var decision string + if err := s.db.QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, executionID).Scan(&decision); err != nil { + return "", fmt.Errorf("read local origination decision: %w", err) + } + return decision, nil +} diff --git a/internal/store/local_origination_recovery.go b/internal/store/local_origination_recovery.go new file mode 100644 index 0000000..5286561 --- /dev/null +++ b/internal/store/local_origination_recovery.go @@ -0,0 +1,116 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +// LoadLocalIssuedOrigination reads only persisted admission and authorization +// facts. Unlike LoadLocalOrigination it NEVER checks current admission or +// reselects a trunk: pause, stop, stale discovery, or configuration expiry +// cannot erase the outcome of a previously issued instruction. +func (s *Store) LoadLocalIssuedOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, time.Time, error) { + candidate, at, _, err := s.loadLocalDecisionOrigination(dispatcherID, executionID, "issued") + return candidate, at, err +} + +func (s *Store) LoadLocalRefusedOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, time.Time, string, error) { + return s.loadLocalDecisionOrigination(dispatcherID, executionID, "refused") +} + +func (s *Store) loadLocalDecisionOrigination(dispatcherID, executionID, decision string) (LocalOriginationCandidate, time.Time, string, error) { + if dispatcherID == "" || executionID == "" || decision != "issued" && decision != "refused" { + return LocalOriginationCandidate{}, time.Time{}, "", ErrLocalOriginationNotAuthorized + } + candidate := LocalOriginationCandidate{DispatcherID: dispatcherID, ExecutionID: executionID} + var snapshotBody []byte + var inboxHash, taskStatus, inboxStatus, reservationState, decidedAt string + var refusalReason sql.NullString + err := s.db.QueryRow(`SELECT t.tenant_id,t.tenant_key,t.task_id,t.task_item_id,t.callee,t.status, + i.body,i.body_hash,i.status,c.body,c.content_sha256,r.state,d.decided_at,d.reason + FROM tasks t + JOIN inbox i ON i.command_id=t.task_item_id AND i.command_type='call.execute' + JOIN local_v01_execution_configs c ON c.execution_id=t.execution_id + JOIN reservations r ON r.execution_id=t.execution_id + JOIN local_v02_origination_decisions d ON d.execution_id=t.execution_id AND d.dispatcher_id=? AND d.decision=? + WHERE t.execution_id=?`, dispatcherID, decision, executionID). + Scan(&candidate.TenantID, &candidate.TenantKey, &candidate.TaskID, &candidate.CommandID, &candidate.Callee, &taskStatus, + &candidate.CommandBody, &inboxHash, &inboxStatus, &snapshotBody, &candidate.SnapshotSHA256, &reservationState, &decidedAt, &refusalReason) + if errors.Is(err, sql.ErrNoRows) { + return LocalOriginationCandidate{}, time.Time{}, "", ErrLocalOriginationNotAuthorized + } + if err != nil { + return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("load %s local execution facts: %w", decision, err) + } + if taskStatus == "finished" && reservationState != "released" || + inboxStatus != "persisted" || reservationState != "held" && reservationState != "unknown" && reservationState != "released" || + decision == "refused" && (!refusalReason.Valid || refusalReason.String == "") { + return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("%w: %s execution task=%s inbox=%s reservation=%s", ErrLocalOriginationNotAuthorized, decision, taskStatus, inboxStatus, reservationState) + } + if err := validateLocalOriginationCandidate(&candidate, snapshotBody, inboxHash); err != nil { + return LocalOriginationCandidate{}, time.Time{}, "", err + } + at, err := time.Parse(time.RFC3339Nano, decidedAt) + if err != nil || at.IsZero() { + return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("decode %s origination time: %w", decision, errors.Join(err, ErrLocalOriginationNotAuthorized)) + } + return candidate, at, refusalReason.String, nil +} + +// The two bounded scans are only for reconciliation. They never consume a +// command or issue another Agent instruction. +func (s *Store) ListLocalIssuedWithoutTerminal(dispatcherID string, offset, limit int) ([]string, error) { + return s.listLocalDecisionsWithoutTerminal(dispatcherID, "issued", offset, limit) +} + +func (s *Store) ListLocalRefusedWithoutTerminal(dispatcherID string, offset, limit int) ([]string, error) { + return s.listLocalDecisionsWithoutTerminal(dispatcherID, "refused", offset, limit) +} + +func (s *Store) listLocalDecisionsWithoutTerminal(dispatcherID, decision string, offset, limit int) ([]string, error) { + if dispatcherID == "" || decision != "issued" && decision != "refused" || offset < 0 || limit <= 0 || limit > 256 { + return nil, errors.New("invalid local issued reconciliation scan") + } + rows, err := s.db.Query(`SELECT d.execution_id FROM local_v02_origination_decisions d + LEFT JOIN local_v01_call_terminals f ON f.execution_id=d.execution_id + WHERE d.dispatcher_id=? AND d.decision=? AND f.execution_id IS NULL + ORDER BY d.decided_at,d.execution_id LIMIT ? OFFSET ?`, dispatcherID, decision, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (s *Store) ListLocalTerminalsWithoutResult(dispatcherID string, offset, limit int) ([]string, error) { + if dispatcherID == "" || offset < 0 || limit <= 0 || limit > 256 { + return nil, errors.New("invalid local result reconciliation scan") + } + rows, err := s.db.Query(`SELECT f.execution_id FROM local_v01_call_terminals f + JOIN local_v02_origination_decisions d ON d.execution_id=f.execution_id + WHERE d.dispatcher_id=? AND d.decision IN ('issued','refused') AND f.result_event_id IS NULL + ORDER BY f.ended_at,f.execution_id LIMIT ? OFFSET ?`, dispatcherID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} diff --git a/internal/store/local_origination_recovery_test.go b/internal/store/local_origination_recovery_test.go new file mode 100644 index 0000000..6d2bcfe --- /dev/null +++ b/internal/store/local_origination_recovery_test.go @@ -0,0 +1,51 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func TestLocalIssuedRecoveryUsesBoundFactsEvenWhenQuotaUnknown(t *testing.T) { + st, record := readyLocalOrigination(t) + at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), at); err != nil { + t.Fatal(err) + } + ids, err := st.ListLocalIssuedWithoutTerminal("d-1", 0, 256) + if err != nil || len(ids) != 1 || ids[0] != "execution-a" { + t.Fatalf("missing issued call on restart: ids=%v err=%v", ids, err) + } + if err := st.ReleaseReservation(record.Admission.ReservationID, true); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 1, "unknown") + if _, err := st.LoadLocalOrigination("d-1", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("ordinary dial admission resumed an unknown reservation: %v", err) + } + candidate, decidedAt, err := st.LoadLocalIssuedOrigination("d-1", "execution-a") + if err != nil || !decidedAt.Equal(at) || candidate.Snapshot.SelectedTrunkID != record.Admission.Snapshot.SelectedTrunkID || + candidate.CommandID != record.CommandID { + t.Fatalf("issued call lost its bound snapshot: candidate=%+v at=%v err=%v", candidate, decidedAt, err) + } + terminal := localTerminalFixture() + if err := st.RecordLocalCallTerminal(terminal, nil); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") + ids, err = st.ListLocalIssuedWithoutTerminal("d-1", 0, 256) + if err != nil || len(ids) != 0 { + t.Fatalf("confirmed terminal remained unknown: ids=%v err=%v", ids, err) + } + ids, err = st.ListLocalTerminalsWithoutResult("d-1", 0, 256) + if err != nil || len(ids) != 1 || ids[0] != "execution-a" { + t.Fatalf("pending result lost after terminal: ids=%v err=%v", ids, err) + } + if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, localFinalEventFixture()); err != nil { + t.Fatal(err) + } + ids, err = st.ListLocalTerminalsWithoutResult("d-1", 0, 256) + if err != nil || len(ids) != 0 { + t.Fatalf("already queued final result replayed: ids=%v err=%v", ids, err) + } +} diff --git a/internal/store/local_origination_test.go b/internal/store/local_origination_test.go new file mode 100644 index 0000000..e58bb2d --- /dev/null +++ b/internal/store/local_origination_test.go @@ -0,0 +1,192 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func localTestOriginationBinding(t *testing.T, st *Store, executionID string) *agentv1.ExecutionBinding { + t.Helper() + binding := &agentv1.ExecutionBinding{ExecutionId: executionID, AttemptId: executionID} + if err := st.DB().QueryRow(`SELECT tenant_id,tenant_key,task_id,task_item_id,task_revision,agent_version_id,route_policy_id,caller_profile_id FROM tasks WHERE execution_id=?`, executionID).Scan( + &binding.TenantId, &binding.TenantKey, &binding.TaskId, &binding.TaskItemId, &binding.TaskRevision, + &binding.AgentVersionId, &binding.RoutePolicyId, &binding.CallerProfileId, + ); err != nil { + t.Fatal(err) + } + return binding +} + +func readyLocalOrigination(t *testing.T) (*Store, LocalCommandRecord) { + t.Helper() + st := openLocalDiscoveryTestStore(t) + at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, at) + if err := st.SaveLocalConfigBundle(bundle); err != nil { + t.Fatal(err) + } + record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a") + seedLocalCommandAssignments(t, st, record) + if duplicate, err := st.PersistLocalCommand(record); err != nil || duplicate { + t.Fatalf("persist accepted command: duplicate=%v err=%v", duplicate, err) + } + return st, record +} + +func TestLocalOriginationDecisionsAreDurableAndOneShot(t *testing.T) { + for _, decision := range []string{"issued", "refused"} { + t.Run(decision, func(t *testing.T) { + st, record := readyLocalOrigination(t) + decisionAt := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + pending, err := st.LocalPendingOriginationForCommand(record.Body) + if err != nil || pending != "execution-a" { + t.Fatalf("pending execution=%q err=%v", pending, err) + } + candidate, err := st.LoadLocalOrigination("d-1", pending) + if err != nil || candidate.CommandID != "command-a" || candidate.Callee != "15003164745" || + candidate.Snapshot.SelectedTrunkID != "mock-trunk" || len(candidate.SnapshotSHA256) != 64 { + t.Fatalf("bound origination=%+v err=%v", candidate, err) + } + binding := localTestOriginationBinding(t, st, pending) + if decision == "issued" { + if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); err != nil { + t.Fatal(err) + } + if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("second claim error=%v", err) + } + if err := st.RefuseLocalOrigination("d-1", pending, "control_closed", decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("issued instruction was overwritten by refusal: %v", err) + } + } else { + if err := st.RefuseLocalOrigination("d-1", pending, "policy_denied", decisionAt); err != nil { + t.Fatal(err) + } + if err := st.RefuseLocalOrigination("d-1", pending, "control_closed", decisionAt); err != nil { + t.Fatalf("replayed refusal error=%v", err) + } + if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("refused instruction was revived: %v", err) + } + } + got, err := st.LocalOriginationDecision(pending) + if err != nil || got != decision { + t.Fatalf("durable decision=%q want=%q err=%v", got, decision, err) + } + var bound int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_agents WHERE execution_id=?`, pending).Scan(&bound); err != nil { + t.Fatal(err) + } + wantBound := 0 + if decision == "issued" { + wantBound = 1 + } + if bound != wantBound { + t.Fatalf("claim and Agent control binding were not atomic: decision=%s bindings=%d", decision, bound) + } + if _, err := st.LocalPendingOriginationForCommand(record.Body); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("decided execution still pending: %v", err) + } + assertLocalQuota(t, st, LocalTenantQuotaScope("d-1", "tenant-a"), 1, 1) // F08 owns release. + }) + } +} + +func TestLocalOriginationFailClosedOnCorruptOrPausedFacts(t *testing.T) { + for _, tc := range []struct { + name string + change func(t *testing.T, st *Store) + }{ + {"modified command hash", func(t *testing.T, st *Store) { + t.Helper() + if _, err := st.db.Exec(`UPDATE inbox SET body_hash='bad' WHERE command_id='command-a'`); err != nil { + t.Fatal(err) + } + }}, + {"modified snapshot hash", func(t *testing.T, st *Store) { + t.Helper() + if _, err := st.db.Exec(`UPDATE local_v01_execution_configs SET content_sha256=? WHERE execution_id='execution-a'`, strings.Repeat("0", 64)); err != nil { + t.Fatal(err) + } + }}, + {"snapshot identity mismatch with valid hash", func(t *testing.T, st *Store) { + t.Helper() + var body []byte + if err := st.db.QueryRow(`SELECT body FROM local_v01_execution_configs WHERE execution_id='execution-a'`).Scan(&body); err != nil { + t.Fatal(err) + } + var snapshot LocalExecutionConfigSnapshot + if err := json.Unmarshal(body, &snapshot); err != nil { + t.Fatal(err) + } + snapshot.TaskID = "different-task" + changed, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(changed) + if _, err := st.db.Exec(`UPDATE local_v01_execution_configs SET body=?,content_sha256=? WHERE execution_id='execution-a'`, changed, hex.EncodeToString(hash[:])); err != nil { + t.Fatal(err) + } + }}, + {"paused assignment", func(t *testing.T, st *Store) { + t.Helper() + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(tc.name, func(t *testing.T) { + st, record := readyLocalOrigination(t) + tc.change(t, st) + if _, err := st.LoadLocalOrigination("d-1", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("invalid facts did not block origination: %v", err) + } + if tc.name == "paused assignment" { + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("pause crossed atomic claim: %v", err) + } + var bound int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_agents WHERE execution_id='execution-a'`).Scan(&bound); err != nil || bound != 0 { + t.Fatalf("failed claim left an Agent binding: count=%d err=%v", bound, err) + } + if err := st.RefuseLocalOrigination("d-1", "execution-a", "control_closed", time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); err != nil { + t.Fatal(err) + } + if _, err := st.LocalPendingOriginationForCommand(record.Body); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("paused refusal reentered queue: %v", err) + } + } + }) + } +} + +func TestLocalOriginationRejectsMissingOrInvalidIdentity(t *testing.T) { + st, record := readyLocalOrigination(t) + if _, err := st.LocalPendingOriginationForCommand(nil); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("empty command lookup error=%v", err) + } + if _, err := st.LocalPendingOriginationForCommand(append(append([]byte(nil), record.Body...), ' ')); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("changed command bytes matched a pending execution: %v", err) + } + if _, err := st.LoadLocalOrigination("other-dispatcher", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("wrong Dispatcher identity error=%v", err) + } + if err := st.RefuseLocalOrigination("d-1", "execution-a", "unrecognized", time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); err == nil { + t.Fatal("invalid refusal reason was persisted") + } + if _, err := st.LocalOriginationDecision(""); !errors.Is(err, ErrLocalOriginationNotAuthorized) { + t.Fatalf("empty execution ID error=%v", err) + } + if _, err := st.LocalOriginationDecision("missing"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("missing decision error=%v", err) + } +} diff --git a/internal/store/local_recording_error_codes_test.go b/internal/store/local_recording_error_codes_test.go new file mode 100644 index 0000000..0a239c0 --- /dev/null +++ b/internal/store/local_recording_error_codes_test.go @@ -0,0 +1,32 @@ +package store + +import "testing" + +func TestLocalUnavailableRecordingUsesOnlyContractErrorCodes(t *testing.T) { + for _, tc := range []struct { + code string + valid bool + }{ + {"upload_authorization_failed", true}, + {"upload_authorization_expired", true}, + {"upload_failed", true}, + {"upload_timeout", true}, + {"deadline_exceeded", true}, + {"checksum_mismatch", true}, + {"storage_unavailable", false}, + {"artifact_missing", false}, + {"unknown", false}, + {"", false}, + } { + t.Run(tc.code, func(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, LocalRecordingOutcome{ + Status: "unavailable", ErrorCode: tc.code, + ObservedAt: terminal.EndedAt.Add(LocalRecordingDeadline), + }) + if (err == nil) != tc.valid { + t.Fatalf("error code %q: valid=%t err=%v", tc.code, tc.valid, err) + } + }) + } +} diff --git a/internal/store/local_recording_failure_fact.go b/internal/store/local_recording_failure_fact.go new file mode 100644 index 0000000..2417c2f --- /dev/null +++ b/internal/store/local_recording_failure_fact.go @@ -0,0 +1,94 @@ +package store + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +// RecordLocalRecordingFailureFact binds one authenticated Mock failure fact to +// the immutable recording outcome in a single transaction. A crash after this +// transaction but before the final result is queued is repaired by the normal +// pending-result recovery; a duplicate fact cannot create another outcome. +func (s *Store) RecordLocalRecordingFailureFact(record ExecutionFactRecord, outcome LocalRecordingOutcome) (bool, error) { + if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || + record.SourceBootID == "" || record.SourceSequence == 0 || len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 || + record.Kind != int32(agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS) { + return false, errors.New("Mock recording failure requires a bound execution fact") + } + if outcome.Status != "unavailable" || + outcome.ErrorCode != "upload_authorization_failed" && outcome.ErrorCode != "upload_authorization_expired" && + outcome.ErrorCode != "upload_failed" && outcome.ErrorCode != "checksum_mismatch" { + return false, fmt.Errorf("Agent cannot declare this recording outcome: %w", ErrCommandConflict) + } + outcome, encoded, outcomeDigest, err := prepareLocalRecordingOutcome(record.ExecutionID, outcome) + if err != nil { + return false, err + } + if !record.ObservedAt.Equal(outcome.ObservedAt) { + return false, fmt.Errorf("Mock failure observation differs from outcome: %w", ErrCommandConflict) + } + payloadDigest := sha256.Sum256(record.PayloadJSON) + if hex.EncodeToString(payloadDigest[:]) != record.ContentSHA256 { + return false, fmt.Errorf("Mock failure fact digest mismatch: %w", ErrFactConflict) + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + + var priorDigest, priorTenantID, priorTenantKey, priorExecutionID, priorBootID, priorEventID string + var priorKind, priorSequence int64 + var priorBinding, priorPayload []byte + err = tx.QueryRow(`SELECT content_sha256,tenant_id,tenant_key,execution_id,kind,binding_json,payload_json, + source_boot_id,source_sequence,event_id FROM execution_facts WHERE fact_id=?`, record.FactID). + Scan(&priorDigest, &priorTenantID, &priorTenantKey, &priorExecutionID, &priorKind, &priorBinding, + &priorPayload, &priorBootID, &priorSequence, &priorEventID) + if err == nil { + if priorDigest != record.ContentSHA256 || priorTenantID != record.TenantID || priorTenantKey != record.TenantKey || + priorExecutionID != record.ExecutionID || priorKind != int64(record.Kind) || + !bytes.Equal(priorBinding, record.BindingJSON) || !bytes.Equal(priorPayload, record.PayloadJSON) || + priorBootID != record.SourceBootID || priorSequence != int64(record.SourceSequence) || priorEventID != "" { + return false, fmt.Errorf("Mock recording failure fact identity changed: %w", ErrFactConflict) + } + var priorOutcome sql.NullString + if err := tx.QueryRow(`SELECT recording_outcome_sha256 FROM local_v01_call_terminals WHERE execution_id=?`, record.ExecutionID).Scan(&priorOutcome); err != nil { + return false, err + } + if !priorOutcome.Valid || priorOutcome.String != outcomeDigest { + return false, fmt.Errorf("Mock recording fact has no matching durable outcome: %w", ErrCommandConflict) + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return false, err + } + if _, err := recordLocalRecordingOutcomeTx(tx, record.ExecutionID, outcome, encoded, outcomeDigest, false, true); err != nil { + return false, err + } + if _, err := tx.Exec(`INSERT INTO execution_facts( + fact_id,tenant_id,tenant_key,execution_id,content_sha256,kind,binding_json,payload_json,observed_at, + source_boot_id,source_sequence,event_id,event_type,aggregate_type,aggregate_id,aggregate_version,received_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, record.FactID, record.TenantID, record.TenantKey, + record.ExecutionID, record.ContentSHA256, record.Kind, record.BindingJSON, record.PayloadJSON, + record.ObservedAt.UTC().Format(time.RFC3339Nano), record.SourceBootID, record.SourceSequence, + "", "", "execution", record.ExecutionID, 1, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return false, fmt.Errorf("persist Mock recording failure fact: %w", err) + } + if err := tx.Commit(); err != nil { + return false, err + } + return false, nil +} diff --git a/internal/store/local_recording_failure_fact_test.go b/internal/store/local_recording_failure_fact_test.go new file mode 100644 index 0000000..0a46769 --- /dev/null +++ b/internal/store/local_recording_failure_fact_test.go @@ -0,0 +1,80 @@ +package store + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func localFailureFactFixture(terminal LocalCallTerminal) (ExecutionFactRecord, LocalRecordingOutcome) { + payload := []byte(`{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"` + terminal.Recording.UploadID + `","recording_id":"` + terminal.Recording.RecordingID + `","error_code":"upload_failed"}`) + sum := sha256.Sum256(payload) + observed := terminal.EndedAt.Add(time.Second) + return ExecutionFactRecord{ + FactID: "failure-fact-a", TenantID: "tenant-a", TenantKey: "tenant-a", ExecutionID: terminal.ExecutionID, + ContentSHA256: hex.EncodeToString(sum[:]), Kind: int32(agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS), + BindingJSON: []byte(`{"tenantId":"tenant-a","tenantKey":"tenant-a","executionId":"` + terminal.ExecutionID + `"}`), + PayloadJSON: payload, ObservedAt: observed, SourceBootID: "boot-a", SourceSequence: 1, + }, LocalRecordingOutcome{Status: "unavailable", ErrorCode: "upload_failed", ObservedAt: observed} +} + +func TestLocalRecordingFailureFactAndOutcomeAreAtomicAndIdempotent(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + fact, outcome := localFailureFactFixture(terminal) + duplicate, err := st.RecordLocalRecordingFailureFact(fact, outcome) + if err != nil || duplicate { + t.Fatalf("first explicit failure: duplicate=%t err=%v", duplicate, err) + } + persisted, found, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) + if err != nil || !found || persisted.ErrorCode != "upload_failed" { + t.Fatalf("failure outcome was not durable: %+v found=%t err=%v", persisted, found, err) + } + duplicate, err = st.RecordLocalRecordingFailureFact(fact, outcome) + if err != nil || !duplicate { + t.Fatalf("same fact was not idempotent: duplicate=%t err=%v", duplicate, err) + } + conflicting := fact + conflicting.ContentSHA256 = "different-digest" + if _, err := st.RecordLocalRecordingFailureFact(conflicting, outcome); !errors.Is(err, ErrFactConflict) { + t.Fatalf("reused fact ID with different digest was accepted: %v", err) + } + secondID := fact + secondID.FactID = "another-fact" + if _, err := st.RecordLocalRecordingFailureFact(secondID, outcome); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("second failure identity replaced the first: %v", err) + } + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_facts WHERE execution_id=?`, terminal.ExecutionID).Scan(&count); err != nil || count != 1 { + t.Fatalf("failure replay created another execution fact: count=%d err=%v", count, err) + } +} + +func TestLocalRecordingFailureFactInsertFailureRollsBackOutcome(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + fact, outcome := localFailureFactFixture(terminal) + if _, err := st.DB().Exec(`CREATE TRIGGER reject_local_failure BEFORE INSERT ON execution_facts + WHEN NEW.fact_id='failure-fact-a' BEGIN SELECT RAISE(ABORT,'injected fact insert failure'); END`); err != nil { + t.Fatal(err) + } + if _, err := st.RecordLocalRecordingFailureFact(fact, outcome); err == nil { + t.Fatal("fact insert failure was swallowed") + } + _, found, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) + if err != nil || found { + t.Fatalf("fact failure left an unacknowledged outcome: found=%t err=%v", found, err) + } +} + +func TestLocalRecordingFailureAfterResultDeadlineIsRejected(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + fact, outcome := localFailureFactFixture(terminal) + outcome.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline + time.Nanosecond) + fact.ObservedAt = outcome.ObservedAt + if _, err := st.RecordLocalRecordingFailureFact(fact, outcome); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("late failure supplanted deadline result: %v", err) + } +} diff --git a/internal/store/local_recording_outcome.go b/internal/store/local_recording_outcome.go new file mode 100644 index 0000000..b6a4d7c --- /dev/null +++ b/internal/store/local_recording_outcome.go @@ -0,0 +1,134 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +const LocalRecordingDeadline = 15 * time.Minute + +// RecordLocalRecordingOutcome persists a completed isolated recording fact, +// not a request to PUT an object. A duplicate fact is harmless; an alternate +// asset or failure cannot replace the first fact or a published final result. +func (s *Store) RecordLocalRecordingOutcome(executionID string, outcome LocalRecordingOutcome) error { + outcome, encoded, digest, err := prepareLocalRecordingOutcome(executionID, outcome) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := recordLocalRecordingOutcomeTx(tx, executionID, outcome, encoded, digest, true, false); err != nil { + return err + } + return tx.Commit() +} + +func prepareLocalRecordingOutcome(executionID string, outcome LocalRecordingOutcome) (LocalRecordingOutcome, []byte, string, error) { + if executionID == "" || outcome.ObservedAt.IsZero() { + return LocalRecordingOutcome{}, nil, "", errors.New("recording outcome requires execution and observation time") + } + outcome.ObservedAt = outcome.ObservedAt.UTC() + switch outcome.Status { + case "uploaded": + if outcome.Bucket == "" || outcome.ObjectKey == "" || outcome.ErrorCode != "" || outcome.SizeBytes < 0 || + len(outcome.ChecksumSHA256) != 64 || strings.ToLower(outcome.ChecksumSHA256) != outcome.ChecksumSHA256 { + return LocalRecordingOutcome{}, nil, "", errors.New("incomplete uploaded recording fact") + } + if _, err := hex.DecodeString(outcome.ChecksumSHA256); err != nil { + return LocalRecordingOutcome{}, nil, "", fmt.Errorf("invalid uploaded checksum: %w", err) + } + case "unavailable": + switch outcome.ErrorCode { + case "upload_authorization_failed", "upload_authorization_expired", "upload_failed", "upload_timeout", "deadline_exceeded", "checksum_mismatch": + default: + return LocalRecordingOutcome{}, nil, "", errors.New("invalid unavailable recording error code") + } + if outcome.Bucket != "" || outcome.ObjectKey != "" || outcome.SizeBytes != 0 || outcome.ChecksumSHA256 != "" { + return LocalRecordingOutcome{}, nil, "", errors.New("unavailable recording cannot claim an uploaded asset") + } + default: + return LocalRecordingOutcome{}, nil, "", errors.New("recording fact must be uploaded or unavailable") + } + encoded, err := json.Marshal(outcome) + if err != nil { + return LocalRecordingOutcome{}, nil, "", err + } + sum := sha256.Sum256(encoded) + return outcome, encoded, hex.EncodeToString(sum[:]), nil +} + +func recordLocalRecordingOutcomeTx(tx *sql.Tx, executionID string, outcome LocalRecordingOutcome, encoded []byte, digest string, allowDuplicate, requireWithinDeadline bool) (bool, error) { + var expected int + var endedAt string + var priorDigest, priorStatus, eventID sql.NullString + if err := tx.QueryRow(`SELECT recording_expected,ended_at,recording_outcome_sha256,recording_status,result_event_id + FROM local_v01_call_terminals WHERE execution_id=?`, executionID). + Scan(&expected, &endedAt, &priorDigest, &priorStatus, &eventID); err != nil { + return false, fmt.Errorf("load confirmed recording call: %w", err) + } + if expected != 1 { + return false, ErrCommandConflict + } + ended, err := time.Parse(time.RFC3339Nano, endedAt) + if err != nil { + return false, fmt.Errorf("decode confirmed call end: %w", err) + } + deadline := ended.Add(LocalRecordingDeadline) + if outcome.ObservedAt.Before(ended) || requireWithinDeadline && outcome.ObservedAt.After(deadline) || + outcome.Status == "uploaded" && outcome.ObservedAt.After(deadline) || + outcome.ErrorCode == "upload_timeout" && outcome.ObservedAt.Before(deadline) { + return false, fmt.Errorf("recording outcome outside confirmed call deadline: %w", ErrCommandConflict) + } + if priorDigest.Valid { + if !allowDuplicate || priorDigest.String != digest || !priorStatus.Valid || priorStatus.String != outcome.Status { + return false, ErrCommandConflict + } + return true, nil + } + if eventID.Valid { + return false, ErrCommandConflict + } + result, err := tx.Exec(`UPDATE local_v01_call_terminals SET recording_outcome=?,recording_outcome_sha256=?,recording_status=? + WHERE execution_id=? AND recording_outcome IS NULL AND result_event_id IS NULL`, encoded, digest, outcome.Status, executionID) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + if err != nil || rows != 1 { + return false, fmt.Errorf("persist first recording outcome (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) + } + return false, nil +} + +func (s *Store) LoadLocalRecordingOutcome(executionID string) (LocalRecordingOutcome, bool, error) { + var encoded []byte + var status sql.NullString + if err := s.db.QueryRow(`SELECT recording_outcome,recording_status FROM local_v01_call_terminals WHERE execution_id=?`, executionID).Scan(&encoded, &status); err != nil { + return LocalRecordingOutcome{}, false, err + } + if encoded == nil && !status.Valid { + return LocalRecordingOutcome{}, false, nil + } + if encoded == nil || !status.Valid { + return LocalRecordingOutcome{}, false, ErrCommandConflict + } + var outcome LocalRecordingOutcome + if err := json.Unmarshal(encoded, &outcome); err != nil { + return LocalRecordingOutcome{}, false, fmt.Errorf("decode persisted recording outcome: %w", err) + } + if outcome.Status != status.String { + return LocalRecordingOutcome{}, false, ErrCommandConflict + } + return outcome, true, nil +} diff --git a/internal/store/local_recording_outcome_test.go b/internal/store/local_recording_outcome_test.go new file mode 100644 index 0000000..b24223c --- /dev/null +++ b/internal/store/local_recording_outcome_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "errors" + "testing" + "time" +) + +func readyLocalRecordingTerminal(t *testing.T) (*Store, LocalCallTerminal) { + t.Helper() + st, _ := readyLocalOrigination(t) + if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { + t.Fatal(err) + } + fact := localTerminalFixture() + fact.RecordingExpected = true + fact.Recording = &LocalRecordingManifest{ + RecordingID: "rec-a", UploadID: "upload-a", Format: "wav", Channels: 1, + SampleRateHz: 8000, DurationMs: 60000, + } + if err := st.RecordLocalCallTerminal(fact, nil); err != nil { + t.Fatal(err) + } + return st, fact +} + +func TestLocalUploadedRecordingFactCannotBeReplacedOrRePUT(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + assertLocalTerminalQuota(t, st, 0, 0, "released") + fact := LocalRecordingOutcome{ + Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", + SizeBytes: 128000, ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ObservedAt: terminal.EndedAt.Add(time.Second), + } + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, fact); err != nil { + t.Fatal(err) + } + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, fact); err != nil { + t.Fatalf("identical uploaded fact replay: %v", err) + } + changed := fact + changed.ObjectKey = "tenant/call/different.wav" + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, changed); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("changed upload fact accepted: %v", err) + } + loaded, exists, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) + if err != nil || !exists || loaded.ObjectKey != fact.ObjectKey { + t.Fatalf("uploaded fact lost or changed: fact=%+v exists=%t err=%v", loaded, exists, err) + } + event := localFinalEventFixture() + if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, event); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, changed); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("uploaded asset replaced after final result: %v", err) + } +} + +func TestLocalRecordingDeadlineClosesUnavailableWithoutLateAsset(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + unavailable := LocalRecordingOutcome{ + Status: "unavailable", ErrorCode: "upload_timeout", ObservedAt: terminal.EndedAt.Add(LocalRecordingDeadline), + } + tooEarly := unavailable + tooEarly.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline - time.Nanosecond) + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, tooEarly); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("premature upload timeout accepted: %v", err) + } + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, unavailable); err != nil { + t.Fatal(err) + } + lateUpload := LocalRecordingOutcome{ + Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/late.wav", + ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ObservedAt: unavailable.ObservedAt.Add(time.Nanosecond), + } + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, lateUpload); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("late upload replaced timeout: %v", err) + } + fact, exists, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) + if err != nil || !exists || fact.ErrorCode != "upload_timeout" { + t.Fatalf("timeout fact changed: fact=%+v exists=%t err=%v", fact, exists, err) + } +} diff --git a/internal/store/local_refusal_terminal_test.go b/internal/store/local_refusal_terminal_test.go new file mode 100644 index 0000000..9b17eb5 --- /dev/null +++ b/internal/store/local_refusal_terminal_test.go @@ -0,0 +1,38 @@ +package store + +import ( + "testing" + "time" +) + +func TestLocalRefusedExecutionClosesOnceWithoutAgentAttempt(t *testing.T) { + st, _ := readyLocalOrigination(t) + at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + if err := st.RefuseLocalOrigination("d-1", "execution-a", "policy_denied", at); err != nil { + t.Fatal(err) + } + ids, err := st.ListLocalRefusedWithoutTerminal("d-1", 0, 256) + if err != nil || len(ids) != 1 || ids[0] != "execution-a" { + t.Fatalf("refused decision unavailable to recover: ids=%v err=%v", ids, err) + } + candidate, decidedAt, reason, err := st.LoadLocalRefusedOrigination("d-1", "execution-a") + if err != nil || candidate.ExecutionID != "execution-a" || reason != "policy_denied" || !decidedAt.Equal(at) { + t.Fatalf("persisted refusal lost: candidate=%+v at=%v reason=%q err=%v", candidate, decidedAt, reason, err) + } + fact := LocalCallTerminal{ + ExecutionID: "execution-a", CallID: "execution-a", Source: "dispatcher_refusal", + StartedAt: at, EndedAt: at, Outcome: "failed", ReasonCode: reason, + } + event := localFinalEventFixture() + if err := st.RecordLocalCallTerminal(fact, &event); err != nil { + t.Fatal(err) + } + assertLocalTerminalQuota(t, st, 0, 0, "released") + if err := st.RecordLocalCallTerminal(fact, &event); err != nil { + t.Fatalf("refusal replay changed terminal fact: %v", err) + } + ids, err = st.ListLocalRefusedWithoutTerminal("d-1", 0, 256) + if err != nil || len(ids) != 0 { + t.Fatalf("closed refusal scheduled twice: ids=%v err=%v", ids, err) + } +} diff --git a/internal/store/local_v01.go b/internal/store/local_v01.go new file mode 100644 index 0000000..5679e50 --- /dev/null +++ b/internal/store/local_v01.go @@ -0,0 +1,327 @@ +package store + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +type LocalCommandRecord struct { + DispatcherID string + TaskID string + TenantID string + TenantKey string + CommandID string + CommandType string + Body []byte + ReceiptID string + Exchange string + RoutingKey string + ReceiptBody []byte + CapacityRejectedReceiptBody []byte + Status string + Admission *LocalCallAdmission +} + +type LocalCallAdmission struct { + DispatcherID string + Task Task + ReservationID string + QuotaScopes []string + Snapshot LocalExecutionConfigSnapshot +} + +// LocalExecutionConfigSnapshot binds an accepted call to the exact raw +// configuration responses used for its admission decision. +type LocalExecutionConfigSnapshot struct { + SchemaVersion string `json:"schema_version"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TaskID string `json:"task_id"` + SelectedTrunkID string `json:"selected_trunk_id"` + SIPRevision int64 `json:"sip_revision"` + TaskRevision int64 `json:"task_revision"` + QuotaRevision int64 `json:"quota_revision"` + SIP json.RawMessage `json:"sip"` + Tasks json.RawMessage `json:"tasks"` + Task json.RawMessage `json:"task"` + TenantQuota json.RawMessage `json:"tenant_quota"` +} + +type LocalEventRecord struct { + EventID string + TenantKey string + Exchange string + RoutingKey string + Body []byte +} + +// PersistLocalCommand stores the inbound command, quota reservation, bound +// config snapshot, and durable receipt in one transaction. +func (s *Store) PersistLocalCommand(record LocalCommandRecord) (bool, error) { + if record.TenantID == "" || record.TenantKey == "" || record.CommandID == "" || record.CommandType == "" || len(record.Body) == 0 || record.ReceiptID == "" || record.Exchange == "" || record.RoutingKey == "" || len(record.ReceiptBody) == 0 { + return false, errors.New("local command persistence fields are required") + } + if record.Status != "persisted" && record.Status != "rejected" { + return false, fmt.Errorf("unsupported local inbox status %q", record.Status) + } + if record.CommandType == "call.execute" && (record.DispatcherID == "" || record.TaskID == "") { + return false, errors.New("call.execute requires a Dispatcher and task identity") + } + if record.CommandType == "call.execute" && record.Status == "persisted" && record.Admission == nil { + return false, errors.New("accepted call.execute requires a durable quota admission") + } + if record.Admission == nil && len(record.CapacityRejectedReceiptBody) != 0 { + return false, errors.New("quota rejection receipt requires an admission") + } + var configBody []byte + if record.Admission != nil { + var err error + configBody, err = validateLocalAdmission(record) + if err != nil { + return false, err + } + } + bodyHash := sha256.Sum256(record.Body) + now := s.now().UTC().Format(time.RFC3339Nano) + + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return false, err + } + defer tx.Rollback() + + var oldTenantID, oldTenantKey, oldBodyHash string + err = tx.QueryRow(`SELECT tenant_id,tenant_key,body_hash FROM inbox WHERE command_id=?`, record.CommandID).Scan(&oldTenantID, &oldTenantKey, &oldBodyHash) + if err == nil { + if oldTenantID != record.TenantID || oldTenantKey != record.TenantKey || oldBodyHash != hex.EncodeToString(bodyHash[:]) { + return false, ErrCommandConflict + } + var receiptID string + if err := tx.QueryRow(`SELECT response_id FROM mq_command_receipts WHERE tenant_id=? AND command_id=?`, record.TenantID, record.CommandID).Scan(&receiptID); err != nil { + return false, fmt.Errorf("load persisted local command receipt: %w", err) + } + if _, err := tx.Exec(`UPDATE outbox SET status='pending', published_at=NULL, last_error=NULL WHERE event_id=? AND status='published'`, receiptID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return false, err + } + if record.CommandType == "call.execute" { + if err := requireLocalTaskRunningTx(tx, record.DispatcherID, record.TenantID, record.TenantKey, record.TaskID); err != nil { + return false, err + } + } + + if _, err := tx.Exec(`INSERT INTO inbox(command_id,tenant_id,tenant_key,command_type,body_hash,body,status,received_at) + VALUES(?,?,?,?,?,?,'received',?)`, record.CommandID, record.TenantID, record.TenantKey, record.CommandType, + hex.EncodeToString(bodyHash[:]), record.Body, now); err != nil { + return false, err + } + + finalStatus := record.Status + finalReceiptBody := record.ReceiptBody + if record.Admission != nil { + available, err := localQuotaScopesAvailable(tx, record.Admission.QuotaScopes) + if err != nil { + return false, err + } + if !available { + finalStatus = "rejected" + finalReceiptBody = record.CapacityRejectedReceiptBody + } else { + if err := insertLocalTask(tx, record.Admission.Task, now); err != nil { + return false, err + } + digest := sha256.Sum256(configBody) + if _, err := tx.Exec(`INSERT INTO local_v01_execution_configs(execution_id,content_sha256,body,stored_at) VALUES(?,?,?,?)`, + record.Admission.Task.ExecutionID, hex.EncodeToString(digest[:]), configBody, now); err != nil { + return false, err + } + if err := reserveLocalCallTx(tx, *record.Admission, now); err != nil { + return false, err + } + } + } + if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) + VALUES(?,?,?,?,?,'pending',?)`, record.ReceiptID, record.TenantKey, record.Exchange, record.RoutingKey, finalReceiptBody, now); err != nil { + return false, err + } + if _, err := tx.Exec(`INSERT INTO mq_command_receipts(tenant_id,command_id,response_id) VALUES(?,?,?)`, record.TenantID, record.CommandID, record.ReceiptID); err != nil { + return false, err + } + if _, err := tx.Exec(`UPDATE inbox SET status=?,persisted_at=? WHERE command_id=?`, finalStatus, now, record.CommandID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return false, nil +} + +func validateLocalAdmission(record LocalCommandRecord) ([]byte, error) { + admission := record.Admission + if admission == nil { + return nil, errors.New("local call admission is required") + } + task := admission.Task + snapshot := admission.Snapshot + if record.CommandType != "call.execute" || record.Status != "persisted" || len(record.CapacityRejectedReceiptBody) == 0 || !json.Valid(record.CapacityRejectedReceiptBody) { + return nil, errors.New("invalid accepted call admission receipt") + } + if admission.DispatcherID == "" || record.DispatcherID != admission.DispatcherID || record.TaskID != task.TaskID || admission.ReservationID == "" || task.ExecutionID == "" || task.TaskID == "" || task.TenantID == "" || task.TenantKey == "" || task.TaskItemID == "" || task.TraceID == "" || + task.TenantID != record.TenantID || task.TenantKey != record.TenantKey || task.TaskItemID != record.CommandID || task.Status != "accepted" || + task.TaskRevision <= 0 || task.Callee == "" || task.RoutePolicyID == "" || task.CallerProfileID == "" || task.AgentVersionID == "" || task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || + admission.ReservationID != task.ExecutionID { + return nil, errors.New("local call admission task identity or limits are invalid") + } + if len(admission.QuotaScopes) != 2 || admission.QuotaScopes[0] != LocalTenantQuotaScope(admission.DispatcherID, task.TenantID) || admission.QuotaScopes[1] != LocalTaskQuotaScope(admission.DispatcherID, task.TenantID, task.TaskID) { + return nil, errors.New("local call admission quota scopes are invalid") + } + if snapshot.SchemaVersion != "execution-config-snapshot.v0.2" || snapshot.SelectedTrunkID == "" || snapshot.DispatcherID != admission.DispatcherID || snapshot.TenantID != task.TenantID || snapshot.TenantKey != task.TenantKey || snapshot.TaskID != task.TaskID || + snapshot.TaskRevision != task.TaskRevision || snapshot.SIPRevision <= 0 || snapshot.QuotaRevision <= 0 || + !validLocalJSONResponse(snapshot.SIP) || !validLocalJSONResponse(snapshot.Tasks) || !validLocalJSONResponse(snapshot.Task) || !validLocalJSONResponse(snapshot.TenantQuota) { + return nil, errors.New("local call admission config snapshot identity or contents are invalid") + } + body, err := json.Marshal(snapshot) + if err != nil { + return nil, fmt.Errorf("marshal local execution config snapshot: %w", err) + } + return body, nil +} + +func validLocalJSONResponse(body json.RawMessage) bool { + trimmed := bytes.TrimSpace(body) + return len(trimmed) > 0 && trimmed[0] == '{' && json.Valid(trimmed) +} + +func localQuotaScopesAvailable(tx *sql.Tx, scopes []string) (bool, error) { + if len(scopes) == 0 { + return false, ErrNoCapacity + } + for _, scope := range scopes { + var limit, reserved, unknown int64 + if err := tx.QueryRow(`SELECT limit_value,reserved_value,unknown_value FROM quotas WHERE scope=?`, scope).Scan(&limit, &reserved, &unknown); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope) + } + return false, err + } + if limit <= 0 || reserved >= limit || unknown >= limit-reserved { + return false, nil + } + } + return true, nil +} + +func insertLocalTask(tx *sql.Tx, task Task, now string) error { + variables := task.Variables + if variables == nil { + variables = map[string]any{} + } + variablesJSON, err := json.Marshal(variables) + if err != nil { + return fmt.Errorf("marshal local task variables: %w", err) + } + _, err = tx.Exec(`INSERT INTO tasks(execution_id,tenant_key,tenant_id,task_id,task_item_id,task_revision,trace_id,callee, + route_policy_id,caller_profile_id,agent_version_id,variables,ring_timeout_ms,max_call_duration_ms,status,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'accepted',?,?)`, + task.ExecutionID, task.TenantKey, task.TenantID, task.TaskID, task.TaskItemID, task.TaskRevision, task.TraceID, task.Callee, + task.RoutePolicyID, task.CallerProfileID, task.AgentVersionID, variablesJSON, task.RingTimeoutMS, task.MaxCallDurationMS, now, now) + return err +} + +func reserveLocalCallTx(tx *sql.Tx, admission LocalCallAdmission, now string) error { + result, err := tx.Exec(`UPDATE tasks SET status='reserved',updated_at=? WHERE execution_id=? AND tenant_key=? AND status='accepted'`, + now, admission.Task.ExecutionID, admission.Task.TenantKey) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return ErrCASConflict + } + scopesJSON, err := json.Marshal(admission.QuotaScopes) + if err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO reservations(reservation_id,execution_id,tenant_key,scopes,state,created_at) + VALUES(?,?,?,?, 'held', ?)`, admission.ReservationID, admission.Task.ExecutionID, admission.Task.TenantKey, scopesJSON, now); err != nil { + return err + } + for _, scope := range admission.QuotaScopes { + result, err := tx.Exec(`UPDATE quotas SET reserved_value=reserved_value+1,updated_at=? + WHERE scope=? AND reserved_value+unknown_value assignment.TaskRevision { + if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,updated_at=? WHERE dispatcher_id=? AND task_id=?`, + taskRevision, observedStatus, now, dispatcherID, taskID); err != nil { + return err + } + } + return tx.Commit() +} + +// PersistLocalTaskControlResult applies the final local state and writes its +// command.result outbox event in the same SQLite transaction. +func (s *Store) PersistLocalTaskControlResult(result LocalTaskControlResultRecord) (LocalTaskAssignment, bool, error) { + if err := validateLocalTaskControlResult(result); err != nil { + return LocalTaskAssignment{}, false, err + } + now := s.now().UTC().Format(time.RFC3339Nano) + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return LocalTaskAssignment{}, false, err + } + defer tx.Rollback() + assignment, err := scanLocalTaskAssignment(tx.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, + result.DispatcherID, result.TaskID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return LocalTaskAssignment{}, false, ErrLocalTaskUnassigned + } + return LocalTaskAssignment{}, false, err + } + if assignment.TenantID != result.TenantID || assignment.TenantKey != result.TenantKey { + return LocalTaskAssignment{}, false, ErrTenantBindingConflict + } + if result.ObservedStatus != "" { + if result.TaskRevision < assignment.TaskRevision { + return LocalTaskAssignment{}, false, ErrConfigRevisionRollback + } + if result.TaskRevision == assignment.TaskRevision && result.ObservedStatus != assignment.Status { + return LocalTaskAssignment{}, false, ErrConfigRevisionConflict + } + assignment.Status = result.ObservedStatus + assignment.TaskRevision = result.TaskRevision + } + state, err := controlResultAdmissionState(assignment.AdmissionState, result) + if err != nil { + return LocalTaskAssignment{}, false, err + } + assignment.AdmissionState = state + assignment.UpdatedAt = now + if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,admission_state=?,updated_at=? WHERE dispatcher_id=? AND task_id=?`, + assignment.TaskRevision, assignment.Status, assignment.AdmissionState, now, result.DispatcherID, result.TaskID); err != nil { + return LocalTaskAssignment{}, false, err + } + duplicate, err := enqueueLocalEventTx(tx, result.Event, now) + if err != nil { + return LocalTaskAssignment{}, false, err + } + if err := tx.Commit(); err != nil { + return LocalTaskAssignment{}, false, err + } + return assignment, duplicate, nil +} + +func validateLocalTaskControlResult(result LocalTaskControlResultRecord) error { + if result.DispatcherID == "" || result.TaskID == "" || result.TenantID == "" || result.TenantKey == "" { + return errors.New("task-control result identity is required") + } + switch result.Action { + case "pause", "resume", "stop": + default: + return fmt.Errorf("unsupported task-control action %q", result.Action) + } + switch result.ResultStatus { + case "applied", "rejected": + default: + return fmt.Errorf("unsupported task-control result status %q", result.ResultStatus) + } + switch result.AdmissionState { + case "running", "paused", "stopped", "finished": + default: + return fmt.Errorf("unsupported task-control result state %q", result.AdmissionState) + } + if result.ObservedStatus != "" { + switch result.ObservedStatus { + case "running", "paused", "stopped", "finished": + default: + return fmt.Errorf("unsupported observed task status %q", result.ObservedStatus) + } + if result.TaskRevision <= 0 { + return errors.New("observed task status requires a positive revision") + } + } else if result.TaskRevision != 0 { + return errors.New("task revision requires an observed task status") + } + return validateLocalEventRecord(result.Event) +} + +func controlResultAdmissionState(current string, result LocalTaskControlResultRecord) (string, error) { + terminal := current == "stopped" || current == "finished" || current == "removed" + if terminal { + if current == "removed" && result.AdmissionState == "stopped" && result.ResultStatus == "rejected" { + return current, nil + } + if result.AdmissionState != current { + return "", ErrLocalTaskStopped + } + } + if result.ResultStatus == "applied" { + switch result.Action { + case "pause": + if result.ObservedStatus != "paused" || result.AdmissionState != "paused" { + return "", errors.New("applied pause requires authoritative paused state") + } + case "resume": + if result.ObservedStatus != "running" || result.AdmissionState != "running" || terminal { + return "", ErrLocalTaskStopped + } + case "stop": + if result.ObservedStatus != "stopped" || result.AdmissionState != "stopped" { + return "", errors.New("applied stop requires authoritative stopped state") + } + } + return result.AdmissionState, nil + } + if result.AdmissionState == current { + return current, nil + } + if current == "running" && result.AdmissionState == "paused" { + return "paused", nil + } + return "", errors.New("rejected task-control result cannot reopen admission") +} + +func validateLocalEventRecord(event LocalEventRecord) error { + if event.EventID == "" || event.TenantKey == "" || event.Exchange == "" || event.RoutingKey == "" || len(event.Body) == 0 { + return errors.New("local event identity, route, and body are required") + } + return nil +} diff --git a/internal/store/local_v01_control_test.go b/internal/store/local_v01_control_test.go new file mode 100644 index 0000000..fb5e3ab --- /dev/null +++ b/internal/store/local_v01_control_test.go @@ -0,0 +1,138 @@ +package store + +import ( + "errors" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +func TestRecordLocalTaskStatusObservationIsDurableWithoutReceipt(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { + t.Fatal(err) + } + if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped", 2); err != nil { + t.Fatal(err) + } + assignment, err := st.LocalTaskAssignment("d-1", "task-a") + if err != nil || assignment.Status != "stopped" || assignment.TaskRevision != 2 || assignment.AdmissionState != "stopped" { + t.Fatalf("observed task state=%+v err=%v", assignment, err) + } + var outboxCount int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil || outboxCount != 0 { + t.Fatalf("status observation wrote a receipt: count=%d err=%v", outboxCount, err) + } + if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped", 2); err != nil { + t.Fatalf("idempotent status observation: %v", err) + } + if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 2); !errors.Is(err, ErrConfigRevisionConflict) { + t.Fatalf("same-revision status change=%v, want conflict", err) + } + if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 1); !errors.Is(err, ErrConfigRevisionRollback) { + t.Fatalf("revision rollback=%v, want rollback error", err) + } +} + +func TestLocalTaskControlBarrierAndResultCommitAtomically(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + + assignment, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused") + if err != nil || assignment.AdmissionState != "paused" { + t.Fatalf("barrier assignment=%+v err=%v", assignment, err) + } + bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, st.now()) + if _, err := st.PersistLocalCommand(localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a")); !errors.Is(err, ErrLocalTaskPaused) { + t.Fatalf("call admission after pause = %v, want ErrLocalTaskPaused", err) + } + + result := LocalTaskControlResultRecord{ + DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", + Action: "pause", ResultStatus: "applied", ObservedStatus: "paused", TaskRevision: 2, + AdmissionState: "paused", Event: localControlResultEvent("event-pause-1", "tenant-key-a"), + } + assignment, duplicate, err := st.PersistLocalTaskControlResult(result) + if err != nil || duplicate || assignment.Status != "paused" || assignment.TaskRevision != 2 || assignment.AdmissionState != "paused" { + t.Fatalf("control assignment=%+v duplicate=%v err=%v", assignment, duplicate, err) + } + assignment, duplicate, err = st.PersistLocalTaskControlResult(result) + if err != nil || !duplicate || assignment.TaskRevision != 2 { + t.Fatalf("duplicate control result assignment=%+v duplicate=%v err=%v", assignment, duplicate, err) + } + + conflict := result + conflict.Event.Body = []byte(`{"event_id":"event-pause-1","different":true}`) + if _, _, err := st.PersistLocalTaskControlResult(conflict); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("conflicting event ID error=%v", err) + } + assignment, err = st.LocalTaskAssignment("d-1", "task-a") + if err != nil || assignment.Status != "paused" || assignment.TaskRevision != 2 || assignment.AdmissionState != "paused" { + t.Fatalf("failed result transaction changed task state: %+v err=%v", assignment, err) + } + var outboxCount int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id='event-pause-1'`).Scan(&outboxCount); err != nil || outboxCount != 1 { + t.Fatalf("outbox rows=%d err=%v", outboxCount, err) + } +} + +func TestLocalTaskStopBarrierCannotBeReopenedByResume(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { + t.Fatal(err) + } + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { + t.Fatal(err) + } + stop := LocalTaskControlResultRecord{ + DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", + Action: "stop", ResultStatus: "applied", ObservedStatus: "stopped", TaskRevision: 2, + AdmissionState: "stopped", Event: localControlResultEvent("event-stop-1", "tenant-key-a"), + } + assignment, _, err := st.PersistLocalTaskControlResult(stop) + if err != nil || assignment.AdmissionState != "stopped" { + t.Fatalf("stop assignment=%+v err=%v", assignment, err) + } + assignment, err = st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused") + if err != nil || assignment.AdmissionState != "stopped" { + t.Fatalf("pause reopened stopped task: %+v err=%v", assignment, err) + } + + resumeRejected := LocalTaskControlResultRecord{ + DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", + Action: "resume", ResultStatus: "rejected", ObservedStatus: "running", TaskRevision: 3, + AdmissionState: "stopped", Event: localControlResultEvent("event-resume-rejected-1", "tenant-key-a"), + } + assignment, _, err = st.PersistLocalTaskControlResult(resumeRejected) + if err != nil || assignment.Status != "running" || assignment.TaskRevision != 3 || assignment.AdmissionState != "stopped" { + t.Fatalf("rejected resume assignment=%+v err=%v", assignment, err) + } +} + +func TestLocalTaskResumeBarrierReopensPausedButNotStopped(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { + t.Fatal(err) + } + assignment, err := st.ResumeLocalTaskAdmission("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 2) + if err != nil || assignment.AdmissionState != "running" || assignment.Status != "running" || assignment.TaskRevision != 2 { + t.Fatalf("resume assignment=%+v err=%v", assignment, err) + } + if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { + t.Fatal(err) + } + assignment, err = st.ResumeLocalTaskAdmission("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 3) + if !errors.Is(err, ErrLocalTaskStopped) || assignment.AdmissionState != "stopped" { + t.Fatalf("resume reopened terminal task: %+v err=%v", assignment, err) + } +} + +func localControlResultEvent(eventID, tenantKey string) LocalEventRecord { + return LocalEventRecord{ + EventID: eventID, TenantKey: tenantKey, Exchange: mq.ResultsExchangeV3, + RoutingKey: "d.d-1.out", Body: []byte(`{"event_id":"` + eventID + `"}`), + } +} diff --git a/internal/store/local_v01_discovery.go b/internal/store/local_v01_discovery.go new file mode 100644 index 0000000..ae9a6ab --- /dev/null +++ b/internal/store/local_v01_discovery.go @@ -0,0 +1,464 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +var ( + ErrLocalDiscoveryCursorMismatch = errors.New("local task-discovery cursor mismatch") + ErrLocalDiscoveryUnavailable = errors.New("task discovery has no fresh complete response") + ErrLocalDiscoveryTaskLimit = errors.New("task discovery exceeds the 256-task per-Dispatcher limit") + ErrLocalDiscoveryTaskMissing = errors.New("local task-discovery update targets an unknown task") + ErrLocalTaskRemoved = errors.New("local task assignment has been removed") + ErrLocalTaskUnassigned = errors.New("task is not assigned to this Dispatcher") + ErrLocalTaskPaused = errors.New("task admission is paused") + ErrLocalTaskStopped = errors.New("task admission is terminal") +) + +type LocalTaskDiscovery struct { + DispatcherID string + Mode string + Cursor string + FromCursor string + NextCursor string + Body []byte + Tasks []LocalDiscoveredTask + Changes []LocalTaskDiscoveryChange +} + +type LocalDiscoveredTask struct { + TaskID string + TenantID string + TenantKey string + Status string + TaskRevision int64 +} + +type LocalTaskDiscoveryChange struct { + Cursor string + Operation string + TaskID string + TenantID string + TenantKey string + Task LocalDiscoveredTask +} + +type LocalTaskQueue struct { + Exchange string + RoutingKey string + BindingKey string + QueueName string +} + +type LocalTaskAssignment struct { + DispatcherID string + TaskID string + TenantID string + TenantKey string + TaskRevision int64 + Status string + AdmissionState string + Removed bool + Queue LocalTaskQueue + UpdatedAt string +} + +// ApplyLocalTaskDiscovery commits the complete response, assignments, and cursor +// together. An invalid response cannot advance durable discovery state. +func (s *Store) ApplyLocalTaskDiscovery(discovery LocalTaskDiscovery) error { + if err := validateLocalTaskDiscovery(discovery); err != nil { + return err + } + now := s.now().UTC().Format(time.RFC3339Nano) + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + cursor := discovery.Cursor + if discovery.Mode == "changes" { + var currentCursor string + if err := tx.QueryRow(`SELECT cursor FROM local_v02_task_discovery_state WHERE dispatcher_id=?`, discovery.DispatcherID).Scan(¤tCursor); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrLocalDiscoveryCursorMismatch + } + return err + } + if currentCursor != discovery.FromCursor { + return ErrLocalDiscoveryCursorMismatch + } + cursor = discovery.NextCursor + } + + if discovery.Mode == "snapshot" { + seen := make(map[string]struct{}, len(discovery.Tasks)) + for _, task := range discovery.Tasks { + if err := bindLocalTenant(tx, discovery.DispatcherID, task.TenantID, task.TenantKey, s.now()); err != nil { + return err + } + if err := applyLocalDiscoveredTask(tx, discovery.DispatcherID, task, true, now); err != nil { + return err + } + seen[task.TaskID] = struct{}{} + } + if err := markLocalTasksAbsent(tx, discovery.DispatcherID, seen, now); err != nil { + return err + } + } else { + for _, change := range discovery.Changes { + if err := applyLocalDiscoveryChange(tx, discovery.DispatcherID, change, now, s.now()); err != nil { + return err + } + } + } + var activeAssignments int + if err := tx.QueryRow(`SELECT COUNT(*) FROM local_v01_task_assignments WHERE dispatcher_id=? AND removed=0`, discovery.DispatcherID).Scan(&activeAssignments); err != nil { + return err + } + if activeAssignments > 256 { + return ErrLocalDiscoveryTaskLimit + } + digest := sha256.Sum256(discovery.Body) + if _, err := tx.Exec(`INSERT INTO local_v02_task_discovery_response(dispatcher_id,content_sha256,body,stored_at) + VALUES(?,?,?,?) ON CONFLICT(dispatcher_id) DO UPDATE SET content_sha256=excluded.content_sha256, + body=excluded.body,stored_at=excluded.stored_at`, discovery.DispatcherID, hex.EncodeToString(digest[:]), discovery.Body, now); err != nil { + return err + } + if _, err := tx.Exec(`INSERT INTO local_v02_task_discovery_state(dispatcher_id,cursor,last_mode,ready,updated_at) + VALUES(?,?,?,1,?) ON CONFLICT(dispatcher_id) DO UPDATE SET cursor=excluded.cursor, + last_mode=excluded.last_mode,ready=1,updated_at=excluded.updated_at`, discovery.DispatcherID, cursor, discovery.Mode, now); err != nil { + return err + } + return tx.Commit() +} + +func validateLocalTaskDiscovery(discovery LocalTaskDiscovery) error { + if discovery.DispatcherID == "" || len(discovery.Body) == 0 || !json.Valid(discovery.Body) { + return errors.New("task discovery requires a Dispatcher ID and one valid response body") + } + switch discovery.Mode { + case "snapshot": + if discovery.Cursor == "" || discovery.FromCursor != "" || discovery.NextCursor != "" || len(discovery.Changes) != 0 || len(discovery.Tasks) > 256 { + return errors.New("invalid task-discovery snapshot window") + } + seen := make(map[string]struct{}, len(discovery.Tasks)) + for _, task := range discovery.Tasks { + if err := validateLocalDiscoveredTask(discovery.DispatcherID, task); err != nil { + return err + } + if _, exists := seen[task.TaskID]; exists { + return fmt.Errorf("task discovery contains duplicate task ID %q", task.TaskID) + } + seen[task.TaskID] = struct{}{} + } + case "changes": + if discovery.FromCursor == "" || discovery.NextCursor == "" || discovery.Cursor != "" || len(discovery.Tasks) != 0 || len(discovery.Changes) > 256 || + (len(discovery.Changes) == 0 && discovery.NextCursor != discovery.FromCursor) || + (len(discovery.Changes) != 0 && discovery.NextCursor == discovery.FromCursor) { + return errors.New("invalid task-discovery change window") + } + for _, change := range discovery.Changes { + if change.Cursor == "" { + return errors.New("task-discovery change cursor is required") + } + switch change.Operation { + case "assigned", "updated": + if err := validateLocalDiscoveredTask(discovery.DispatcherID, change.Task); err != nil { + return err + } + if change.TaskID != "" && change.TaskID != change.Task.TaskID { + return errors.New("task-discovery change task ID conflicts with its task payload") + } + case "removed": + if !validLocalTaskID(change.TaskID) || change.TenantID == "" || change.TenantKey == "" || len([]byte(change.TenantKey)) > 196 { + return errors.New("invalid task-discovery removal tombstone") + } + default: + return fmt.Errorf("unsupported task-discovery operation %q", change.Operation) + } + } + default: + return fmt.Errorf("unsupported task-discovery mode %q", discovery.Mode) + } + return nil +} + +func validateLocalDiscoveredTask(dispatcherID string, task LocalDiscoveredTask) error { + if dispatcherID == "" || !validLocalTaskID(task.TaskID) || task.TenantID == "" || task.TenantKey == "" || task.TaskRevision <= 0 || len([]byte(task.TenantKey)) > 196 { + return errors.New("incomplete task-discovery assignment") + } + switch task.Status { + case "running", "paused", "stopped", "finished": + default: + return fmt.Errorf("unsupported discovered task status %q", task.Status) + } + return nil +} + +func validLocalTaskID(taskID string) bool { + if len(taskID) == 0 || len(taskID) > 128 { + return false + } + for _, b := range []byte(taskID) { + if (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && (b < '0' || b > '9') && b != '_' && b != '-' { + return false + } + } + return true +} + +func derivedLocalTaskQueue(dispatcherID, taskID string) LocalTaskQueue { + route := fmt.Sprintf("d.%s.task.%s.in", dispatcherID, taskID) + return LocalTaskQueue{Exchange: mq.CommandsExchangeV3, RoutingKey: route, BindingKey: route, + QueueName: fmt.Sprintf("agent-call.d.%s.task.%s.v3", dispatcherID, taskID)} +} + +func applyLocalDiscoveredTask(tx *sql.Tx, dispatcherID string, task LocalDiscoveredTask, allowCreate bool, now string) error { + queue := derivedLocalTaskQueue(dispatcherID, task.TaskID) + var old LocalTaskAssignment + var removed int + err := tx.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, + dispatcherID, task.TaskID).Scan(&old.DispatcherID, &old.TaskID, &old.TenantID, &old.TenantKey, &old.TaskRevision, + &old.Status, &old.AdmissionState, &removed, &old.Queue.Exchange, &old.Queue.RoutingKey, &old.Queue.BindingKey, &old.Queue.QueueName, &old.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + if !allowCreate { + return ErrLocalDiscoveryTaskMissing + } + _, err = tx.Exec(`INSERT INTO local_v01_task_assignments(dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at) VALUES(?,?,?,?,?,?,?,0,?,?,?,?,?)`, + dispatcherID, task.TaskID, task.TenantID, task.TenantKey, task.TaskRevision, task.Status, task.Status, + queue.Exchange, queue.RoutingKey, queue.BindingKey, queue.QueueName, now) + return err + } + if err != nil { + return err + } + old.Removed = removed != 0 + if old.Removed { + return ErrLocalTaskRemoved + } + if old.TenantID != task.TenantID || old.TenantKey != task.TenantKey { + return ErrTenantBindingConflict + } + if task.TaskRevision < old.TaskRevision { + return ErrConfigRevisionRollback + } + if task.TaskRevision == old.TaskRevision { + if old.Status != task.Status || old.Queue != queue { + return ErrConfigRevisionConflict + } + return nil + } + state := mergeLocalTaskState(old.AdmissionState, task.Status) + _, err = tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,admission_state=?,queue_exchange=?,routing_key=?,binding_key=?,queue_name=?,updated_at=? + WHERE dispatcher_id=? AND task_id=?`, task.TaskRevision, task.Status, state, queue.Exchange, queue.RoutingKey, + queue.BindingKey, queue.QueueName, now, dispatcherID, task.TaskID) + return err +} + +func mergeLocalTaskState(current, incoming string) string { + if current == "removed" || current == "finished" { + return current + } + if current == "stopped" { + if incoming == "finished" { + return "finished" + } + return current + } + switch incoming { + case "running": + return current + case "paused", "stopped", "finished": + return incoming + default: + return current + } +} + +func applyLocalDiscoveryChange(tx *sql.Tx, dispatcherID string, change LocalTaskDiscoveryChange, now string, createdAt time.Time) error { + switch change.Operation { + case "assigned": + if err := bindLocalTenant(tx, dispatcherID, change.Task.TenantID, change.Task.TenantKey, createdAt); err != nil { + return err + } + return applyLocalDiscoveredTask(tx, dispatcherID, change.Task, true, now) + case "updated": + if err := bindLocalTenant(tx, dispatcherID, change.Task.TenantID, change.Task.TenantKey, createdAt); err != nil { + return err + } + return applyLocalDiscoveredTask(tx, dispatcherID, change.Task, false, now) + case "removed": + if err := bindLocalTenant(tx, dispatcherID, change.TenantID, change.TenantKey, createdAt); err != nil { + return err + } + return removeLocalTaskTx(tx, dispatcherID, change, now) + default: + return fmt.Errorf("unsupported task-discovery operation %q", change.Operation) + } +} + +func removeLocalTaskTx(tx *sql.Tx, dispatcherID string, change LocalTaskDiscoveryChange, now string) error { + var tenantID, tenantKey string + err := tx.QueryRow(`SELECT tenant_id,tenant_key FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, dispatcherID, change.TaskID).Scan(&tenantID, &tenantKey) + if errors.Is(err, sql.ErrNoRows) { + queue := derivedLocalTaskQueue(dispatcherID, change.TaskID) + _, err = tx.Exec(`INSERT INTO local_v01_task_assignments(dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at) VALUES(?,?,?,?,0,'removed','removed',1,?,?,?,?,?)`, + dispatcherID, change.TaskID, change.TenantID, change.TenantKey, queue.Exchange, queue.RoutingKey, queue.BindingKey, queue.QueueName, now) + return err + } + if err != nil { + return err + } + if tenantID != change.TenantID || tenantKey != change.TenantKey { + return ErrTenantBindingConflict + } + _, err = tx.Exec(`UPDATE local_v01_task_assignments SET saas_status='removed',admission_state='removed',removed=1,updated_at=? + WHERE dispatcher_id=? AND task_id=?`, now, dispatcherID, change.TaskID) + return err +} + +func markLocalTasksAbsent(tx *sql.Tx, dispatcherID string, present map[string]struct{}, now string) error { + rows, err := tx.Query(`SELECT task_id FROM local_v01_task_assignments WHERE dispatcher_id=? AND removed=0`, dispatcherID) + if err != nil { + return err + } + var absent []string + for rows.Next() { + var taskID string + if err := rows.Scan(&taskID); err != nil { + _ = rows.Close() + return err + } + if _, exists := present[taskID]; !exists { + absent = append(absent, taskID) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, taskID := range absent { + if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET saas_status='removed',admission_state='removed',removed=1,updated_at=? WHERE dispatcher_id=? AND task_id=?`, now, dispatcherID, taskID); err != nil { + return err + } + } + return nil +} + +// CloseLocalTaskDiscoveryAdmission rejects new work without losing the cursor +// or changing an authoritative task pause/stop state. +func (s *Store) CloseLocalTaskDiscoveryAdmission(dispatcherID string) error { + s.mu.Lock() + defer s.mu.Unlock() + _, err := s.db.Exec(`UPDATE local_v02_task_discovery_state SET ready=0 WHERE dispatcher_id=?`, dispatcherID) + return err +} + +func (s *Store) LocalTaskDiscoveryCursor(dispatcherID string) (cursor string, exists bool, err error) { + s.mu.Lock() + defer s.mu.Unlock() + err = s.db.QueryRow(`SELECT cursor FROM local_v02_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&cursor) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return cursor, true, nil +} + +func (s *Store) LocalTaskAssignments(dispatcherID string) ([]LocalTaskAssignment, error) { + s.mu.Lock() + defer s.mu.Unlock() + rows, err := s.db.Query(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? ORDER BY task_id`, dispatcherID) + if err != nil { + return nil, err + } + defer rows.Close() + var assignments []LocalTaskAssignment + for rows.Next() { + assignment, err := scanLocalTaskAssignment(rows) + if err != nil { + return nil, err + } + assignments = append(assignments, assignment) + } + if err := rows.Err(); err != nil { + return nil, err + } + return assignments, nil +} + +func (s *Store) LocalTaskAssignment(dispatcherID, taskID string) (LocalTaskAssignment, error) { + s.mu.Lock() + defer s.mu.Unlock() + return scanLocalTaskAssignment(s.db.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, + queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, dispatcherID, taskID)) +} + +func scanLocalTaskAssignment(row interface{ Scan(...any) error }) (LocalTaskAssignment, error) { + var assignment LocalTaskAssignment + var removed int + err := row.Scan(&assignment.DispatcherID, &assignment.TaskID, &assignment.TenantID, &assignment.TenantKey, &assignment.TaskRevision, + &assignment.Status, &assignment.AdmissionState, &removed, &assignment.Queue.Exchange, &assignment.Queue.RoutingKey, + &assignment.Queue.BindingKey, &assignment.Queue.QueueName, &assignment.UpdatedAt) + if err != nil { + return LocalTaskAssignment{}, err + } + assignment.Removed = removed != 0 + return assignment, nil +} + +func requireLocalTaskRunningTx(tx *sql.Tx, dispatcherID, tenantID, tenantKey, taskID string) error { + var ready int + if err := tx.QueryRow(`SELECT ready FROM local_v02_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&ready); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrLocalDiscoveryUnavailable + } + return err + } + if ready != 1 { + return ErrLocalDiscoveryUnavailable + } + var assignedTenantID, assignedTenantKey, admissionState string + var removed int + err := tx.QueryRow(`SELECT tenant_id,tenant_key,admission_state,removed FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, + dispatcherID, taskID).Scan(&assignedTenantID, &assignedTenantKey, &admissionState, &removed) + if errors.Is(err, sql.ErrNoRows) { + return ErrLocalTaskUnassigned + } + if err != nil { + return err + } + if assignedTenantID != tenantID || assignedTenantKey != tenantKey { + return ErrTenantBindingConflict + } + if removed != 0 || admissionState == "stopped" || admissionState == "finished" || admissionState == "removed" { + return ErrLocalTaskStopped + } + if admissionState == "paused" { + return ErrLocalTaskPaused + } + if admissionState != "running" { + return fmt.Errorf("unsupported local task admission state %q", admissionState) + } + return nil +} diff --git a/internal/store/local_v01_discovery_test.go b/internal/store/local_v01_discovery_test.go new file mode 100644 index 0000000..4d74b82 --- /dev/null +++ b/internal/store/local_v01_discovery_test.go @@ -0,0 +1,292 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +func TestApplyLocalTaskDiscoveryPersistsSingleResponseAndDerivedQueue(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + body := []byte(`{"cursor":"opaque-cursor-1","tasks":[{"task_id":"task-a"}]}`) + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow("d-1", "opaque-cursor-1", body, + localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running"), + localDiscoveredTask("d-1", "task-b", "tenant-a", "tenant-key-a", 4, "paused"), + )); err != nil { + t.Fatal(err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || !exists || cursor != "opaque-cursor-1" { + t.Fatalf("cursor=%q exists=%v err=%v", cursor, exists, err) + } + assignments, err := st.LocalTaskAssignments("d-1") + if err != nil || len(assignments) != 2 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + if assignments[0].TaskID != "task-a" || assignments[0].AdmissionState != "running" || assignments[1].AdmissionState != "paused" { + t.Fatalf("initial task states = %+v", assignments) + } + if assignments[0].Queue.Exchange != mq.CommandsExchangeV3 || assignments[0].Queue.RoutingKey != "d.d-1.task.task-a.in" || assignments[0].Queue.BindingKey != "d.d-1.task.task-a.in" || assignments[0].Queue.QueueName != "agent-call.d.d-1.task.task-a.v3" { + t.Fatalf("derived queue = %+v", assignments[0].Queue) + } + var saved []byte + var digest string + if err := st.db.QueryRow(`SELECT body,content_sha256 FROM local_v02_task_discovery_response WHERE dispatcher_id=?`, "d-1").Scan(&saved, &digest); err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(body) + if string(saved) != string(body) || digest != hex.EncodeToString(hash[:]) { + t.Fatalf("stored discovery response mismatch: %s %s", saved, digest) + } +} + +func TestApplyLocalTaskDiscoveryChangesAndCursorAdvanceAreAtomic(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + changes := LocalTaskDiscovery{ + DispatcherID: "d-1", Mode: "changes", FromCursor: "cursor-1", NextCursor: "cursor-3", Body: []byte(`{"changes":[{"operation":"updated"},{"operation":"removed"}]}`), + Changes: []LocalTaskDiscoveryChange{ + {Cursor: "cursor-2", Operation: "updated", Task: localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 2, "paused")}, + {Cursor: "cursor-3", Operation: "removed", TaskID: "task-b", TenantID: "tenant-a", TenantKey: "tenant-key-a"}, + }, + } + if err := st.ApplyLocalTaskDiscovery(changes); err != nil { + t.Fatal(err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || !exists || cursor != "cursor-3" { + t.Fatalf("cursor=%q exists=%v err=%v", cursor, exists, err) + } + assignments, err := st.LocalTaskAssignments("d-1") + if err != nil || len(assignments) != 2 { + t.Fatalf("assignments=%+v err=%v", assignments, err) + } + if assignments[0].TaskRevision != 2 || assignments[0].Status != "paused" || assignments[0].AdmissionState != "paused" || !assignments[1].Removed || assignments[1].AdmissionState != "removed" { + t.Fatalf("change application = %+v", assignments) + } + var stored []byte + if err := st.db.QueryRow(`SELECT body FROM local_v02_task_discovery_response WHERE dispatcher_id=?`, "d-1").Scan(&stored); err != nil || string(stored) != string(changes.Body) { + t.Fatalf("stored response=%s err=%v", stored, err) + } +} + +func TestApplyLocalTaskDiscoveryNoChangeKeepsCursor(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if err := st.ApplyLocalTaskDiscovery(LocalTaskDiscovery{DispatcherID: "d-1", Mode: "changes", FromCursor: "cursor-1", NextCursor: "cursor-1", Body: []byte(`{"changes":[]}`)}); err != nil { + t.Fatal(err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || !exists || cursor != "cursor-1" { + t.Fatalf("empty change cursor=%q exists=%v err=%v", cursor, exists, err) + } +} + +func TestLocalTaskDiscoveryFailureClosesAdmissionUntilFreshResponse(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if err := st.CloseLocalTaskDiscoveryAdmission("d-1"); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, now) + record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a") + record.DispatcherID = "d-1" + if _, err := st.PersistLocalCommand(record); !errors.Is(err, ErrLocalDiscoveryUnavailable) { + t.Fatalf("admission despite failed discovery: %v", err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || !exists || cursor != "cursor-1" { + t.Fatalf("lost durable cursor: %q exists=%v err=%v", cursor, exists, err) + } + if err := st.ApplyLocalTaskDiscovery(LocalTaskDiscovery{DispatcherID: "d-1", Mode: "changes", FromCursor: "cursor-1", NextCursor: "cursor-1", Body: []byte(`{"changes":[]}`)}); err != nil { + t.Fatal(err) + } + if _, err := st.PersistLocalCommand(record); !errors.Is(err, ErrNoCapacity) { + t.Fatalf("fresh discovery should pass the discovery gate and reach the quota check, got %v", err) + } +} + +func TestApplyLocalTaskDiscoveryRejectsCursorMismatchWithoutAdvancing(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + bad := LocalTaskDiscovery{DispatcherID: "d-1", Mode: "changes", FromCursor: "wrong-cursor", NextCursor: "cursor-2", Body: []byte(`{"changes":[{}]}`), Changes: []LocalTaskDiscoveryChange{{Cursor: "cursor-2", Operation: "updated", Task: localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 2, "paused")}}} + if err := st.ApplyLocalTaskDiscovery(bad); !errors.Is(err, ErrLocalDiscoveryCursorMismatch) { + t.Fatalf("cursor mismatch error=%v", err) + } + cursor, _, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || cursor != "cursor-1" { + t.Fatalf("cursor after rejected response=%q err=%v", cursor, err) + } +} + +func TestApplyLocalTaskDiscoveryRollsBackEarlierChangesOnLaterConflict(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + bad := LocalTaskDiscovery{DispatcherID: "d-1", Mode: "changes", FromCursor: "cursor-1", NextCursor: "cursor-3", Body: []byte(`{"changes":[{},{}]}`), Changes: []LocalTaskDiscoveryChange{ + {Cursor: "cursor-2", Operation: "updated", Task: localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 2, "paused")}, + {Cursor: "cursor-3", Operation: "updated", Task: localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running")}, + }} + if err := st.ApplyLocalTaskDiscovery(bad); !errors.Is(err, ErrConfigRevisionRollback) { + t.Fatalf("revision rollback error=%v", err) + } + cursor, _, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || cursor != "cursor-1" { + t.Fatalf("cursor after rollback=%q err=%v", cursor, err) + } + task, err := st.LocalTaskAssignment("d-1", "task-a") + if err != nil || task.TaskRevision != 1 || task.Status != "running" || task.AdmissionState != "running" { + t.Fatalf("task after rollback=%+v err=%v", task, err) + } +} + +func TestApplyLocalTaskDiscoveryRejectsMoreThan256AssignedTasksWithoutAdvancing(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + tasks := make([]LocalDiscoveredTask, 256) + for i := range tasks { + tasks[i] = localDiscoveredTask("d-1", fmt.Sprintf("task-%03d", i), "tenant-a", "tenant-key-a", 1, "running") + } + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow("d-1", "cursor-1", []byte(`{"tasks":[]}`), tasks...)); err != nil { + t.Fatal(err) + } + change := LocalTaskDiscovery{DispatcherID: "d-1", Mode: "changes", FromCursor: "cursor-1", NextCursor: "cursor-2", Body: []byte(`{"changes":[{}]}`), Changes: []LocalTaskDiscoveryChange{ + {Cursor: "cursor-2", Operation: "assigned", Task: localDiscoveredTask("d-1", "task-256", "tenant-a", "tenant-key-a", 1, "running")}, + }} + if err := st.ApplyLocalTaskDiscovery(change); !errors.Is(err, ErrLocalDiscoveryTaskLimit) { + t.Fatalf("task overflow error=%v", err) + } + cursor, exists, err := st.LocalTaskDiscoveryCursor("d-1") + if err != nil || !exists || cursor != "cursor-1" { + t.Fatalf("task overflow advanced cursor: %q exists=%v err=%v", cursor, exists, err) + } + if _, err := st.LocalTaskAssignment("d-1", "task-256"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("task overflow persisted partial assignment: %v", err) + } +} + +func TestApplyLocalTaskDiscoverySnapshotMarksOmittedTasksRemoved(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + seedLocalDiscovery(t, st) + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow("d-1", "cursor-2", []byte(`{"tasks":[{}]}`), + localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running"), + )); err != nil { + t.Fatal(err) + } + task, err := st.LocalTaskAssignment("d-1", "task-b") + if err != nil || !task.Removed || task.AdmissionState != "removed" { + t.Fatalf("omitted task=%+v err=%v", task, err) + } +} + +func TestApplyLocalTaskDiscoveryRejectsTenantBindingConflict(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + bad := localSnapshotWindow("d-1", "cursor-1", []byte(`{"tasks":[{},{}]}`), + localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running"), + localDiscoveredTask("d-1", "task-b", "tenant-a", "tenant-key-b", 1, "running"), + ) + if err := st.ApplyLocalTaskDiscovery(bad); !errors.Is(err, ErrTenantBindingConflict) { + t.Fatalf("tenant binding error=%v", err) + } + if _, exists, err := st.LocalTaskDiscoveryCursor("d-1"); err != nil || exists { + t.Fatalf("partial snapshot cursor persisted=%v err=%v", exists, err) + } +} + +func TestPersistLocalCommandRequiresRunningDiscoveredTask(t *testing.T) { + for _, tc := range []struct { + name string + discovered bool + status string + wantErr error + }{ + {name: "paused", discovered: true, status: "paused", wantErr: ErrLocalTaskPaused}, + {name: "stopped", discovered: true, status: "stopped", wantErr: ErrLocalTaskStopped}, + {name: "no-discovery", wantErr: ErrLocalDiscoveryUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + st := openLocalDiscoveryTestStore(t) + if tc.discovered { + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow("d-1", "cursor-1", []byte(`{"tasks":[{}]}`), + localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, tc.status), + )); err != nil { + t.Fatal(err) + } + } + now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) + bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, now) + record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a") + record.DispatcherID = "d-1" + if _, err := st.PersistLocalCommand(record); !errors.Is(err, tc.wantErr) { + t.Fatalf("PersistLocalCommand error=%v, want %v", err, tc.wantErr) + } + for _, table := range []string{"inbox", "outbox", "tasks", "reservations"} { + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 { + t.Fatalf("table %s rows=%d err=%v after blocked admission", table, count, err) + } + } + }) + } +} + +func openLocalDiscoveryTestStore(t *testing.T) *Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func seedLocalCommandAssignments(t *testing.T, st *Store, records ...LocalCommandRecord) { + t.Helper() + if len(records) == 0 { + return + } + dispatcherID := records[0].DispatcherID + tasks := make([]LocalDiscoveredTask, 0, len(records)) + seen := make(map[string]struct{}, len(records)) + for _, record := range records { + if record.DispatcherID != dispatcherID { + t.Fatalf("test commands use different Dispatcher IDs: %q and %q", dispatcherID, record.DispatcherID) + } + if _, exists := seen[record.TaskID]; exists { + continue + } + seen[record.TaskID] = struct{}{} + revision := int64(1) + if record.Admission != nil && record.Admission.Task.TaskRevision > 0 { + revision = record.Admission.Task.TaskRevision + } + tasks = append(tasks, localDiscoveredTask(dispatcherID, record.TaskID, record.TenantID, record.TenantKey, revision, "running")) + } + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow(dispatcherID, "test-cursor", []byte(`{"tasks":[]}`), tasks...)); err != nil { + t.Fatal(err) + } +} + +func seedLocalDiscovery(t *testing.T, st *Store) { + t.Helper() + if err := st.ApplyLocalTaskDiscovery(localSnapshotWindow("d-1", "cursor-1", []byte(`{"tasks":[{},{}]}`), + localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running"), + localDiscoveredTask("d-1", "task-b", "tenant-a", "tenant-key-a", 1, "running"), + )); err != nil { + t.Fatal(err) + } +} + +func localSnapshotWindow(dispatcherID, cursor string, body []byte, tasks ...LocalDiscoveredTask) LocalTaskDiscovery { + return LocalTaskDiscovery{DispatcherID: dispatcherID, Mode: "snapshot", Cursor: cursor, Body: body, Tasks: tasks} +} + +func localDiscoveredTask(_ string, taskID, tenantID, tenantKey string, revision int64, status string) LocalDiscoveredTask { + return LocalDiscoveredTask{TaskID: taskID, TenantID: tenantID, TenantKey: tenantKey, TaskRevision: revision, Status: status} +} diff --git a/internal/store/local_v01_test.go b/internal/store/local_v01_test.go new file mode 100644 index 0000000..f4425aa --- /dev/null +++ b/internal/store/local_v01_test.go @@ -0,0 +1,148 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +func TestPersistLocalCommandIsAtomicAndIdempotent(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + bundle := localConfigBundle("dispatcher-1", "tenant-id-1", "tenant-key-1", "task-1", 1, 1, 1, 2, 2, time.Now().UTC()) + if err := st.SaveLocalConfigBundle(bundle); err != nil { + t.Fatal(err) + } + record := localAdmissionCommand("command-1", "execution-1", "task-1", bundle, "dispatcher-1", "tenant-id-1", "tenant-key-1") + seedLocalCommandAssignments(t, st, record) + record.Exchange = "agent-call.saas.v3" + record.RoutingKey = "d.dispatcher-1.out" + duplicate, err := st.PersistLocalCommand(record) + if err != nil { + t.Fatal(err) + } + if duplicate { + t.Fatal("first command was reported as a duplicate") + } + + var inboxStatus string + var receiptCount, outboxCount int + if err := st.db.QueryRow(`SELECT status FROM inbox WHERE tenant_id=? AND command_id=?`, record.TenantID, record.CommandID).Scan(&inboxStatus); err != nil { + t.Fatal(err) + } + if inboxStatus != "persisted" { + t.Fatalf("inbox status = %q, want persisted", inboxStatus) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM mq_command_receipts WHERE tenant_id=? AND command_id=? AND response_id=?`, record.TenantID, record.CommandID, record.ReceiptID).Scan(&receiptCount); err != nil { + t.Fatal(err) + } + if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=? AND status='pending'`, record.ReceiptID).Scan(&outboxCount); err != nil { + t.Fatal(err) + } + if receiptCount != 1 || outboxCount != 1 { + t.Fatalf("receipt links=%d outbox rows=%d, want 1 each", receiptCount, outboxCount) + } + + first, err := st.ClaimOutbox(1) + if err != nil || len(first) != 1 { + t.Fatalf("claim first receipt: records=%d err=%v", len(first), err) + } + if err := st.MarkOutboxPublished(first[0].ID); err != nil { + t.Fatal(err) + } + duplicate, err = st.PersistLocalCommand(record) + if err != nil { + t.Fatal(err) + } + if !duplicate { + t.Fatal("redelivered command was not identified as a duplicate") + } + second, err := st.ClaimOutbox(1) + if err != nil || len(second) != 1 { + t.Fatalf("claim recovered receipt: records=%d err=%v", len(second), err) + } + if second[0].EventID != first[0].EventID || string(second[0].Body) != string(first[0].Body) { + t.Fatalf("duplicate changed receipt: first=%+v second=%+v", first[0], second[0]) + } + + record.Body = []byte(`{"command_id":"command-1","callee":"different"}`) + if _, err := st.PersistLocalCommand(record); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("same command ID with different body error = %v, want ErrCommandConflict", err) + } +} + +func TestEnqueueLocalEventIsIdempotentAndRejectsContentConflict(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + event := LocalEventRecord{ + EventID: "call-result-1", TenantKey: "tenant-key-1", Exchange: "agent-call.saas.v3", + RoutingKey: "d.dispatcher-1.out", Body: []byte(`{"event_id":"call-result-1"}`), + } + duplicate, err := st.EnqueueLocalEvent(event) + if err != nil || duplicate { + t.Fatalf("first enqueue duplicate=%v err=%v", duplicate, err) + } + duplicate, err = st.EnqueueLocalEvent(event) + if err != nil || !duplicate { + t.Fatalf("same event enqueue duplicate=%v err=%v", duplicate, err) + } + var count int + if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, event.EventID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("outbox rows = %d, want 1", count) + } + event.Body = []byte(`{"event_id":"call-result-1","different":true}`) + if _, err := st.EnqueueLocalEvent(event); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("same event ID with different body error = %v, want ErrCommandConflict", err) + } +} + +func TestVerifyLocalAcceptedCommandBindsCallResultToReceipt(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + bundle := localConfigBundle("dispatcher-a", "tenant-a", "tenant-a", "task-a", 1, 1, 1, 2, 2, time.Now().UTC()) + if err := st.SaveLocalConfigBundle(bundle); err != nil { + t.Fatal(err) + } + record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "dispatcher-a", "tenant-a", "tenant-a") + seedLocalCommandAssignments(t, st, record) + record.Body = []byte(`{"command_id":"command-a","command_type":"call.execute","tenant_id":"tenant-a","tenant_key":"tenant-a","payload":{"task_id":"task-a"}}`) + record.ReceiptID = "receipt-a" + record.Exchange = "agent-call.saas.v3" + record.RoutingKey = "d.dispatcher-a.out" + record.ReceiptBody = []byte(`{"event_type":"command.result","tenant_id":"tenant-a","tenant_key":"tenant-a","payload":{"command_id":"command-a","command_type":"call.execute","status":"accepted","execution_id":"execution-a"}}`) + _, err = st.PersistLocalCommand(record) + if err != nil { + t.Fatal(err) + } + if err := st.VerifyLocalAcceptedCommand("tenant-a", "tenant-a", "command-a", "execution-a", "task-a"); err != nil { + t.Fatalf("valid result binding: %v", err) + } + for _, tc := range []struct { + tenantID, tenantKey, commandID, executionID, taskID string + }{ + {"tenant-b", "tenant-a", "command-a", "execution-a", "task-a"}, + {"tenant-a", "tenant-b", "command-a", "execution-a", "task-a"}, + {"tenant-a", "tenant-a", "command-a", "execution-b", "task-a"}, + {"tenant-a", "tenant-a", "command-a", "execution-a", "task-b"}, + } { + if err := st.VerifyLocalAcceptedCommand(tc.tenantID, tc.tenantKey, tc.commandID, tc.executionID, tc.taskID); !errors.Is(err, ErrCommandConflict) { + t.Fatalf("invalid result binding %+v error = %v, want ErrCommandConflict", tc, err) + } + } +} diff --git a/internal/store/local_v01_verify.go b/internal/store/local_v01_verify.go new file mode 100644 index 0000000..182c15c --- /dev/null +++ b/internal/store/local_v01_verify.go @@ -0,0 +1,66 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" +) + +// VerifyLocalAcceptedCommand binds a final call result to the exact accepted +// command and execution recorded in the durable inbox and receipt outbox. +func (s *Store) VerifyLocalAcceptedCommand(tenantID, tenantKey, commandID, executionID, taskID string) error { + if tenantID == "" || tenantKey == "" || commandID == "" || executionID == "" || taskID == "" { + return ErrCommandConflict + } + s.mu.Lock() + defer s.mu.Unlock() + var storedTenantKey, status string + var commandBody, receiptBody []byte + err := s.db.QueryRow(`SELECT i.tenant_key, i.status, i.body, o.body + FROM inbox i + JOIN mq_command_receipts r ON r.tenant_id=i.tenant_id AND r.command_id=i.command_id + JOIN outbox o ON o.event_id=r.response_id + WHERE i.tenant_id=? AND i.command_id=?`, tenantID, commandID).Scan(&storedTenantKey, &status, &commandBody, &receiptBody) + if errors.Is(err, sql.ErrNoRows) { + return ErrCommandConflict + } + if err != nil { + return err + } + if storedTenantKey != tenantKey || status != "persisted" { + return ErrCommandConflict + } + var command struct { + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + Payload struct { + TaskID string `json:"task_id"` + } `json:"payload"` + } + if err := json.Unmarshal(commandBody, &command); err != nil { + return ErrCommandConflict + } + var receipt struct { + EventType string `json:"event_type"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + Payload struct { + CommandID string `json:"command_id"` + CommandType string `json:"command_type"` + Status string `json:"status"` + ExecutionID string `json:"execution_id"` + } `json:"payload"` + } + if err := json.Unmarshal(receiptBody, &receipt); err != nil { + return ErrCommandConflict + } + if command.CommandID != commandID || command.CommandType != "call.execute" || command.TenantID != tenantID || + command.TenantKey != tenantKey || command.Payload.TaskID != taskID || receipt.EventType != "command.result" || + receipt.TenantID != tenantID || receipt.TenantKey != tenantKey || receipt.Payload.CommandID != commandID || + receipt.Payload.CommandType != "call.execute" || receipt.Payload.Status != "accepted" || receipt.Payload.ExecutionID != executionID { + return ErrCommandConflict + } + return nil +} diff --git a/internal/store/local_v3_upload_state_test.go b/internal/store/local_v3_upload_state_test.go new file mode 100644 index 0000000..2e8b045 --- /dev/null +++ b/internal/store/local_v3_upload_state_test.go @@ -0,0 +1,71 @@ +package store + +import ( + "errors" + "testing" +) + +func TestLocalFinalResultOwnsUploadDeliveryWithoutSplitNotification(t *testing.T) { + for _, tc := range []struct { + name string + outcome LocalRecordingOutcome + finalState string + }{ + {name: "uploaded", outcome: LocalRecordingOutcome{ + Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", SizeBytes: 128000, + ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, finalState: "completed"}, + {name: "unavailable", outcome: LocalRecordingOutcome{Status: "unavailable", ErrorCode: "upload_timeout"}, finalState: "failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + st, terminal := readyLocalRecordingTerminal(t) + grant := UploadRecord{ + UploadID: terminal.Recording.UploadID, Binding: []byte("bound-agent"), Asset: []byte("bound-recording"), + Grant: []byte("one-put-only"), Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", + State: "granted", CreatedAt: terminal.EndedAt, + } + if _, err := st.IssueUploadGrant(grant, "op-a", "request-a"); err != nil { + t.Fatal(err) + } + tc.outcome.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline) + if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, tc.outcome); err != nil { + t.Fatal(err) + } + loaded, err := st.LoadUpload(grant.UploadID) + if err != nil || loaded.State != "uploaded" { + t.Fatalf("completed upload still offered a second PUT: state=%q err=%v", loaded.State, err) + } + if _, err := st.IssueUploadGrant(grant, "op-b", "request-b"); !errors.Is(err, ErrUploadMismatch) { + t.Fatalf("second PUT grant issued after durable outcome: %v", err) + } + event := localFinalEventFixture() + if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, event); err != nil { + t.Fatal(err) + } + messages, err := st.ClaimOutbox(10) + if err != nil { + t.Fatal(err) + } + found := false + for _, message := range messages { + if message.EventID == event.EventID { + found = true + if err := st.MarkOutboxPublished(message.ID); err != nil { + t.Fatal(err) + } + } + } + if !found { + t.Fatal("single final result was not queued") + } + loaded, err = st.LoadUpload(grant.UploadID) + if err != nil || loaded.State != tc.finalState { + t.Fatalf("upload delivery was marked before/without the final result: state=%q want=%q err=%v", loaded.State, tc.finalState, err) + } + var splitEvents int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM upload_notifications WHERE upload_id=?`, grant.UploadID).Scan(&splitEvents); err != nil || splitEvents != 0 { + t.Fatalf("obsolete recording.uploaded notification persisted: count=%d err=%v", splitEvents, err) + } + }) + } +} diff --git a/internal/store/migrations/014_local_v01_config_snapshots.sql b/internal/store/migrations/014_local_v01_config_snapshots.sql new file mode 100644 index 0000000..33fc126 --- /dev/null +++ b/internal/store/migrations/014_local_v01_config_snapshots.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS local_v01_config_snapshots ( + dispatcher_id TEXT NOT NULL, + resource TEXT NOT NULL CHECK (resource IN ('sip', 'tasks', 'task', 'tenant_quota')), + tenant_id TEXT NOT NULL DEFAULT '', + task_id TEXT NOT NULL DEFAULT '', + revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), + version TEXT NOT NULL, + content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), + fetched_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + body BLOB NOT NULL, + PRIMARY KEY (dispatcher_id, resource, tenant_id, task_id) +); + +CREATE TABLE IF NOT EXISTS local_v01_tenant_bindings ( + dispatcher_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (dispatcher_id, tenant_id), + UNIQUE (dispatcher_id, tenant_key) +); + +CREATE TABLE IF NOT EXISTS local_v01_execution_configs ( + execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), + content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), + body BLOB NOT NULL, + stored_at TEXT NOT NULL +); diff --git a/internal/store/migrations/015_local_v02_task_discovery.sql b/internal/store/migrations/015_local_v02_task_discovery.sql new file mode 100644 index 0000000..b54158d --- /dev/null +++ b/internal/store/migrations/015_local_v02_task_discovery.sql @@ -0,0 +1,39 @@ +-- Old paginated cursors cannot authorize the single-response discovery contract. +-- Keep task assignments and execution recovery; require a fresh snapshot. +DROP TABLE IF EXISTS local_v01_task_discovery_pages; +DROP TABLE IF EXISTS local_v01_task_discovery_state; + +CREATE TABLE IF NOT EXISTS local_v02_task_discovery_state ( + dispatcher_id TEXT PRIMARY KEY, + cursor TEXT NOT NULL, + last_mode TEXT NOT NULL CHECK (last_mode IN ('snapshot', 'changes')), + ready INTEGER NOT NULL CHECK (ready IN (0, 1)), + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS local_v02_task_discovery_response ( + dispatcher_id TEXT PRIMARY KEY, + content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), + body BLOB NOT NULL, + stored_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS local_v01_task_assignments ( + dispatcher_id TEXT NOT NULL, + task_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + task_revision INTEGER NOT NULL CHECK (task_revision >= 0), + saas_status TEXT NOT NULL CHECK (saas_status IN ('running', 'paused', 'stopped', 'finished', 'removed')), + admission_state TEXT NOT NULL CHECK (admission_state IN ('running', 'paused', 'stopped', 'finished', 'removed')), + removed INTEGER NOT NULL DEFAULT 0 CHECK (removed IN (0, 1)), + queue_exchange TEXT NOT NULL, + routing_key TEXT NOT NULL, + binding_key TEXT NOT NULL, + queue_name TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (dispatcher_id, task_id) +); + +CREATE INDEX IF NOT EXISTS local_v01_task_assignments_admission + ON local_v01_task_assignments(dispatcher_id, removed, admission_state); diff --git a/internal/store/migrations/016_local_v02_origination_decisions.sql b/internal/store/migrations/016_local_v02_origination_decisions.sql new file mode 100644 index 0000000..a6c8d4d --- /dev/null +++ b/internal/store/migrations/016_local_v02_origination_decisions.sql @@ -0,0 +1,11 @@ +-- A quota reservation is held at acceptance. A terminal local dial decision +-- is durable and one-shot whether it issues an Agent instruction or refuses it. +-- No later MQ duplicate, clock change or process restart can revive a refusal. +CREATE TABLE IF NOT EXISTS local_v02_origination_decisions ( + execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), + dispatcher_id TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('issued', 'refused')), + reason TEXT NOT NULL CHECK (reason IN ('', 'policy_denied', 'control_closed', 'invalid_binding')), + decided_at TEXT NOT NULL, + CHECK ((decision = 'issued' AND reason = '') OR (decision = 'refused' AND reason <> '')) +); diff --git a/internal/store/migrations/017_local_v01_call_terminals.sql b/internal/store/migrations/017_local_v01_call_terminals.sql new file mode 100644 index 0000000..f06dce8 --- /dev/null +++ b/internal/store/migrations/017_local_v01_call_terminals.sql @@ -0,0 +1,29 @@ +-- One immutable terminal fact per accepted local execution. Releasing the +-- reservation and recording the fact share a transaction; the final result +-- may be enqueued immediately or later after the recording outcome is known. +CREATE TABLE IF NOT EXISTS local_v01_call_terminals ( + execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), + call_id TEXT NOT NULL UNIQUE, + source TEXT NOT NULL CHECK (source IN ('mock_agent', 'dispatcher_refusal', 'mock_agent_deadline', 'dispatcher_deadline')), + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + outcome TEXT NOT NULL, + reason_code TEXT NOT NULL, + recording_expected INTEGER NOT NULL CHECK (recording_expected IN (0, 1)), + recording_id TEXT UNIQUE, + upload_id TEXT UNIQUE, + recording_manifest BLOB, + recording_outcome BLOB, + recording_outcome_sha256 TEXT, + recording_status TEXT CHECK (recording_status IN ('uploaded', 'unavailable')), + fact_sha256 TEXT NOT NULL, + result_event_id TEXT UNIQUE, + result_sha256 TEXT, + CHECK ((result_event_id IS NULL) = (result_sha256 IS NULL)), + CHECK ((recording_expected = 0 AND recording_id IS NULL AND upload_id IS NULL AND recording_manifest IS NULL AND recording_outcome IS NULL) + OR (recording_expected = 1 AND recording_id IS NOT NULL AND upload_id IS NOT NULL AND recording_manifest IS NOT NULL)), + CHECK ((recording_outcome IS NULL) = (recording_outcome_sha256 IS NULL)), + CHECK ((recording_outcome IS NULL) = (recording_status IS NULL)) +); +CREATE INDEX IF NOT EXISTS local_v01_call_terminals_pending_idx + ON local_v01_call_terminals (result_event_id, ended_at); diff --git a/internal/store/store.go b/internal/store/store.go index b1f8872..392f348 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -298,6 +298,9 @@ func verifyRouting(tenantKey, routingKey string) error { return nil } +const MaxOutboxPayloadBytes = 8 * 1024 * 1024 +const OutboxPayloadBlockedReason = "blocked_payload_too_large" + type OutboxRecord struct { ID int64 EventID string @@ -320,7 +323,8 @@ func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) { } defer tx.Rollback() rows, err := tx.Query(`SELECT id, event_id, tenant_key, exchange, routing_key, body, attempts - FROM outbox WHERE status IN ('pending', 'retry') ORDER BY id LIMIT ?`, limit) + FROM outbox WHERE status='pending' OR (status='retry' AND COALESCE(last_error,'') != ?) + ORDER BY id LIMIT ?`, OutboxPayloadBlockedReason, limit) if err != nil { return nil, err } @@ -347,6 +351,28 @@ func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) { return records, nil } +// BlockOutboxPayload retains an oversized message intact and prevents another +// publish attempt across restarts. The durable reason, not a transient process +// flag, distinguishes a blocked row from a retryable one. +func (s *Store) BlockOutboxPayload(id int64, size int) error { + if id <= 0 || size <= MaxOutboxPayloadBytes { + return errors.New("outbox payload block requires a claimed oversized message") + } + result, err := s.db.Exec(`UPDATE outbox SET status='retry', last_error=? + WHERE id=? AND status='dispatching'`, OutboxPayloadBlockedReason, id) + if err != nil { + return fmt.Errorf("block oversized outbox message: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("block oversized outbox message %d: %w", id, ErrCommandConflict) + } + return nil +} + func (s *Store) MarkOutboxPublished(id int64) error { s.mu.Lock() defer s.mu.Unlock() @@ -363,6 +389,16 @@ func (s *Store) MarkOutboxPublished(id int64) error { (SELECT n.upload_id FROM upload_notifications n JOIN outbox o ON o.event_id=n.event_id WHERE o.id=? AND o.status='published')`, now, id); err != nil { return err } + if _, err := tx.Exec(`UPDATE uploads SET state='completed', completed_at=? WHERE state='granted' AND upload_id IN + (SELECT f.upload_id FROM local_v01_call_terminals f JOIN outbox o ON o.event_id=f.result_event_id + WHERE o.id=? AND o.status='published' AND f.recording_status='uploaded')`, now, id); err != nil { + return err + } + if _, err := tx.Exec(`UPDATE uploads SET state='failed' WHERE state='granted' AND upload_id IN + (SELECT f.upload_id FROM local_v01_call_terminals f JOIN outbox o ON o.event_id=f.result_event_id + WHERE o.id=? AND o.status='published' AND f.recording_status='unavailable')`, id); err != nil { + return err + } return tx.Commit() } @@ -371,8 +407,18 @@ func (s *Store) MarkOutboxRetry(id int64, cause error) error { if cause != nil { message = cause.Error() } - _, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = ? WHERE id = ?`, message, id) - return err + result, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = ? WHERE id = ? AND status='dispatching'`, message, id) + if err != nil { + return err + } + rows, err := result.RowsAffected() + if err != nil { + return err + } + if rows != 1 { + return fmt.Errorf("retry outbox message %d: %w", id, ErrCommandConflict) + } + return nil } type Task struct { @@ -698,40 +744,81 @@ func (s *Store) releaseReservation(reservationID string, unknown bool, scopes [] return err } defer tx.Rollback() + if err := releaseReservationTx(tx, reservationID, unknown, scopes, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + return tx.Commit() +} + +// releaseReservationTx is shared by explicit release and confirmed terminal +// calls. Unknown consumption remains held until a confirmed outcome arrives. +func releaseReservationTx(tx *sql.Tx, reservationID string, unknown bool, scopes []string, now string) error { var state string var scopesJSON []byte if err := tx.QueryRow(`SELECT state, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &scopesJSON); err != nil { return err } - if state != "held" { + if state == "released" || state == "unknown" && unknown { return nil } - if len(scopes) == 0 { - if err := json.Unmarshal(scopesJSON, &scopes); err != nil { - return fmt.Errorf("decode reservation scopes: %w", err) - } + var persisted []string + if err := json.Unmarshal(scopesJSON, &persisted); err != nil { + return fmt.Errorf("decode reservation scopes: %w", err) } - if len(scopes) == 0 { + if len(persisted) == 0 { return errors.New("quota scopes are required to release a reservation") } - now := s.now().UTC().Format(time.RFC3339Nano) - for _, scope := range scopes { - if unknown { - if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil { - return err + if len(scopes) > 0 { + selected := make(map[string]int, len(scopes)) + for _, scope := range scopes { + selected[scope]++ + } + for _, scope := range persisted { + selected[scope]-- + } + if len(scopes) != len(persisted) { + return ErrCommandConflict + } + for _, difference := range selected { + if difference != 0 { + return ErrCommandConflict } - } else if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil { + } + } + for _, scope := range persisted { + if scope == "" { + return errors.New("empty persisted quota scope") + } + column := "reserved_value" + if state == "unknown" { + column = "unknown_value" + } + query := `UPDATE quotas SET ` + column + ` = ` + column + ` - 1, updated_at = ? WHERE scope = ? AND ` + column + ` > 0` + if state == "held" && unknown { + query = `UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0` + } + result, err := tx.Exec(query, now, scope) + if err != nil { return err } + rows, err := result.RowsAffected() + if err != nil || rows != 1 { + return fmt.Errorf("release quota scope %s (rows=%d): %w", scope, rows, errors.Join(err, ErrCommandConflict)) + } } newState := "released" if unknown { newState = "unknown" } - if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ?`, newState, now, reservationID); err != nil { + result, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ? AND state = ?`, newState, now, reservationID, state) + if err != nil { return err } - return tx.Commit() + rows, err := result.RowsAffected() + if err != nil || rows != 1 { + return fmt.Errorf("release reservation %s (rows=%d): %w", reservationID, rows, errors.Join(err, ErrCommandConflict)) + } + return nil } type Lease struct { diff --git a/internal/store/upload_grants.go b/internal/store/upload_grants.go index 8a0dc7f..1af65d3 100644 --- a/internal/store/upload_grants.go +++ b/internal/store/upload_grants.go @@ -32,6 +32,17 @@ func (s *Store) IssueUploadGrant(record UploadRecord, operationID, digest string return nil, err } defer tx.Rollback() + // Grant replay and first issuance must both stop once the recording has + // an immutable outcome. Keep this check inside the same SQLite transaction + // as the grant write so a concurrent outcome cannot authorize a late PUT. + var recorded sql.NullString + err = tx.QueryRow(`SELECT recording_status FROM local_v01_call_terminals WHERE upload_id=?`, record.UploadID).Scan(&recorded) + if err == nil && recorded.Valid { + return nil, ErrUploadMismatch + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } var previous string var grant []byte err = tx.QueryRow(`SELECT request_hash,grant FROM upload_grant_requests WHERE upload_id=? AND operation_id=?`, record.UploadID, operationID).Scan(&previous, &grant) @@ -67,7 +78,10 @@ func (s *Store) IssueUploadGrant(record UploadRecord, operationID, digest string if bucket != record.Bucket || state != "granted" || objectKey != record.ObjectKey || !bytes.Equal(binding, record.Binding) || !bytes.Equal(asset, record.Asset) { return nil, ErrUploadMismatch } - result, err := tx.Exec(`UPDATE uploads SET grant=? WHERE upload_id=? AND NOT EXISTS(SELECT 1 FROM upload_notifications WHERE upload_id=?)`, record.Grant, record.UploadID, record.UploadID) + result, err := tx.Exec(`UPDATE uploads SET grant=? WHERE upload_id=? + AND NOT EXISTS(SELECT 1 FROM upload_notifications WHERE upload_id=?) + AND NOT EXISTS(SELECT 1 FROM local_v01_call_terminals f WHERE f.upload_id=? AND f.recording_status IS NOT NULL)`, + record.Grant, record.UploadID, record.UploadID, record.UploadID) if err != nil { return nil, err } diff --git a/internal/store/uploads.go b/internal/store/uploads.go index 54eec0a..00a7e1e 100644 --- a/internal/store/uploads.go +++ b/internal/store/uploads.go @@ -29,7 +29,10 @@ func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) { var createdAt string var completedAt sql.NullString err := s.db.QueryRow(`SELECT uploads.upload_id,binding,asset,grant,object_key,d.bucket, - CASE WHEN state='granted' AND EXISTS(SELECT 1 FROM upload_notifications n WHERE n.upload_id=uploads.upload_id) THEN 'uploaded' ELSE state END, + CASE WHEN state='granted' AND ( + EXISTS(SELECT 1 FROM upload_notifications n WHERE n.upload_id=uploads.upload_id) + OR EXISTS(SELECT 1 FROM local_v01_call_terminals f WHERE f.upload_id=uploads.upload_id AND f.recording_status IS NOT NULL) + ) THEN 'uploaded' ELSE state END, created_at,completed_at FROM uploads JOIN upload_destinations d ON d.upload_id=uploads.upload_id WHERE uploads.upload_id=?`, uploadID).Scan(&record.UploadID, &record.Binding, &record.Asset, &record.Grant, &record.ObjectKey, &record.Bucket, &record.State, &createdAt, &completedAt) if err != nil { return UploadRecord{}, fmt.Errorf("load upload: %w", err) diff --git a/proto/ERRORS.md b/proto/ERRORS.md index 3d8e34b..5ed7be6 100644 --- a/proto/ERRORS.md +++ b/proto/ERRORS.md @@ -40,6 +40,13 @@ request. - `Execute`: the execution binding and permit ID are the deduplication identity. An unknown result is reconciled with `QueryExecution`; it is never retried as a new originate. +- `ExecuteAuthorized` (`agent-authorized-origination.v0.1`, isolated Mock only): + the Dispatcher first persists one `issued` or `refused` decision per execution. + The Agent durably records `UNKNOWN` **before** invoking the mock adapter; + identical operation replays return the stored receipt, changed content or a + second operation for the same execution is a conflict. A lost reply or mock + adapter failure requires `QueryExecution`, never another originate or a new + execution ID. An expired Dispatcher-issued deadline cannot invoke the adapter. - `GetExecutionPermit`: the reservation, binding, expected revision and idempotency key are persisted. A permit is not issued after the reservation is released or fenced. @@ -66,8 +73,12 @@ request and trace identity: `GetAgentStatus`, `GetBootstrap`, and identity or endpoint values are not authorization. 3. A newer boot or session generation fences older requests. The old request returns `UNAUTHENTICATED` or `ABORTED` and cannot release an unknown lease. -4. A permit contains the dispatcher epoch, session generation, reservation and - `fencing_token`. The Agent checks all of them immediately before originate. +4. The existing `Execute` permit contains the dispatcher epoch, session + generation, reservation and `fencing_token`; the Agent checks them before + that legacy path. The separate Mock-only `ExecuteAuthorized` path requires + an active session and the Dispatcher-issued exclusive deadline, without a + second business-policy calculation or an implicit permit/fallback. Its + mixed/real execution is disabled pending separate authorization. 5. Admission close/drain is a prerequisite barrier. `SetAdmissionState` and `ApplyTaskControl` are applied only when their expected generation/revision matches durable state. @@ -80,5 +91,6 @@ request and trace identity: `GetAgentStatus`, `GetBootstrap`, and The Agent receives a restricted `UploadGrant` and uploads directly to the approved OSS target. The Dispatcher never receives audio bytes. The Agent reports only asset metadata/checksum through `CompleteUpload`; the Dispatcher -coordinates the SaaS completion/verification and publishes the resulting OSS ID -through the existing MQ event path. +reliably queues the original `recording.uploaded` fact in MQ. This project does +not request a SaaS upload session, wait for `verified`, or invent an OSS ID; +MQ publisher confirmation is not SaaS application receipt. diff --git a/proto/README.md b/proto/README.md index cc932c3..36d77df 100644 --- a/proto/README.md +++ b/proto/README.md @@ -10,6 +10,13 @@ or HTTP call-execution callback is introduced. upload grants. It never carries recording/audio bytes. - `call_execute_json` and `payload_json` preserve the approved external JSON bytes; this Proto does not create a second external SaaS Schema. +- `ExecuteAuthorized` carries the project-local +`agent-authorized-origination.v0.1` decision. The Dispatcher persists a +one-shot issued/refused decision after checking task × selected-line policy; +the Agent checks only active session identity and the Dispatcher-issued +exclusive deadline. This method is enabled only with an explicit isolated Mock +adapter, which sends **no SIP**. Dispatcher V3 mixed/real startup rejects this +path; neither the existing `Execute` method nor MQ provides a fallback. - `accepted` is durable receipt only; `applied`, terminal state and verified asset facts require later evidence. - IDs, epochs, boot/session generations, operation IDs and explicit idempotency diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index 8a215e4..323947f 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -13,6 +13,8 @@ service AgentControlService { rpc GetBootstrap(GetBootstrapRequest) returns (GetBootstrapResponse); rpc SetAdmissionState(SetAdmissionStateRequest) returns (SetAdmissionStateResponse); rpc Execute(ExecuteRequest) returns (ExecuteResponse); + // Local P1 Mock: accepts a Dispatcher-authorized, immutable dial decision. + rpc ExecuteAuthorized(ExecuteAuthorizedRequest) returns (ExecuteAuthorizedResponse); rpc GetExecutionPermit(GetExecutionPermitRequest) returns (GetExecutionPermitResponse); rpc ApplyTaskControl(ApplyTaskControlRequest) returns (ApplyTaskControlResponse); rpc QueryExecution(QueryExecutionRequest) returns (QueryExecutionResponse); @@ -181,6 +183,10 @@ message ResourceSample { string missing_reason = 11; } +// Project-local v0.1 status convention: exactly one kind="sip" entry identifies +// the SIP artifact loaded by the Agent/Asterisk; state="applied" means that exact +// artifact is active. revision and config_sha256 must match the approved artifact. +// Missing, duplicate, or mismatched SIP entries are unknown and fail closed. message AppliedConfig { string kind = 1; string revision = 2; @@ -330,6 +336,28 @@ message ExecuteResponse { ExecutionState state = 2; } +// Versioned project-local D→Agent execution contract. Only the Dispatcher +// evaluates the task/line schedule, allowlist, route and effective duration. +// The Agent checks the authorized deadline and transport/session identity; it +// never selects another trunk or recalculates outbound business policy. +message ExecuteAuthorizedRequest { + string schema_version = 1; // agent-authorized-origination.v0.1 + RequestMeta meta = 2; + ExecutionBinding binding = 3; + string selected_trunk_id = 4; + string caller_id = 5; + string callee = 6; + int64 ring_timeout_ms = 7; + int64 max_call_duration_ms = 8; + int64 dial_before_unix_ms = 9; // Exclusive D-issued deadline. + string bound_snapshot_sha256 = 10; +} + +message ExecuteAuthorizedResponse { + OperationReceipt receipt = 1; + ExecutionState state = 2; +} + message GetExecutionPermitRequest { RequestMeta meta = 1; ExecutionBinding binding = 2; diff --git a/proto/manifest.json b/proto/manifest.json index d4f98e6..6e94316 100644 --- a/proto/manifest.json +++ b/proto/manifest.json @@ -24,28 +24,28 @@ }, { "path": "proto/ERRORS.md", - "bytes": 4722, - "sha256": "794c2b5f901803a0d8f7febfda3295781b612270fcce64b4b7361106228a9295" + "bytes": 5664, + "sha256": "98db6c9d568a06ce6506fbd880758ecdf22323c57fd7c76e77a39c571ffe1be5" }, { "path": "proto/README.md", - "bytes": 1579, - "sha256": "d2754ceb47fd54bdfc4a05c5b5be818fe984399748b502f7ea8707fddf56c7dd" + "bytes": 2102, + "sha256": "730e2940a887f541527767f9fe68e3f437285d7c91447a1ef74498438a2ec68d" }, { "path": "proto/agent/v1/agent.proto", - "bytes": 10730, - "sha256": "81e2b9b89e0eea8fef0ca583acf83b1e314b3d4ac681f6164f5895060e4cf731" + "bytes": 12029, + "sha256": "f126aa941b06b31c45f31bceb8d5355ddf24d571856b09ef6a7a193aedca88c9" }, { "path": "gen/agent/v1/agent.pb.go", - "bytes": 144174, - "sha256": "18e870af711d7890ff80fe9095c82203d76c81402b7e333b76b37171ce5dfaaf" + "bytes": 151876, + "sha256": "d68179699408f850eb54c115239276389f3bb7d42cfa4b936b43c6ca6e9cb452" }, { "path": "gen/agent/v1/agent_grpc.pb.go", - "bytes": 23035, - "sha256": "e87a0114e6d7d1d3d0801a7bba302e76945e3d244ec67cf56249eef0e838617d" + "bytes": 25021, + "sha256": "dade4714efcbbf13ed761db9c4480639300780eae74abfe7a4ea16a8847135e6" } ] } diff --git a/scripts/acceptance-local.sh b/scripts/acceptance-local.sh index 3bd086c..dcd9e52 100755 --- a/scripts/acceptance-local.sh +++ b/scripts/acceptance-local.sh @@ -1,39 +1,33 @@ -#!/bin/sh -set -eu +#!/usr/bin/env bash +set -euo pipefail -root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -cd "$root" +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +unformatted=$(find contracts internal cmd -type f -name '*.go' -print0 | xargs -0 gofmt -l) +if [[ -n "$unformatted" ]]; then + printf 'Go files need gofmt:\n%s\n' "$unformatted" >&2 + exit 1 +fi + +if grep -R -n -E -- '--tenant-key|--consume|DISPATCHER_TENANT_KEY' deploys/systemd deploys/env; then + echo "obsolete tenant-queue CLI configuration remains in deployment templates" >&2 + exit 1 +fi +if [[ -e scripts/mq-only-acceptance-local.sh ]]; then + echo "obsolete MQ-only acceptance runner remains" >&2 + exit 1 +fi ./scripts/check-contracts.sh go mod verify -go test -race ./... +go test -race ./... -count=1 go vet ./... -go build -trimpath -buildvcs=false -o dist/sip-go-agent ./cmd/sip-go-agent tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT -cp deploys/config/dispatcher.json.example "$tmp/dispatcher.json" -chmod 600 "$tmp/dispatcher.json" -# The strict Dispatcher file requires explicitly referenced credentials even in -# mock mode. These are local-test values and never reach an OSS endpoint. -export GO_SIP_OSS_ACCESS_KEY_ID=local-mock-access-key -export GO_SIP_OSS_ACCESS_KEY_SECRET=local-mock-access-secret -./dist/sip-go-agent agent --mode mock --spool "$tmp/spool" >/dev/null -rabbit_url=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-}} -if [ -z "$rabbit_url" ]; then - echo "GO_SIP_LOCAL_MQ_URL or RABBITMQ_URL is required for Dispatcher acceptance" >&2 - exit 1 -fi -export GO_SIP_LOCAL_MQ_URL=${GO_SIP_LOCAL_MQ_URL:-$rabbit_url} -export GO_SIP_LOCAL_QUERY_MQ_URL=${GO_SIP_LOCAL_QUERY_MQ_URL:-$rabbit_url} -export GO_SIP_LOCAL_UPLOAD_MQ_URL=${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$rabbit_url} -export RABBITMQ_URL=${RABBITMQ_URL:-$rabbit_url} -./dist/sip-go-agent dispatcher --mode mock --config "$tmp/dispatcher.json" --db "$tmp/dispatcher.db" --rabbit-url "$rabbit_url" --tenant-key local-acceptance-tenant --once >/dev/null -./scripts/mq-only-acceptance-local.sh >/dev/null +go build -trimpath -buildvcs=false -o "$tmp/sip-go-agent" ./cmd/sip-go-agent -if env -u GO_SIP_LOCAL_MQ_URL -u GO_SIP_LOCAL_QUERY_MQ_URL -u GO_SIP_LOCAL_UPLOAD_MQ_URL -u RABBITMQ_URL ./dist/sip-go-agent dispatcher --mode real --config "$tmp/dispatcher.json" --db "$tmp/real.db" --tenant-key local-acceptance-tenant --once >/dev/null 2>&1; then - echo "real mode unexpectedly started without broker credentials" >&2 - exit 1 -fi +./scripts/mq-integration-local.sh -echo "local P1 acceptance passed for the current single-node/Cell/tenant scope; external production gates are intentionally deferred to phase two" +echo "P1 local F09 checks passed (business-module coverage only; see docs/evidence/f09-local-acceptance-v0.2.md); external SaaS/management, cloud, supplier, SIP, F06 deployment, and production acceptance remain unverified" diff --git a/scripts/check-contracts.sh b/scripts/check-contracts.sh index 86594cd..50714e1 100755 --- a/scripts/check-contracts.sh +++ b/scripts/check-contracts.sh @@ -10,4 +10,5 @@ bundle=$(sed -n 's/^active_bundle=//p' "$manifest") cd "$root" grep '^sha256=' "$manifest" | sed 's/^sha256=//' | sha256sum -c - +python3 scripts/validate-local-contracts.py go test ./contracts ./internal/contract ./internal/ai diff --git a/scripts/generate-local-contract-bundle.sh b/scripts/generate-local-contract-bundle.sh new file mode 100644 index 0000000..a834ef8 --- /dev/null +++ b/scripts/generate-local-contract-bundle.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p contracts/local/v0.1 contracts/local/v0.2 +rm -f contracts/local/v0.1/task-discovery-v0.1-proposal.schema.json +cp docs/contracts/config-read-v0.1.schema.json docs/contracts/command-next-v0.1-proposal.schema.json docs/contracts/call-result-v0.1-proposal.schema.json docs/contracts/local-mock-recording-failure-v0.1.schema.json contracts/local/v0.1/ +cp docs/contracts/task-discovery-v0.2-proposal.schema.json contracts/local/v0.2/ diff --git a/scripts/mq-integration-local.sh b/scripts/mq-integration-local.sh index 9b1c7cb..8acf134 100755 --- a/scripts/mq-integration-local.sh +++ b/scripts/mq-integration-local.sh @@ -6,8 +6,15 @@ cd -- "$ROOT" IMAGE=${RABBITMQ_IMAGE:-rabbitmq:4.1-management-alpine} NAME="sip-go-agent-rabbit-poc-${$}" PORT="" +command -v docker >/dev/null 2>&1 || { echo "docker is required for local RabbitMQ tests" >&2; exit 1; } +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "RabbitMQ image $IMAGE is not cached locally; refusing an implicit image pull" >&2 + exit 1 +fi RABBIT_USER=${RABBITMQ_TEST_USER:-agent_call_integration} RABBIT_PASSWORD=${RABBITMQ_TEST_PASSWORD:-$(openssl rand -hex 16)} +DISPATCHER_USER="sip_go_agent_no_config_${$}" +DISPATCHER_PASSWORD=$(openssl rand -hex 16) RABBIT_UID=$(docker run --rm "$IMAGE" id -u rabbitmq) RABBIT_GID=$(docker run --rm "$IMAGE" id -g rabbitmq) @@ -36,5 +43,11 @@ if [[ -z "$PORT" ]]; then exit 1 fi IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE") -printf 'local RabbitMQ image=%s id=%s uid=%s gid=%s port=%s\n' "$IMAGE" "$IMAGE_ID" "$RABBIT_UID" "$RABBIT_GID" "$PORT" -RABBITMQ_URL="amqp://${RABBIT_USER}:${RABBIT_PASSWORD}@127.0.0.1:${PORT}/" go test -tags=integration ./internal/mq ./internal/dispatcher +docker exec "$NAME" rabbitmqctl add_user "$DISPATCHER_USER" "$DISPATCHER_PASSWORD" >/dev/null +docker exec "$NAME" rabbitmqctl set_permissions -p / "$DISPATCHER_USER" '^$' '.*' '.*' >/dev/null +ADMIN_URL="amqp://${RABBIT_USER}:${RABBIT_PASSWORD}@127.0.0.1:${PORT}/" +DISPATCHER_URL="amqp://${DISPATCHER_USER}:${DISPATCHER_PASSWORD}@127.0.0.1:${PORT}/" +printf 'local RabbitMQ image=%s id=%s uid=%s gid=%s port=%s; restricted Dispatcher configure permission=none\n' "$IMAGE" "$IMAGE_ID" "$RABBIT_UID" "$RABBIT_GID" "$PORT" +RABBITMQ_URL="$ADMIN_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" go test -race -cover -tags=integration ./internal/mq ./internal/dispatcher +RABBITMQ_URL="$DISPATCHER_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" RABBITMQ_EXPECT_NO_CONFIG=1 go test -race -tags=integration ./internal/mq -run '^TestV3BrokerConsumesSaaSOwnedQueueAndPublishesConfirmedResult$' -count=1 +RABBITMQ_URL="$DISPATCHER_URL" RABBITMQ_PROVISIONER_URL="$ADMIN_URL" go test -race -tags=integration ./internal/dispatcher -run '^TestLocalDispatcherMockSaaSEndToEndWithOutboxRecovery$' -count=1 diff --git a/scripts/mq-only-acceptance-local.sh b/scripts/mq-only-acceptance-local.sh deleted file mode 100755 index b53005d..0000000 --- a/scripts/mq-only-acceptance-local.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -cd "$ROOT" - -# A missing broker must fail the command, never turn these tests into skips. -BASE_URL=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-amqp://guest:guest@127.0.0.1:33252/}} -export GO_SIP_LOCAL_MQ_URL="$BASE_URL" -export GO_SIP_LOCAL_QUERY_MQ_URL="${GO_SIP_LOCAL_QUERY_MQ_URL:-$BASE_URL}" -export GO_SIP_LOCAL_UPLOAD_MQ_URL="${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$BASE_URL}" -export RABBITMQ_URL="${RABBITMQ_URL:-$BASE_URL}" - -LOG_FILE=${MQ_ONLY_ACCEPTANCE_LOG:-/tmp/go-sip-mq-only-acceptance.log} -: > "$LOG_FILE" -run() { - printf '$' - printf ' %q' "$@" - printf '\n' | tee -a "$LOG_FILE" - "$@" 2>&1 | tee -a "$LOG_FILE" -} - -run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/dispatcher \ - -run 'TestLocalMQ(AIConfigurationAuthorizationRoundTrip|ActiveControlReplyRecovery|RequestRoundTrip)$' -count=1 -run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/rpc \ - -run '^TestLocalUploadNoticeSurvivesUnroutableAndRestart$' -count=1 -run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v -tags integration ./internal/mq \ - -run 'TestV2LocalBrokerIdentityIsolationAndReliableRouting|TestLocalRabbitMQConfirmAckAndDeadLetter|TestV2LocalBrokerDisconnectStopsPublisher' -count=1 -run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/store \ - -run 'TestMQControlRejectsLateRevisionWithoutRegressingTask|TestMQReplayOnlyRequeuesOriginalBusinessFacts' -count=1 - -if grep -Eq -- '--- SKIP:|\(no tests to run\)' "$LOG_FILE"; then - echo "targeted MQ acceptance unexpectedly skipped a test; see $LOG_FILE" >&2 - exit 1 -fi -echo "targeted MQ acceptance passed without skipped tests; log=$LOG_FILE" diff --git a/scripts/validate-local-contracts.py b/scripts/validate-local-contracts.py new file mode 100644 index 0000000..449a86e --- /dev/null +++ b/scripts/validate-local-contracts.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Developer-only validation for local F01/F07 contracts, examples, and hashes.""" + +import hashlib +import json +import re +from pathlib import Path + +try: + from jsonschema import Draft202012Validator, FormatChecker, ValidationError + from referencing import Registry, Resource +except ImportError as exc: + raise SystemExit("requires the developer tool jsonschema 4.x; not a Go runtime dependency") from exc + +ROOT = Path(__file__).resolve().parents[1] +DOC = ROOT / "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md" +SCHEMA_PATHS = { + "config-read": ROOT / "docs/contracts/config-read-v0.1.schema.json", + "task-discovery": ROOT / "docs/contracts/task-discovery-v0.1-proposal.schema.json", + "command-next": ROOT / "docs/contracts/command-next-v0.1-proposal.schema.json", + "call-result": ROOT / "docs/contracts/call-result-v0.1-proposal.schema.json", +} +EXAMPLES = ROOT / "docs/contracts/examples" +MANIFEST_PATH = ROOT / "docs/contracts/local-contract-manifest-v0.1.json" +STATUS_FIXTURE = EXAMPLES / "config-read-http-statuses-v0.1.json" +TASK_STATUS_FIXTURE = EXAMPLES / "task-discovery-http-statuses-v0.1.json" + + +def load_json(path): + return json.loads(path.read_text(encoding="utf-8")) + + +registry = Registry() +for path in (ROOT / "contracts/upstream/v1").glob("*.schema.json"): + schema = load_json(path) + if "$id" in schema: + registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) + + +def schema_versions(node): + found = set() + if isinstance(node, dict): + version = node.get("properties", {}).get("schema_version", {}).get("const") + if isinstance(version, str): + found.add(version) + for value in node.values(): + found.update(schema_versions(value)) + elif isinstance(node, list): + for value in node: + found.update(schema_versions(value)) + return found + + +validators = {} +versions = {} +for name, path in SCHEMA_PATHS.items(): + schema = load_json(path) + Draft202012Validator.check_schema(schema) + schema_version_values = schema_versions(schema) + if not schema_version_values: + raise SystemExit(f"no schema_version const found: {path.relative_to(ROOT)}") + for version in schema_version_values: + if version in versions and versions[version] != name: + raise SystemExit(f"duplicate schema_version {version}") + versions[version] = name + validators[name] = Draft202012Validator( + schema, + registry=registry, + format_checker=FormatChecker(), + ) + +positive_counts = {name: 0 for name in SCHEMA_PATHS} +doc = DOC.read_text(encoding="utf-8") +for index, match in enumerate(re.finditer(r"```json\s*(.*?)\s*```", doc, re.DOTALL), 1): + try: + sample = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + if any(version in match.group(1) for version in versions): + raise SystemExit(f"invalid JSON in local-schema Markdown example {index}: {exc}") from exc + continue + if not isinstance(sample, dict) or sample.get("schema_version") not in versions: + continue + name = versions[sample["schema_version"]] + validators[name].validate(sample) + positive_counts[name] += 1 + +for path in sorted(EXAMPLES.glob("config-read-*.json")): + if "invalid" in path.name or path == STATUS_FIXTURE: + continue + validators["config-read"].validate(load_json(path)) + positive_counts["config-read"] += 1 + +for path in sorted(EXAMPLES.glob("task-discovery-*-v0.1.json")): + if "invalid" in path.name or path == TASK_STATUS_FIXTURE: + continue + validators["task-discovery"].validate(load_json(path)) + positive_counts["task-discovery"] += 1 + +for path in sorted(EXAMPLES.glob("call-result-*.json")): + if "invalid" in path.name: + continue + validators["call-result"].validate(load_json(path)) + positive_counts["call-result"] += 1 + +CONFIG_READ_HTTP_ERROR_CODES = { + 400: {"invalid_request"}, + 401: {"unauthorized"}, + 403: {"dispatcher_not_authorized"}, + 404: {"resource_not_found"}, + 503: {"tenant_quota_unavailable", "service_unavailable"}, +} +TASK_DISCOVERY_HTTP_ERROR_CODES = { + 400: {"invalid_cursor", "invalid_page_token"}, + 401: {"unauthorized"}, + 403: {"dispatcher_not_authorized"}, + 410: {"cursor_expired", "snapshot_expired"}, + 503: {"service_unavailable"}, +} + +def validate_status_fixture(path, schema_name, fixture_version, status_codes): + fixture = load_json(path) + if not isinstance(fixture, dict) or set(fixture) != {"fixture_version", "responses"}: + raise SystemExit(f"invalid HTTP status fixture structure: {path.relative_to(ROOT)}") + if fixture["fixture_version"] != fixture_version or not isinstance(fixture["responses"], list): + raise SystemExit(f"unexpected HTTP status fixture version or responses: {path.relative_to(ROOT)}") + expected = {(status, code) for status, codes in status_codes.items() for code in codes} + actual = set() + for response in fixture["responses"]: + if not isinstance(response, dict) or set(response) != {"status", "body"}: + raise SystemExit(f"invalid HTTP error fixture entry: {response}") + status = response["status"] + body = response["body"] + if type(status) is not int or not isinstance(body, dict): + raise SystemExit(f"invalid HTTP error fixture status/body: {response}") + validators[schema_name].validate(body) + code = body["error"]["code"] + if code not in status_codes.get(status, set()): + raise SystemExit(f"HTTP {status} does not match local {schema_name} error code {code}") + pair = (status, code) + if pair in actual: + raise SystemExit(f"duplicate HTTP error case: {pair}") + actual.add(pair) + if actual != expected: + raise SystemExit(f"HTTP error cases differ from local {schema_name} mapping: missing={expected - actual}, extra={actual - expected}") + return len(actual) + +positive_counts["config-read"] += validate_status_fixture( + STATUS_FIXTURE, "config-read", "config-read-http-statuses.v0.1", CONFIG_READ_HTTP_ERROR_CODES +) +positive_counts["task-discovery"] += validate_status_fixture( + TASK_STATUS_FIXTURE, "task-discovery", "task-discovery-http-statuses.v0.1", TASK_DISCOVERY_HTTP_ERROR_CODES +) +if any(count == 0 for count in positive_counts.values()): + raise SystemExit(f"missing positive contract example: {positive_counts}") + +invalid_prefixes = { + "config-read-invalid-": "config-read", + "task-discovery-invalid-": "task-discovery", + "command-next-invalid-": "command-next", + "call-result-invalid-": "call-result", +} +negative_counts = {name: 0 for name in SCHEMA_PATHS} +for path in sorted(EXAMPLES.glob("*-invalid-*.json")): + name = next((schema for prefix, schema in invalid_prefixes.items() if path.name.startswith(prefix)), None) + if name is None or (name == "task-discovery" and not path.name.endswith("-v0.1.json")): + continue + sample = load_json(path) + try: + validators[name].validate(sample) + except ValidationError: + negative_counts[name] += 1 + else: + raise SystemExit(f"negative fixture unexpectedly valid: {path.relative_to(ROOT)}") + +if any(count == 0 for count in negative_counts.values()): + raise SystemExit(f"missing negative fixture per local schema: {negative_counts}") + +def validate_manifest(): + if not MANIFEST_PATH.is_file(): + raise SystemExit(f"required local contract manifest is missing: {MANIFEST_PATH.relative_to(ROOT)}") + manifest = load_json(MANIFEST_PATH) + expected_source = DOC.relative_to(ROOT).as_posix() + if manifest.get("manifest_version") != "local-contract-manifest.v0.1": + raise SystemExit("unexpected local contract manifest version") + if manifest.get("hash_algorithm") != "SHA-256": + raise SystemExit("local contract manifest must use SHA-256") + source = manifest.get("source") + if not isinstance(source, dict) or source.get("path") != expected_source: + raise SystemExit(f"local contract manifest source must be {expected_source}") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + raise SystemExit("local contract manifest artifacts must be a list") + required_artifacts = {path.relative_to(ROOT).as_posix() for path in SCHEMA_PATHS.values()} + required_artifacts.update({ + "docs/contracts/config-read-fields-v0.1-proposal.md", + "docs/contracts/mq-topology-v0.1-proposal.json", + }) + required_artifacts.update( + path.relative_to(ROOT).as_posix() + for path in EXAMPLES.glob("*.json") + if path.name.startswith(("config-read-", "command-next-", "call-result-")) + or (path.name.startswith("task-discovery-") and path.name.endswith("-v0.1.json")) + ) + artifact_paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] + if len(artifact_paths) != len(artifacts) or len(set(artifact_paths)) != len(artifact_paths): + raise SystemExit("local contract manifest has malformed or duplicate artifacts") + if set(artifact_paths) != required_artifacts: + raise SystemExit(f"local contract manifest artifact set mismatch: missing={required_artifacts - set(artifact_paths)}, extra={set(artifact_paths) - required_artifacts}") + entries = [source, *artifacts] + for entry in entries: + relative = Path(entry.get("path", "")) + expected_hash = entry.get("sha256") + if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(expected_hash)): + raise SystemExit(f"invalid path or SHA-256 in local contract manifest: {entry}") + path = ROOT / relative + if not path.is_file(): + raise SystemExit(f"manifest file is missing: {relative}") + actual_hash = hashlib.sha256(path.read_bytes()).hexdigest() + if actual_hash != expected_hash: + raise SystemExit(f"SHA-256 mismatch for {relative}: expected {expected_hash}, got {actual_hash}") + return len(entries) + +manifest_file_count = validate_manifest() +print(f"Local schemas: {len(validators)} valid; positive examples: {positive_counts}; negative fixtures rejected: {negative_counts}; SHA-256 manifest files verified: {manifest_file_count}") + +# Validate the separate v0.2 task-discovery proposal without changing the +# v0.1 runtime baseline or the historical v0.1 manifest. +proposal_path = ROOT / "docs/contracts/task-discovery-v0.2-proposal.schema.json" +proposal_schema = load_json(proposal_path) +Draft202012Validator.check_schema(proposal_schema) +if schema_versions(proposal_schema) != {"task-discovery.v0.2-proposal"}: + raise SystemExit("unexpected task-discovery v0.2 schema version") +validators["task-discovery-v0.2"] = Draft202012Validator( + proposal_schema, registry=registry, format_checker=FormatChecker() +) +proposal = validators["task-discovery-v0.2"] +proposal_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.2.json")) +proposal_status = EXAMPLES / "task-discovery-http-statuses-v0.2.json" +proposal_positive = 0 +proposal_negative = 0 +for path in proposal_examples: + if path == proposal_status: + continue + sample = load_json(path) + if "invalid" in path.name: + try: + proposal.validate(sample) + except ValidationError: + proposal_negative += 1 + else: + raise SystemExit(f"negative v0.2 fixture unexpectedly valid: {path.relative_to(ROOT)}") + else: + proposal.validate(sample) + proposal_positive += 1 +proposal_positive += validate_status_fixture( + proposal_status, "task-discovery-v0.2", "task-discovery-http-statuses.v0.2", + {400: {"invalid_cursor"}, 401: {"unauthorized"}, + 403: {"dispatcher_not_authorized"}, 410: {"cursor_expired"}, + 503: {"service_unavailable"}}, +) +if proposal_positive < 8 or proposal_negative < 2: + raise SystemExit("missing v0.2 positive/negative contract cases") +for example in ("snapshot", "changes"): + payload = load_json(EXAMPLES / f"task-discovery-{example}-v0.2.json") + field = "tasks" if example == "snapshot" else "changes" + payload[field] = [payload[field][0]] * 256 + proposal.validate(payload) + payload[field].append(payload[field][0]) + try: + proposal.validate(payload) + except ValidationError: + proposal_negative += 1 + else: + raise SystemExit(f"v0.2 {field} accepted more than 256 items") + +proposal_doc = ROOT / "docs/thirds/v0.2.md" +proposal_versions = {**versions, "task-discovery.v0.2-proposal": "task-discovery-v0.2"} +for match in re.finditer(r"```json\s*(.*?)\s*```", proposal_doc.read_text(encoding="utf-8"), re.DOTALL): + sample = json.loads(match.group(1)) + if isinstance(sample, dict) and sample.get("schema_version") in proposal_versions: + validators[proposal_versions[sample["schema_version"]]].validate(sample) + proposal_positive += 1 + +proposal_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.2.json") +if proposal_manifest.get("manifest_version") != "local-contract-manifest.v0.2" or proposal_manifest.get("hash_algorithm") != "SHA-256": + raise SystemExit("invalid v0.2 manifest version or hash algorithm") +proposal_artifacts = { + proposal_path.relative_to(ROOT).as_posix(), + *(path.relative_to(ROOT).as_posix() for path in proposal_examples), + *(SCHEMA_PATHS[name].relative_to(ROOT).as_posix() for name in ("config-read", "command-next", "call-result")), + "docs/contracts/mq-topology-v0.1-proposal.json", +} +source = proposal_manifest.get("source", {}) +artifacts = proposal_manifest.get("artifacts", []) +if source.get("path") != proposal_doc.relative_to(ROOT).as_posix() or not isinstance(artifacts, list): + raise SystemExit("v0.2 manifest source/artifacts mismatch") +paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] +if len(paths) != len(artifacts) or len(paths) != len(set(paths)) or set(paths) != proposal_artifacts: + raise SystemExit("v0.2 manifest artifact set mismatch") +for entry in [source, *artifacts]: + relative = Path(entry.get("path", "")) + digest = entry.get("sha256", "") + if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)): + raise SystemExit(f"invalid v0.2 manifest entry: {entry}") + if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: + raise SystemExit(f"v0.2 manifest SHA-256 mismatch: {relative}") +print(f"Task discovery v0.2 proposal: positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}") + +# The approved Agent→Dispatcher failure fact is Mock-only. Its independent +# manifest must not alter the historical SaaS v0.1 or discovery v0.2 baselines. +mock_doc = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.md" +mock_schema_path = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.schema.json" +mock_schema = load_json(mock_schema_path) +embedded_mock_schema = ROOT / "contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json" +if embedded_mock_schema.read_bytes() != mock_schema_path.read_bytes(): + raise SystemExit("embedded Mock failure schema differs from its hashed project source") +Draft202012Validator.check_schema(mock_schema) +if schema_versions(mock_schema) != {"local-mock-recording-failure.v0.1"}: + raise SystemExit("unexpected local Mock recording-failure schema version") +mock_validator = Draft202012Validator(mock_schema, registry=registry, format_checker=FormatChecker()) +mock_examples = sorted(EXAMPLES.glob("local-mock-recording-failure-*-v0.1.json")) +mock_positive = mock_negative = 0 +for path in mock_examples: + sample = load_json(path) + if "invalid" in path.name: + try: + mock_validator.validate(sample) + except ValidationError: + mock_negative += 1 + else: + raise SystemExit(f"invalid Mock failure fixture passed: {path.relative_to(ROOT)}") + else: + mock_validator.validate(sample) + mock_positive += 1 +if mock_positive < 2 or mock_negative < 2: + raise SystemExit("missing positive/negative Mock recording-failure examples") +mock_manifest = load_json(ROOT / "docs/contracts/local-mock-recording-failure-manifest-v0.1.json") +if mock_manifest.get("manifest_version") != "local-mock-recording-failure-manifest.v0.1" or mock_manifest.get("hash_algorithm") != "SHA-256": + raise SystemExit("invalid Mock failure manifest version or hash algorithm") +mock_source = mock_manifest.get("source", {}) +mock_artifacts = mock_manifest.get("artifacts", []) +mock_paths = [entry.get("path") for entry in mock_artifacts if isinstance(entry, dict)] +required_mock_paths = {mock_schema_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in mock_examples)} +if mock_source.get("path") != mock_doc.relative_to(ROOT).as_posix() or len(mock_paths) != len(mock_artifacts) or len(mock_paths) != len(set(mock_paths)) or set(mock_paths) != required_mock_paths: + raise SystemExit("Mock failure manifest source/artifact set mismatch") +for entry in [mock_source, *mock_artifacts]: + relative = Path(entry.get("path", "")) + digest = entry.get("sha256", "") + if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)): + raise SystemExit(f"invalid Mock failure manifest entry: {entry}") + if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: + raise SystemExit(f"Mock failure manifest SHA-256 mismatch: {relative}") +print(f"Mock recording failure v0.1: positive={mock_positive}, negative={mock_negative}, manifest files={1 + len(mock_artifacts)}")