From 1d385e0c4a34cfcb1d02d476e88d6027d369d49d Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 29 Sep 2026 18:50:29 +0800 Subject: [PATCH] Freeze current project-local SaaS Dispatcher contract and examples --- contracts/contracts.go | 13 +- contracts/current_contract_test.go | 68 +++ contracts/current_read_test.go | 23 + contracts/current_topology_test.go | 59 +++ contracts/local/config-read.schema.json | 190 ++++++++ .../local/examples/config-read-error.json | 1 + .../local/examples/config-read-providers.json | 9 + .../local/examples/config-read-quota.json | 1 + contracts/local/examples/config-read-sip.json | 12 + .../local/examples/config-read-task-asr.json | 8 + .../local/examples/config-read-task-full.json | 15 + .../invalid/config-read-legacy-version.json | 1 + .../invalid/config-read-provider-ref.json | 1 + .../config-read-sip-missing-revision.json | 1 + .../invalid/config-read-string-tenant.json | 1 + .../config-read-task-asr-with-llm.json | 1 + .../invalid/mq-control-unapproved-policy.json | 1 + .../invalid/mq-legacy-schema-version.json | 1 + .../mq-result-failure-missing-message.json | 1 + .../invalid/mq-result-invented-identity.json | 1 + .../mq-result-invented-recording-state.json | 1 + .../examples/invalid/mq-string-tenant.json | 1 + .../invalid/mq-unapproved-result-event.json | 1 + .../invalid/task-discovery-old-snapshot.json | 1 + .../invalid/task-discovery-string-tenant.json | 1 + contracts/local/examples/mq-control-ack.json | 1 + contracts/local/examples/mq-control.json | 1 + contracts/local/examples/mq-execute-ack.json | 1 + .../local/examples/mq-execute-rejected.json | 1 + contracts/local/examples/mq-execute.json | 1 + .../examples/mq-result-no-recording.json | 1 + .../local/examples/mq-result-uploaded.json | 1 + contracts/local/examples/mq-sip-change.json | 1 + .../local/examples/task-discovery-end.json | 1 + .../local/examples/task-discovery-page.json | 1 + contracts/local/manifest.json | 11 + contracts/local/mq-topology.json | 30 ++ contracts/local/mq.schema.json | 105 +++++ contracts/local/task-discovery.schema.json | 36 ++ docs/plan-saas-dispatcher-v05-v0.1.md | 161 +++++++ docs/thirds/saas-dispatcher.md | 50 ++ docs/thirds/v0.5-proposal.md | 446 ++++++++++++++++++ internal/contract/current.go | 62 +++ internal/contract/current_test.go | 41 ++ scripts/check-current-contracts.py | 40 ++ scripts/check-current-contracts.sh | 5 + 46 files changed, 1409 insertions(+), 1 deletion(-) create mode 100644 contracts/current_contract_test.go create mode 100644 contracts/current_read_test.go create mode 100644 contracts/current_topology_test.go create mode 100644 contracts/local/config-read.schema.json create mode 100644 contracts/local/examples/config-read-error.json create mode 100644 contracts/local/examples/config-read-providers.json create mode 100644 contracts/local/examples/config-read-quota.json create mode 100644 contracts/local/examples/config-read-sip.json create mode 100644 contracts/local/examples/config-read-task-asr.json create mode 100644 contracts/local/examples/config-read-task-full.json create mode 100644 contracts/local/examples/invalid/config-read-legacy-version.json create mode 100644 contracts/local/examples/invalid/config-read-provider-ref.json create mode 100644 contracts/local/examples/invalid/config-read-sip-missing-revision.json create mode 100644 contracts/local/examples/invalid/config-read-string-tenant.json create mode 100644 contracts/local/examples/invalid/config-read-task-asr-with-llm.json create mode 100644 contracts/local/examples/invalid/mq-control-unapproved-policy.json create mode 100644 contracts/local/examples/invalid/mq-legacy-schema-version.json create mode 100644 contracts/local/examples/invalid/mq-result-failure-missing-message.json create mode 100644 contracts/local/examples/invalid/mq-result-invented-identity.json create mode 100644 contracts/local/examples/invalid/mq-result-invented-recording-state.json create mode 100644 contracts/local/examples/invalid/mq-string-tenant.json create mode 100644 contracts/local/examples/invalid/mq-unapproved-result-event.json create mode 100644 contracts/local/examples/invalid/task-discovery-old-snapshot.json create mode 100644 contracts/local/examples/invalid/task-discovery-string-tenant.json create mode 100644 contracts/local/examples/mq-control-ack.json create mode 100644 contracts/local/examples/mq-control.json create mode 100644 contracts/local/examples/mq-execute-ack.json create mode 100644 contracts/local/examples/mq-execute-rejected.json create mode 100644 contracts/local/examples/mq-execute.json create mode 100644 contracts/local/examples/mq-result-no-recording.json create mode 100644 contracts/local/examples/mq-result-uploaded.json create mode 100644 contracts/local/examples/mq-sip-change.json create mode 100644 contracts/local/examples/task-discovery-end.json create mode 100644 contracts/local/examples/task-discovery-page.json create mode 100644 contracts/local/manifest.json create mode 100644 contracts/local/mq-topology.json create mode 100644 contracts/local/mq.schema.json create mode 100644 contracts/local/task-discovery.schema.json create mode 100644 docs/plan-saas-dispatcher-v05-v0.1.md create mode 100644 docs/thirds/saas-dispatcher.md create mode 100644 docs/thirds/v0.5-proposal.md create mode 100644 internal/contract/current.go create mode 100644 internal/contract/current_test.go create mode 100644 scripts/check-current-contracts.py create mode 100644 scripts/check-current-contracts.sh diff --git a/contracts/contracts.go b/contracts/contracts.go index b3259b8..563fae2 100644 --- a/contracts/contracts.go +++ b/contracts/contracts.go @@ -11,11 +11,22 @@ import ( // 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 local/v0.1 local/v0.2 local/v0.3 local/v0.4 +//go:embed upstream local/v0.1 local/v0.2 local/v0.3 local/v0.4 local/*.json var Files embed.FS const SourceCommit = "v1" +// ReadCurrent reads the sole active project-local contract, with no fallback +// to a historical bundle if a name is missing or invalid. +func ReadCurrent(name string) ([]byte, error) { + switch name { + case "config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json": + return Files.ReadFile(path.Join("local", name)) + default: + return nil, fmt.Errorf("invalid current contract %q", name) + } +} + func ReadLocal(version, name string) ([]byte, error) { if (version != "v0.1" && version != "v0.2" && version != "v0.3" && version != "v0.4") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { return nil, fmt.Errorf("invalid project-local schema %q/%q", version, name) diff --git a/contracts/current_contract_test.go b/contracts/current_contract_test.go new file mode 100644 index 0000000..0cba0db --- /dev/null +++ b/contracts/current_contract_test.go @@ -0,0 +1,68 @@ +package contracts_test + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// The current, project-owned contracts have one schema per transport surface. +// Historical upstream bundles are not fallback validators for these inputs. +func TestCurrentContractExamples(t *testing.T) { + for _, surface := range []string{"config-read", "task-discovery", "mq"} { + t.Run(surface, func(t *testing.T) { + root := filepath.Join("local", surface+".schema.json") + raw, err := os.ReadFile(root) + if err != nil { + t.Fatal(err) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + const base = "https://go-sip.local/contracts/current/" + if err := compiler.AddResource(base+surface+".schema.json", value); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile(base + surface + ".schema.json") + if err != nil { + t.Fatal(err) + } + positives, err := filepath.Glob(filepath.Join("local", "examples", surface+"-*.json")) + if err != nil || len(positives) == 0 { + t.Fatalf("missing positive examples: %v", err) + } + negatives, err := filepath.Glob(filepath.Join("local", "examples", "invalid", surface+"-*.json")) + if err != nil || len(negatives) == 0 { + t.Fatalf("missing negative examples: %v", err) + } + for _, tc := range []struct { + files []string + valid bool + }{{positives, true}, {negatives, false}} { + for _, path := range tc.files { + t.Run(strings.TrimSuffix(filepath.Base(path), ".json"), func(t *testing.T) { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + if err := schema.Validate(value); (err == nil) != tc.valid { + t.Fatalf("valid=%v, validation error=%v", tc.valid, err) + } + }) + } + } + }) + } +} diff --git a/contracts/current_read_test.go b/contracts/current_read_test.go new file mode 100644 index 0000000..3ce98a1 --- /dev/null +++ b/contracts/current_read_test.go @@ -0,0 +1,23 @@ +package contracts_test + +import ( + "strings" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestCurrentContractRead(t *testing.T) { + for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json"} { + data, err := contracts.ReadCurrent(name) + if err != nil || len(data) == 0 { + t.Fatalf("read %s: %v", name, err) + } + } + for _, name := range []string{"", "v0.4/task-discovery-v0.4-proposal.schema.json", "../upstream/v1/mq.schema.json", "examples/mq-execute.json", "arbitrary.json"} { + _, err := contracts.ReadCurrent(name) + if err == nil || !strings.Contains(err.Error(), "invalid") { + t.Fatalf("unexpected read of %q: %v", name, err) + } + } +} diff --git a/contracts/current_topology_test.go b/contracts/current_topology_test.go new file mode 100644 index 0000000..a051e4c --- /dev/null +++ b/contracts/current_topology_test.go @@ -0,0 +1,59 @@ +package contracts_test + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestCurrentContractTopology(t *testing.T) { + type route struct { + RoutingKey string `json:"routing_key"` + BindingKey string `json:"binding_key"` + Queue string `json:"queue"` + } + var topology struct { + Ownership string `json:"ownership"` + Dispatcher struct { + Exchange string `json:"exchange"` + Control route `json:"control"` + Task route `json:"task"` + } `json:"dispatcher"` + SaaS struct { + Exchange string `json:"exchange"` + Result route `json:"result"` + } `json:"saas"` + } + data, err := os.ReadFile("local/mq-topology.json") + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &topology); err != nil { + t.Fatal(err) + } + if topology.Ownership != "saas" || topology.Dispatcher.Exchange != "agent-call.dispatchers.v1" || topology.SaaS.Exchange != "agent-call.saas.v1" { + t.Fatalf("unapproved owner/exchanges: %+v", topology) + } + if topology.Dispatcher.Control.Queue != "agent-call.d..control.v1" || topology.Dispatcher.Control.BindingKey != "d..control.in" { + t.Fatalf("wrong control queue or exact binding: %+v", topology.Dispatcher.Control) + } + if topology.Dispatcher.Task.Queue != "agent-call.d..task..v1" || topology.Dispatcher.Task.BindingKey != "d..task..in" { + t.Fatalf("wrong per-task queue or binding: %+v", topology.Dispatcher.Task) + } + result := topology.SaaS.Result + if result.Queue != "agent-call.saas.events.v1" || result.RoutingKey != "d..out" || result.BindingKey != result.RoutingKey { + t.Fatalf("SaaS must consume a shared queue bound exactly to each D: %+v", result) + } + for _, r := range []route{topology.Dispatcher.Control, topology.Dispatcher.Task, result} { + if r.RoutingKey != r.BindingKey || strings.ContainsAny(r.BindingKey, "*#") { + t.Fatalf("route must have exact binding: %+v", r) + } + } + for _, id := range []string{"d-1", "d-2"} { + key := strings.ReplaceAll(result.RoutingKey, "", id) + if len(key) > 255 || key == result.BindingKey { + t.Fatalf("invalid dispatcher-specific binding: %q", key) + } + } +} diff --git a/contracts/local/config-read.schema.json b/contracts/local/config-read.schema.json new file mode 100644 index 0000000..171ace8 --- /dev/null +++ b/contracts/local/config-read.schema.json @@ -0,0 +1,190 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/current/config-read.schema.json", + "title": "Project-local read-only Dispatcher configuration; external SaaS compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/sip"}, + {"$ref": "#/$defs/providers"}, + {"$ref": "#/$defs/task"}, + {"$ref": "#/$defs/quota"}, + {"$ref": "#/$defs/error"} + ], + "$defs": { + "dispatcher_id": {"type": "string", "format": "uuid"}, + "tenant_id": {"type": "integer", "minimum": 1}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "sip": { + "type": "object", "additionalProperties": false, + "required": ["resource", "dispatcher_id", "revision", "trunks"], + "properties": { + "resource": {"const": "sip_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "revision": {"type": "integer", "minimum": 1}, + "trunks": {"type": "array", "items": {"$ref": "#/$defs/trunk"}} + } + }, + "trunk": { + "type": "object", "additionalProperties": false, + "required": ["trunk_id", "provider_id", "codec", "dial_prefix", "enabled", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_profiles", "schedule"], + "properties": { + "trunk_id": {"type": "string", "minLength": 1}, + "provider_id": {"type": "string", "minLength": 1}, + "codec": {"const": "PCMA"}, + "dial_prefix": {"type": "string"}, + "enabled": {"type": "boolean"}, + "server_host": {"type": "string", "minLength": 1}, + "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", "items": {"type": "object", "additionalProperties": false, "required": ["caller_profile_id", "caller_id"], "properties": {"caller_profile_id": {"type": "string", "minLength": 1}, "caller_id": {"type": "string", "minLength": 1}}}}, + "schedule": {"$ref": "#/$defs/weekly_schedule"} + } + }, + "providers": { + "type": "object", "additionalProperties": false, + "required": ["resource", "dispatcher_id", "providers"], + "properties": { + "resource": {"const": "ai_providers"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}} + } + }, + "provider": { + "type": "object", "additionalProperties": false, + "required": ["provider_ref", "role", "enabled", "adapter", "endpoint", "credential"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1}, + "role": {"enum": ["asr", "llm", "tts"]}, + "enabled": {"type": "boolean"}, + "adapter": {"type": "string", "minLength": 1}, + "endpoint": {"type": "string", "minLength": 1}, + "credential": {"type": "string", "minLength": 1, "$comment": "Value is supplied verbatim to the provider SDK. Never log its content."} + } + }, + "task": { + "type": "object", "additionalProperties": false, + "required": ["resource", "dispatcher_id", "tenant_id", "task_id", "task_revision", "status", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "caller_profile_id", "allowed_trunk_ids", "schedule", "agent"], + "properties": { + "resource": {"const": "task_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/tenant_id"}, + "task_id": {"$ref": "#/$defs/task_id"}, + "task_revision": {"type": "integer", "minimum": 1}, + "status": {"enum": ["running", "paused", "stopped"]}, + "name": {"type": "string", "minLength": 1}, + "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}, + "caller_profile_id": {"type": "string", "minLength": 1}, + "allowed_trunk_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, + "schedule": {"$ref": "#/$defs/task_schedule"}, + "agent": {"$ref": "#/$defs/agent"} + } + }, + "quota": { + "type": "object", "additionalProperties": false, + "required": ["resource", "dispatcher_id", "tenant_id", "quota_revision", "max_concurrent_calls"], + "properties": { + "resource": {"const": "tenant_quota"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"$ref": "#/$defs/tenant_id"}, + "quota_revision": {"type": "integer", "minimum": 1}, + "max_concurrent_calls": {"type": "integer", "minimum": 0} + } + }, + "error": { + "type": "object", "additionalProperties": false, + "required": ["resource", "error"], + "properties": { + "resource": {"const": "error"}, + "error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}} + } + }, + "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": {"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)$"}}}}, + "agent": { + "type": "object", "additionalProperties": false, + "required": ["immutable", "mode", "asr"], + "properties": { + "immutable": {"const": true}, + "mode": {"enum": ["asr_only", "full_ai"]}, + "asr": {"$ref": "#/$defs/asr"}, + "llm": {"$ref": "#/$defs/llm"}, + "tts": {"$ref": "#/$defs/tts"}, + "prompt": {"$ref": "#/$defs/prompt"}, + "conversation": {"$ref": "#/$defs/conversation"} + }, + "allOf": [ + {"if": {"properties": {"mode": {"const": "full_ai"}}}, "then": {"required": ["llm", "tts", "prompt", "conversation"]}}, + {"if": {"properties": {"mode": {"const": "asr_only"}}}, "then": {"not": {"anyOf": [{"required": ["llm"]}, {"required": ["tts"]}, {"required": ["prompt"]}]}}} + ] + }, + "asr": { + "type": "object", "additionalProperties": false, + "required": ["provider_ref", "language", "input"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1}, + "model": {"type": "string", "minLength": 1}, + "language": {"type": "string", "minLength": 1}, + "interim": {"type": "boolean"}, + "timeout_ms": {"type": "integer", "minimum": 1}, + "input": {"$ref": "#/$defs/audio_input"} + } + }, + "audio_input": { + "type": "object", "additionalProperties": false, + "required": ["encoding", "sample_rate_hz", "channels", "sample_width_bytes"], + "properties": { + "encoding": {"const": "pcm_s16le"}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}, "sample_width_bytes": {"const": 2} + } + }, + "llm": { + "type": "object", "additionalProperties": false, + "required": ["provider_ref", "model"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "temperature": {"type": "number", "minimum": 0, "maximum": 2}, "max_tokens": {"type": "integer", "minimum": 1}, "timeout_ms": {"type": "integer", "minimum": 1} + } + }, + "tts": { + "type": "object", "additionalProperties": false, + "required": ["provider_ref", "model", "voice", "format"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "voice": {"type": "string", "minLength": 1}, "speed": {"type": "number", "minimum": 0.25, "maximum": 3}, "timeout_ms": {"type": "integer", "minimum": 1}, "format": {"type": "object", "additionalProperties": false, "required": ["encoding", "sample_rate_hz", "channels"], "properties": {"encoding": {"enum": ["pcm_s16le", "pcma"]}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}}} + } + }, + "prompt": { + "type": "object", "additionalProperties": false, + "required": ["text", "allowed_variables"], + "properties": {"text": {"type": "string", "minLength": 1}, "allowed_variables": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "max_bytes": {"type": "integer", "minimum": 1}} + }, + "conversation": { + "type": "object", "additionalProperties": false, + "properties": {"opening": {"type": "string"}, "hangup_keywords": {"type": "array", "items": {"type": "string", "minLength": 1}}, "allow_interrupt": {"type": "boolean"}, "silence_timeout_ms": {"type": "integer", "minimum": 1}, "max_duration_ms": {"type": "integer", "minimum": 1}, "max_turns": {"type": "integer", "minimum": 1}, "sentence_max_chars": {"type": "integer", "minimum": 1}, "max_pending_audio_chunks": {"type": "integer", "minimum": 1}} + } + } +} diff --git a/contracts/local/examples/config-read-error.json b/contracts/local/examples/config-read-error.json new file mode 100644 index 0000000..111126f --- /dev/null +++ b/contracts/local/examples/config-read-error.json @@ -0,0 +1 @@ +{"resource":"error","error":{"code":"resource_not_found","message":"The requested resource is not available"}} diff --git a/contracts/local/examples/config-read-providers.json b/contracts/local/examples/config-read-providers.json new file mode 100644 index 0000000..cf89a5c --- /dev/null +++ b/contracts/local/examples/config-read-providers.json @@ -0,0 +1,9 @@ +{ + "resource":"ai_providers", + "dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6", + "providers":[ + {"provider_ref":"asr-example","role":"asr","enabled":true,"adapter":"volcengine_asr","endpoint":"https://asr.example.invalid","credential":"example-only-not-a-real-secret"}, + {"provider_ref":"llm-example","role":"llm","enabled":true,"adapter":"openai_compatible","endpoint":"https://llm.example.invalid/v1","credential":"example-only-not-a-real-secret"}, + {"provider_ref":"tts-example","role":"tts","enabled":true,"adapter":"volcengine_tts","endpoint":"https://tts.example.invalid","credential":"example-only-not-a-real-secret"} + ] +} diff --git a/contracts/local/examples/config-read-quota.json b/contracts/local/examples/config-read-quota.json new file mode 100644 index 0000000..52176ca --- /dev/null +++ b/contracts/local/examples/config-read-quota.json @@ -0,0 +1 @@ +{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"quota_revision":1,"max_concurrent_calls":3} diff --git a/contracts/local/examples/config-read-sip.json b/contracts/local/examples/config-read-sip.json new file mode 100644 index 0000000..588c12f --- /dev/null +++ b/contracts/local/examples/config-read-sip.json @@ -0,0 +1,12 @@ +{ + "resource":"sip_config", + "dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6", + "revision":8, + "trunks":[{ + "trunk_id":"trunk-mock","provider_id":"provider-mock","codec":"PCMA","dial_prefix":"","enabled":true, + "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":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]}} + }] +} diff --git a/contracts/local/examples/config-read-task-asr.json b/contracts/local/examples/config-read-task-asr.json new file mode 100644 index 0000000..796aff2 --- /dev/null +++ b/contracts/local/examples/config-read-task-asr.json @@ -0,0 +1,8 @@ +{ + "resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001, + "task_id":"task-asr","task_revision":1,"status":"running","name":"ASR example", + "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":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]}, + "agent":{"immutable":true,"mode":"asr_only","asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":false,"timeout_ms":5000}} +} diff --git a/contracts/local/examples/config-read-task-full.json b/contracts/local/examples/config-read-task-full.json new file mode 100644 index 0000000..25be20c --- /dev/null +++ b/contracts/local/examples/config-read-task-full.json @@ -0,0 +1,15 @@ +{ + "resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001, + "task_id":"task-full","task_revision":2,"status":"running","name":"Full AI example", + "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":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":["2026-10-01"]}, + "agent":{ + "immutable":true,"mode":"full_ai", + "asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":true,"timeout_ms":5000}, + "llm":{"provider_ref":"llm-example","model":"example-chat","temperature":0,"max_tokens":256,"timeout_ms":5000}, + "tts":{"provider_ref":"tts-example","model":"example-tts","voice":"example-neutral","speed":1,"format":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1},"timeout_ms":5000}, + "prompt":{"text":"Example only","allowed_variables":[],"max_bytes":32768}, + "conversation":{"opening":"Example greeting","hangup_keywords":["不用了"],"allow_interrupt":false,"silence_timeout_ms":3000,"max_duration_ms":120000,"max_turns":20,"sentence_max_chars":80,"max_pending_audio_chunks":32} + } +} diff --git a/contracts/local/examples/invalid/config-read-legacy-version.json b/contracts/local/examples/invalid/config-read-legacy-version.json new file mode 100644 index 0000000..83abd24 --- /dev/null +++ b/contracts/local/examples/invalid/config-read-legacy-version.json @@ -0,0 +1 @@ +{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"quota_revision":1,"max_concurrent_calls":3,"schema_version":"v0.5"} diff --git a/contracts/local/examples/invalid/config-read-provider-ref.json b/contracts/local/examples/invalid/config-read-provider-ref.json new file mode 100644 index 0000000..398d065 --- /dev/null +++ b/contracts/local/examples/invalid/config-read-provider-ref.json @@ -0,0 +1 @@ +{"resource":"ai_providers","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","providers":[{"provider_ref":"asr-example","role":"asr","enabled":true,"adapter":"volcengine_asr","endpoint":"https://asr.example.invalid","credential_ref":"must-not-be-referenced"}]} diff --git a/contracts/local/examples/invalid/config-read-sip-missing-revision.json b/contracts/local/examples/invalid/config-read-sip-missing-revision.json new file mode 100644 index 0000000..5680b61 --- /dev/null +++ b/contracts/local/examples/invalid/config-read-sip-missing-revision.json @@ -0,0 +1 @@ +{"resource":"sip_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","trunks":[]} diff --git a/contracts/local/examples/invalid/config-read-string-tenant.json b/contracts/local/examples/invalid/config-read-string-tenant.json new file mode 100644 index 0000000..1e4992f --- /dev/null +++ b/contracts/local/examples/invalid/config-read-string-tenant.json @@ -0,0 +1 @@ +{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":"1001","quota_revision":1,"max_concurrent_calls":3} diff --git a/contracts/local/examples/invalid/config-read-task-asr-with-llm.json b/contracts/local/examples/invalid/config-read-task-asr-with-llm.json new file mode 100644 index 0000000..fd43173 --- /dev/null +++ b/contracts/local/examples/invalid/config-read-task-asr-with-llm.json @@ -0,0 +1 @@ +{"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"task_id":"task-asr","task_revision":1,"status":"running","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":null,"ends_at":null,"weekly_windows":{"monday":[],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]},"agent":{"immutable":true,"mode":"asr_only","asr":{"provider_ref":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2}},"llm":{"provider_ref":"llm-example","model":"must-not-be-used"}}} diff --git a/contracts/local/examples/invalid/mq-control-unapproved-policy.json b/contracts/local/examples/invalid/mq-control-unapproved-policy.json new file mode 100644 index 0000000..bb301e2 --- /dev/null +++ b/contracts/local/examples/invalid/mq-control-unapproved-policy.json @@ -0,0 +1 @@ +{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"pause","reason":"operator","options":{"active_call_policy":"ignore"}}} diff --git a/contracts/local/examples/invalid/mq-legacy-schema-version.json b/contracts/local/examples/invalid/mq-legacy-schema-version.json new file mode 100644 index 0000000..d3288a6 --- /dev/null +++ b/contracts/local/examples/invalid/mq-legacy-schema-version.json @@ -0,0 +1 @@ +{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","schema_version":"v0.5","payload":{"task_id":"task-asr","callee":"15003164745"}} diff --git a/contracts/local/examples/invalid/mq-result-failure-missing-message.json b/contracts/local/examples/invalid/mq-result-failure-missing-message.json new file mode 100644 index 0000000..025cbf6 --- /dev/null +++ b/contracts/local/examples/invalid/mq-result-failure-missing-message.json @@ -0,0 +1 @@ +{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"transcript":[],"opt_out":false,"recording":{}}} diff --git a/contracts/local/examples/invalid/mq-result-invented-identity.json b/contracts/local/examples/invalid/mq-result-invented-identity.json new file mode 100644 index 0000000..1fa31d6 --- /dev/null +++ b/contracts/local/examples/invalid/mq-result-invented-identity.json @@ -0,0 +1 @@ +{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","call_id":"legacy-call-id","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"reason_message":"storage unavailable","transcript":[],"opt_out":false,"recording":{}}} diff --git a/contracts/local/examples/invalid/mq-result-invented-recording-state.json b/contracts/local/examples/invalid/mq-result-invented-recording-state.json new file mode 100644 index 0000000..5b53df1 --- /dev/null +++ b/contracts/local/examples/invalid/mq-result-invented-recording-state.json @@ -0,0 +1 @@ +{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"reason_message":"storage unavailable","transcript":[],"opt_out":false,"recording":{"status":"unavailable"}}} diff --git a/contracts/local/examples/invalid/mq-string-tenant.json b/contracts/local/examples/invalid/mq-string-tenant.json new file mode 100644 index 0000000..e904b4b --- /dev/null +++ b/contracts/local/examples/invalid/mq-string-tenant.json @@ -0,0 +1 @@ +{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":"1001","issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","callee":"15003164745"}} diff --git a/contracts/local/examples/invalid/mq-unapproved-result-event.json b/contracts/local/examples/invalid/mq-unapproved-result-event.json new file mode 100644 index 0000000..2dfc9f2 --- /dev/null +++ b/contracts/local/examples/invalid/mq-unapproved-result-event.json @@ -0,0 +1 @@ +{"event_id":"call-result-example","event_type":"call.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{}} diff --git a/contracts/local/examples/invalid/task-discovery-old-snapshot.json b/contracts/local/examples/invalid/task-discovery-old-snapshot.json new file mode 100644 index 0000000..88e6be6 --- /dev/null +++ b/contracts/local/examples/invalid/task-discovery-old-snapshot.json @@ -0,0 +1 @@ +{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","snapshot_id":"legacy","tasks":[]} diff --git a/contracts/local/examples/invalid/task-discovery-string-tenant.json b/contracts/local/examples/invalid/task-discovery-string-tenant.json new file mode 100644 index 0000000..7a83acb --- /dev/null +++ b/contracts/local/examples/invalid/task-discovery-string-tenant.json @@ -0,0 +1 @@ +{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","tasks":[{"task_id":"task-asr","tenant_id":"1001","status":"running","task_revision":1}]} diff --git a/contracts/local/examples/mq-control-ack.json b/contracts/local/examples/mq-control-ack.json new file mode 100644 index 0000000..f0fa027 --- /dev/null +++ b/contracts/local/examples/mq-control-ack.json @@ -0,0 +1 @@ +{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"payload":{"status":"applied"}} diff --git a/contracts/local/examples/mq-control.json b/contracts/local/examples/mq-control.json new file mode 100644 index 0000000..9057084 --- /dev/null +++ b/contracts/local/examples/mq-control.json @@ -0,0 +1 @@ +{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"pause","reason":"operator","options":{"active_call_policy":"drain"}}} diff --git a/contracts/local/examples/mq-execute-ack.json b/contracts/local/examples/mq-execute-ack.json new file mode 100644 index 0000000..84faaf0 --- /dev/null +++ b/contracts/local/examples/mq-execute-ack.json @@ -0,0 +1 @@ +{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"dispatched"}} diff --git a/contracts/local/examples/mq-execute-rejected.json b/contracts/local/examples/mq-execute-rejected.json new file mode 100644 index 0000000..b2a4fc2 --- /dev/null +++ b/contracts/local/examples/mq-execute-rejected.json @@ -0,0 +1 @@ +{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"rejected","reason_code":null,"reason_message":"outside permitted time"}} diff --git a/contracts/local/examples/mq-execute.json b/contracts/local/examples/mq-execute.json new file mode 100644 index 0000000..6337e31 --- /dev/null +++ b/contracts/local/examples/mq-execute.json @@ -0,0 +1 @@ +{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","callee":"15003164745"}} diff --git a/contracts/local/examples/mq-result-no-recording.json b/contracts/local/examples/mq-result-no-recording.json new file mode 100644 index 0000000..527fb72 --- /dev/null +++ b/contracts/local/examples/mq-result-no-recording.json @@ -0,0 +1 @@ +{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"reason_message":"recording storage unavailable","transcript":[],"opt_out":false,"recording":{}}} diff --git a/contracts/local/examples/mq-result-uploaded.json b/contracts/local/examples/mq-result-uploaded.json new file mode 100644 index 0000000..8e81c17 --- /dev/null +++ b/contracts/local/examples/mq-result-uploaded.json @@ -0,0 +1 @@ +{"event_id":"call-result-example-2","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","caller_profile_id":"caller-profile-mock","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"answered","reason_code":null,"reason_message":"answered, SIP status unavailable in isolated Mock","transcript":[{"turn_id":"turn-1","segment_id":"segment-1","role":"user","text":"Example utterance","start_ms":0,"end_ms":1000}],"opt_out":true,"recording":{"status":"uploaded","bucket":"example-bucket","object_key":"example/recording.wav","format":"wav","channels":1,"sample_rate_hz":16000,"duration_ms":60000,"size_bytes":64000,"checksum_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}} diff --git a/contracts/local/examples/mq-sip-change.json b/contracts/local/examples/mq-sip-change.json new file mode 100644 index 0000000..95920ea --- /dev/null +++ b/contracts/local/examples/mq-sip-change.json @@ -0,0 +1 @@ +{"event_id":"sip-change-example","event_type":"sip.config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","payload":{"trunk_id":"trunk-mock","change_type":"disabled","revision":8}} diff --git a/contracts/local/examples/task-discovery-end.json b/contracts/local/examples/task-discovery-end.json new file mode 100644 index 0000000..6b297d7 --- /dev/null +++ b/contracts/local/examples/task-discovery-end.json @@ -0,0 +1 @@ +{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-end-token","tasks":[]} diff --git a/contracts/local/examples/task-discovery-page.json b/contracts/local/examples/task-discovery-page.json new file mode 100644 index 0000000..5be8636 --- /dev/null +++ b/contracts/local/examples/task-discovery-page.json @@ -0,0 +1 @@ +{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","tasks":[{"task_id":"task-asr","tenant_id":1001,"status":"running","task_revision":1}]} diff --git a/contracts/local/manifest.json b/contracts/local/manifest.json new file mode 100644 index 0000000..141bb39 --- /dev/null +++ b/contracts/local/manifest.json @@ -0,0 +1,11 @@ +{ + "scope": "project-local-current", + "status": "isolated-mock-only-external-unverified", + "sources": { + "docs/thirds/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada", + "docs/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87", + "docs/thirds/saas-dispatcher.md": "0ee54323ac815ced2327dc8f37140fca53605466e8f78cf8183223326075ca03" + }, + "bundle_sha256": "4ff0afffa2c865050091c042d8f98bbe344ba9a4f4c3652e721a5077217722ed", + "bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json" +} diff --git a/contracts/local/mq-topology.json b/contracts/local/mq-topology.json new file mode 100644 index 0000000..39c155e --- /dev/null +++ b/contracts/local/mq-topology.json @@ -0,0 +1,30 @@ +{ + "ownership": "saas", + "dispatcher": { + "exchange": "agent-call.dispatchers.v1", + "exchange_type": "topic", + "durable": true, + "control": { + "routing_key": "d..control.in", + "binding_key": "d..control.in", + "queue": "agent-call.d..control.v1" + }, + "task": { + "routing_key": "d..task..in", + "binding_key": "d..task..in", + "queue": "agent-call.d..task..v1" + } + }, + "saas": { + "exchange": "agent-call.saas.v1", + "exchange_type": "topic", + "durable": true, + "result": { + "routing_key": "d..out", + "binding_key": "d..out", + "queue": "agent-call.saas.events.v1" + } + }, + "dead_letter_exchange": "agent-call.dead-letter.v1", + "provisioning_note": "SaaS owns exchanges, queues and exact bindings. For every active dispatcher bind its exact d..out key to the same SaaS result queue; Dispatcher has no configure permission. The queue name is the isolated Mock fixture, not a requirement on SaaS deployment naming." +} diff --git a/contracts/local/mq.schema.json b/contracts/local/mq.schema.json new file mode 100644 index 0000000..0151c9a --- /dev/null +++ b/contracts/local/mq.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/current/mq.schema.json", + "title": "Project-local Dispatcher MQ messages; external SaaS compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/sip_change"}, + {"$ref": "#/$defs/control_in"}, + {"$ref": "#/$defs/control_ack"}, + {"$ref": "#/$defs/execute_in"}, + {"$ref": "#/$defs/execute_ack"}, + {"$ref": "#/$defs/execute_result"} + ], + "$defs": { + "event_id": {"type": "string", "minLength": 1}, + "dispatcher_id": {"type": "string", "format": "uuid"}, + "tenant_id": {"type": "integer", "minimum": 1}, + "issued_at": {"type": "string", "format": "date-time"}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "sip_change": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, + "event_type": {"const": "sip.config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "payload": {"type": "object", "additionalProperties": false, "required": ["trunk_id", "change_type", "revision"], "properties": { + "trunk_id": {"type": "string", "minLength": 1}, "change_type": {"enum": ["enabled", "disabled", "removed", "created"]}, "revision": {"type": "integer", "minimum": 1} + }} + } + }, + "control_in": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"}, + "payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "action", "reason"], "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, "action": {"enum": ["pause", "resume", "stop"]}, "reason": {"type": "string", "minLength": 1}, + "options": {"type": "object", "additionalProperties": false, "properties": {"active_call_policy": {"enum": ["drain", "hangup"]}}} + }} + } + }, + "control_ack": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, + "payload": {"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"type": "string", "minLength": 1}}} + } + }, + "execute_in": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"}, + "payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "callee"], "properties": {"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1}}} + } + }, + "execute_ack": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"}, + "payload": {"oneOf": [ + {"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"const": "dispatched"}}}, + {"type": "object", "additionalProperties": false, "required": ["status", "reason_code", "reason_message"], "properties": {"status": {"const": "rejected"}, "reason_code": {"type": "null"}, "reason_message": {"type": "string", "minLength": 1}}} + ]} + } + }, + "execute_result": { + "type": "object", "additionalProperties": false, + "required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"], + "properties": { + "event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute.result"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"}, + "payload": {"type": "object", "additionalProperties": false, + "required": ["task_id", "caller_profile_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "transcript", "opt_out", "recording"], + "properties": { + "task_id": {"$ref": "#/$defs/task_id"}, "caller_profile_id": {"type": "string", "minLength": 1}, "callee": {"type": "string", "minLength": 1}, "trunk_id": {"type": "string", "minLength": 1}, + "started_at": {"$ref": "#/$defs/issued_at"}, "ended_at": {"$ref": "#/$defs/issued_at"}, "duration_ms": {"type": "integer", "minimum": 0}, + "outcome": {"enum": ["answered", "no_answer", "failed"]}, + "reason_code": {"type": ["integer", "null"], "minimum": 100, "maximum": 699}, + "reason_message": {"type": "string", "minLength": 1}, + "transcript": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"], "properties": { + "turn_id": {"type": "string", "minLength": 1}, "segment_id": {"type": "string", "minLength": 1}, "role": {"enum": ["user", "assistant"]}, "text": {"type": "string"}, "start_ms": {"type": "integer", "minimum": 0}, "end_ms": {"type": "integer", "minimum": 0} + }}}, + "opt_out": {"type": "boolean"}, + "recording": {"oneOf": [ + {"type": "object", "additionalProperties": false, "maxProperties": 0}, + {"$ref": "#/$defs/recording_uploaded"} + ]} + }, + "allOf": [ + {"if": {"properties": {"reason_code": {"type": "null"}}, "required": ["reason_code"]}, "then": {"required": ["reason_message"]}} + ] + } + } + }, + "recording_uploaded": { + "type": "object", "additionalProperties": false, + "required": ["status", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"], + "properties": { + "status": {"const": "uploaded"}, "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": "^[0-9a-f]{64}$"} + } + } + } +} diff --git a/contracts/local/task-discovery.schema.json b/contracts/local/task-discovery.schema.json new file mode 100644 index 0000000..b4d33d0 --- /dev/null +++ b/contracts/local/task-discovery.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/current/task-discovery.schema.json", + "title": "Project-local Dispatcher task list; external SaaS compatibility unverified", + "oneOf": [{"$ref": "#/$defs/page"}, {"$ref": "#/$defs/error"}], + "$defs": { + "page": { + "type": "object", "additionalProperties": false, + "required": ["dispatcher_id", "cursor", "tasks"], + "properties": { + "dispatcher_id": {"type": "string", "format": "uuid"}, + "cursor": {"type": "string", "minLength": 1}, + "tasks": {"type": "array", "items": {"$ref": "#/$defs/task"}} + }, + "$comment": "A page containing zero tasks and a cursor ends enumeration. Non-empty short pages never end enumeration. Persist non-empty pages before using their returned cursor." + }, + "task": { + "type": "object", "additionalProperties": false, + "required": ["task_id", "tenant_id", "status", "task_revision"], + "properties": { + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "tenant_id": {"type": "integer", "minimum": 1}, + "status": {"enum": ["running", "paused", "stopped"]}, + "task_revision": {"type": "integer", "minimum": 1} + } + }, + "error": { + "type": "object", "additionalProperties": false, + "required": ["resource", "error"], + "properties": { + "resource": {"const": "error"}, + "error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}} + } + } + } +} diff --git a/docs/plan-saas-dispatcher-v05-v0.1.md b/docs/plan-saas-dispatcher-v05-v0.1.md new file mode 100644 index 0000000..4928f28 --- /dev/null +++ b/docs/plan-saas-dispatcher-v05-v0.1.md @@ -0,0 +1,161 @@ +# SaaS↔Dispatcher 通信调整与去版本标识计划 + +## 1. 已确认范围与完成定义 + +### 1.1 依据及优先级 + +1. 用户已确认 [`docs/thirds/v0.5-proposal.md`](thirds/v0.5-proposal.md) 是与 SaaS 负责人最终协商的数据通信结构。文件名中的 `proposal` 不再表示整个方向待批准;现有实现、历史计划和旧 Schema 与之冲突时,以该文档为准。本轮逐项补充确认以 §3.1–§3.2 为最新规则;原始通信文档本轮不修改,后续由 P01 同步这些已确认内容,不重新审批已解决问题。 +2. 用户补充确认:**项目尚未发版,移除自有代码、文件名及目录名中用于区分实现代次的 Vx 标识,禁止版本迭代。** 不再新增另一代实现、另一套版本化契约、兼容入口或自动回退;保留唯一现行实现。 +3. **通信名称与实现代次分开处理**:MQ exchange、queue、routing/binding key 中已约定的版本标识固定为 `v1`,HTTP `/internal/v1/dispatcher/...` 路径保持双方约定。它们不随文件、代码或 Schema 的变化递增,也不能在清理代码名称时被误删。 +4. 第三方依赖及其 module/import 路径版本、Go/工具链版本、HTTP/AMQP/UUID 等标准中的版本标识,以及业务 `revision`、配置身份、消息身份和数据迁移顺序号,不属于实现代次清理。保留这些值不等于允许项目版本迭代。 +5. 文档内部真正互相矛盾或不足以确定执行行为的部分,按 §3 定点确认;不能以“当前代码需要”为由要求 SaaS 恢复已取消字段,也不重新审批已确认方向。 + +### 1.2 本轮与后续实施的边界 + +- **本轮仅审查、修订本计划**;不修改代码、Schema、样例、配置、其他文档或已有数据,不执行任何重命名。当前计划文件名保留,后续去版本标识时统一改名。 +- 后续实施覆盖 MQ、五类只读 HTTP、任务/控制/结果处理、Agent 执行快照,以及所有相关自有文件和标识;不是只替换 MQ 字符串。 +- 单节点、单 Agent、单 Cell、单租户仍是本地验收范围。真实 SaaS/management、真实 MQ、供应商、ECS、生产切换和真实拨号分别授权、验收,不能用 Mock 结果代替。 +- 后续完成条件:受影响的协议歧义已有明确答案,唯一现行契约与实现一致,§5 工作包完成,§6 验收通过。已读文档、已有 Mock、改完名称均不等于完成通信调整。 + +## 2. 审查结论与计划纠正 + +以下是对原计划的审查结果;“纠正”指本计划已修订,**不表示实现已修复**。 + +| 编号 | 级别 | 原计划问题 | 本计划的纠正 | +| --- | --- | --- | --- | +| R01 | 高 | §1/P01 仍要求“独立版本”“版本化机器契约”,并讨论将来版本升级,与未发版、禁止版本迭代冲突。 | 使用唯一无代次名称的当前契约、类型、校验器和清单;直接修正当前内容并更新来源/hash,不创建下一版本,不按版本号分支解析。 | +| R02 | 高 | 只列 MQ `.v3`→`.v1`,没有覆盖 Go 符号、文件/目录、Proto、生成物、Schema 引用、SQL、脚本及文档名称。 | 增加 §4 全范围清理和 §6 残留检查;通信固定 `v1` 是明确例外,不是保留 `V3Broker` 等代码名的理由。 | +| R03 | 高 | C02–C05/P03/P05 将旧实现要求的 `tenant_key`、`agent_version_id`、SIP revision、授权期限等缺失一概当成对方应补齐的字段;还将明文 `credential` 误设计为凭据引用。 | 按最终字段重整解析、持久绑定和 Agent 交付。用户已确认 `credential` 直接返回明文凭据,按供应商 SDK 要求使用,不需要 ref、引用解析、凭据交换服务或额外安全架构。旧字段不再是隐含入站必填项;既有归属与实际加载检查不变,不伪造缺失值或新增对外字段。 | +| R04 | 高 | 对 `call.execute` 回执、`call.execute.result`、`recording.uploaded` 的处理仍偏向保留旧路径,缺少明确的对外事件替换边界。 | 以最终文档确定唯一对外事件清单,调整信封及关联方式;旧 `command.result`、`call.result` 和分散事件不能作为别名或并行通知继续外发。录音上传与待交付事实仍须可靠保存。 | +| R05 | 中 | C01–C09 全部绑定“双方确认”,把 JSON 排版错误、Mock 引用值不一致、旧字段删除,与真实协议歧义混为同一总阻塞。 | 已确认规则列入 §3.1–§3.2,不再列为待批准;本轮待确认问题已按 §3.3 关闭,不将整项工作重新置于待批准状态;未来发现新的真实缺口才定点确认。 | +| R06 | 中 | P02 把启动被动检查描述成可以核验全部绑定。当前 AMQP 被动声明不能枚举或证明完整绑定关系。 | 区分 exchange/queue 存在性检查、实际消息路由测试和 mandatory/return/confirm;不能把 exchange 存在或 confirm 成功写成指定队列已收到。 | +| R07 | 高 | 未处理改名碰撞、嵌入/生成/hash 引用及持久数据,直接去后缀可能覆盖现有同名实现或使恢复入口失效。 | 大规模清理前建分支、做旧→新映射并按职责合并;不覆盖同名文件,不自动清库,不删除未交付执行/上传/outbox 记录;见 §4.3。 | + +## 3. 已确认规则与确认状态 + +### 3.1 已确认,不再重复询问 + +| 编号 | 事项 | 明确规则及实现边界 | +| --- | --- | --- | +| K01 队列组织 | 外呼队列与原来一致;SaaS 共用一个结果队列。 | 沿用现有每 D 独立控制队列、按任务消费外呼的组织方式,外呼路由 `d..task..in`、队列 `agent-call.d..task..v1`;不改成按租户混合消费。取消“每 D 独立结果队列”的目标,SaaS 将结果收进同一个队列。D 身份、任务归属和精确路由仍保留;SaaS 管理队列/绑定,D 不建队。所有版本后缀固定 v1。 | +| K02 身份与结构字段 | tenant_id 使用数字;schema_version 删除。 | HTTP/MQ 相应字段统一校验数字类型,不再接受字符串租户 ID 作为新接口格式;移除 schema_version 字段及其版本选择分支,不替换成另一个固定版本值。消息身份仍保留,不能因删除结构版本字段而破坏内部幂等。 | +| K03 任务发现 | 逐页读取返回的 cursor;带 cursor 且任务数量为零的响应表示已取全。 | 不因非空页数量较少就判断结束;非空页持久成功后才使用返回 cursor 继续查询。首次/重启沿用全量初始化和控制积压优先的流程,不恢复旧代次持久游标;失败关新准入,控制和结果恢复照常处理。不强加旧 snapshot_id/watermark/mode=snapshot。 | +| K04 任务范围 | 任务不会删除,只会启用、停用、暂停;不考虑切换 Dispatcher。停用对应终止 stop,同一 task_id 不允许再次启用;需要恢复的任务使用 pause/resume。 | 不设计任务删除通知、移出 tombstone、归属迁移、跨 D 接管或终止后恢复。状态变化持久保存,HTTP 旧 running 或 resume 不能解除已终止状态;暂停才可经最新配置核验后恢复。 | +| K05 控制缺省与回执 | active_call_policy 未提供时默认 hangup;回执在调度到 Agent 后返回。 | 缺省挂断不再当成非法配置;显式不合法值仍拒绝。回执表示已调度,不等同于活动通话已全部挂断/排空;未成功调度不能回成功。实际通话终结与释放资源仍独立核实。 | +| K06 外呼回执与关联 | dispatched 表示已发出呼叫指令;最终结果 payload 中 task_id+手机号即可对应。 | 对外按 task_id/callee 关联,不强加 call_id/source_command_id 等新字段。内部继续保留每次执行的事件身份、消息身份和幂等记录,不把 task_id+callee 擅自改成永久禁止同号码再次执行的唯一键。回执不表示已接通。 | +| K07 SIP 变更 | 全量 SIP 接口补充 revision 字段。 | 读取和加载核验使用该 revision,与 sip.config 通知对齐;不再以“全量缺少 revision”阻塞计划。业务 revision 保留,不属于自有实现代次清理;仍须证明实际加载,不能只更新数据库中的编号。 | +| K08 凭据 | credential 直接返回明文,不需要 ref。 | D 将凭据交给 Agent 的对应 SDK,不引入凭据引用解析、交换服务或额外安全架构。provider_ref 仅用于选择 provider。真实凭据不写日志/样例的既有要求不变。 | +| K09 挂断关键词 | 仅用户侧 ASR 最终识别文本包含任意配置关键词时挂断。 | 按字面包含匹配,等最终识别文本,不使用中间结果触发;不引入语义模型或额外归一化。助手回复、提示词、开场白和 TTS 内容不得触发;重复识别/通知不得造成重复执行终结操作。 | +| K10 录音与最终结果 | OSS 只上传录音。正常上传成功即回报,不生成本地录音或外呼记录文件;只有 OSS 上传失败,才把录音及结果恢复信息保存本地。48 小时从首次失败后的两类恢复文件均保存完成时起算;重试间隔 1 分钟起、逐次翻倍、最长每小时一次。 | 有有效录音时,上传成功后才回最终结果;录音生成失败按 K15 的明确例外回报。48 小时耗尽仍上传失败则停止自动重试,保留文件,暂不回报,等待人工处理。本地结果信息只用于恢复 MQ 回报,不上传 OSS。此最新确认取代“所有通话先落盘”的旧表述;完整流程见 §3.2。 | +| K11 错误码表达 | 有真实 SIP 状态码则 reason_code 返回原数字;没有 SIP 状态码时 reason_code=null,reason_message 说明原因。 | 不新建本地数字错误码,不把本地拒绝、SDK HTTP 错误、录音或 OSS 错误伪装成 SIP 状态码;文字说明保留必要原因,但不带真实凭据或原始敏感报文。通话事件/状态见 K13–K14,录音生成失败见 K15,失败恢复文件无法保存见 K16;这些处理规则均已确认。 | +| K12 规则不满足时等待 | 不在允许时段等调度规则不满足时,暂停该任务的呼叫调度,直到规则允许后继续;不把暂时不能调度直接判成呼叫失败。 | 保留尚未执行的外呼,不因等待而丢弃、伪造结果或重复拨号;恢复前重新检查规则。规则等待与人工 pause/stop 分开,不能自动解除人工暂停或终止。单号码问题按 K13 处理;本规则不授权真实拨号或放宽现行真实门禁。 | +| K13 单号码错误 | 某号码不在白名单或格式错误,返回 call.execute 拒绝回执:status=rejected、reason_code=null、reason_message 说明原因;不发 call.execute.result,不暂停整个任务。 | 不拨号、不创建录音/上传任务,不发 dispatched;继续处理其他正常号码。保留处理与消息身份记录,不无记录丢弃,不通过清洗/替换号码绕过校验。此规则不用于 K12 的时段/额度等待。 | +| K14 通话结果状态 | 已发出指令后的最终 outcome 使用 answered、no_answer、failed:确实接通过为 answered;已发起但忙线/拒接/无人接听且确认结束为 no_answer;Agent/Asterisk 等执行故障、已确认未接通且执行结束为 failed。 | 后续异常不抹掉已接通事实;SIP 码/文字原因按 K11。仍不能确认是否接通或结束时继续核实,不伪造最终结果、不重拨、不释放未知占用。录音上传失败不改变通话 outcome,按 K10 等待上传/人工处理。 | +| K15 录音生成失败例外 | 已确认通话结束,但预期录音无法生成时,仍回报真实通话结果,recording={},reason_message 明确说明录音生成失败。 | 不把正常接通改成 failed,不伪造录音/OSS 路径或 SIP 错误码;没有可上传内容,不进入 OSS 上传或 48 小时重试,不为此新建本地业务文件。此例外只针对录音生成失败,不用于已有录音的 OSS 上传失败或重试超期。 | +| K16 上传失败且无法保存恢复文件 | 已有录音,但 OSS 上传失败后本地恢复文件也无法完整保存时,明确报错,暂不返回最终结果,等待人工修复。 | 保留已有内容,不伪称已有完整恢复副本;恢复文件完整保存后才能进入 K10 的重试流程并开始计时。未落盘内容不能保证跨进程退出恢复;不得擅自套用 K15 的空录音回报例外或无副本地宣称已开始可靠重试。 | + +### 3.2 录音上传、失败落盘与回报顺序 + +1. **正常路径不落业务文件**:OSS 只上传录音内容;正常情况下直接上传,成功后回报单份最终通话结果,不生成本地录音文件或外呼记录信息文件。本地结果 JSON 不上传 OSS,也不新增该类资产/通知。不得先写临时文件再删除,并声称“没有落盘”。 +2. **仅上传失败才落盘**:OSS 上传失败后,将录音和用于恢复 MQ 回报的外呼记录信息一起保存本地。录音路径与原 OSS bucket/对象路径对应,结果恢复信息关联同一执行和目标;保持原文件、原对象目标和原消息身份,不因重试新建资产。若恢复文件无法完整保存,按 K16 明确报错、保留已有内容、暂不返回最终结果,等待人工修复;不能声称已有完整可恢复副本,也不启动依赖该副本的 48 小时重试。 +3. **确认通话终结**:释放已经确认结束的通话资源及占用,不等待 OSS 或最终结果发布。仍未确认终结的执行不能因上传窗口到期而释放;派发回执按 K05/K06 正常发送,不跟随最终结果等待录音。 +4. **失败上传重试**:间隔依次为 **1、2、4、8、16、32、60 分钟,之后保持每 60 分钟一次**;**从首次 OSS 上传失败后,录音和结果恢复信息均保存完成的时刻起算 48 小时**。起点固定记录一次,后续失败、修改重试进度、等待或重启均不延长截止时间。恢复记录保存原目标、尝试进度、窗口时间和下次尝试信息;不能用 SDK 默认重试覆盖此节奏。 +5. **上传成功后回报**:经 D 的既有可靠 MQ 交付返回最终结果;D 的 SQLite 状态/outbox 持久化不因“正常路径不落业务文件”而取消。MQ 发送失败只恢复原消息的交付,不再次上传已确认成功的录音、不重新拨号,也不把 MQ 故障当成 OSS 上传失败来生成录音缓存。48 小时不是删除待交付 MQ 结果的期限。 +6. **满 48 小时仍未成功**:停止自动上传重试;本地录音、结果恢复信息及进度继续保留,标记待人工处理;**不返回最终通话结果、不伪造 uploaded、不自动发 unavailable、不删除文件、不自动开启下一个 48 小时窗口**。SaaS 在人工处理并确认上传成功前收不到该最终结果。人工处置入口另行明确,不自动创建管理后台或补传协议。 +7. **取代旧行为**:删除“所有通话先写录音/结果文件”的前提;对已失败落盘的上传,取代旧 `call.ended_at + 15m` 自动收口/返回 unavailable 的逻辑,不留旧超时兜底。上传授权的有效期与重试窗口分开;Agent 仍经现有 D↔A RPC 显式取得有效上传授权,目标不变,不向 SaaS 申请 OSS TOKEN。 +8. **分清失败来源**:SIP 响应不能替录音生成、失败落盘或 OSS 上传报告状态。上传失败不把正常接通的通话改成呼叫失败;没有真实 SIP 状态码时按 K11 使用 reason_code=null 并说明原因,不编造状态码。录音无法生成按 K15 返回真实通话结果、recording={},并说明生成失败;上传失败后的恢复文件也写不出时按 K16 报错待人工、暂不回报,不套用空录音例外。 +9. **无录音路径与恢复边界**:单号码白名单/格式错误只按 K13 回拒绝回执,不进入最终通话结果或录音流程。已发起但无应答且未生成录音,按 recording 空对象返回最终结果,不等待不存在的文件上传,也不生成本地业务文件。正常路径不落盘意味着在上传成功或失败恢复文件保存完成前,进程异常退出时不能保证恢复内存中的录音;文档和验收不得承诺此阶段录音零丢失,也不能为掩盖限制偷偷恢复预写盘。上传重试不授权重拨、换线或真实测试。 + +### 3.3 本轮确认已完成 + +本轮列出的待确认问题已经逐项答复,当前清单没有剩余待用户确认项。K01–K16 为本轮明确规则,不再重复审批。 + +待完成的是 P01–P08 的契约同步、代码调整和验收,不是再次确认这些业务方向;本轮仍只修改计划,原始通信文档、Schema 和代码尚未同步。人工处理是明确报错并保留现有内容,不表示已实现新的人工恢复工具、后台或额外补传协议。 + +后续实施若发现新的真实字段冲突或现有组件无法满足要求,应带证据单独确认,只阻塞依赖该答案的部分;不能猜默认值、静默降级或重新启用废弃路径。 + +实施处理规则: + +- 注释、Markdown 粗体键、重复冒号和缺逗号等先整理为合法 JSON,保持原业务含义,保留来源记录;这些排版问题不各自形成外部审批门槛。本轮不修改原文。 +- `provider_ref: "mock"` 与 provider 清单示例不匹配时,在隔离样例中提供一致且按角色可解析的引用;不能把一个示例值硬编码为生产默认值。 +- `hangup_keywords` 按 K09 检查用户最终识别文本是否包含关键词;对象、方式和时机均已确认,不再列为待批准。必须验证真实控制行为,不把“字段能解析”当成“功能已实现”。 +- 最终文档没有列出的旧对外事件不默认保留。确有额外必需事件时,必须明确修正同一份当前约定,不能通过兼容层暗中外发。 +- 每项答复直接修正唯一现行契约和测试;不产生下一版文件,不扩展为对已确认方向的再次审批。 + +### 3.4 已确定的字段变化,不再列为待批准事项 + +| 部分 | 必须调整的内容 | +| --- | --- | +| MQ 信封 | 按消息类型使用最终文档的 event_id/event_type、身份与时间字段;tenant_id 统一为数字,schema_version 删除。不能沿用旧信封统一要求的 tenant_key/trace_id/aggregate_*。例如 sip.config 示例没有 tenant_id/issued_at,不能因旧解码器需要而强迫添加。外呼最终 payload 按 task_id/callee 对应,不强加新的执行关联字段;内部追踪和幂等身份仍保留,但不擅自外发。 | +| 任务与额度 | 删除对外 `agent_version_id` 等已移除字段依赖;保留真实业务 `task_revision/quota_revision`,它们不是实现代次。`agent.immutable` 及任务内完整 AI 配置必须实际绑定;ASR-only 不能被旧 full_ai 必填字段拒绝。 | +| AI provider | 新增全量 `providers` 读取,按 `provider_ref/role/enabled/adapter/endpoint/credential` 解析;`credential` 是 SaaS 直接返回的明文凭据字符串,传给对应供应商 SDK,不改成对象或 `credential_ref`,不增加引用解析、凭据交换或密钥管理服务。`provider_ref` 仍仅用于选择 provider,不是凭据引用。禁用、缺失或角色不符的 provider 不可执行;真实凭据不写日志/样例的既有要求不变。 | +| SIP 读取与未知值 | 全量响应新增业务 revision,与变更通知及实际加载核验对应。transport/auth_mode/registration_required/max_concurrent_calls 的 null 按原文表示尚未确认;读取可以表达未知,执行不能据此默认 UDP、免鉴权、不注册或无限并发。 | +| 回执与最终事件 | 任务回执仍是 `task.control`,外呼开始回执仍是 `call.execute`,结果是 `call.execute.result`;保留各自事件 ID 语义,不沿用旧 `command.result/call.result` 信封。最终时间字段为 `issued_at`,不是旧 `occurred_at`。 | +| 结果字段 | 当前 `call_result_v01.go` 使用字符串 `reason_code`,最终文档为 `null` 或数值(无应答示例为 `480`),并出现 `reason_message`;须调整类型及校验。无应答按例发送空 `transcript`、`opt_out: false`、`recording: {}`,不能强制套旧 `not_created` 结构;上传成功按约定输出 OSS 字段及哈希,旧本地执行/录音 ID 不强加到对外消息。 | + +## 4. 去版本标识的完整范围 + +### 4.1 清理对象与改名原则 + +初筛已发现 235 个含代次式路径的候选文件,分布在 `internal/`、`contracts/`、`docs/`、`proto/`、生成目录等;这是候选清单,不能据此机械删除。还须检查无版本文件名内部的 Go 符号、JSON 标识、嵌入路径、SQL 和脚本引用。 + +| 范围 | 当前已核实的例子 | 目标及同步检查 | +| --- | --- | --- | +| Go 源码、测试及符号 | `internal/mq/amqp_v3.go`、`V3Broker`、`internal/dispatcher/task_queue_v3.go`、`task_control_v3.go`、`task_runtime_v3.go`、`LocalV01Runtime`、`call_result_v01.go`、`internal/configread/discovery_v04.go`;store 的代次式方法和测试 | 使用职责名,如 `Broker`、`TaskRuntime`、`task_queue.go`、`task_control.go`、`call_result.go`、`discovery.go`。先处理现存同名文件/符号,再统一调用点、测试、错误/日志标签;不保留类型别名或转发包装。 | +| 当前契约与加载入口 | `contracts/upstream/v1/`、`docs/contracts/*-v0.x*.schema.json`、`local-contract-manifest-v0.x.json`、`contracts/contracts.go`、`internal/contract/contract.go` 的加载/校验路径 | 按领域维护唯一无代次文件,例如 `call-result.schema.json`、`config-read.schema.json`、`local-contract-manifest.json`。同步 `$id/$ref`、示例、Schema 选择器、编译缓存键、`go:embed` 和来源/hash;删除按旧版本加载的分支。来源事实中的原始外部版本不伪造、不改写。 | +| 自有 Proto 与生成物 | `proto/agent/v1/agent.proto`、`gen/go/agent/v1/` | 目标为 `proto/agent/agent.proto`、`gen/go/agent/` 等无代次路径;同步自有 package、`go_package`、服务全名、导入、生成配置和 `scripts/check-proto.sh`。必须重新生成,不手改生成文件;D/A 同步构建,不能保留旧服务别名。去名本身不随意改变字段号及业务语义。 | +| SQLite、恢复文件与检查脚本 | `internal/store/migrations/019_local_v04_discovery.sql`、相关 store 方法、状态/结果恢复入口,以及引用这些路径的脚本 | 清理迁移文件名称、确含实现代次的自有表/列/索引/记录类型及测试标识;保留有实际含义的迁移顺序号、业务 revision 和幂等身份。不得因为改名而丢失已执行事实或重做拨号/上传。 | +| 部署、构建及验收入口 | Makefile、脚本、CI、配置样例、夹具、测试选择条件、hash 清单中的旧路径/命令 | 同步更新可执行引用;普通构建、检查和隔离验收不读取旧代次路径,不因缺文件跳过检查。各模式必须显式,不用改名引入 Mock/real 回退。 | +| 文档、计划和归档文件名 | 本计划、`docs/thirds/v0.5-proposal.md`、相关计划、契约、验收证据和 `AGENTS.md` 中的引用 | 本计划后续改为 `docs/plan-saas-dispatcher.md`,当前通信标准改为 `docs/thirds/saas-dispatcher.md`;同步所有引用及权威入口。多份同主题材料只保留一个当前入口;有必要的历史证据按主题/事实日期归档,不再按版本并行维护,也不再参与运行校验。 | + +### 4.2 必须保留的例外 + +- 双方已确认的 MQ 固定 `v1` 名称、HTTP 固定 `/internal/v1/...` 路径。它们是外部通信约定;自有 Go 类型/函数和文件不得因此命名为 `V1...`。 +- 第三方依赖、标准协议和工具链的合法版本,例如 SDK module 路径中的 `/v2`、Go 1.27.1、UUID v4、IPv4/IPv6;真实供应商 endpoint 和 model ID 也必须保留原值。禁止为了通过名称扫描而改依赖路径、供应商请求参数、协议行为或工具链基线。自有 Mock 的 `mock-chat-v1/mock-tts-v1` 等代次式名称不借此豁免。 +- 真实业务 revision、配置/任务身份、哈希、消息 ID、迁移顺序号,以及外部原始来源记录。它们不得成为“仍保留多代实现”的借口;自有历史实现代次不能冒充业务字段。 +- 例外必须按具体用途核实;不能将整个目录、所有 `v1` 字符串或所有测试一律放行。 + +### 4.3 改名与数据保留边界 + +1. 大规模清理前新建分支,记录工作树和可追溯基线;不得暂存、提交、移动或清理与本任务无关的用户改动。 +2. 建立路径/符号的旧→新映射,覆盖大小写、`V01/V04`、`v0_1`、`v0.1` 等形式。当前同时有 `amqp.go` 与 `amqp_v3.go`,不能简单去后缀覆盖;先保留必要行为、移除废弃路径,再统一职责名。 +3. 每批改名同步修正代码调用、嵌入、生成、构建和测试引用;改路径后的 hash 清单必须重新核验,不能只改条目文字。 +4. 不为旧实现保留兼容层、备用消费路径、旧 Schema 解析器或自动切换机制。有效的可靠性测试转到唯一现行实现,不能通过删测试消除失败。 +5. **不自动清空或重建现有 SQLite/spool/outbox。** 新结构先在独立空目录中验收;若现有持久记录受名称变化影响,须在受控停机前明确其状态及处置。存在未完成执行、未知占用、待交付结果或上传恢复记录时,未有获授权且验证可行的处理方式不得切换;不以项目未发版为由假定数据可丢。 +6. 当前源码、文件名、目录名和运行入口清理结束后,再做全仓残留检查;仅搜 MQ 后缀或仅看 `git diff` 不能作为完成证明。 + +## 5. 调整工作包与逐项验收 + +| 工作包 | 调整内容和依赖 | 验收标准 | +| --- | --- | --- | +| P01 唯一通信契约 | 将原始通信文档与 §3.1–§3.2 的最新确认同步到唯一字段/事件/路由清单;规范化 JSON,形成无代次 Schema、正反例和来源/hash。采用共享结果队列、数字 tenant_id、删除 schema_version、全量 SIP revision 及已确认回报顺序;不新增下一版本。 | 已确认事项不再列为待批准;旧必填字段不混入新接口;未知/非法字段拒绝;K01–K16 同步到当前唯一契约和测试;实现中新发现的问题按 §3.3 带证据单独确认,不能猜默认值。 | +| P02 无代次骨架与引用整理 | 建分支,完成 §4 路径/符号清单及碰撞处理;按职责统一核心接口、存储调用和 Proto/生成路径。依赖已明确的契约部分;大范围改动须分批回归。 | 无覆盖同名文件、旧类型别名、重复服务或失效导入;生成物可重复生成,内部 RPC 参数/语义检查通过;旧运行代次入口关闭而非自动回退。 | +| P03 五类 HTTP 与新数据模型 | 调整 SIP、任务、任务发现、租户额度和 ai-providers 五类读取;tenant_id 使用数字,去掉 schema_version/agent_version_id 的旧要求;SIP 全量读取增加 revision。按 agent/provider_ref/明文 credential 交付,保持 HTTP Header 大小写无关语义。 | 五类读取均有正反例;字符串租户 ID 不再作为新格式接纳;schema_version 不再是选择器或必填项;全量与通知 revision 能核对;0/false、数组保真;错误归属、HTTP/结构错误不能回退旧 MQ 配置或过期数据。 | +| P04 MQ、任务发现与控制接纳 | 保留原外呼队列并改用 SaaS 共享结果队列;SaaS 建队,D 只消费/发布。发现翻页到带 cursor 的空清单;不开发删除/跨 D 迁移。按 K12 对规则不满足的任务暂停调度、保留待执行呼叫,条件允许后继续;K13 的单号码错误只回 call.execute rejected,不发最终结果、不暂停任务。policy 缺省 hangup,业务回执在调度 Agent 后返回,dispatched 须已发出呼叫指令。 | 持久 inbox 后才做 RabbitMQ ACK;规则等待不能借 ACK 丢掉未执行呼叫,业务回执不冒充已拨出。控制积压未清不准入,非空短页不提前结束;恢复前核对当前规则,不用过期许可放行。自动恢复只针对规则等待,不能覆盖人工 pause/stop;已发出或未知的执行不再次拨号。停用同 ID 不可再启用,不擅加 CAS/控制去重。 | +| P05 Agent 配置与实际执行 | 明文 credential 经 D 交付 Agent 并用于 SDK,无 ref 查找/凭据交换;适配新快照,移除旧实现版本依赖。ASR-only 不强迫带 LLM/TTS;完整 AI 配置实传 SDK/控制器;opening 与 hangup_keywords 有实际行为,后者仅响应用户文本。SIP 变更关准入、排空并核验 revision 对应的实际加载。 | 两种 AI 场景运行;已接纳呼叫固定任务/provider 快照;同 task_revision 异内容拒绝;缺失/禁用/角色错误 provider 或无效凭据拒绝;只有用户最终识别文本包含关键词才触发挂断;中间文本、TTS/助手文字不触发;无真实加载证据不放行真实执行。 | +| P06 正常直传、失败落盘与结果 | OSS 只接收录音;正常上传成功直接回报,不写录音/结果业务文件;调整现有录音及上传入口对文件路径的依赖,复用现有库/SDK,不另写协议。仅 OSS 上传失败时保存录音和结果恢复信息,路径对应原目标,并按 §3.2 启动 48 小时重试。窗口耗尽保留待人工,不发最终结果。D 状态/outbox 同事务;移除旧事件别名、总是预写盘及旧 15 分钟 unavailable 路径。 | 正常路径与无录音路径无业务文件写入,不能靠先写后删达标;OSS 不接收结果 JSON。失败落盘后可重启恢复原目标/消息/窗口;MQ 失败不重新 PUT。满 48 小时停止自动重试,不删文件、不自动回失败结果;资源按实际通话结束释放,派发回执不等上传。最终 outcome 按 K14 区分,未知执行不伪造结果;录音生成失败按 K15 回真实结果、空录音和明确原因;恢复文件写入失败按 K16 报错、保留内容、待人工且不回最终结果;不宣称未落盘录音能跨崩溃恢复。 | +| P07 全仓清理与当前入口更新 | 随 P02–P06 每批处理 §4 改名/引用;删除废弃运行路径、旧 Schema 选择器和重复清单;同步当前契约、AGENTS、脚本、Makefile、嵌入与 hash,特别纠正旧“每 D 结果队列”和“15 分钟自动收口”等冲突规则。 | 当前入口唯一,无未批准代次残留;合法例外明确;普通构建/测试不读旧路径。记录已确认的新规则,不把本地实现或文档更新写成真实外部联调通过。 | +| P08 端到端及交付验证 | 按已确认规则更新隔离 SaaS/MQ Mock 与验收入口,执行 §6;使用独立目录、可控时间和既有测试资源,不修改现存数据。 | 正常直传回报、录音生成失败空录音回报、OSS 失败落盘后重试成功、48 小时耗尽待人工不回报、恢复文件写入失败待人工不回报五条路径均通过;不靠真实等待 48 小时或真实 OSS 消费验收。部署/诊断门禁照常;区分本地通过、外部未验证及阻塞。 | + +依赖顺序:P01 明确契约 → P02 建立无代次入口 → P03/P04/P05/P06 按接口依赖逐项完成 → P07 全仓收口 → P08 总验收。P07 的引用修正随每批工作同步完成,不留到最后才修编译和测试。本轮业务确认已完成;未来确有新增问题时按 §3.3 定点确认,不因已解决的问题重复停工,也不开不确定的执行准入。 + +## 6. 验收清单与完成证据 + +以下均为后续实施要求;本轮文档修改不将任何一项标成实现通过。 + +| 编号 | 检查 | 必须留存的结果 | +| --- | --- | --- | +| A01 契约一致性 | 对照最终正文逐项检查五类 HTTP、MQ 命令、控制/执行回执、最终结果、provider/agent 新字段;Schema 正反例全部执行。 | 字段/消息→Schema→测试映射;JSON 可解析;没有仅因旧代码需要而保留的必填字段、未经约定的新字段或版本分支。 | +| A02 无代次名称 | 扫描全部自有源码、符号、注释/日志标签、文件/目录、SQL、脚本、样例和当前文档引用,覆盖 `V1/V2/V3/V01/V04/v0.x/v0_1` 等形式;人工核对例外与误报。 | 候选清单、旧→新映射、删除/保留理由及复扫结果;未批准的实现代次残留为零。不能靠把命名改成另一种代次格式或放行整个目录达标。 | +| A03 固定通信名称 | 检查实际 publish/consume 的 exchange、queue、routing/binding key 和 HTTP 路径;以不同内部配置/Schema 内容运行同一流程。 | 始终使用约定固定 `v1`;不从代码/文件/Schema 版本推导拓扑,不存在 `.v3` 等旧拓扑的自动回退。 | +| A04 引用及生成一致性 | 检查 import、Proto package/服务名、go_package、嵌入、Schema 引用、生成配置、脚本、文档链接和 manifest hash。 | 构建和重新生成成功;生成后无意外差异;无悬空引用、覆盖冲突或旧路径依赖。外部来源事实与本地整理产物的 hash 各自可核验。 | +| A05 配置读取与失效 | 覆盖数字/字符串 tenant_id、删除 schema_version、SIP 全量 revision 与通知对应、错误归属/字段、未知或禁用 provider、角色错配、无效凭据、网络失败、0/false 和合法 null。用虚构 credential 验证原值直达 SDK,无 ref 查找服务。 | 合法且满足准入条件的新结构可执行;字符串租户 ID、旧 schema_version 等不作为兼容格式放行;未知配置不默认可执行;无旧 MQ/过期缓存回退;日志/样例/证据不含真实密钥、TOKEN 或完整音频/对话。 | +| A06 队列所有权与路由 | 验证原外呼/控制队列组织及 SaaS 单个共享结果队列;D 无 configure 权限;故障覆盖缺资源、错绑定、不可路由、return、confirm 丢失和重连。 | D 不建队/绑定/删除,不要求每 D 独立结果队列;来源身份仍能区分;以实际接收证明路由,不把 exchange 存在或 confirm 当目的队列收到。D1/D2 仅作隔离夹具,不冒充多 D 业务运行。 | +| A07 控制与发现竞态 | 覆盖带 cursor 的空页完成、非空短页继续、读取/持久化失败、重启全量与控制积压;覆盖 policy 缺省挂断、显式 drain/hangup、业务回执调度时机、旧 running 页、重投/乱序及 SIP revision 变更。 | 无任务删除/跨 D 迁移流程;非空页不能提前完成;未调度 Agent 不回成功,回执不冒充活动通话已结束;HTTP 不覆盖已应用控制,不删 SaaS 队列。stop 后同 ID resume 或 HTTP running 均不能恢复,只有暂停任务可以恢复。 | +| A08 执行与 AI 行为 | 覆盖重复/历史执行消息、同号码不同呼叫、白名单/时段/额度/期限、两类 AI 场景、开场白及 SDK 实参;挂断词分别输入用户最终文本、仅中间结果命中、助手文本和 TTS 内容,核对字面包含规则与重复通知行为。 | 接纳与实际发出前门禁有效;任务级规则不满足时等待而非丢弃,恢复时重新校验,不自动解除人工 pause/stop;单号码白名单/格式错误只回 call.execute rejected、不发最终结果,不阻塞后续正常号码;dispatched 确已发出呼叫指令但不代表接通。已发出/未知执行不重拨,无自动换线或旧音频重播;仅用户最终文本可触发关键词。真实路径仍受 09:00(含)–20:00(不含)Asia/Shanghai 限制,Mock 不授权真实拨号。 | +| A09 结果与录音 | 分别验证正常上传成功不写业务文件、OSS 失败才保存录音/结果恢复信息、无录音直接回报;OSS 接收端断言只有录音内容。用可控时间核对 1、2、4、8、16、32、60 分钟节奏及封顶,48 小时从首次失败后的两类恢复文件保存完成起算。验证 K14 的 answered/no_answer/failed 及未知时不发最终结果;验证 K15 录音生成失败仍回真实结果、recording={} 并注明原因;验证 K16 恢复文件写入失败明确报错、保留内容、待人工且不发最终结果,不假装已开始可靠重试。 | 文件写入观测证明正常路径没有先写后删;有有效录音时上传成功后才发最终结果,无录音及 K15 生成失败例外不等待上传;派发回执不等上传;满 48 小时停止自动上传、保留待人工、不发 unavailable/失败结果,不恢复旧 15 分钟逻辑。MQ 失败不触发录音落盘/重复 PUT;无应答无录音不等待上传。 | +| A10 数据保护与恢复 | 对失败后已落盘的录音/结果信息、重试窗口/进度,以及既有 inbox、未知占用、上传成功事实和 outbox 注入故障;检查 D 的可靠消息持久化没有被正常路径不落业务文件的规则取消。 | 失败落盘后重启不重置 48 小时、不换对象、不重拨;授权过期经现有 D 接口取有效授权且目标不变。已持久到 D 的状态/outbox 同事务,MQ 失败恢复原消息。满 48 小时不删缓存,不给 MQ 交付加删除期限;未落盘录音的进程崩溃不可恢复边界明确,不伪称零丢失。资源按通话终结释放,现有数据未经明确处置不清库。 | +| A11 测试与构建 | 按 TDD 开发;执行格式化检查、现有 contract/proto 检查、`go vet ./...`、`go test -race ./...`、构建及隔离端到端。 | 核验 Go 1.27.1;单元测试覆盖率 ≥65%,报告注明范围,不靠排除修改代码达标;每批合并后回归,无跳过必需部署/诊断步骤。 | +| A12 交付边界 | 对照 P01–P08、§3 答复和上述检查逐项填写证据,不沿用旧版本通过记录代签。 | 当前文档/代码入口唯一;本地验收、真实 SaaS/MQ、真实 Agent/Asterisk 加载及供应商/生产验证分开列明。未获授权不执行真实切换、消费或拨号。 | + +本轮文档交付核验:只修改本文件;检查文字规则、表格、现有引用及工作树差异。不运行或宣称完成上述代码测试、部署和通信验收。 diff --git a/docs/thirds/saas-dispatcher.md b/docs/thirds/saas-dispatcher.md new file mode 100644 index 0000000..5879c88 --- /dev/null +++ b/docs/thirds/saas-dispatcher.md @@ -0,0 +1,50 @@ +# SaaS ↔ Dispatcher:项目内唯一现行通信约定 + +> 本文根据用户提供的 `v0.5-proposal.md` 及已确认的 K01–K16 整理为可校验的项目内合同;原提案含注释、排版错误及被后续确认取代的旧队列/租户字段。项目内 Schema 与合法/非法 JSON 的唯一机器来源为 [`contracts/local/`](../../contracts/local/);不得从本 Markdown 复制第二套手写 Schema。**本地隔离 Mock 可验收,不等于 SaaS/management 已签收或真实外呼获授权。** + +## HTTP:五类只读配置 + +只由归属 D 使用 `X-DISPATCHER-ID` 和 `X-DISPATCHER-SECRET-KEY` 读取;HTTP header 名大小写无关。返回中的 `dispatcher_id` 须等于本 D,任务/配额的 `tenant_id` 是正整数;不接受旧字符串租户 ID。不在日志、样例或证据中打印真实凭据。业务正文只使用当前 Schema,不接受 `schema_version`、旧版 `tenant_key`、`agent_version_id`、授权期限或内容摘要等删除字段;也不根据字段做版本分支或从 HTTP 失败回退旧 MQ 配置。 + +| 资源 | 方法和路径 | 应用规则 | 正例 | +| --- | --- | --- | --- | +| SIP 全量 | `GET /internal/v1/dispatcher/sip` | `revision` 是实际加载版本核对依据;未知 `transport/auth_mode/registration_required/max_concurrent_calls` 为 `null`,不能作为可执行线路默认值;变更时关准入、排空并核验 Agent/Asterisk 实际加载 | [`sip`](../../contracts/local/examples/config-read-sip.json) | +| AI provider 全量 | `GET /internal/v1/dispatcher/ai-providers` | `provider_ref` 只标识供应商;将明文 `credential` 原值交给选定角色的 SDK,不引入 ref 查找或交换;缺失、禁用、角色不匹配、无效凭据拒绝执行 | [`providers`](../../contracts/local/examples/config-read-providers.json) | +| 任务 | `GET /internal/v1/dispatcher/task/{task_id}` | 归属、状态和 `task_revision` 校验后持久绑定同一 `agent`/provider 快照;不可变摘要由本项目计算。同一 revision 内容不同拒绝;ASR-only 只需 ASR,不强制 LLM/TTS;0 和 false 原样保留 | [`ASR-only`](../../contracts/local/examples/config-read-task-asr.json) · [`full AI`](../../contracts/local/examples/config-read-task-full.json) | +| 任务列表 | `GET /internal/v1/dispatcher/tasks`,后续 `?after=` | 首次/重启完整读到 **带 cursor 且 tasks=[]** 的终止页;非空短页不能提前结束。非空页持久成功后才使用下一 cursor;控制队列积压处理前不开新任务准入。不使用旧 snapshot_id/watermark/mode=snapshot | [`page`](../../contracts/local/examples/task-discovery-page.json) · [`end`](../../contracts/local/examples/task-discovery-end.json) | +| 租户额度 | `GET /internal/v1/dispatcher/tenant/{tenant_id}/quota` | `quota_revision` 为业务修订号;未知占用不得算成已释放 | [`quota`](../../contracts/local/examples/config-read-quota.json) | + +HTTP 非 200、正文不合法、缺字段、归属冲突、缓存失效或读取失败时拒绝新的相关准入并记录脱敏错误;已持久接纳的执行继续使用原快照。错误体示例 [`error`](../../contracts/local/examples/config-read-error.json),不能当成功配置解析。任务仅有运行/暂停/终止;终止是不可逆 stop,同一 `task_id` 不再启用。HTTP 旧 running 不得覆盖已持久的 pause/stop;resume 经最新任务配置核验后只能解除人工暂停。 + +## MQ:固定 v1 传输,唯一事件清单 + +RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D 只消费/发布,无 configure 权限**。机器拓扑见 [`mq-topology.json`](../../contracts/local/mq-topology.json)。外呼维持每 D、每任务独立的 `d..task..in` 路由及 `agent-call.d..task..v1` 队列;每 D 单独控制路由 `d..control.in` 及队列 `agent-call.d..control.v1`。SaaS 将所有 D 的输出 `d..out` **精确绑定至同一个结果队列**(本地 Mock 名 `agent-call.saas.events.v1`,不是强制 SaaS 实际队列名)。入站 exchange `agent-call.dispatchers.v1`;出站 exchange `agent-call.saas.v1`;死信 exchange `agent-call.dead-letter.v1`。不广播后靠正文过滤,不使用独立通配段,不把被动声明/发布 confirm 冒充指定队列已收到:须以实际路由、mandatory return 与 confirm 联合验证。 + +`event_id` 是入站/回执关联和内部防重复处理的消息身份;`dispatcher_id` 是 D 身份,`tenant_id` 是正整数。**不是所有事件共用一个统一必填信封**:`sip.config` 没有 tenant_id/issued_at;回执按其示例字段,最终结果使用 `issued_at` 而非 `occurred_at`。正文的 `schema_version` 已移除。只接受以下外发事件及必要入站事件;所有字段直接按 [`mq.schema.json`](../../contracts/local/mq.schema.json) 和正例校验: + +| 事件 | 方向和处理规则 | 正例 | +| --- | --- | --- | +| `sip.config` | SaaS→D;`revision` 触发全量重新读取和实际加载核验,不用通知正文代替全量 | [`notification`](../../contracts/local/examples/mq-sip-change.json) | +| `task.control` | SaaS→D;pause/resume/stop,无控制去重/CAS 字段;省略 `active_call_policy` 默认 **hangup**,显式仅 drain/hangup。成功将操作发给 Agent 后才回应用回执,回执不是已完成排空/挂断的证据;stop 同 ID 不可恢复 | [`control`](../../contracts/local/examples/mq-control.json) · [`ack`](../../contracts/local/examples/mq-control-ack.json) | +| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/主叫/AI/时限从绑定任务读取。`dispatched` 表示已发出呼叫指令,**不表示接通**;白名单/单号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) | +| `call.execute.result` | D→SaaS;按 `task_id` + 原号码关联,每次呼叫仅一份最终结果;不新增外部 call_id/source_command_id;真正终结且录音成功上传、无录音或预期录音生成失败后才发送 | [`uploaded`](../../contracts/local/examples/mq-result-uploaded.json) · [`empty`](../../contracts/local/examples/mq-result-no-recording.json) | + +旧 `command.result`、`call.result`、分散通话/转写/拒联事件、`recording.uploaded` 不再作为对外并行通知或兼容别名。D 在 inbox 持久后 ACK;状态/outbox 同事务;结果发布使用原消息身份可靠交付;confirm 不是 SaaS 应用收讫。消息年龄不让旧命令绕过准入;未来 issued_at 不提前接纳。重复投递/未知执行不触发再次拨号。 + +## 调度、AI 与真实结果(K01–K09、K11–K14) + +- 白名单仅含 `15003164745`、`15830461047` 原值;已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。本规则**不**放宽真实路径 Asia/Shanghai `09:00`–`20:00` 固定门禁或授权真实拨号。 +- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。 +- 只有**用户侧 ASR 最终识别文本**包含任一 `hangup_keywords` 字面字符串才挂断;中间识别、助手回复、开场白、TTS 均不能触发;重复结果不可反复终结。同一任务 revision 不同内容拒绝准入;provider 禁用/角色不符不可调用。Mock 参数验证不等于真实供应商验收。 + +## 录音与最终结果(K10、K15、K16) + +1. 有效录音先直接 PUT 至 OSS,**只上传录音**;成功即持久入 D 的最终结果/outbox,正常路径不生成录音或结果业务文件(不得先写临时文件再删)。无应答无录音直接发真实结果,`recording={}`;录音**生成失败**仍报告真实通话 outcome、空录音与明确 `reason_message`,不走 OSS/重试,不伪造 SIP 码。 +2. OSS 上传首次失败时,**录音和结果恢复信息两者均保存成功**后才建立固定重试起点/48 小时截止,保持原 bucket、object_key 和消息身份。本地结果文件仅用于恢复 MQ 回报,不上传 OSS。任一恢复文件不能完整保存:显式报错,保留已有内容,暂不发送最终结果、也不宣称有可恢复副本,等待人工修复。 +3. 从首次两文件完整保存起重试:间隔为 1、2、4、8、16、32、60 分钟,其后每 60 分钟一次;重启/失败不重置起点,SDK 默认自动重试不得改变节奏。每次经 D↔A RPC 显式领取有效上传授权,不换对象/不向 SaaS 申请 TOKEN。48 小时仍不成功:停止自动重试、保留两文件和进度、标记待人工,**不发**伪造 uploaded/unavailable 或最终结果,也不自动重开窗口。 +4. 上传成功后仅恢复原最终结果消息的 MQ 可靠交付,MQ 失败不重新 PUT、不新建资产、不重拨;48 小时是 OSS 重试窗口,**不是** MQ outbox 的清除期限。已确认结束的通话资源及时释放,不等待 OSS/MQ;未知执行不释放。进程在正常上传尚未成功、失败恢复两文件尚未完整保存前退出,内存录音可能丢失,不能声称零丢失,也不能为了隐藏限制悄悄预写盘。 + +## 校验、来源与界限 + +- 字段及结构的唯一机器契约:[`config-read.schema.json`](../../contracts/local/config-read.schema.json)、[`task-discovery.schema.json`](../../contracts/local/task-discovery.schema.json)、[`mq.schema.json`](../../contracts/local/mq.schema.json);正反例在 `contracts/local/examples/`,来源/hash 在 `contracts/local/manifest.json`,检查入口 `go test ./contracts -run TestCurrentContractExamples` 及 `scripts/check-current-contracts.sh`。外部供应商仍未签收。 +- JSON 样例是隔离 Mock 虚构数据;`example-only-not-a-real-secret` **不是凭据**。严禁将真实凭据、完整用户音频或完整对话放入源码/日志/证据。运行时须按接入方权限与实际加载事实再核验,不以机器 Schema 通过取代拨号授权。 diff --git a/docs/thirds/v0.5-proposal.md b/docs/thirds/v0.5-proposal.md new file mode 100644 index 0000000..a049f33 --- /dev/null +++ b/docs/thirds/v0.5-proposal.md @@ -0,0 +1,446 @@ +SaaS Dispatcher 对接文档 v0.5 +相关占位说明: +`/internal/v1/dispatcher/xxxxx` 路由按系统当前架构路由进行定义即可 +`X-DISPATCHER-ID`/`X-DISPATCHER-SECRET-KEY` HEADER 头KEY 获取忽略大小写 +Dispatcher 角色下称 D +触发顺序 +1.1 MQ 发布消费规则 +RabbitMQ 有发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费四步; +```Plain Text +SaaS→D exchange: agent-call.dispatchers.v1 + routing key: d..t..in + binding key: d..t..in + queue: agent-call.d..t..v1 + consumer: 对应 Dispatcher +D→SaaS exchange: agent-call.saas.v1 + routing key: d..t..out + queue: agent-call.saas.events.v1 + consumer: SaaS +``` +1.2 本轮任务队列与事件路由 +硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。 +```Plain Text +SaaS 创建并绑定: + exchange: agent-call.dispatchers.v1 (topic, durable) + task routing: d..task..in + task queue: agent-call.d..task..v1 + control route: d..control.in + control queue: agent-call.d..control.v1 + dead-letter: agent-call.dead-letter.v1 + D -> SaaS exchange: agent-call.saas.v1 (topic, durable) + result route: d..out + SaaS result queue: agent-call.saas.d..v1 +``` +HTTP请求 +SIP列表配置 +```HTTP +GET /internal/v1/dispatcher/sip HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <密钥> +``` +响应: +```JSON +{ + "resource": "sip_config", // 资源类型:SIP 配置 + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", // 这份配置所属的 Dispatcher + "trunks": [ // 该 Dispatcher 获批的线路列表 + { + "trunk_id": "trunk-mock", // 线路的唯一标识 + "provider_id": "provider-mock", // SIP 服务商标识 + "codec": "PCMA", // 线路使用的语音编码 + "dial_prefix": "", // 该线路拨号时添加的被叫前缀;空串表示不添加 + "enabled": true, // 是否启用这条线路 + "server_host": "sip.example.invalid", // SIP 服务端地址;此处是 Mock 地址 + "server_port": 5060, // SIP 服务端端口 + "transport": null, // 传输方式:udp、tcp、tls;null 表示尚未确认 + "auth_mode": null, // 鉴权方式:ip、digest、none;null 表示尚未确认 + "registration_required": null, // 是否需要 SIP 注册;null 表示尚未确认 + "max_concurrent_calls": null, // 分配给该 Dispatcher 的线路并发上限;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": [] // 周日不允许外呼 + } } + } ] +} +``` +AI 服务商全量读取 +`GET /internal/v1/dispatcher/ai-providers` +```JSON +{ + "resource": "ai_providers", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "providers": [ + { + "provider_ref": "asr-provider-a", + "role": "asr", + "enabled": true, + "adapter": "volcengine_asr", + "endpoint": "https://asr.example.invalid", + "credential": "managed-asr-a" + }, + { + "provider_ref": "llm-provider-a", + "role": "llm", + "enabled": true, + "adapter": "openai_compatible", + "endpoint": "https://llm.example.invalid/v1", + "credential": "managed-llm-a" + }, + { + "provider_ref": "tts-provider-a", + "role": "tts", + "enabled": true, + "adapter": "volcengine_tts", + "endpoint": "https://tts.example.invalid", + "credential": "managed-tts-a" + } + ] +} +``` +任务配置 +```HTTP +GET /internal/v1/dispatcher/task/{task_id} HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <密钥> +``` +响应体: +仅 ASR 模式(Agent配置仅返回 ASR即可) +ASR + LLM + TTS 模式 +```JSON +{ + **"resource"**: **"task_config"**, + **"dispatcher_id"**: **"c046b893-8628-4589-ae50-619d049248a6"**, + **"tenant_id"**: **"tenant-id-mock"**, + **"task_id"**: **"task-mock"**, + **"task_revision"**: **2**, + **"status"**: **"running"**, + **"name"**: **"Mock task"**, + **"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"**: { + **"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**, + **"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"**: **"开场白"**, + **"hangup_keywords"**: [ + **"不用了"**, + **"请挂机"** + ], + **"allow_interrupt"**: **true**, + **"silence_timeout_ms"**: **3000**, + **"max_duration_ms"**: **120000**, + **"max_turns"**: **20**, + **"sentence_max_chars"**: **80**, + **"max_pending_audio_chunks"**: **32** + } + } +} +``` +任务列表 +```HTTP +GET /internal/v1/dispatcher/tasks HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <密钥> +``` +增量请求 +```HTTP +GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <密钥> +``` +响应: +```JSON +{ + "schema_version": "task-discovery.v0.2-proposal", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "cursor": "1042", + "tasks": [ + { + "task_id": "task-a", + "tenant_id": 1001, + "status": "running", + "task_revision": 1 + }, + { + "task_id": "task-old", + "tenant_id": 1001, + "status": "stopped", + "task_revision": 3 + } + ] +} +``` +按租户 ID 获取配额信息 +```HTTP +GET /internal/v1/dispatcher/tenant/{tenant-id}/quota HTTP/1.1 +Host: +X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6 +X-DISPATCHER-SECRET-KEY: <密钥> +``` +```JSON +{ + "resource": "tenant_quota", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-id-mock", + "quota_revision": 1, + "max_concurrent_calls": 3 +} +``` +控制事件 +> 队列:agent\-call\.d\.\\.control\.v1 +> +> +SIP 线路变更 +```JSON +{ + "event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb", + "event_type": "sip.config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "payload": { + "trunk_id": "trunk-mock", + "change_type": "disabled", // enabled/removed/created + "revision": 8 + } +} +``` +任务控制 +```JSON +{ + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": 1001, + "issued_at": "2026-09-21T00:00:00Z", + "event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb", + "event_type":: "task.control", + "payload": { + "task_id": "task-a", + "action": "pause", // resume , stop + "reason": "local-test", + "options": { // 附加控制参数 + "active_call_policy": "drain", // 活跃通话策略,仅stop,pause事件生效 [drain|hangup] + } + } +} +``` +任务控制处理回执 +> 队列 agent\-call\.saas\.v1 +> +> +```JSON +{ + "event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb", + "event_type":: "task.control", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": 1001, + "payload": { + "status": "applied" + } +} +``` + +外呼任务队列 +> 队列:agent\-call\.d\.\\.t\.\\.v1 +> +> +呼出 +```JSON +{ + "event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb", + "event_type":: "call.execute", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": 1001, + "issued_at": "2026-09-18T10:00:00+08:00" + "payload": { + "task_id": "task-a", + "callee": "15003164745" + } +} +``` +回执 +> 队列 agent\-call\.saas\.v1 +> +> +开始调度执行 +```JSON +{ + "event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb", + "event_type":: "call.execute", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": 1001, + "issued_at": "2026-09-18T10:00:00+08:00" + "payload": { + "status": "dispatched", + } +} +``` +完成结果上报 +成功 +```JSON +{ + "event_id": "call-result-001", + "event_type": "call.execute.result", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": 1001, + "issued_at": "2026-09-18T10:10:15+08:00", + "payload": { + "task_id": "task-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", + "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" + } + } +} +``` +无应答 +```JSON +{ + // ... + "payload": { + //... + "outcome": "no_answer", + "reason_code": 480, + "reason_message": "SIP ring timeout Reason", + "transcript": [], + "opt_out": false, + "recording": {} + } +} +``` + diff --git a/internal/contract/current.go b/internal/contract/current.go new file mode 100644 index 0000000..2fb82af --- /dev/null +++ b/internal/contract/current.go @@ -0,0 +1,62 @@ +package contract + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "git.ipao.vip/rogee/go-sip/contracts" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// ValidateCurrent checks only the current project-local HTTP/MQ contract. +// An absent schema is an error; it never falls back to historical bundles. +func ValidateCurrent(surface string, raw []byte) error { + var name string + switch surface { + case "config-read", "task-discovery", "mq": + name = surface + ".schema.json" + default: + return fmt.Errorf("invalid current contract surface %q", surface) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("decode %s body: %w", surface, err) + } + key := "current/" + name + cached, ok := compiledSchemas.Load(key) + var schema *jsonschema.Schema + if ok { + schema = cached.(*jsonschema.Schema) + } else { + data, err := contracts.ReadCurrent(name) + if err != nil { + return err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("decode current schema %s: %w", name, err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + resource := "https://go-sip.local/contracts/current/" + name + if err := compiler.AddResource(resource, doc); err != nil { + return fmt.Errorf("register %s: %w", name, err) + } + schema, err = compiler.Compile(resource) + if err != nil { + return fmt.Errorf("compile %s: %w", name, err) + } + actual, _ := compiledSchemas.LoadOrStore(key, schema) + schema = actual.(*jsonschema.Schema) + } + if err := schema.Validate(value); err != nil { + var validation *jsonschema.ValidationError + if errors.As(err, &validation) { + return fmt.Errorf("%s validation failed at /%s", surface, strings.Join(validation.InstanceLocation, "/")) + } + return fmt.Errorf("%s validation failed", surface) + } + return nil +} diff --git a/internal/contract/current_test.go b/internal/contract/current_test.go new file mode 100644 index 0000000..b277068 --- /dev/null +++ b/internal/contract/current_test.go @@ -0,0 +1,41 @@ +package contract + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateCurrent(t *testing.T) { + for _, tc := range []struct { + name string + file string + valid bool + }{ + {"config-read", "config-read-task-asr.json", true}, + {"config-read", "config-read-task-full.json", true}, + {"config-read", "config-read-sip.json", true}, + {"config-read", "config-read-providers.json", true}, + {"config-read", "config-read-quota.json", true}, + {"task-discovery", "task-discovery-end.json", true}, + {"mq", "mq-execute.json", true}, + {"config-read", "invalid/config-read-string-tenant.json", false}, + {"mq", "invalid/mq-legacy-schema-version.json", false}, + } { + t.Run(tc.file, func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", tc.file)) + if err != nil { + t.Fatal(err) + } + if err := ValidateCurrent(tc.name, raw); (err == nil) != tc.valid { + t.Fatalf("valid=%v, err=%v", tc.valid, err) + } + }) + } + for _, name := range []string{"", "config-read-v0.3", "../upstream/v1/mq"} { + if err := ValidateCurrent(name, []byte(`{}`)); err == nil || !strings.Contains(err.Error(), "invalid") { + t.Fatalf("accepted unknown contract %q: %v", name, err) + } + } +} diff --git a/scripts/check-current-contracts.py b/scripts/check-current-contracts.py new file mode 100644 index 0000000..abe2b28 --- /dev/null +++ b/scripts/check-current-contracts.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Check provenance and reproducible digests of the sole current local bundle.""" +import hashlib +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUNDLE = ROOT / "contracts" / "local" +MANIFEST = BUNDLE / "manifest.json" + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + for relative, expected in manifest["sources"].items(): + path = ROOT / relative + actual = digest(path) + if actual != expected: + raise SystemExit(f"source hash mismatch: {relative}: {actual} != {expected}") + + paths = sorted( + (p for p in BUNDLE.rglob("*.json") + if p != MANIFEST and (p.parent == BUNDLE or p.relative_to(BUNDLE).parts[0] == "examples")), + key=lambda p: p.relative_to(BUNDLE).as_posix(), + ) + if len(paths) < 10: + raise SystemExit("missing current contract examples or schemas") + listing = "".join(f"{p.relative_to(BUNDLE).as_posix()} {digest(p)}\n" for p in paths) + actual = hashlib.sha256(listing.encode("utf-8")).hexdigest() + if actual != manifest["bundle_sha256"]: + raise SystemExit(f"contract bundle hash mismatch: {actual} != {manifest['bundle_sha256']}") + print(f"current local contract: {len(paths)} JSON files, source and bundle hashes valid") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-current-contracts.sh b/scripts/check-current-contracts.sh new file mode 100644 index 0000000..458af58 --- /dev/null +++ b/scripts/check-current-contracts.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +python3 scripts/check-current-contracts.py +go test ./contracts -run 'TestCurrentContract' -count=1