diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index a210d7e..b8e2322 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -102,7 +102,7 @@ - Dispatcher 旧任务消费分支:删除 `task_runtime_v3`、`task_queue_v3`、`task_control_v3` 及其专属测试和仅依赖这些旧测试夹具的旧 Mock 恢复测试;当前 `CurrentRuntime`、`CurrentBootstrap`、`CurrentDiscoveryFollower` 和根命令隔离测试仍独立通过。 - Dispatcher 旧本地执行与控制分支:删除 `local_v01`、旧 Mock 呼叫/上传/恢复、旧路由与时间策略、旧 `Dispatcher`/lease 实现及依赖它们的测试;现行命令、任务消费、录音/结果链路的定向测试继续通过。 - Agent 会话共用边界:保留当前根命令使用的 `AgentCoordinator` 注册、状态探测、激活、最新会话授权和过期拒绝;移除仅供旧执行链路使用的 `ExecuteRaw`、旧控制、查询补偿与旧 SIP applied-config 核验及其测试。当前 SIP 门禁仍由现行加载回报与隔离测试校验,不冒充 Asterisk 实际加载;旧 MQ/Store 和其余契约尚待清理,不能将此批视为 P07 完成。 -- SQLite 代码归属:当前 Store 所需的 SQLite 驱动和 Dispatcher 身份/命令冲突错误已从旧 Store 迁到当前入口,保留拒绝旧布局及外来 Dispatcher 抢占的原测试。旧 Store 与迁移文件尚未删除;此改动只移动源码定义,不读取、改写或清理现存业务数据。 +- SQLite 代码归属:当前 Store 所需的 SQLite 驱动和 Dispatcher 身份/命令冲突错误已迁到当前入口;随后删除旧 Store 的源码、旧测试和旧迁移 SQL 文件。当前 Store 的独立建表、拒绝旧布局及外来 Dispatcher 抢占测试继续通过;这些操作仅移除仓库内过时源码,不读取、改写或清理现存 SQLite、spool 或 outbox 业务数据。 ## 验收台账 diff --git a/internal/store/active_task_controls.go b/internal/store/active_task_controls.go deleted file mode 100644 index ea829fe..0000000 --- a/internal/store/active_task_controls.go +++ /dev/null @@ -1,55 +0,0 @@ -package store - -import ( - "errors" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" - "google.golang.org/protobuf/proto" -) - -type ActiveTaskExecutionControl struct { - ExecutionID string - Status string - AgentID string - Binding *agentpb.ExecutionBinding -} - -// ActiveTaskExecutionControls returns only reserved or in-flight executions -// that already have a durable Agent assignment. The Store is Dispatcher-local. -func (s *Store) ActiveTaskExecutionControls(tenantID, tenantKey, taskID string) ([]ActiveTaskExecutionControl, error) { - if tenantID == "" || tenantKey == "" || taskID == "" { - return nil, errors.New("tenant and task identity are required") - } - s.mu.Lock() - defer s.mu.Unlock() - rows, err := s.db.Query(`SELECT t.execution_id,t.status,a.agent_id,a.binding - FROM tasks t JOIN execution_agents a ON a.execution_id=t.execution_id - WHERE t.tenant_id=? AND t.tenant_key=? AND t.task_id=? - AND t.status IN ('reserved','running','draining','paused','unknown') - ORDER BY t.execution_id`, tenantID, tenantKey, taskID) - if err != nil { - return nil, err - } - defer rows.Close() - targets := make([]ActiveTaskExecutionControl, 0) - for rows.Next() { - var target ActiveTaskExecutionControl - var encoded []byte - if err := rows.Scan(&target.ExecutionID, &target.Status, &target.AgentID, &encoded); err != nil { - return nil, err - } - binding := &agentpb.ExecutionBinding{} - if err := proto.Unmarshal(encoded, binding); err != nil { - return nil, err - } - if target.ExecutionID != binding.ExecutionId || tenantID != binding.TenantId || tenantKey != binding.TenantKey || taskID != binding.TaskId { - return nil, errors.New("persisted Agent binding does not match task execution") - } - target.Binding = binding - targets = append(targets, target) - } - if err := rows.Err(); err != nil { - return nil, err - } - return targets, nil -} diff --git a/internal/store/ai_authorization.go b/internal/store/ai_authorization.go deleted file mode 100644 index 79b3af4..0000000 --- a/internal/store/ai_authorization.go +++ /dev/null @@ -1,66 +0,0 @@ -package store - -import ( - "encoding/json" - "errors" - - "git.ipao.vip/rogee/go-sip/internal/ai" - "git.ipao.vip/rogee/go-sip/internal/contract" -) - -// LoadAuthorizedAI never treats a cached configuration as permission to run. -// The latest received reply governs admission; rejection, revocation and expiry -// cannot fall back to an earlier successful reply. -func (s *Store) LoadAuthorizedAI(tenantID, tenantKey, version string) (ai.Snapshot, []byte, error) { - snapshot, err := s.LoadAIConfig(tenantID, tenantKey, version) - if err != nil { - return ai.Snapshot{}, nil, err - } - var raw []byte - if err := s.db.QueryRow(`SELECT r.response FROM ai_mq_requests r JOIN outbox o ON o.event_id=r.message_id - WHERE r.tenant_id=? AND r.tenant_key=? AND r.agent_version_id=? AND r.response IS NOT NULL - ORDER BY o.id DESC LIMIT 1`, tenantID, tenantKey, version).Scan(&raw); err != nil { - return ai.Snapshot{}, nil, err - } - response, err := contract.DecodeService(raw) - if err != nil { - return ai.Snapshot{}, nil, err - } - if response.Status != "ok" { - return ai.Snapshot{}, nil, errors.New("latest AI configuration authorization request was rejected") - } - var payload struct { - Authorization json.RawMessage `json:"authorization"` - } - if err := json.Unmarshal(response.Payload, &payload); err != nil { - return ai.Snapshot{}, nil, err - } - authorization, err := ai.ValidateAuthorization(payload.Authorization, snapshot, tenantID, tenantKey, s.now()) - if err != nil { - return ai.Snapshot{}, nil, err - } - // Revocation is a permanent fact for this grant identity, even if a later - // correlated request returns an older non-revoked representation. - rows, err := s.db.Query(`SELECT json_extract(response,'$.payload.authorization') FROM ai_mq_requests - WHERE tenant_id=? AND tenant_key=? AND agent_version_id=? - AND json_extract(response,'$.payload.authorization.authorization_id')=? - AND json_extract(response,'$.payload.authorization.revoked')=1`, tenantID, tenantKey, version, authorization.AuthorizationID) - if err != nil { - return ai.Snapshot{}, nil, err - } - defer rows.Close() - for rows.Next() { - var revokedRaw []byte - if err := rows.Scan(&revokedRaw); err != nil { - return ai.Snapshot{}, nil, err - } - if _, err := ai.DecodeBoundAuthorization(revokedRaw, snapshot, tenantID, tenantKey); err != nil { - return ai.Snapshot{}, nil, err - } - return ai.Snapshot{}, nil, errors.New("AI authorization has a persisted revocation") - } - if err := rows.Err(); err != nil { - return ai.Snapshot{}, nil, err - } - return snapshot, append([]byte(nil), payload.Authorization...), nil -} diff --git a/internal/store/ai_authorization_test.go b/internal/store/ai_authorization_test.go deleted file mode 100644 index 9679484..0000000 --- a/internal/store/ai_authorization_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package store - -import ( - "encoding/json" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func validAIReply(t *testing.T, now time.Time) []byte { - t.Helper() - raw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/ai-config-result.json") - if err != nil { - t.Fatal(err) - } - var message map[string]any - if err := json.Unmarshal(raw, &message); err != nil { - t.Fatal(err) - } - payload := message["payload"].(map[string]any) - authorization := payload["authorization"].(map[string]any) - delete(authorization, "allowed_egress_pool_ids") - authorization["config_sha256"] = payload["snapshot"].(map[string]any)["content_sha256"] - authorization["issued_at"] = now.Add(-time.Second).Format(time.RFC3339Nano) - authorization["expires_at"] = now.Add(time.Minute).Format(time.RFC3339Nano) - raw, err = json.Marshal(message) - if err != nil { - t.Fatal(err) - } - return raw -} - -func TestCachedAIRequiresLiveBoundAuthorization(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) - s.now = func() time.Time { return now } - request, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/ai-config-request.json") - if err != nil { - t.Fatal(err) - } - if err := s.QueueAIConfigRequest(request); err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := s.StoreAIConfigResponse(validAIReply(t, now), route.InboundKey); err != nil { - t.Fatal(err) - } - snapshot, authorization, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a") - if err != nil || len(authorization) == 0 || snapshot.AgentVersionID != "version-a" { - t.Fatalf("authorized cache: %v", err) - } - now = now.Add(time.Hour) - if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a"); err == nil { - t.Fatal("expired cached authorization admitted work") - } -} diff --git a/internal/store/ai_mq.go b/internal/store/ai_mq.go deleted file mode 100644 index a028020..0000000 --- a/internal/store/ai_mq.go +++ /dev/null @@ -1,172 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/ai" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -var ErrAIConfigResponse = errors.New("invalid AI configuration response") - -func (s *Store) QueueAIConfigRequest(raw []byte) error { - request, err := contract.DecodeService(raw) - if err != nil { - return err - } - if request.MessageType != "ai.config.request" { - return errors.New("expected AI configuration request") - } - route, err := tenant.NewDispatcherRoute(request.DispatcherID, request.TenantKey) - if err != nil { - return err - } - var payload struct { - AgentVersionID string `json:"agent_version_id"` - } - if err := json.Unmarshal(request.Payload, &payload); err != nil { - return err - } - sum := sha256.Sum256(raw) - digest := hex.EncodeToString(sum[:]) - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := bindMQScope(tx, request.DispatcherID, request.TenantID, request.TenantKey); err != nil { - return err - } - var previous string - err = tx.QueryRow(`SELECT body_hash FROM ai_mq_requests WHERE tenant_id=? AND message_id=?`, request.TenantID, request.MessageID).Scan(&previous) - if err == nil { - if previous != digest { - return ErrIdempotencyConflict - } - return nil - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - deadline, err := time.Parse(time.RFC3339Nano, request.NotAfter) - if err != nil { - return err - } - if !s.now().Before(deadline) { - return errors.New("AI configuration request expired") - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, request.MessageID, request.TenantKey, mq.EventExchange, route.OutboundKey, raw, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - if _, err := tx.Exec(`INSERT INTO ai_mq_requests(tenant_id,message_id,tenant_key,dispatcher_id,agent_version_id,body,body_hash) VALUES(?,?,?,?,?,?,?)`, request.TenantID, request.MessageID, request.TenantKey, request.DispatcherID, payload.AgentVersionID, raw, digest); err != nil { - return err - } - return tx.Commit() -} - -// StoreAIConfigResponse records the original response and immutable snapshot -// before the consumer ACK. Configuration alone is not execution authorization. -func (s *Store) StoreAIConfigResponse(raw []byte, routingKey string) error { - response, err := contract.DecodeService(raw) - if err != nil { - return err - } - if response.MessageType != "ai.config.result" { - return ErrAIConfigResponse - } - route, err := tenant.NewDispatcherRoute(response.DispatcherID, response.TenantKey) - if err != nil { - return err - } - if routingKey != route.InboundKey { - return ErrMessageScope - } - sum := sha256.Sum256(raw) - digest := hex.EncodeToString(sum[:]) - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := bindMQScope(tx, response.DispatcherID, response.TenantID, response.TenantKey); err != nil { - return err - } - var requestRaw []byte - var previousID, previousHash sql.NullString - err = tx.QueryRow(`SELECT body,response_id,response_hash FROM ai_mq_requests WHERE tenant_id=? AND tenant_key=? AND message_id=?`, response.TenantID, response.TenantKey, response.CorrelationID).Scan(&requestRaw, &previousID, &previousHash) - if errors.Is(err, sql.ErrNoRows) { - return ErrMessageScope - } - if err != nil { - return err - } - if previousID.Valid { - if previousID.String != response.MessageID || previousHash.String != digest { - return ErrIdempotencyConflict - } - return nil - } - request, err := contract.DecodeService(requestRaw) - if err != nil { - return err - } - if response.Status == "ok" { - snapshot, err := ai.ValidateConfigResponse(raw, request, s.now()) - if err != nil { - return fmt.Errorf("%w: %v", ErrAIConfigResponse, err) - } - var payload struct { - Authorization json.RawMessage `json:"authorization"` - } - if err := json.Unmarshal(response.Payload, &payload); err != nil { - return err - } - if _, err := ai.DecodeBoundAuthorization(payload.Authorization, snapshot, response.TenantID, response.TenantKey); err != nil { - return fmt.Errorf("%w: %v", ErrAIConfigResponse, err) - } - var existing string - err = tx.QueryRow(`SELECT config_sha256 FROM ai_config_snapshots WHERE tenant_id=? AND agent_version_id=?`, response.TenantID, snapshot.AgentVersionID).Scan(&existing) - if err == nil && existing != snapshot.Digest { - return ErrIdempotencyConflict - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - if _, err := tx.Exec(`INSERT INTO ai_config_snapshots(tenant_id,agent_version_id,tenant_key,config_sha256,config) VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING`, response.TenantID, snapshot.AgentVersionID, response.TenantKey, snapshot.Digest, snapshot.Raw); err != nil { - return err - } - } - if _, err := tx.Exec(`UPDATE ai_mq_requests SET response_id=?,response_hash=?,response=? WHERE tenant_id=? AND message_id=?`, response.MessageID, digest, raw, response.TenantID, response.CorrelationID); err != nil { - return err - } - return tx.Commit() -} - -func (s *Store) LoadAIConfig(tenantID, tenantKey, version string) (ai.Snapshot, error) { - var raw []byte - var digest string - if err := s.db.QueryRow(`SELECT config,config_sha256 FROM ai_config_snapshots WHERE tenant_id=? AND tenant_key=? AND agent_version_id=?`, tenantID, tenantKey, version).Scan(&raw, &digest); err != nil { - return ai.Snapshot{}, err - } - snapshot, err := ai.Validate(raw) - if err != nil { - return ai.Snapshot{}, err - } - if snapshot.Digest != digest || snapshot.AgentVersionID != version { - return ai.Snapshot{}, errors.New("persisted AI configuration integrity mismatch") - } - snapshot.TenantKey = tenantKey - return snapshot, nil -} diff --git a/internal/store/ai_mq_test.go b/internal/store/ai_mq_test.go deleted file mode 100644 index 2af1eae..0000000 --- a/internal/store/ai_mq_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package store - -import ( - "path/filepath" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestAIConfigurationMQRequestAndSnapshotSurviveRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - s, err := Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { s.Close() }() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) - s.now = func() time.Time { return now } - base := "upstream/" + contract.MQSourceCommit + "/examples/" - request, err := contracts.Files.ReadFile(base + "ai-config-request.json") - if err != nil { - t.Fatal(err) - } - if err := s.QueueAIConfigRequest(request); err != nil { - t.Fatal(err) - } - if err := s.QueueAIConfigRequest(request); err != nil { - t.Fatal(err) - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil || count != 1 { - t.Fatalf("request count=%d err=%v", count, err) - } - raw := validAIReply(t, now) - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := s.StoreAIConfigResponse(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - s, err = Open(path) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return now.Add(time.Hour) } - if err := s.StoreAIConfigResponse(raw, route.InboundKey); err != nil { - t.Fatalf("known reply revalidated as new expired response: %v", err) - } - snapshot, err := s.LoadAIConfig("tenant-a", "tenant-a", "version-a") - if err != nil { - t.Fatal(err) - } - if snapshot.AgentVersionID != "version-a" || snapshot.TenantKey != "tenant-a" { - t.Fatal("recovered snapshot lost tenant/version") - } - if _, err := s.LoadAIConfig("another-tenant", "tenant-a", "version-a"); err == nil { - t.Fatal("cross-tenant snapshot access") - } -} diff --git a/internal/store/ai_revocation_test.go b/internal/store/ai_revocation_test.go deleted file mode 100644 index 60b0ee1..0000000 --- a/internal/store/ai_revocation_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package store - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestRevokedAuthorizationCannotReappearUnderNewRequest(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) - s.now = func() time.Time { return now } - raw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/ai-config-request.json") - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - for step := 0; step < 4; step++ { - var request, response map[string]any - if err := json.Unmarshal(raw, &request); err != nil { - t.Fatal(err) - } - request["message_id"] = fmt.Sprintf("request-%d", step) - requestRaw, err := json.Marshal(request) - if err != nil { - t.Fatal(err) - } - if err := s.QueueAIConfigRequest(requestRaw); err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(validAIReply(t, now), &response); err != nil { - t.Fatal(err) - } - response["message_id"] = fmt.Sprintf("response-%d", step) - response["correlation_id"] = request["message_id"] - auth := response["payload"].(map[string]any)["authorization"].(map[string]any) - auth["revoked"] = step == 1 - if step == 1 { - auth["revocation_reason"] = "revoked by local test" - } - if step == 3 { - auth["authorization_id"] = "new-grant-after-revocation" - } - responseRaw, err := json.Marshal(response) - if err != nil { - t.Fatal(err) - } - if err := s.StoreAIConfigResponse(responseRaw, route.InboundKey); err != nil { - t.Fatal(err) - } - _, _, err = s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a") - if step == 1 || step == 2 { - if err == nil { - t.Fatalf("revoked grant admitted at step %d", step) - } - } else if err != nil { - t.Fatalf("valid new grant rejected: %v", err) - } - } -} diff --git a/internal/store/call_query.go b/internal/store/call_query.go deleted file mode 100644 index 2a28626..0000000 --- a/internal/store/call_query.go +++ /dev/null @@ -1,110 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" -) - -var ErrQuerySnapshotTooLarge = errors.New("query snapshot exceeds MQ message budget") - -func callQuerySnapshot(tx *sql.Tx, request contract.ServiceMessage, callID string, now time.Time) (map[string]any, error) { - rows, err := tx.Query(`SELECT body,status FROM outbox WHERE tenant_key=? - AND json_extract(body,'$.tenant_id')=? AND json_extract(body,'$.dispatcher_id')=? - AND json_extract(body,'$.event_type') IS NOT NULL - AND json_extract(body,'$.payload.call_id')=? - ORDER BY json_extract(body,'$.occurred_at'),event_id`, request.TenantKey, request.TenantID, request.DispatcherID, callID) - if err != nil { - return nil, err - } - defer rows.Close() - attempts, transcripts, recordings := []json.RawMessage{}, []json.RawMessage{}, []json.RawMessage{} - delivery := map[string]int{"pending": 0, "retry": 0, "dispatching": 0, "published": 0} - var current map[string]any - var currentVersion int64 = -1 - var finished map[string]any - var finishedVersion int64 = -1 - count, size := 0, 0 - for rows.Next() { - var raw []byte - var status string - if err := rows.Scan(&raw, &status); err != nil { - return nil, err - } - if _, ok := delivery[status]; !ok { - return nil, fmt.Errorf("unknown outbox delivery state %q", status) - } - delivery[status]++ - count++ - var event struct { - EventType string `json:"event_type"` - Payload json.RawMessage `json:"payload"` - } - if err := json.Unmarshal(raw, &event); err != nil { - return nil, err - } - switch event.EventType { - case "call.status", "call.finished": - var fields map[string]any - if err := json.Unmarshal(event.Payload, &fields); err != nil { - return nil, err - } - var state struct { - Version int64 `json:"call_version"` - } - if err := json.Unmarshal(event.Payload, &state); err != nil { - return nil, err - } - if state.Version < 1 { - return nil, errors.New("call fact has no positive call_version") - } - if event.EventType == "call.status" { - attempts = append(attempts, json.RawMessage(raw)) - size += len(raw) - if state.Version >= currentVersion { - current, currentVersion = fields, state.Version - } - } else if state.Version >= finishedVersion { - finished, finishedVersion = fields, state.Version - } - case "transcript.updated", "transcript.failed": - transcripts = append(transcripts, json.RawMessage(raw)) - size += len(raw) - case "recording.uploaded", "recording.failed": - recordings = append(recordings, json.RawMessage(raw)) - size += len(raw) - } - if size > 256<<10 { - return nil, ErrQuerySnapshotTooLarge - } - } - if err := rows.Err(); err != nil { - return nil, err - } - if count == 0 { - return nil, sql.ErrNoRows - } - if finished != nil && finishedVersion >= currentVersion { - current, currentVersion = finished, finishedVersion - // A persisted call.finished fact establishes that the call has ended. - current["call_state"] = "ended" - } - if current == nil { - return nil, errors.New("call facts contain no state/version evidence") - } - result := map[string]any{"call_id": callID, "call_version": currentVersion, "snapshot_at": now.Format(time.RFC3339Nano)} - result["attempts"] = attempts - result["transcript"] = map[string]any{"events": transcripts} - result["recordings"] = recordings - result["delivery"] = delivery - for _, key := range []string{"execution_id", "task_id", "task_item_id", "call_state", "reason_code", "outcome", "started_at", "ended_at", "duration_ms"} { - if value, ok := current[key]; ok { - result[key] = value - } - } - return result, nil -} diff --git a/internal/store/call_query_test.go b/internal/store/call_query_test.go deleted file mode 100644 index cfccd0c..0000000 --- a/internal/store/call_query_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package store - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestCallQueryCollectsOnlyBoundRecordedFacts(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"call-status", "transcript-updated", "recording-uploaded"} { - raw, err := contracts.Files.ReadFile("upstream/v1/examples/event-" + name + ".json") - if err != nil { - t.Fatal(err) - } - var event map[string]any - if err := json.Unmarshal(raw, &event); err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,'agent-call.saas.v2',?,?,'pending',?)`, event["event_id"], "tenant-a", route.OutboundKey, raw, event["occurred_at"]); err != nil { - t.Fatal(err) - } - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/call-query.json") - if err != nil { - t.Fatal(err) - } - id, _, err := s.HandleQuery(raw, route.InboundKey) - if err != nil { - t.Fatal(err) - } - var body []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, id).Scan(&body); err != nil { - t.Fatal(err) - } - response, err := contract.DecodeService(body) - if err != nil { - t.Fatal(err) - } - var snapshot struct { - CallID string `json:"call_id"` - State string `json:"call_state"` - Version int64 `json:"call_version"` - Attempts []json.RawMessage `json:"attempts"` - Transcript struct { - Events []json.RawMessage `json:"events"` - } `json:"transcript"` - Recordings []json.RawMessage `json:"recordings"` - Delivery map[string]int `json:"delivery"` - } - if err := json.Unmarshal(response.Payload, &snapshot); err != nil { - t.Fatal(err) - } - if response.MessageType != "call.query.result" || response.Status != "ok" || snapshot.CallID != "call-a" || snapshot.State != "answered" || snapshot.Version != 1 || len(snapshot.Attempts) != 1 || len(snapshot.Transcript.Events) != 1 || len(snapshot.Recordings) != 1 || snapshot.Delivery["pending"] != 3 { - t.Fatalf("wrong snapshot: %+v", snapshot) - } - largeRaw, err := contracts.Files.ReadFile("upstream/v1/examples/event-transcript-updated.json") - if err != nil { - t.Fatal(err) - } - var large map[string]any - if err := json.Unmarshal(largeRaw, &large); err != nil { - t.Fatal(err) - } - large["payload"].(map[string]any)["text"] = strings.Repeat("x", 150000) - for _, id := range []string{"large-1", "large-2"} { - large["event_id"] = id - largeRaw, err = json.Marshal(large) - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,'tenant-a','agent-call.saas.v2',?,?,'pending','2026-09-21T00:00:00Z')`, id, route.OutboundKey, largeRaw); err != nil { - t.Fatal(err) - } - } - var query map[string]any - if err := json.Unmarshal(raw, &query); err != nil { - t.Fatal(err) - } - query["message_id"] = "large-query" - raw, err = json.Marshal(query) - if err != nil { - t.Fatal(err) - } - id, _, err = s.HandleQuery(raw, route.InboundKey) - if err != nil { - t.Fatalf("oversize query must receive a bounded refusal, not endless requeue: %v", err) - } - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, id).Scan(&body); err != nil { - t.Fatal(err) - } - response, err = contract.DecodeService(body) - if err != nil || response.Status != "rejected" || response.ReasonCode != "unavailable" { - t.Fatalf("oversize response: %+v %v", response, err) - } - query["message_id"] = "delivery-only-query" - query["payload"].(map[string]any)["include"] = []string{"delivery"} - raw, err = json.Marshal(query) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleQuery(raw, route.InboundKey); err == nil { - t.Fatal("unapproved include field accepted") - } -} diff --git a/internal/store/control_ack.go b/internal/store/control_ack.go deleted file mode 100644 index ea2a2bd..0000000 --- a/internal/store/control_ack.go +++ /dev/null @@ -1,106 +0,0 @@ -package store - -import ( - "errors" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" -) - -// CompleteTaskControl records an observed applied revision. The caller must -// validate the Agent's APPLIED receipt before invoking this transaction. -func (s *Store) CompleteTaskControl(target PendingTaskControl, appliedRevision int64) error { - if target.AgentID == "" || target.ExpectedRevision < 1 || appliedRevision != target.ExpectedRevision+1 { - return ErrCASConflict - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var agentID string - var expected int64 - var already bool - if err := tx.QueryRow(`SELECT a.agent_id,c.expected_revision,t.applied FROM mq_task_control_targets t - JOIN mq_task_controls c ON c.tenant_id=t.tenant_id AND c.command_id=t.command_id - JOIN execution_agents a ON a.execution_id=t.execution_id - WHERE t.tenant_id=? AND t.command_id=? AND t.execution_id=?`, target.TenantID, target.CommandID, target.ExecutionID).Scan(&agentID, &expected, &already); err != nil { - return err - } - if agentID != target.AgentID || expected != target.ExpectedRevision { - return ErrMessageScope - } - if already { - return nil - } - if _, err := tx.Exec(`UPDATE mq_task_control_targets SET applied=1 WHERE tenant_id=? AND command_id=? AND execution_id=?`, target.TenantID, target.CommandID, target.ExecutionID); err != nil { - return err - } - var pending int - if err := tx.QueryRow(`SELECT COUNT(*) FROM mq_task_control_targets WHERE tenant_id=? AND command_id=? AND applied=0`, target.TenantID, target.CommandID).Scan(&pending); err != nil { - return err - } - if pending != 0 { - return tx.Commit() - } - var raw []byte - if err := tx.QueryRow(`SELECT body FROM inbox WHERE tenant_id=? AND command_id=?`, target.TenantID, target.CommandID).Scan(&raw); err != nil { - return err - } - command, err := contract.DecodeMQCommand(raw) - if err != nil { - return err - } - route, err := tenant.NewDispatcherRoute(command.DispatcherID, command.TenantKey) - if err != nil { - return err - } - if err := bindMQScope(tx, command.DispatcherID, command.TenantID, command.TenantKey); err != nil { - return err - } - var taskID, action string - if err := tx.QueryRow(`SELECT task_id,action FROM mq_task_controls WHERE tenant_id=? AND command_id=?`, target.TenantID, target.CommandID).Scan(&taskID, &action); err != nil { - return err - } - var total, matches int - if err := tx.QueryRow(`SELECT COUNT(*),COALESCE(SUM(tasks.task_revision=?),0) FROM tasks JOIN mq_task_control_targets t ON t.execution_id=tasks.execution_id WHERE t.tenant_id=? AND t.command_id=?`, expected, target.TenantID, target.CommandID).Scan(&total, &matches); err != nil { - return err - } - if total == 0 || matches != total { - return ErrCASConflict - } - now := s.now().UTC() - if _, err := tx.Exec(`UPDATE tasks SET task_revision=?,updated_at=?,status=CASE WHEN ?='resume' AND EXISTS - (SELECT 1 FROM reservations r WHERE r.execution_id=tasks.execution_id AND r.state IN ('held','unknown')) THEN 'running' ELSE status END - WHERE execution_id IN (SELECT execution_id FROM mq_task_control_targets WHERE tenant_id=? AND command_id=?)`, appliedRevision, now.Format(time.RFC3339Nano), action, target.TenantID, target.CommandID); err != nil { - return err - } - eventID := uuid.NewSHA1(uuid.NameSpaceURL, []byte("task.control.applied:"+command.DispatcherID+":"+command.TenantID+":"+command.CommandID)).String() - body, err := (contract.EventBuilder{TenantID: command.TenantID, TenantKey: command.TenantKey, TraceID: command.TraceID, EventType: "command.result", Aggregate: "command", AggregateID: command.CommandID, Version: 2, Payload: map[string]any{"command_id": command.CommandID, "command_type": "task.control", "task_id": taskID, "status": "applied", "reason_code": "applied", "requested_task_revision": expected, "applied_task_revision": appliedRevision}}).MarshalMQ(command.DispatcherID, now, eventID) - if err != nil { - return err - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, eventID, command.TenantKey, mq.EventExchange, route.OutboundKey, body, now.Format(time.RFC3339Nano)); err != nil { - return err - } - if _, err := tx.Exec(`UPDATE mq_task_controls SET state='applied' WHERE tenant_id=? AND command_id=?`, target.TenantID, target.CommandID); err != nil { - return err - } - result, err := tx.Exec(`UPDATE mq_command_receipts SET response_id=? WHERE tenant_id=? AND command_id=?`, eventID, target.TenantID, target.CommandID) - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return errors.New("control receipt link missing") - } - return tx.Commit() -} diff --git a/internal/store/control_ack_test.go b/internal/store/control_ack_test.go deleted file mode 100644 index 866e8bc..0000000 --- a/internal/store/control_ack_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package store - -import ( - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestControlAcknowledgementCommitsFinalReceiptOnce(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - base := "upstream/" + contract.MQSourceCommit + "/examples/" - raw, err := contracts.Files.ReadFile(base + "call-execute.json") - if err != nil { - t.Fatal(err) - } - envelope, payload, err := contract.DecodeExecute(raw) - if err != nil { - t.Fatal(err) - } - route, _ := tenant.NewDispatcherRoute(identityA, envelope.TenantKey) - if _, err := s.IngestCommand(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - if err := s.Reserve("reservation-a", payload.ExecutionID, envelope.TenantKey, []string{"global"}); err != nil { - t.Fatal(err) - } - binding := &agentpb.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} - if err := s.BindExecutionAgent("agent-a", binding); err != nil { - t.Fatal(err) - } - control, err := contracts.Files.ReadFile(base + "task-control.json") - if err != nil { - t.Fatal(err) - } - original, _, err := s.HandleTaskControl(control, route.InboundKey) - if err != nil { - t.Fatal(err) - } - targets, err := s.PendingTaskControls(10) - if err != nil || len(targets) != 1 { - t.Fatalf("targets: %v %v", targets, err) - } - if err := s.CompleteTaskControl(targets[0], 1); err == nil { - t.Fatal("unapplied revision accepted") - } - if err := s.CompleteTaskControl(targets[0], 2); err != nil { - t.Fatal(err) - } - if err := s.CompleteTaskControl(targets[0], 2); err != nil { - t.Fatal(err) - } - final, duplicate, err := s.HandleTaskControl(control, route.InboundKey) - if err != nil || !duplicate || final == original { - t.Fatalf("final receipt missing: %v", err) - } - pending, err := s.PendingTaskControls(10) - if err != nil || len(pending) != 0 { - t.Fatal("applied control remained pending") - } - var revision, count int - if err := s.DB().QueryRow(`SELECT task_revision FROM tasks`).Scan(&revision); err != nil { - t.Fatal(err) - } - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil { - t.Fatal(err) - } - if revision != 2 || count != 3 { - t.Fatalf("revision=%d outbox=%d", revision, count) - } -} diff --git a/internal/store/control_order_test.go b/internal/store/control_order_test.go deleted file mode 100644 index 35d33d8..0000000 --- a/internal/store/control_order_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package store - -import ( - "encoding/json" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestMQControlRejectsLateRevisionWithoutRegressingTask(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - base := "upstream/" + contract.MQSourceCommit + "/examples/" - execute, err := contracts.Files.ReadFile(base + "call-execute.json") - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(execute, route.InboundKey); err != nil { - t.Fatal(err) - } - first, err := contracts.Files.ReadFile(base + "task-control.json") - if err != nil { - t.Fatal(err) - } - if _, duplicate, err := s.HandleTaskControl(first, route.InboundKey); err != nil || duplicate { - t.Fatalf("first control: err=%v duplicate=%v", err, duplicate) - } - - var late map[string]any - if err := json.Unmarshal(first, &late); err != nil { - t.Fatal(err) - } - late["command_id"] = "task.control-late" - payload := late["payload"].(map[string]any) - payload["action"] = "resume" - payload["expected_task_revision"] = float64(1) - lateRaw, err := json.Marshal(late) - if err != nil { - t.Fatal(err) - } - responseID, duplicate, err := s.HandleTaskControl(lateRaw, route.InboundKey) - if err != nil || duplicate { - t.Fatalf("late control: err=%v duplicate=%v", err, duplicate) - } - var body []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, responseID).Scan(&body); err != nil { - t.Fatal(err) - } - var event struct { - Payload struct { - Status string `json:"status"` - Reason string `json:"reason_code"` - } `json:"payload"` - } - if err := json.Unmarshal(body, &event); err != nil { - t.Fatal(err) - } - if event.Payload.Status != "rejected" || event.Payload.Reason != "revision_conflict" { - t.Fatalf("late control was not rejected: %+v", event.Payload) - } - var status string - var revision int - if err := s.DB().QueryRow(`SELECT status,task_revision FROM tasks`).Scan(&status, &revision); err != nil { - t.Fatal(err) - } - if status != "paused" || revision != 2 { - t.Fatalf("late control regressed task: status=%s revision=%d", status, revision) - } -} diff --git a/internal/store/control_pending.go b/internal/store/control_pending.go deleted file mode 100644 index 3c63364..0000000 --- a/internal/store/control_pending.go +++ /dev/null @@ -1,53 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" - "google.golang.org/protobuf/proto" -) - -type PendingTaskControl struct { - TenantID, CommandID, ExecutionID, AgentID, Action, Policy string - ExpectedRevision int64 - Binding *agentpb.ExecutionBinding -} - -// PendingTaskControls includes unroutable targets rather than silently dropping -// them through an inner join. Missing assignment is an explicit recovery error. -func (s *Store) PendingTaskControls(limit int) ([]PendingTaskControl, error) { - if limit <= 0 { - return nil, errors.New("control batch limit must be positive") - } - rows, err := s.db.Query(`SELECT c.tenant_id,c.command_id,t.execution_id,c.action,c.active_call_policy,c.expected_revision,a.agent_id,a.binding - FROM mq_task_controls c JOIN mq_task_control_targets t ON t.tenant_id=c.tenant_id AND t.command_id=c.command_id - LEFT JOIN execution_agents a ON a.execution_id=t.execution_id - WHERE c.state='pending' AND t.applied=0 ORDER BY c.rowid,t.execution_id LIMIT ?`, limit) - if err != nil { - return nil, err - } - defer rows.Close() - var result []PendingTaskControl - for rows.Next() { - var item PendingTaskControl - var agentID sql.NullString - var raw []byte - if err := rows.Scan(&item.TenantID, &item.CommandID, &item.ExecutionID, &item.Action, &item.Policy, &item.ExpectedRevision, &agentID, &raw); err != nil { - return nil, err - } - if agentID.Valid { - item.AgentID = agentID.String - item.Binding = &agentpb.ExecutionBinding{} - if err := proto.Unmarshal(raw, item.Binding); err != nil { - return nil, err - } - if item.Binding.ExecutionId != item.ExecutionID || item.Binding.TenantId != item.TenantID { - return nil, ErrMessageScope - } - item.Binding.TaskRevision = item.ExpectedRevision - } - result = append(result, item) - } - return result, rows.Err() -} diff --git a/internal/store/control_routes.go b/internal/store/control_routes.go deleted file mode 100644 index 4607afa..0000000 --- a/internal/store/control_routes.go +++ /dev/null @@ -1,66 +0,0 @@ -package store - -import ( - "bytes" - "database/sql" - "errors" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" - "google.golang.org/protobuf/proto" -) - -// BindExecutionAgent records the controlled endpoint identity before any remote -// execution. An execution may not be reassigned after a lost response. -func (s *Store) BindExecutionAgent(agentID string, binding *agentpb.ExecutionBinding) error { - if err := validateExecutionAgentBinding(agentID, binding); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := bindExecutionAgentTx(tx, agentID, binding); err != nil { - return err - } - return tx.Commit() -} - -func validateExecutionAgentBinding(agentID string, binding *agentpb.ExecutionBinding) error { - if agentID == "" || binding == nil { - return errors.New("agent and execution binding are required") - } - return nil -} - -// bindExecutionAgentTx is shared by the legacy control route and the local -// Mock origination claim, which must commit its Agent assignment atomically. -func bindExecutionAgentTx(tx *sql.Tx, agentID string, binding *agentpb.ExecutionBinding) error { - encoded, err := (proto.MarshalOptions{Deterministic: true}).Marshal(binding) - if err != nil { - return err - } - var count int - if err := tx.QueryRow(`SELECT COUNT(*) FROM tasks WHERE execution_id=? AND tenant_id=? AND tenant_key=? AND task_id=? AND task_item_id=? AND task_revision=? AND agent_version_id=? AND route_policy_id=? AND caller_profile_id=? AND status='reserved'`, binding.ExecutionId, binding.TenantId, binding.TenantKey, binding.TaskId, binding.TaskItemId, binding.TaskRevision, binding.AgentVersionId, binding.RoutePolicyId, binding.CallerProfileId).Scan(&count); err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - var previousAgent string - var previousBinding []byte - err = tx.QueryRow(`SELECT agent_id,binding FROM execution_agents WHERE execution_id=?`, binding.ExecutionId).Scan(&previousAgent, &previousBinding) - if err == nil { - if previousAgent != agentID || !bytes.Equal(previousBinding, encoded) { - return ErrIdempotencyConflict - } - return nil - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - _, err = tx.Exec(`INSERT INTO execution_agents(execution_id,agent_id,binding) VALUES(?,?,?)`, binding.ExecutionId, agentID, encoded) - return err -} diff --git a/internal/store/control_routes_test.go b/internal/store/control_routes_test.go deleted file mode 100644 index 6a4ff16..0000000 --- a/internal/store/control_routes_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package store - -import ( - "testing" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/testfixture" -) - -func TestActiveTaskExecutionControlsReturnsOnlyBoundNonterminalExecutions(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - envelope, payload, err := contract.DecodeExecute(raw) - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey(envelope.TenantKey)); err != nil { - t.Fatal(err) - } - binding := &agentpb.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - if err := s.Reserve("reservation-a", binding.ExecutionId, binding.TenantKey, []string{"global"}); err != nil { - t.Fatal(err) - } - if err := s.BindExecutionAgent("agent-a", binding); err != nil { - t.Fatal(err) - } - targets, err := s.ActiveTaskExecutionControls(binding.TenantId, binding.TenantKey, binding.TaskId) - if err != nil || len(targets) != 1 || targets[0].ExecutionID != binding.ExecutionId || targets[0].AgentID != "agent-a" { - t.Fatalf("active targets=%+v err=%v", targets, err) - } - if _, err := s.DB().Exec(`UPDATE tasks SET status='finished' WHERE execution_id=?`, binding.ExecutionId); err != nil { - t.Fatal(err) - } - targets, err = s.ActiveTaskExecutionControls(binding.TenantId, binding.TenantKey, binding.TaskId) - if err != nil || len(targets) != 0 { - t.Fatalf("terminal task remained active: %+v err=%v", targets, err) - } -} - -func TestExecutionRouteRequiresReservedMatchingTask(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - envelope, payload, err := contract.DecodeExecute(raw) - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey(envelope.TenantKey)); err != nil { - t.Fatal(err) - } - binding := &agentpb.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} - if err := s.BindExecutionAgent("agent-a", binding); err == nil { - t.Fatal("unreserved task dispatched") - } - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - if err := s.Reserve("reservation-a", binding.ExecutionId, binding.TenantKey, []string{"global"}); err != nil { - t.Fatal(err) - } - if err := s.BindExecutionAgent("agent-a", binding); err != nil { - t.Fatal(err) - } - if err := s.BindExecutionAgent("agent-a", binding); err != nil { - t.Fatal(err) - } - if err := s.BindExecutionAgent("other-agent", binding); err == nil { - t.Fatal("execution reassigned to another agent") - } - if _, err := s.DB().Exec(`UPDATE tasks SET status='paused'`); err != nil { - t.Fatal(err) - } - if err := s.BindExecutionAgent("agent-a", binding); err == nil { - t.Fatal("existing route bypassed pause barrier") - } -} diff --git a/internal/store/execute_business_duplicate_test.go b/internal/store/execute_business_duplicate_test.go deleted file mode 100644 index bef6f0d..0000000 --- a/internal/store/execute_business_duplicate_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package store - -import ( - "encoding/json" - "errors" - "git.ipao.vip/rogee/go-sip/internal/testfixture" - "testing" -) - -func TestBusinessDuplicateGetsOwnReceiptWithoutNewTask(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - key := testfixture.InboundKey("tenant-demo-key") - if _, err := s.IngestCommand(raw, key); err != nil { - t.Fatal(err) - } - var envelope map[string]any - if err := json.Unmarshal(raw, &envelope); err != nil { - t.Fatal(err) - } - envelope["command_id"] = "second-command" - raw, err = json.Marshal(envelope) - if err != nil { - t.Fatal(err) - } - result, err := s.IngestCommand(raw, key) - if err != nil || !result.Duplicate { - t.Fatalf("duplicate: %+v %v", result, err) - } - var tasks, receipts int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&tasks); err != nil { - t.Fatal(err) - } - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id='second-command-result'`).Scan(&receipts); err != nil { - t.Fatal(err) - } - if tasks != 1 || receipts != 1 { - t.Fatalf("tasks=%d receipts=%d", tasks, receipts) - } - envelope["command_id"] = "changed-business-command" - envelope["payload"].(map[string]any)["callee"] = "15830461047" - changed, err := json.Marshal(envelope) - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(changed, key); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("changed business payload accepted: %v", err) - } -} diff --git a/internal/store/execute_deadline_test.go b/internal/store/execute_deadline_test.go deleted file mode 100644 index 6de4e84..0000000 --- a/internal/store/execute_deadline_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package store - -import ( - "encoding/json" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/internal/testfixture" -) - -func TestExecutionDuplicateAfterDeadlineRestoresOriginalReceipt(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - key := testfixture.InboundKey("tenant-demo-key") - first, err := s.IngestCommand(raw, key) - if err != nil { - t.Fatal(err) - } - records, err := s.ClaimOutbox(1) - if err != nil || len(records) != 1 { - t.Fatalf("receipt: %v", err) - } - if err := s.MarkOutboxPublished(records[0].ID); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) } - again, err := s.IngestCommand(raw, key) - if err != nil || !again.Duplicate || again.ExecutionID != first.ExecutionID { - t.Fatalf("late duplicate: %+v %v", again, err) - } - records, err = s.ClaimOutbox(1) - if err != nil || len(records) != 1 || records[0].EventID != first.CommandID+"-result" { - t.Fatal("late duplicate lost original response") - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatal("late duplicate created another task") - } -} - -func TestNewExpiredExecutionIsPermanentRejection(t *testing.T) { - s := testStore(t) - s.now = func() time.Time { return time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC) } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatalf("expiry must persist a rejection instead of being requeued: %v", err) - } - var body []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox`).Scan(&body); err != nil { - t.Fatal(err) - } - var event struct { - Payload struct { - Status string `json:"status"` - Reason string `json:"reason_code"` - } `json:"payload"` - } - if err := json.Unmarshal(body, &event); err != nil { - t.Fatal(err) - } - if event.Payload.Status != "rejected" || event.Payload.Reason != "expired" { - t.Fatal("expired execution lacks durable rejection") - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatal("expired command admitted") - } -} diff --git a/internal/store/execute_mq_test.go b/internal/store/execute_mq_test.go deleted file mode 100644 index 0145855..0000000 --- a/internal/store/execute_mq_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package store - -import ( - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestExecuteMQPersistsScopedV2Receipt(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/call-execute.json") - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, route.InboundKey); err != nil { - t.Fatal(err) - } - var exchange, key string - var body []byte - if err := s.DB().QueryRow(`SELECT exchange,routing_key,body FROM outbox`).Scan(&exchange, &key, &body); err != nil { - t.Fatal(err) - } - if exchange != mq.EventExchange || key != route.OutboundKey { - t.Fatal("receipt does not target this Dispatcher and tenant") - } - if err := contract.ValidateMQMessage(body); err != nil { - t.Fatal(err) - } - duplicate, err := s.IngestCommand(raw, route.InboundKey) - if err != nil || !duplicate.Duplicate { - t.Fatalf("duplicate: %+v %v", duplicate, err) - } - other, _ := tenant.NewDispatcherRoute(identityB, "tenant-a") - if _, err := s.IngestCommand(raw, other.InboundKey); err == nil { - t.Fatal("foreign Dispatcher route accepted") - } -} diff --git a/internal/store/facts.go b/internal/store/facts.go deleted file mode 100644 index 26672e0..0000000 --- a/internal/store/facts.go +++ /dev/null @@ -1,126 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -var ErrFactConflict = errors.New("execution fact content conflict") - -type ExecutionFactRecord struct { - FactID string - TenantID string - TenantKey string - ExecutionID string - ContentSHA256 string - Kind int32 - BindingJSON []byte - PayloadJSON []byte - ObservedAt time.Time - SourceBootID string - SourceSequence uint64 - EventID string - EventType string - AggregateType string - AggregateID string - AggregateVersion int64 -} - -type ExecutionFactResult struct { - Duplicate bool - EventID string -} - -type FactEventBuilder func(aggregateVersion int64) ([]byte, error) - -// RecordExecutionFact persists an Agent fact and, when eventBuilder is set, -// the authoritative MQ event in one SQLite transaction. Aggregate versions are -// allocated by Dispatcher from durable state; the Agent cannot select them. A -// duplicate fact with the same digest is accepted without creating a second -// outbox row; reuse with a different digest is rejected. -func (s *Store) RecordExecutionFact(record ExecutionFactRecord, eventExchange, routingKey string, eventBuilder FactEventBuilder) (ExecutionFactResult, error) { - if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || record.ContentSHA256 == "" { - return ExecutionFactResult{}, errors.New("fact identity and tenant/execution binding are required") - } - if len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 { - return ExecutionFactResult{}, errors.New("fact binding and payload are required") - } - if record.ObservedAt.IsZero() { - return ExecutionFactResult{}, errors.New("fact observed time is required") - } - if eventBuilder != nil && record.EventID == "" { - return ExecutionFactResult{}, errors.New("event ID is required for outbox event") - } - if eventBuilder != nil && (eventExchange == "" || routingKey == "") { - return ExecutionFactResult{}, errors.New("event exchange and routing key are required for outbox event") - } - - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return ExecutionFactResult{}, err - } - defer tx.Rollback() - - var existingDigest, existingEventID string - err = tx.QueryRow(`SELECT content_sha256, event_id FROM execution_facts WHERE fact_id = ?`, record.FactID).Scan(&existingDigest, &existingEventID) - if err == nil { - if existingDigest != record.ContentSHA256 { - return ExecutionFactResult{}, fmt.Errorf("%w: %s", ErrFactConflict, record.FactID) - } - if err := tx.Commit(); err != nil { - return ExecutionFactResult{}, err - } - return ExecutionFactResult{Duplicate: true, EventID: existingEventID}, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return ExecutionFactResult{}, err - } - - var eventBody []byte - if eventBuilder != nil { - var latestVersion sql.NullInt64 - if err := tx.QueryRow(`SELECT MAX(aggregate_version) FROM execution_facts WHERE aggregate_type = ? AND aggregate_id = ?`, record.AggregateType, record.AggregateID).Scan(&latestVersion); err != nil { - return ExecutionFactResult{}, err - } - record.AggregateVersion = latestVersion.Int64 + 1 - if record.AggregateVersion < 1 { - record.AggregateVersion = 1 - } - var buildErr error - eventBody, buildErr = eventBuilder(record.AggregateVersion) - if buildErr != nil { - return ExecutionFactResult{}, buildErr - } - if len(eventBody) == 0 { - return ExecutionFactResult{}, errors.New("event builder returned an empty body") - } - } else if record.AggregateVersion < 1 { - record.AggregateVersion = 1 - } - now := s.now().UTC().Format(time.RFC3339Nano) - if _, err := tx.Exec(`INSERT INTO execution_facts( - fact_id, tenant_id, tenant_key, execution_id, content_sha256, kind, - binding_json, payload_json, observed_at, source_boot_id, source_sequence, - event_id, event_type, aggregate_type, aggregate_id, aggregate_version, received_at - ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - record.FactID, record.TenantID, record.TenantKey, record.ExecutionID, record.ContentSHA256, - record.Kind, record.BindingJSON, record.PayloadJSON, record.ObservedAt.UTC().Format(time.RFC3339Nano), - record.SourceBootID, record.SourceSequence, record.EventID, record.EventType, - record.AggregateType, record.AggregateID, record.AggregateVersion, now); err != nil { - return ExecutionFactResult{}, err - } - if eventBuilder != nil { - if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`, - record.EventID, record.TenantKey, eventExchange, routingKey, eventBody, now); err != nil { - return ExecutionFactResult{}, err - } - } - if err := tx.Commit(); err != nil { - return ExecutionFactResult{}, err - } - return ExecutionFactResult{EventID: record.EventID}, nil -} diff --git a/internal/store/finalize_control_test.go b/internal/store/finalize_control_test.go deleted file mode 100644 index 02aa619..0000000 --- a/internal/store/finalize_control_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package store - -import ( - "testing" - - "git.ipao.vip/rogee/go-sip/internal/testfixture" -) - -func TestFinalizeReservationPreservesControlBarrier(t *testing.T) { - for _, state := range []string{"paused", "draining", "stopped"} { - for _, unknown := range []bool{false, true} { - t.Run(state+map[bool]string{false: "-rejected", true: "-unknown"}[unknown], func(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - if err := s.Reserve("reservation-a", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`UPDATE tasks SET status=?`, state); err != nil { - t.Fatal(err) - } - if err := s.FinalizeReservation("reservation-a", "exec_demo_001", unknown); err != nil { - t.Fatal(err) - } - var current, reservation string - if err := s.DB().QueryRow(`SELECT status FROM tasks`).Scan(¤t); err != nil { - t.Fatal(err) - } - if err := s.DB().QueryRow(`SELECT state FROM reservations`).Scan(&reservation); err != nil { - t.Fatal(err) - } - expected := "released" - if unknown { - expected = "unknown" - } - if current != state || reservation != expected { - t.Fatalf("task=%s reservation=%s", current, reservation) - } - }) - } - } -} diff --git a/internal/store/identity.go b/internal/store/identity.go deleted file mode 100644 index f253575..0000000 --- a/internal/store/identity.go +++ /dev/null @@ -1,47 +0,0 @@ -package store - -import ( - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -// DispatcherID returns the already-bound database identity, never a new identity. -func (s *Store) DispatcherID() (string, error) { - var id string - if err := s.db.QueryRow(`SELECT dispatcher_id FROM dispatcher_identity WHERE singleton=1`).Scan(&id); err != nil { - return "", fmt.Errorf("read dispatcher identity: %w", err) - } - return id, tenant.ValidateDispatcherID(id) -} - -// BindDispatcherID permanently associates this database with a logical owner. -// Changing a lease epoch or restarting never changes this identity. -func (s *Store) BindDispatcherID(id string) error { - if err := tenant.ValidateDispatcherID(id); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin dispatcher identity binding: %w", err) - } - defer tx.Rollback() - if _, err := tx.Exec(`INSERT INTO dispatcher_identity(singleton, dispatcher_id, created_at) - VALUES(1, ?, ?) ON CONFLICT(singleton) DO NOTHING`, id, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return fmt.Errorf("persist dispatcher identity: %w", err) - } - var stored string - if err := tx.QueryRow(`SELECT dispatcher_id FROM dispatcher_identity WHERE singleton=1`).Scan(&stored); err != nil { - return fmt.Errorf("read dispatcher identity: %w", err) - } - if stored != id { - return fmt.Errorf("%w: stored=%s requested=%s", ErrDispatcherIdentityMismatch, stored, id) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit dispatcher identity: %w", err) - } - return nil -} diff --git a/internal/store/identity_test.go b/internal/store/identity_test.go deleted file mode 100644 index 9314359..0000000 --- a/internal/store/identity_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package store - -import ( - "errors" - "path/filepath" - "testing" -) - -const identityA = "c046b893-8628-4589-ae50-619d049248a6" -const identityB = "a50b1569-aa17-4503-bfc1-cf55c10a1c24" - -func TestDispatcherIdentityPersistsAndCannotChange(t *testing.T) { - path := filepath.Join(t.TempDir(), "identity.db") - first, err := Open(path) - if err != nil { - t.Fatal(err) - } - if err := first.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - if err := first.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - if err := first.Close(); err != nil { - t.Fatal(err) - } - second, err := Open(path) - if err != nil { - t.Fatal(err) - } - defer second.Close() - if err := second.BindDispatcherID(identityB); !errors.Is(err, ErrDispatcherIdentityMismatch) { - t.Fatalf("different identity accepted: %v", err) - } - if err := second.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - var stored string - if err := second.DB().QueryRow(`SELECT dispatcher_id FROM dispatcher_identity WHERE singleton=1`).Scan(&stored); err != nil { - t.Fatal(err) - } - if stored != identityA { - t.Fatalf("identity was changed: %s", stored) - } -} - -func TestDispatcherIdentityInvalidValuesDoNotBind(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - for _, id := range []string{"", "dispatcher", "C046B893-8628-4589-AE50-619D049248A6", "00000000-0000-0000-0000-000000000000"} { - if err := s.BindDispatcherID(id); err == nil { - t.Fatal("invalid identity accepted") - } - } - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM dispatcher_identity`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatal("invalid identity persisted") - } - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } -} - -func TestDispatcherIdentityDatabaseErrorsAreNotHidden(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - if err := s.BindDispatcherID(identityA); err == nil { - t.Fatal("closed database accepted") - } -} diff --git a/internal/store/lease_test.go b/internal/store/lease_test.go deleted file mode 100644 index 2c1b337..0000000 --- a/internal/store/lease_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package store - -import ( - "errors" - "path/filepath" - "testing" - "time" -) - -func TestSingleActiveLeaseAndExpiry(t *testing.T) { - now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) - var current = now - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - s.now = func() time.Time { return current } - if _, err := s.AcquireLease("lease-a", "dispatcher", "holder-a", time.Minute); err != nil { - t.Fatal(err) - } - if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); !errors.Is(err, ErrLeaseHeld) { - t.Fatalf("second holder error = %v", err) - } - current = current.Add(2 * time.Minute) - if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); err != nil { - t.Fatal(err) - } - if err := s.ReleaseLease("lease-b"); err != nil { - t.Fatal(err) - } -} - -func TestRecoverOutboxRequeuesClaimedRows(t *testing.T) { - s := testStore(t) - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`); err != nil { - t.Fatal(err) - } - if err := s.RecoverOutbox(); err != nil { - t.Fatal(err) - } - var status, lastError string - if err := s.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'e1'`).Scan(&status, &lastError); err != nil { - t.Fatal(err) - } - if status != "retry" || lastError != "recovered_after_restart" { - t.Fatalf("status=%q error=%q", status, lastError) - } -} - -func TestOutboxRecoverySurvivesSQLiteReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "dispatcher.db") - first, err := Open(path) - if err != nil { - t.Fatal(err) - } - firstClosed := false - t.Cleanup(func() { - if !firstClosed { - if err := first.Close(); err != nil { - t.Error(err) - } - } - }) - result, err := first.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('restart-e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`) - if err != nil { - t.Fatal(err) - } - if rows, err := result.RowsAffected(); err != nil || rows != 1 { - t.Fatalf("inserted rows=%d err=%v, want 1", rows, err) - } - if err := first.Close(); err != nil { - t.Fatal(err) - } - firstClosed = true - - second, err := Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := second.Close(); err != nil { - t.Error(err) - } - }) - if err := second.RecoverOutbox(); err != nil { - t.Fatal(err) - } - var status, lastError string - if err := second.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'restart-e1'`).Scan(&status, &lastError); err != nil { - t.Fatal(err) - } - if status != "retry" || lastError != "recovered_after_restart" { - t.Fatalf("status=%q error=%q after reopen", status, lastError) - } -} diff --git a/internal/store/local_call_terminal.go b/internal/store/local_call_terminal.go deleted file mode 100644 index 03bcc53..0000000 --- a/internal/store/local_call_terminal.go +++ /dev/null @@ -1,257 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" -) - -// LocalCallTerminal is an immutable, confirmed Agent observation. Recording -// delivery is deliberately separate: a completed call stops consuming quota -// even when its final result is waiting for an upload outcome. -type LocalRecordingManifest struct { - RecordingID string - UploadID string - Format string - Channels int - SampleRateHz int - DurationMs int64 -} - -// LocalRecordingOutcome contains only a finished upload fact or a terminal -// failure. It contains no OSS authorization or media bytes. -type LocalRecordingOutcome struct { - Status string - ErrorCode string - Bucket string - ObjectKey string - SizeBytes int64 - ChecksumSHA256 string - ObservedAt time.Time -} - -type LocalCallTerminal struct { - ExecutionID string - CallID string - Source string - StartedAt time.Time - EndedAt time.Time - Outcome string - ReasonCode string - RecordingExpected bool - Recording *LocalRecordingManifest -} - -// RecordLocalCallTerminal atomically records an issued call's terminal fact and -// releases its reservation. A known final result can be enqueued in the same -// transaction; otherwise the durable fact remains available for recovery. -func (s *Store) RecordLocalCallTerminal(fact LocalCallTerminal, event *LocalEventRecord) error { - if fact.ExecutionID == "" || fact.CallID == "" || fact.Outcome == "" || - fact.StartedAt.IsZero() || fact.EndedAt.IsZero() || fact.EndedAt.Before(fact.StartedAt) { - return errors.New("incomplete or reversed local call terminal fact") - } - expectedDecision := "issued" - switch fact.Source { - case "mock_agent": - case "dispatcher_refusal", "mock_agent_deadline", "dispatcher_deadline": - if fact.Source == "dispatcher_refusal" { - expectedDecision = "refused" - } - if fact.Outcome != "failed" || fact.RecordingExpected { - return errors.New("no-dial terminal cannot claim a call or recording") - } - default: - return errors.New("unknown local terminal source") - } - fact.StartedAt = fact.StartedAt.UTC() - fact.EndedAt = fact.EndedAt.UTC() - if fact.RecordingExpected != (fact.Recording != nil) { - return errors.New("expected recording requires a manifest; no recording must not have one") - } - var manifestJSON []byte - if fact.Recording != nil { - if fact.Recording.RecordingID == "" || fact.Recording.UploadID == "" || fact.Recording.Format == "" || - fact.Recording.Channels < 1 || fact.Recording.SampleRateHz < 1 || fact.Recording.DurationMs < 0 { - return errors.New("incomplete local recording manifest") - } - var err error - manifestJSON, err = json.Marshal(fact.Recording) - if err != nil { - return err - } - } - digest, err := localTerminalDigest(fact) - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var oldDigest string - err = tx.QueryRow(`SELECT fact_sha256 FROM local_v01_call_terminals WHERE execution_id=?`, fact.ExecutionID).Scan(&oldDigest) - if err == nil { - if oldDigest != digest { - return ErrCommandConflict - } - } else if errors.Is(err, sql.ErrNoRows) { - var reservationID, status, decision string - if err := tx.QueryRow(`SELECT r.reservation_id,t.status,d.decision FROM tasks t - JOIN reservations r ON r.execution_id=t.execution_id - JOIN local_v02_origination_decisions d ON d.execution_id=t.execution_id - WHERE t.execution_id=?`, fact.ExecutionID).Scan(&reservationID, &status, &decision); err != nil { - return fmt.Errorf("confirm local call terminal ownership: %w", err) - } - if decision != expectedDecision || status == "finished" { - return ErrCommandConflict - } - expected := 0 - if fact.RecordingExpected { - expected = 1 - } - var recordingID, uploadID any - if fact.Recording != nil { - recordingID, uploadID = fact.Recording.RecordingID, fact.Recording.UploadID - } - if _, err := tx.Exec(`INSERT INTO local_v01_call_terminals - (execution_id,call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_id,upload_id,recording_manifest,fact_sha256) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, fact.ExecutionID, fact.CallID, fact.Source, fact.StartedAt.Format(time.RFC3339Nano), - fact.EndedAt.Format(time.RFC3339Nano), fact.Outcome, fact.ReasonCode, expected, recordingID, uploadID, manifestJSON, digest); err != nil { - return fmt.Errorf("persist local terminal fact: %w", err) - } - if err := releaseReservationTx(tx, reservationID, false, nil, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return fmt.Errorf("release terminated local call quota: %w", err) - } - result, err := tx.Exec(`UPDATE tasks SET status='finished', updated_at=? WHERE execution_id=? AND status!='finished'`, - s.now().UTC().Format(time.RFC3339Nano), fact.ExecutionID) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil || rows != 1 { - return fmt.Errorf("finish local execution (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) - } - } else { - return err - } - if event != nil { - if err := enqueueLocalFinalEventTx(tx, fact.ExecutionID, fact.CallID, *event, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - } - return tx.Commit() -} - -// EnqueueLocalFinalEvent links a unique final result to the persisted terminal -// fact. A duplicate with the same event and bytes is harmless; a different -// event ID or body for the same execution is rejected. -func (s *Store) EnqueueLocalFinalEvent(executionID, callID string, event LocalEventRecord) error { - if executionID == "" || callID == "" { - return errors.New("final result requires execution and call identity") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := enqueueLocalFinalEventTx(tx, executionID, callID, event, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - return tx.Commit() -} - -// LoadLocalCallTerminal returns only persisted Agent facts. An empty result ID -// means recording or final-result delivery is still pending; it does not -// authorize another originate or upload. -func (s *Store) LoadLocalCallTerminal(executionID string) (LocalCallTerminal, string, error) { - var fact LocalCallTerminal - var startedAt, endedAt string - var expected int - var manifestJSON []byte - var eventID sql.NullString - fact.ExecutionID = executionID - err := s.db.QueryRow(`SELECT call_id,source,started_at,ended_at,outcome,reason_code,recording_expected,recording_manifest,result_event_id - FROM local_v01_call_terminals WHERE execution_id=?`, executionID). - Scan(&fact.CallID, &fact.Source, &startedAt, &endedAt, &fact.Outcome, &fact.ReasonCode, &expected, &manifestJSON, &eventID) - if err != nil { - return LocalCallTerminal{}, "", err - } - fact.StartedAt, err = time.Parse(time.RFC3339Nano, startedAt) - if err != nil { - return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call start: %w", err) - } - fact.EndedAt, err = time.Parse(time.RFC3339Nano, endedAt) - if err != nil { - return LocalCallTerminal{}, "", fmt.Errorf("decode persisted call end: %w", err) - } - if expected != 0 && expected != 1 { - return LocalCallTerminal{}, "", ErrCommandConflict - } - fact.RecordingExpected = expected == 1 - if fact.RecordingExpected != (manifestJSON != nil) { - return LocalCallTerminal{}, "", ErrCommandConflict - } - if fact.RecordingExpected { - fact.Recording = &LocalRecordingManifest{} - if err := json.Unmarshal(manifestJSON, fact.Recording); err != nil { - return LocalCallTerminal{}, "", fmt.Errorf("decode persisted recording manifest: %w", err) - } - } - return fact, eventID.String, nil -} - -func enqueueLocalFinalEventTx(tx *sql.Tx, executionID, callID string, event LocalEventRecord, now string) error { - if event.EventID == "" || event.TenantKey == "" || event.Exchange == "" || event.RoutingKey == "" || len(event.Body) == 0 { - return errors.New("complete local final event is required") - } - var persistedCallID string - var expected int - var recordingOutcome []byte - var priorEventID, priorDigest sql.NullString - if err := tx.QueryRow(`SELECT call_id,recording_expected,recording_outcome,result_event_id,result_sha256 - FROM local_v01_call_terminals WHERE execution_id=?`, executionID). - Scan(&persistedCallID, &expected, &recordingOutcome, &priorEventID, &priorDigest); err != nil { - return fmt.Errorf("load confirmed terminal before final result: %w", err) - } - if expected != 0 && expected != 1 || expected == 1 && recordingOutcome == nil || persistedCallID != callID { - return ErrCommandConflict - } - bodyDigest := sha256.Sum256(event.Body) - sha := hex.EncodeToString(bodyDigest[:]) - if priorEventID.Valid && (priorEventID.String != event.EventID || !priorDigest.Valid || priorDigest.String != sha) { - return ErrCommandConflict - } - if _, err := enqueueLocalEventTx(tx, event, now); err != nil { - return err - } - if !priorEventID.Valid { - result, err := tx.Exec(`UPDATE local_v01_call_terminals SET result_event_id=?,result_sha256=? - WHERE execution_id=? AND result_event_id IS NULL`, event.EventID, sha, executionID) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil || rows != 1 { - return fmt.Errorf("bind unique local final event (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) - } - } - return nil -} - -func localTerminalDigest(fact LocalCallTerminal) (string, error) { - encoded, err := json.Marshal(fact) - if err != nil { - return "", err - } - sum := sha256.Sum256(encoded) - return hex.EncodeToString(sum[:]), nil -} diff --git a/internal/store/local_call_terminal_test.go b/internal/store/local_call_terminal_test.go deleted file mode 100644 index b575a19..0000000 --- a/internal/store/local_call_terminal_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "testing" - "time" -) - -func localTerminalFixture() LocalCallTerminal { - return LocalCallTerminal{ - ExecutionID: "execution-a", CallID: "call-a", Source: "mock_agent", - StartedAt: time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC), - EndedAt: time.Date(2026, 9, 21, 2, 1, 0, 0, time.UTC), - Outcome: "no_answer", ReasonCode: "no_answer", - } -} - -func localFinalEventFixture() LocalEventRecord { - return LocalEventRecord{ - EventID: "result-a", TenantKey: "tenant-key-a", - Exchange: "sip.results.v3", RoutingKey: "d.d-1.out", Body: []byte(`{"event_type":"call.result"}`), - } -} - -func assertLocalTerminalQuota(t *testing.T, st *Store, wantReserved, wantUnknown int, wantState string) { - t.Helper() - var reserved, unknown int - var state string - if err := st.db.QueryRow(`SELECT q.reserved_value,q.unknown_value,r.state FROM reservations r - JOIN quotas q ON q.scope=? WHERE r.execution_id='execution-a'`, - LocalTenantQuotaScope("d-1", "tenant-a")).Scan(&reserved, &unknown, &state); err != nil { - t.Fatal(err) - } - if reserved != wantReserved || unknown != wantUnknown || state != wantState { - t.Fatalf("quota reserved=%d unknown=%d reservation=%s; want %d/%d/%s", reserved, unknown, state, wantReserved, wantUnknown, wantState) - } -} - -func TestLocalTerminalReleaseAndFinalEventAreAtomicAndUnique(t *testing.T) { - st, _ := readyLocalOrigination(t) - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { - t.Fatal(err) - } - fact, event := localTerminalFixture(), localFinalEventFixture() - if err := st.RecordLocalCallTerminal(fact, &event); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") - var status, eventID string - if err := st.db.QueryRow(`SELECT t.status,f.result_event_id FROM tasks t JOIN local_v01_call_terminals f ON f.execution_id=t.execution_id WHERE t.execution_id='execution-a'`).Scan(&status, &eventID); err != nil || status != "finished" || eventID != event.EventID { - t.Fatalf("finished terminal status=%q event=%q err=%v", status, eventID, err) - } - if err := st.RecordLocalCallTerminal(fact, &event); err != nil { - t.Fatalf("identical confirmed terminal replay: %v", err) - } - if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); err != nil { - t.Fatalf("identical final event replay: %v", err) - } - changed := event - changed.EventID = "result-b" - if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, changed); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("second final event=%v", err) - } - changed = event - changed.Body = []byte(`{"event_type":"different"}`) - if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, changed); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("conflicting final body=%v", err) - } - changedFact := fact - changedFact.EndedAt = changedFact.EndedAt.Add(time.Second) - if err := st.RecordLocalCallTerminal(changedFact, &event); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("conflicting terminal fact=%v", err) - } - var count int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id='result-a'`).Scan(&count); err != nil || count != 1 { - t.Fatalf("unique outbox row count=%d err=%v", count, err) - } -} - -func TestLocalTerminalOutboxFailureRollsBackQuotaAndTerminal(t *testing.T) { - st, _ := readyLocalOrigination(t) - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { - t.Fatal(err) - } - event := localFinalEventFixture() - prior := event - prior.Body = []byte(`{"event_type":"unrelated"}`) - if _, err := st.EnqueueLocalEvent(prior); err != nil { - t.Fatal(err) - } - if err := st.RecordLocalCallTerminal(localTerminalFixture(), &event); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("conflicting result did not roll back terminal: %v", err) - } - assertLocalTerminalQuota(t, st, 1, 0, "held") - var count int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM local_v01_call_terminals WHERE execution_id='execution-a'`).Scan(&count); err != nil || count != 0 { - t.Fatalf("terminal persisted despite outbox conflict: count=%d err=%v", count, err) - } - event.EventID = "result-b" - if err := st.RecordLocalCallTerminal(localTerminalFixture(), &event); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") -} - -func TestLocalTerminalReleasesUnknownQuotaBeforeRecordingAndFinalResult(t *testing.T) { - st, record := readyLocalOrigination(t) - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { - t.Fatal(err) - } - if err := st.ReleaseReservation(record.Admission.ReservationID, true); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 1, "unknown") - fact := localTerminalFixture() - fact.RecordingExpected = true - fact.Recording = &LocalRecordingManifest{ - RecordingID: "rec-a", UploadID: "upload-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 60000, - } - if err := st.RecordLocalCallTerminal(fact, nil); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") - var resultID sql.NullString - if err := st.db.QueryRow(`SELECT result_event_id FROM local_v01_call_terminals WHERE execution_id='execution-a'`).Scan(&resultID); err != nil || resultID.Valid { - t.Fatalf("recording-pending call already had a result: event=%v err=%v", resultID, err) - } - event := localFinalEventFixture() - if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("pending recording allowed final result without an outcome: %v", err) - } - if err := st.RecordLocalRecordingOutcome(fact.ExecutionID, LocalRecordingOutcome{ - Status: "unavailable", ErrorCode: "upload_timeout", ObservedAt: fact.EndedAt.Add(LocalRecordingDeadline), - }); err != nil { - t.Fatal(err) - } - if err := st.EnqueueLocalFinalEvent(fact.ExecutionID, fact.CallID, event); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") -} - -func TestLocalTerminalRequiresIssuedOrigination(t *testing.T) { - st, _ := readyLocalOrigination(t) - if err := st.RefuseLocalOrigination("d-1", "execution-a", "policy_denied", localTerminalFixture().StartedAt); err != nil { - t.Fatal(err) - } - if err := st.RecordLocalCallTerminal(localTerminalFixture(), nil); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("refused origination recorded a call: %v", err) - } - assertLocalTerminalQuota(t, st, 1, 0, "held") -} diff --git a/internal/store/local_discovery_helpers_test.go b/internal/store/local_discovery_helpers_test.go deleted file mode 100644 index 5b39828..0000000 --- a/internal/store/local_discovery_helpers_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package store - -import ( - "path/filepath" - "testing" - "time" -) - -func openLocalDiscoveryTestStore(t *testing.T) *Store { - t.Helper() - st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = st.Close() }) - return st -} - -func seedLocalCommandAssignments(t *testing.T, st *Store, records ...LocalCommandRecord) { - t.Helper() - if len(records) == 0 { - return - } - dispatcherID := records[0].DispatcherID - tasks := make([]LocalDiscoveredTask, 0, len(records)) - seen := make(map[string]struct{}, len(records)) - for _, record := range records { - if record.DispatcherID != dispatcherID { - t.Fatalf("test commands use different Dispatcher IDs: %q and %q", dispatcherID, record.DispatcherID) - } - if _, exists := seen[record.TaskID]; exists { - continue - } - seen[record.TaskID] = struct{}{} - revision := int64(1) - if record.Admission != nil && record.Admission.Task.TaskRevision > 0 { - revision = record.Admission.Task.TaskRevision - } - tasks = append(tasks, localDiscoveredTask(dispatcherID, record.TaskID, record.TenantID, record.TenantKey, revision, "running")) - } - seedLocalSnapshotTasks(t, st, dispatcherID, tasks...) -} - -func seedLocalDiscovery(t *testing.T, st *Store) { - t.Helper() - seedLocalSnapshotTasks(t, st, "d-1", - localDiscoveredTask("d-1", "task-a", "tenant-a", "tenant-key-a", 1, "running"), - localDiscoveredTask("d-1", "task-b", "tenant-a", "tenant-key-a", 1, "running"), - ) -} - -func seedLocalSnapshotTasks(t *testing.T, st *Store, dispatcherID string, tasks ...LocalDiscoveredTask) { - t.Helper() - now := time.Now().UTC() - if err := st.ApplyLocalTaskSnapshot(dispatcherID, tasks, now); err != nil { - t.Fatal(err) - } - if err := st.MarkLocalTaskDiscoveryReadyV04(dispatcherID, now); err != nil { - t.Fatal(err) - } -} - -func localDiscoveredTask(_ string, taskID, tenantID, tenantKey string, revision int64, status string) LocalDiscoveredTask { - return LocalDiscoveredTask{TaskID: taskID, TenantID: tenantID, TenantKey: tenantKey, TaskRevision: revision, Status: status} -} diff --git a/internal/store/local_grant_after_outcome_test.go b/internal/store/local_grant_after_outcome_test.go deleted file mode 100644 index 4547dda..0000000 --- a/internal/store/local_grant_after_outcome_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package store - -import ( - "errors" - "testing" -) - -func TestLocalRecordingOutcomePreventsFirstGrantRacingWithCompletion(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, LocalRecordingOutcome{ - Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", SizeBytes: 4, - ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ObservedAt: terminal.EndedAt, - }); err != nil { - t.Fatal(err) - } - grant := UploadRecord{ - UploadID: terminal.Recording.UploadID, Binding: []byte("bound-agent"), Asset: []byte("bound-recording"), - Grant: []byte("one-put-only"), Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", - State: "granted", CreatedAt: terminal.EndedAt, - } - if _, err := st.IssueUploadGrant(grant, "op-a", "request-a"); !errors.Is(err, ErrUploadMismatch) { - t.Fatalf("completed Mock recording received its first late PUT grant: %v", err) - } - var count int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM uploads WHERE upload_id=?`, grant.UploadID).Scan(&count); err != nil || count != 0 { - t.Fatalf("late PUT grant survived: rows=%d err=%v", count, err) - } -} diff --git a/internal/store/local_origination.go b/internal/store/local_origination.go deleted file mode 100644 index 9301278..0000000 --- a/internal/store/local_origination.go +++ /dev/null @@ -1,164 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" -) - -var ErrLocalOriginationNotAuthorized = errors.New("local execution is not authorized for origination") - -type LocalOriginationCandidate struct { - DispatcherID string - ExecutionID string - TenantID string - TenantKey string - TaskID string - CommandID string - Callee string - CommandBody json.RawMessage - Snapshot LocalExecutionConfigSnapshot - SnapshotSHA256 string -} - -// LoadLocalOrigination checks current task, reservation, control, discovery and -// assignment state. Its caller must still make the final time/policy decision -// against the immutable bound snapshot and atomically claim the task below. -func (s *Store) LoadLocalOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, error) { - s.mu.Lock() - defer s.mu.Unlock() - if dispatcherID == "" || executionID == "" { - return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized - } - candidate := LocalOriginationCandidate{DispatcherID: dispatcherID, ExecutionID: executionID} - var taskStatus, inboxStatus, inboxHash, reservationState, admissionState string - var removed, ready int - var snapshotBody []byte - err := s.db.QueryRow(`SELECT t.tenant_id,t.tenant_key,t.task_id,t.task_item_id,t.callee,t.status, - i.body,i.body_hash,i.status,c.body,c.content_sha256,r.state,a.admission_state,a.removed,d.ready - FROM tasks t - JOIN inbox i ON i.command_id=t.task_item_id AND i.command_type='call.execute' - JOIN local_v01_execution_configs c ON c.execution_id=t.execution_id - JOIN reservations r ON r.execution_id=t.execution_id - JOIN local_v01_task_assignments a ON a.dispatcher_id=? AND a.task_id=t.task_id - JOIN local_v04_task_discovery_state d ON d.dispatcher_id=a.dispatcher_id - WHERE t.execution_id=?`, dispatcherID, executionID).Scan(&candidate.TenantID, &candidate.TenantKey, &candidate.TaskID, &candidate.CommandID, &candidate.Callee, &taskStatus, - &candidate.CommandBody, &inboxHash, &inboxStatus, &snapshotBody, &candidate.SnapshotSHA256, &reservationState, &admissionState, &removed, &ready) - if errors.Is(err, sql.ErrNoRows) { - return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized - } - if err != nil { - return LocalOriginationCandidate{}, fmt.Errorf("load local origination facts: %w", err) - } - if taskStatus != "reserved" || inboxStatus != "persisted" || reservationState != "held" || admissionState != "running" || removed != 0 || ready != 1 { - return LocalOriginationCandidate{}, fmt.Errorf("%w: task=%s inbox=%s reservation=%s admission=%s removed=%d discovery_ready=%d", ErrLocalOriginationNotAuthorized, - taskStatus, inboxStatus, reservationState, admissionState, removed, ready) - } - if err := validateLocalOriginationCandidate(&candidate, snapshotBody, inboxHash); err != nil { - return LocalOriginationCandidate{}, err - } - return candidate, nil -} - -func validateLocalOriginationCandidate(candidate *LocalOriginationCandidate, snapshotBody []byte, inboxHash string) error { - commandDigest := sha256.Sum256(candidate.CommandBody) - snapshotDigest := sha256.Sum256(snapshotBody) - if hex.EncodeToString(commandDigest[:]) != inboxHash || hex.EncodeToString(snapshotDigest[:]) != candidate.SnapshotSHA256 { - return fmt.Errorf("%w: persisted command or snapshot digest mismatch", ErrLocalOriginationNotAuthorized) - } - if err := json.Unmarshal(snapshotBody, &candidate.Snapshot); err != nil { - return fmt.Errorf("decode bound execution snapshot: %w", err) - } - if candidate.Snapshot.SchemaVersion != "execution-config-snapshot.v0.3" || candidate.Snapshot.DispatcherID != candidate.DispatcherID || - candidate.Snapshot.TaskID != candidate.TaskID || candidate.Snapshot.TenantID != candidate.TenantID || candidate.Snapshot.TenantKey != candidate.TenantKey || - candidate.Snapshot.SelectedTrunkID == "" { - return fmt.Errorf("%w: execution snapshot identity or selected trunk mismatch", ErrLocalOriginationNotAuthorized) - } - return nil -} - -// ClaimLocalOrigination commits the one-shot dial decision and its Agent -// control route together. A crash between them must not leave an unaddressable -// in-flight call. The admission reservation is not debited a second time. -func (s *Store) ClaimLocalOrigination(dispatcherID, executionID, agentID string, binding *agentpb.ExecutionBinding, decidedAt time.Time) error { - if decidedAt.IsZero() || binding == nil || binding.ExecutionId != executionID || binding.AttemptId != executionID || agentID == "" { - return ErrLocalOriginationNotAuthorized - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := bindExecutionAgentTx(tx, agentID, binding); err != nil { - return fmt.Errorf("bind local origination Agent: %w", err) - } - result, err := tx.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at) - SELECT t.execution_id,?,'issued','',? FROM tasks t WHERE t.execution_id=? AND t.status='reserved' - AND EXISTS (SELECT 1 FROM reservations r WHERE r.execution_id=t.execution_id AND r.state='held') - AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key AND a.removed=0 AND a.admission_state='running') - AND EXISTS (SELECT 1 FROM local_v04_task_discovery_state d WHERE d.dispatcher_id=? AND d.ready=1) - AND NOT EXISTS (SELECT 1 FROM controls c WHERE c.execution_id=t.execution_id AND c.action IN ('pause','drain','stop','hangup')) - AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`, - dispatcherID, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID, dispatcherID) - if err != nil { - return fmt.Errorf("claim local origination: %w", err) - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrLocalOriginationNotAuthorized - } - return tx.Commit() -} - -// RefuseLocalOrigination records a failed final gate, not just a transient -// return value. A duplicate command or a clock change cannot revive it. It -// never changes an already issued decision. -func (s *Store) RefuseLocalOrigination(dispatcherID, executionID, reason string, decidedAt time.Time) error { - switch reason { - case "policy_denied", "control_closed", "invalid_binding": - default: - return fmt.Errorf("invalid local origination refusal reason %q", reason) - } - if dispatcherID == "" || executionID == "" || decidedAt.IsZero() { - return ErrLocalOriginationNotAuthorized - } - s.mu.Lock() - defer s.mu.Unlock() - result, err := s.db.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at) - SELECT t.execution_id,?,'refused',?,? FROM tasks t WHERE t.execution_id=? AND t.status='reserved' - AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key) - AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`, - dispatcherID, reason, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID) - if err != nil { - return fmt.Errorf("persist local origination refusal: %w", err) - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count == 1 { - return nil - } - var previous string - if err := s.db.QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=? AND dispatcher_id=?`, executionID, dispatcherID).Scan(&previous); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrLocalOriginationNotAuthorized - } - return fmt.Errorf("read local origination decision: %w", err) - } - if previous == "refused" { - return nil - } - return ErrLocalOriginationNotAuthorized -} diff --git a/internal/store/local_origination_queue.go b/internal/store/local_origination_queue.go deleted file mode 100644 index 9a2c2f4..0000000 --- a/internal/store/local_origination_queue.go +++ /dev/null @@ -1,45 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "fmt" -) - -// LocalPendingOriginationForCommand resolves only a durably accepted, held -// execution for the exact delivered command bytes. Missing rows include -// rejected/terminal commands and already issued or refused decisions. -func (s *Store) LocalPendingOriginationForCommand(body []byte) (string, error) { - if len(body) == 0 { - return "", sql.ErrNoRows - } - hash := sha256.Sum256(body) - s.mu.Lock() - defer s.mu.Unlock() - var executionID string - err := s.db.QueryRow(`SELECT t.execution_id FROM tasks t - JOIN inbox i ON i.command_id=t.task_item_id AND i.tenant_id=t.tenant_id AND i.tenant_key=t.tenant_key - JOIN reservations r ON r.execution_id=t.execution_id AND r.state='held' - WHERE i.command_type='call.execute' AND i.status='persisted' AND i.body_hash=? AND i.body=? - AND t.status='reserved' - AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions d WHERE d.execution_id=t.execution_id)`, - hex.EncodeToString(hash[:]), body).Scan(&executionID) - if err != nil { - return "", err - } - return executionID, nil -} - -func (s *Store) LocalOriginationDecision(executionID string) (string, error) { - if executionID == "" { - return "", ErrLocalOriginationNotAuthorized - } - s.mu.Lock() - defer s.mu.Unlock() - var decision string - if err := s.db.QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=?`, executionID).Scan(&decision); err != nil { - return "", fmt.Errorf("read local origination decision: %w", err) - } - return decision, nil -} diff --git a/internal/store/local_origination_recovery.go b/internal/store/local_origination_recovery.go deleted file mode 100644 index 5286561..0000000 --- a/internal/store/local_origination_recovery.go +++ /dev/null @@ -1,116 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -// LoadLocalIssuedOrigination reads only persisted admission and authorization -// facts. Unlike LoadLocalOrigination it NEVER checks current admission or -// reselects a trunk: pause, stop, stale discovery, or configuration expiry -// cannot erase the outcome of a previously issued instruction. -func (s *Store) LoadLocalIssuedOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, time.Time, error) { - candidate, at, _, err := s.loadLocalDecisionOrigination(dispatcherID, executionID, "issued") - return candidate, at, err -} - -func (s *Store) LoadLocalRefusedOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, time.Time, string, error) { - return s.loadLocalDecisionOrigination(dispatcherID, executionID, "refused") -} - -func (s *Store) loadLocalDecisionOrigination(dispatcherID, executionID, decision string) (LocalOriginationCandidate, time.Time, string, error) { - if dispatcherID == "" || executionID == "" || decision != "issued" && decision != "refused" { - return LocalOriginationCandidate{}, time.Time{}, "", ErrLocalOriginationNotAuthorized - } - candidate := LocalOriginationCandidate{DispatcherID: dispatcherID, ExecutionID: executionID} - var snapshotBody []byte - var inboxHash, taskStatus, inboxStatus, reservationState, decidedAt string - var refusalReason sql.NullString - err := s.db.QueryRow(`SELECT t.tenant_id,t.tenant_key,t.task_id,t.task_item_id,t.callee,t.status, - i.body,i.body_hash,i.status,c.body,c.content_sha256,r.state,d.decided_at,d.reason - FROM tasks t - JOIN inbox i ON i.command_id=t.task_item_id AND i.command_type='call.execute' - JOIN local_v01_execution_configs c ON c.execution_id=t.execution_id - JOIN reservations r ON r.execution_id=t.execution_id - JOIN local_v02_origination_decisions d ON d.execution_id=t.execution_id AND d.dispatcher_id=? AND d.decision=? - WHERE t.execution_id=?`, dispatcherID, decision, executionID). - Scan(&candidate.TenantID, &candidate.TenantKey, &candidate.TaskID, &candidate.CommandID, &candidate.Callee, &taskStatus, - &candidate.CommandBody, &inboxHash, &inboxStatus, &snapshotBody, &candidate.SnapshotSHA256, &reservationState, &decidedAt, &refusalReason) - if errors.Is(err, sql.ErrNoRows) { - return LocalOriginationCandidate{}, time.Time{}, "", ErrLocalOriginationNotAuthorized - } - if err != nil { - return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("load %s local execution facts: %w", decision, err) - } - if taskStatus == "finished" && reservationState != "released" || - inboxStatus != "persisted" || reservationState != "held" && reservationState != "unknown" && reservationState != "released" || - decision == "refused" && (!refusalReason.Valid || refusalReason.String == "") { - return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("%w: %s execution task=%s inbox=%s reservation=%s", ErrLocalOriginationNotAuthorized, decision, taskStatus, inboxStatus, reservationState) - } - if err := validateLocalOriginationCandidate(&candidate, snapshotBody, inboxHash); err != nil { - return LocalOriginationCandidate{}, time.Time{}, "", err - } - at, err := time.Parse(time.RFC3339Nano, decidedAt) - if err != nil || at.IsZero() { - return LocalOriginationCandidate{}, time.Time{}, "", fmt.Errorf("decode %s origination time: %w", decision, errors.Join(err, ErrLocalOriginationNotAuthorized)) - } - return candidate, at, refusalReason.String, nil -} - -// The two bounded scans are only for reconciliation. They never consume a -// command or issue another Agent instruction. -func (s *Store) ListLocalIssuedWithoutTerminal(dispatcherID string, offset, limit int) ([]string, error) { - return s.listLocalDecisionsWithoutTerminal(dispatcherID, "issued", offset, limit) -} - -func (s *Store) ListLocalRefusedWithoutTerminal(dispatcherID string, offset, limit int) ([]string, error) { - return s.listLocalDecisionsWithoutTerminal(dispatcherID, "refused", offset, limit) -} - -func (s *Store) listLocalDecisionsWithoutTerminal(dispatcherID, decision string, offset, limit int) ([]string, error) { - if dispatcherID == "" || decision != "issued" && decision != "refused" || offset < 0 || limit <= 0 || limit > 256 { - return nil, errors.New("invalid local issued reconciliation scan") - } - rows, err := s.db.Query(`SELECT d.execution_id FROM local_v02_origination_decisions d - LEFT JOIN local_v01_call_terminals f ON f.execution_id=d.execution_id - WHERE d.dispatcher_id=? AND d.decision=? AND f.execution_id IS NULL - ORDER BY d.decided_at,d.execution_id LIMIT ? OFFSET ?`, dispatcherID, decision, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - return ids, rows.Err() -} - -func (s *Store) ListLocalTerminalsWithoutResult(dispatcherID string, offset, limit int) ([]string, error) { - if dispatcherID == "" || offset < 0 || limit <= 0 || limit > 256 { - return nil, errors.New("invalid local result reconciliation scan") - } - rows, err := s.db.Query(`SELECT f.execution_id FROM local_v01_call_terminals f - JOIN local_v02_origination_decisions d ON d.execution_id=f.execution_id - WHERE d.dispatcher_id=? AND d.decision IN ('issued','refused') AND f.result_event_id IS NULL - ORDER BY f.ended_at,f.execution_id LIMIT ? OFFSET ?`, dispatcherID, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - return ids, rows.Err() -} diff --git a/internal/store/local_origination_recovery_test.go b/internal/store/local_origination_recovery_test.go deleted file mode 100644 index 6d2bcfe..0000000 --- a/internal/store/local_origination_recovery_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package store - -import ( - "errors" - "testing" - "time" -) - -func TestLocalIssuedRecoveryUsesBoundFactsEvenWhenQuotaUnknown(t *testing.T) { - st, record := readyLocalOrigination(t) - at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), at); err != nil { - t.Fatal(err) - } - ids, err := st.ListLocalIssuedWithoutTerminal("d-1", 0, 256) - if err != nil || len(ids) != 1 || ids[0] != "execution-a" { - t.Fatalf("missing issued call on restart: ids=%v err=%v", ids, err) - } - if err := st.ReleaseReservation(record.Admission.ReservationID, true); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 1, "unknown") - if _, err := st.LoadLocalOrigination("d-1", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("ordinary dial admission resumed an unknown reservation: %v", err) - } - candidate, decidedAt, err := st.LoadLocalIssuedOrigination("d-1", "execution-a") - if err != nil || !decidedAt.Equal(at) || candidate.Snapshot.SelectedTrunkID != record.Admission.Snapshot.SelectedTrunkID || - candidate.CommandID != record.CommandID { - t.Fatalf("issued call lost its bound snapshot: candidate=%+v at=%v err=%v", candidate, decidedAt, err) - } - terminal := localTerminalFixture() - if err := st.RecordLocalCallTerminal(terminal, nil); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") - ids, err = st.ListLocalIssuedWithoutTerminal("d-1", 0, 256) - if err != nil || len(ids) != 0 { - t.Fatalf("confirmed terminal remained unknown: ids=%v err=%v", ids, err) - } - ids, err = st.ListLocalTerminalsWithoutResult("d-1", 0, 256) - if err != nil || len(ids) != 1 || ids[0] != "execution-a" { - t.Fatalf("pending result lost after terminal: ids=%v err=%v", ids, err) - } - if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, localFinalEventFixture()); err != nil { - t.Fatal(err) - } - ids, err = st.ListLocalTerminalsWithoutResult("d-1", 0, 256) - if err != nil || len(ids) != 0 { - t.Fatalf("already queued final result replayed: ids=%v err=%v", ids, err) - } -} diff --git a/internal/store/local_origination_test.go b/internal/store/local_origination_test.go deleted file mode 100644 index d33802c..0000000 --- a/internal/store/local_origination_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "strings" - "testing" - "time" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" -) - -func localTestOriginationBinding(t *testing.T, st *Store, executionID string) *agentpb.ExecutionBinding { - t.Helper() - binding := &agentpb.ExecutionBinding{ExecutionId: executionID, AttemptId: executionID} - if err := st.DB().QueryRow(`SELECT tenant_id,tenant_key,task_id,task_item_id,task_revision,agent_version_id,route_policy_id,caller_profile_id FROM tasks WHERE execution_id=?`, executionID).Scan( - &binding.TenantId, &binding.TenantKey, &binding.TaskId, &binding.TaskItemId, &binding.TaskRevision, - &binding.AgentVersionId, &binding.RoutePolicyId, &binding.CallerProfileId, - ); err != nil { - t.Fatal(err) - } - return binding -} - -func readyLocalOrigination(t *testing.T) (*Store, LocalCommandRecord) { - t.Helper() - st := openLocalDiscoveryTestStore(t) - at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) - bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, at) - if err := st.SaveLocalConfigBundle(bundle); err != nil { - t.Fatal(err) - } - record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a") - seedLocalCommandAssignments(t, st, record) - if duplicate, err := st.PersistLocalCommand(record); err != nil || duplicate { - t.Fatalf("persist accepted command: duplicate=%v err=%v", duplicate, err) - } - return st, record -} - -func TestLocalOriginationDecisionsAreDurableAndOneShot(t *testing.T) { - for _, decision := range []string{"issued", "refused"} { - t.Run(decision, func(t *testing.T) { - st, record := readyLocalOrigination(t) - decisionAt := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) - pending, err := st.LocalPendingOriginationForCommand(record.Body) - if err != nil || pending != "execution-a" { - t.Fatalf("pending execution=%q err=%v", pending, err) - } - candidate, err := st.LoadLocalOrigination("d-1", pending) - if err != nil || candidate.CommandID != "command-a" || candidate.Callee != "15003164745" || - candidate.Snapshot.SelectedTrunkID != "mock-trunk" || len(candidate.SnapshotSHA256) != 64 { - t.Fatalf("bound origination=%+v err=%v", candidate, err) - } - binding := localTestOriginationBinding(t, st, pending) - if decision == "issued" { - if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); err != nil { - t.Fatal(err) - } - if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("second claim error=%v", err) - } - if err := st.RefuseLocalOrigination("d-1", pending, "control_closed", decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("issued instruction was overwritten by refusal: %v", err) - } - } else { - if err := st.RefuseLocalOrigination("d-1", pending, "policy_denied", decisionAt); err != nil { - t.Fatal(err) - } - if err := st.RefuseLocalOrigination("d-1", pending, "control_closed", decisionAt); err != nil { - t.Fatalf("replayed refusal error=%v", err) - } - if err := st.ClaimLocalOrigination("d-1", pending, "agent-1", binding, decisionAt); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("refused instruction was revived: %v", err) - } - } - got, err := st.LocalOriginationDecision(pending) - if err != nil || got != decision { - t.Fatalf("durable decision=%q want=%q err=%v", got, decision, err) - } - var bound int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_agents WHERE execution_id=?`, pending).Scan(&bound); err != nil { - t.Fatal(err) - } - wantBound := 0 - if decision == "issued" { - wantBound = 1 - } - if bound != wantBound { - t.Fatalf("claim and Agent control binding were not atomic: decision=%s bindings=%d", decision, bound) - } - if _, err := st.LocalPendingOriginationForCommand(record.Body); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("decided execution still pending: %v", err) - } - assertLocalQuota(t, st, LocalTenantQuotaScope("d-1", "tenant-a"), 1, 1) // F08 owns release. - }) - } -} - -func TestLocalOriginationFailClosedOnCorruptOrPausedFacts(t *testing.T) { - for _, tc := range []struct { - name string - change func(t *testing.T, st *Store) - }{ - {"modified command hash", func(t *testing.T, st *Store) { - t.Helper() - if _, err := st.db.Exec(`UPDATE inbox SET body_hash='bad' WHERE command_id='command-a'`); err != nil { - t.Fatal(err) - } - }}, - {"modified snapshot hash", func(t *testing.T, st *Store) { - t.Helper() - if _, err := st.db.Exec(`UPDATE local_v01_execution_configs SET content_sha256=? WHERE execution_id='execution-a'`, strings.Repeat("0", 64)); err != nil { - t.Fatal(err) - } - }}, - {"snapshot identity mismatch with valid hash", func(t *testing.T, st *Store) { - t.Helper() - var body []byte - if err := st.db.QueryRow(`SELECT body FROM local_v01_execution_configs WHERE execution_id='execution-a'`).Scan(&body); err != nil { - t.Fatal(err) - } - var snapshot LocalExecutionConfigSnapshot - if err := json.Unmarshal(body, &snapshot); err != nil { - t.Fatal(err) - } - snapshot.TaskID = "different-task" - changed, err := json.Marshal(snapshot) - if err != nil { - t.Fatal(err) - } - hash := sha256.Sum256(changed) - if _, err := st.db.Exec(`UPDATE local_v01_execution_configs SET body=?,content_sha256=? WHERE execution_id='execution-a'`, changed, hex.EncodeToString(hash[:])); err != nil { - t.Fatal(err) - } - }}, - {"paused assignment", func(t *testing.T, st *Store) { - t.Helper() - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { - t.Fatal(err) - } - }}, - } { - t.Run(tc.name, func(t *testing.T) { - st, record := readyLocalOrigination(t) - tc.change(t, st) - if _, err := st.LoadLocalOrigination("d-1", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("invalid facts did not block origination: %v", err) - } - if tc.name == "paused assignment" { - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("pause crossed atomic claim: %v", err) - } - var bound int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_agents WHERE execution_id='execution-a'`).Scan(&bound); err != nil || bound != 0 { - t.Fatalf("failed claim left an Agent binding: count=%d err=%v", bound, err) - } - if err := st.RefuseLocalOrigination("d-1", "execution-a", "control_closed", time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); err != nil { - t.Fatal(err) - } - if _, err := st.LocalPendingOriginationForCommand(record.Body); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("paused refusal reentered queue: %v", err) - } - } - }) - } -} - -func TestLocalOriginationRejectsMissingOrInvalidIdentity(t *testing.T) { - st, record := readyLocalOrigination(t) - if _, err := st.LocalPendingOriginationForCommand(nil); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("empty command lookup error=%v", err) - } - if _, err := st.LocalPendingOriginationForCommand(append(append([]byte(nil), record.Body...), ' ')); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("changed command bytes matched a pending execution: %v", err) - } - if _, err := st.LoadLocalOrigination("other-dispatcher", "execution-a"); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("wrong Dispatcher identity error=%v", err) - } - if err := st.RefuseLocalOrigination("d-1", "execution-a", "unrecognized", time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC)); err == nil { - t.Fatal("invalid refusal reason was persisted") - } - if _, err := st.LocalOriginationDecision(""); !errors.Is(err, ErrLocalOriginationNotAuthorized) { - t.Fatalf("empty execution ID error=%v", err) - } - if _, err := st.LocalOriginationDecision("missing"); !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("missing decision error=%v", err) - } -} diff --git a/internal/store/local_recording_error_codes_test.go b/internal/store/local_recording_error_codes_test.go deleted file mode 100644 index 0a239c0..0000000 --- a/internal/store/local_recording_error_codes_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package store - -import "testing" - -func TestLocalUnavailableRecordingUsesOnlyContractErrorCodes(t *testing.T) { - for _, tc := range []struct { - code string - valid bool - }{ - {"upload_authorization_failed", true}, - {"upload_authorization_expired", true}, - {"upload_failed", true}, - {"upload_timeout", true}, - {"deadline_exceeded", true}, - {"checksum_mismatch", true}, - {"storage_unavailable", false}, - {"artifact_missing", false}, - {"unknown", false}, - {"", false}, - } { - t.Run(tc.code, func(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, LocalRecordingOutcome{ - Status: "unavailable", ErrorCode: tc.code, - ObservedAt: terminal.EndedAt.Add(LocalRecordingDeadline), - }) - if (err == nil) != tc.valid { - t.Fatalf("error code %q: valid=%t err=%v", tc.code, tc.valid, err) - } - }) - } -} diff --git a/internal/store/local_recording_failure_fact.go b/internal/store/local_recording_failure_fact.go deleted file mode 100644 index 6ac0fdc..0000000 --- a/internal/store/local_recording_failure_fact.go +++ /dev/null @@ -1,94 +0,0 @@ -package store - -import ( - "bytes" - "crypto/sha256" - "database/sql" - "encoding/hex" - "errors" - "fmt" - "time" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" -) - -// RecordLocalRecordingFailureFact binds one authenticated Mock failure fact to -// the immutable recording outcome in a single transaction. A crash after this -// transaction but before the final result is queued is repaired by the normal -// pending-result recovery; a duplicate fact cannot create another outcome. -func (s *Store) RecordLocalRecordingFailureFact(record ExecutionFactRecord, outcome LocalRecordingOutcome) (bool, error) { - if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || - record.SourceBootID == "" || record.SourceSequence == 0 || len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 || - record.Kind != int32(agentpb.FactKind_FACT_KIND_RECORDING_PROGRESS) { - return false, errors.New("Mock recording failure requires a bound execution fact") - } - if outcome.Status != "unavailable" || - outcome.ErrorCode != "upload_authorization_failed" && outcome.ErrorCode != "upload_authorization_expired" && - outcome.ErrorCode != "upload_failed" && outcome.ErrorCode != "checksum_mismatch" { - return false, fmt.Errorf("Agent cannot declare this recording outcome: %w", ErrCommandConflict) - } - outcome, encoded, outcomeDigest, err := prepareLocalRecordingOutcome(record.ExecutionID, outcome) - if err != nil { - return false, err - } - if !record.ObservedAt.Equal(outcome.ObservedAt) { - return false, fmt.Errorf("Mock failure observation differs from outcome: %w", ErrCommandConflict) - } - payloadDigest := sha256.Sum256(record.PayloadJSON) - if hex.EncodeToString(payloadDigest[:]) != record.ContentSHA256 { - return false, fmt.Errorf("Mock failure fact digest mismatch: %w", ErrFactConflict) - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return false, err - } - defer tx.Rollback() - - var priorDigest, priorTenantID, priorTenantKey, priorExecutionID, priorBootID, priorEventID string - var priorKind, priorSequence int64 - var priorBinding, priorPayload []byte - err = tx.QueryRow(`SELECT content_sha256,tenant_id,tenant_key,execution_id,kind,binding_json,payload_json, - source_boot_id,source_sequence,event_id FROM execution_facts WHERE fact_id=?`, record.FactID). - Scan(&priorDigest, &priorTenantID, &priorTenantKey, &priorExecutionID, &priorKind, &priorBinding, - &priorPayload, &priorBootID, &priorSequence, &priorEventID) - if err == nil { - if priorDigest != record.ContentSHA256 || priorTenantID != record.TenantID || priorTenantKey != record.TenantKey || - priorExecutionID != record.ExecutionID || priorKind != int64(record.Kind) || - !bytes.Equal(priorBinding, record.BindingJSON) || !bytes.Equal(priorPayload, record.PayloadJSON) || - priorBootID != record.SourceBootID || priorSequence != int64(record.SourceSequence) || priorEventID != "" { - return false, fmt.Errorf("Mock recording failure fact identity changed: %w", ErrFactConflict) - } - var priorOutcome sql.NullString - if err := tx.QueryRow(`SELECT recording_outcome_sha256 FROM local_v01_call_terminals WHERE execution_id=?`, record.ExecutionID).Scan(&priorOutcome); err != nil { - return false, err - } - if !priorOutcome.Valid || priorOutcome.String != outcomeDigest { - return false, fmt.Errorf("Mock recording fact has no matching durable outcome: %w", ErrCommandConflict) - } - if err := tx.Commit(); err != nil { - return false, err - } - return true, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return false, err - } - if _, err := recordLocalRecordingOutcomeTx(tx, record.ExecutionID, outcome, encoded, outcomeDigest, false, true); err != nil { - return false, err - } - if _, err := tx.Exec(`INSERT INTO execution_facts( - fact_id,tenant_id,tenant_key,execution_id,content_sha256,kind,binding_json,payload_json,observed_at, - source_boot_id,source_sequence,event_id,event_type,aggregate_type,aggregate_id,aggregate_version,received_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, record.FactID, record.TenantID, record.TenantKey, - record.ExecutionID, record.ContentSHA256, record.Kind, record.BindingJSON, record.PayloadJSON, - record.ObservedAt.UTC().Format(time.RFC3339Nano), record.SourceBootID, record.SourceSequence, - "", "", "execution", record.ExecutionID, 1, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return false, fmt.Errorf("persist Mock recording failure fact: %w", err) - } - if err := tx.Commit(); err != nil { - return false, err - } - return false, nil -} diff --git a/internal/store/local_recording_failure_fact_test.go b/internal/store/local_recording_failure_fact_test.go deleted file mode 100644 index 8d2f99a..0000000 --- a/internal/store/local_recording_failure_fact_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package store - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "testing" - "time" - - agentpb "git.ipao.vip/rogee/go-sip/gen/agent" -) - -func localFailureFactFixture(terminal LocalCallTerminal) (ExecutionFactRecord, LocalRecordingOutcome) { - payload := []byte(`{"schema_version":"local-mock-recording-failure.v0.1","upload_id":"` + terminal.Recording.UploadID + `","recording_id":"` + terminal.Recording.RecordingID + `","error_code":"upload_failed"}`) - sum := sha256.Sum256(payload) - observed := terminal.EndedAt.Add(time.Second) - return ExecutionFactRecord{ - FactID: "failure-fact-a", TenantID: "tenant-a", TenantKey: "tenant-a", ExecutionID: terminal.ExecutionID, - ContentSHA256: hex.EncodeToString(sum[:]), Kind: int32(agentpb.FactKind_FACT_KIND_RECORDING_PROGRESS), - BindingJSON: []byte(`{"tenantId":"tenant-a","tenantKey":"tenant-a","executionId":"` + terminal.ExecutionID + `"}`), - PayloadJSON: payload, ObservedAt: observed, SourceBootID: "boot-a", SourceSequence: 1, - }, LocalRecordingOutcome{Status: "unavailable", ErrorCode: "upload_failed", ObservedAt: observed} -} - -func TestLocalRecordingFailureFactAndOutcomeAreAtomicAndIdempotent(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - fact, outcome := localFailureFactFixture(terminal) - duplicate, err := st.RecordLocalRecordingFailureFact(fact, outcome) - if err != nil || duplicate { - t.Fatalf("first explicit failure: duplicate=%t err=%v", duplicate, err) - } - persisted, found, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) - if err != nil || !found || persisted.ErrorCode != "upload_failed" { - t.Fatalf("failure outcome was not durable: %+v found=%t err=%v", persisted, found, err) - } - duplicate, err = st.RecordLocalRecordingFailureFact(fact, outcome) - if err != nil || !duplicate { - t.Fatalf("same fact was not idempotent: duplicate=%t err=%v", duplicate, err) - } - conflicting := fact - conflicting.ContentSHA256 = "different-digest" - if _, err := st.RecordLocalRecordingFailureFact(conflicting, outcome); !errors.Is(err, ErrFactConflict) { - t.Fatalf("reused fact ID with different digest was accepted: %v", err) - } - secondID := fact - secondID.FactID = "another-fact" - if _, err := st.RecordLocalRecordingFailureFact(secondID, outcome); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("second failure identity replaced the first: %v", err) - } - var count int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_facts WHERE execution_id=?`, terminal.ExecutionID).Scan(&count); err != nil || count != 1 { - t.Fatalf("failure replay created another execution fact: count=%d err=%v", count, err) - } -} - -func TestLocalRecordingFailureFactInsertFailureRollsBackOutcome(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - fact, outcome := localFailureFactFixture(terminal) - if _, err := st.DB().Exec(`CREATE TRIGGER reject_local_failure BEFORE INSERT ON execution_facts - WHEN NEW.fact_id='failure-fact-a' BEGIN SELECT RAISE(ABORT,'injected fact insert failure'); END`); err != nil { - t.Fatal(err) - } - if _, err := st.RecordLocalRecordingFailureFact(fact, outcome); err == nil { - t.Fatal("fact insert failure was swallowed") - } - _, found, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) - if err != nil || found { - t.Fatalf("fact failure left an unacknowledged outcome: found=%t err=%v", found, err) - } -} - -func TestLocalRecordingFailureAfterResultDeadlineIsRejected(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - fact, outcome := localFailureFactFixture(terminal) - outcome.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline + time.Nanosecond) - fact.ObservedAt = outcome.ObservedAt - if _, err := st.RecordLocalRecordingFailureFact(fact, outcome); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("late failure supplanted deadline result: %v", err) - } -} diff --git a/internal/store/local_recording_outcome.go b/internal/store/local_recording_outcome.go deleted file mode 100644 index b6a4d7c..0000000 --- a/internal/store/local_recording_outcome.go +++ /dev/null @@ -1,134 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strings" - "time" -) - -const LocalRecordingDeadline = 15 * time.Minute - -// RecordLocalRecordingOutcome persists a completed isolated recording fact, -// not a request to PUT an object. A duplicate fact is harmless; an alternate -// asset or failure cannot replace the first fact or a published final result. -func (s *Store) RecordLocalRecordingOutcome(executionID string, outcome LocalRecordingOutcome) error { - outcome, encoded, digest, err := prepareLocalRecordingOutcome(executionID, outcome) - if err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if _, err := recordLocalRecordingOutcomeTx(tx, executionID, outcome, encoded, digest, true, false); err != nil { - return err - } - return tx.Commit() -} - -func prepareLocalRecordingOutcome(executionID string, outcome LocalRecordingOutcome) (LocalRecordingOutcome, []byte, string, error) { - if executionID == "" || outcome.ObservedAt.IsZero() { - return LocalRecordingOutcome{}, nil, "", errors.New("recording outcome requires execution and observation time") - } - outcome.ObservedAt = outcome.ObservedAt.UTC() - switch outcome.Status { - case "uploaded": - if outcome.Bucket == "" || outcome.ObjectKey == "" || outcome.ErrorCode != "" || outcome.SizeBytes < 0 || - len(outcome.ChecksumSHA256) != 64 || strings.ToLower(outcome.ChecksumSHA256) != outcome.ChecksumSHA256 { - return LocalRecordingOutcome{}, nil, "", errors.New("incomplete uploaded recording fact") - } - if _, err := hex.DecodeString(outcome.ChecksumSHA256); err != nil { - return LocalRecordingOutcome{}, nil, "", fmt.Errorf("invalid uploaded checksum: %w", err) - } - case "unavailable": - switch outcome.ErrorCode { - case "upload_authorization_failed", "upload_authorization_expired", "upload_failed", "upload_timeout", "deadline_exceeded", "checksum_mismatch": - default: - return LocalRecordingOutcome{}, nil, "", errors.New("invalid unavailable recording error code") - } - if outcome.Bucket != "" || outcome.ObjectKey != "" || outcome.SizeBytes != 0 || outcome.ChecksumSHA256 != "" { - return LocalRecordingOutcome{}, nil, "", errors.New("unavailable recording cannot claim an uploaded asset") - } - default: - return LocalRecordingOutcome{}, nil, "", errors.New("recording fact must be uploaded or unavailable") - } - encoded, err := json.Marshal(outcome) - if err != nil { - return LocalRecordingOutcome{}, nil, "", err - } - sum := sha256.Sum256(encoded) - return outcome, encoded, hex.EncodeToString(sum[:]), nil -} - -func recordLocalRecordingOutcomeTx(tx *sql.Tx, executionID string, outcome LocalRecordingOutcome, encoded []byte, digest string, allowDuplicate, requireWithinDeadline bool) (bool, error) { - var expected int - var endedAt string - var priorDigest, priorStatus, eventID sql.NullString - if err := tx.QueryRow(`SELECT recording_expected,ended_at,recording_outcome_sha256,recording_status,result_event_id - FROM local_v01_call_terminals WHERE execution_id=?`, executionID). - Scan(&expected, &endedAt, &priorDigest, &priorStatus, &eventID); err != nil { - return false, fmt.Errorf("load confirmed recording call: %w", err) - } - if expected != 1 { - return false, ErrCommandConflict - } - ended, err := time.Parse(time.RFC3339Nano, endedAt) - if err != nil { - return false, fmt.Errorf("decode confirmed call end: %w", err) - } - deadline := ended.Add(LocalRecordingDeadline) - if outcome.ObservedAt.Before(ended) || requireWithinDeadline && outcome.ObservedAt.After(deadline) || - outcome.Status == "uploaded" && outcome.ObservedAt.After(deadline) || - outcome.ErrorCode == "upload_timeout" && outcome.ObservedAt.Before(deadline) { - return false, fmt.Errorf("recording outcome outside confirmed call deadline: %w", ErrCommandConflict) - } - if priorDigest.Valid { - if !allowDuplicate || priorDigest.String != digest || !priorStatus.Valid || priorStatus.String != outcome.Status { - return false, ErrCommandConflict - } - return true, nil - } - if eventID.Valid { - return false, ErrCommandConflict - } - result, err := tx.Exec(`UPDATE local_v01_call_terminals SET recording_outcome=?,recording_outcome_sha256=?,recording_status=? - WHERE execution_id=? AND recording_outcome IS NULL AND result_event_id IS NULL`, encoded, digest, outcome.Status, executionID) - if err != nil { - return false, err - } - rows, err := result.RowsAffected() - if err != nil || rows != 1 { - return false, fmt.Errorf("persist first recording outcome (rows=%d): %w", rows, errors.Join(err, ErrCommandConflict)) - } - return false, nil -} - -func (s *Store) LoadLocalRecordingOutcome(executionID string) (LocalRecordingOutcome, bool, error) { - var encoded []byte - var status sql.NullString - if err := s.db.QueryRow(`SELECT recording_outcome,recording_status FROM local_v01_call_terminals WHERE execution_id=?`, executionID).Scan(&encoded, &status); err != nil { - return LocalRecordingOutcome{}, false, err - } - if encoded == nil && !status.Valid { - return LocalRecordingOutcome{}, false, nil - } - if encoded == nil || !status.Valid { - return LocalRecordingOutcome{}, false, ErrCommandConflict - } - var outcome LocalRecordingOutcome - if err := json.Unmarshal(encoded, &outcome); err != nil { - return LocalRecordingOutcome{}, false, fmt.Errorf("decode persisted recording outcome: %w", err) - } - if outcome.Status != status.String { - return LocalRecordingOutcome{}, false, ErrCommandConflict - } - return outcome, true, nil -} diff --git a/internal/store/local_recording_outcome_test.go b/internal/store/local_recording_outcome_test.go deleted file mode 100644 index b24223c..0000000 --- a/internal/store/local_recording_outcome_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package store - -import ( - "errors" - "testing" - "time" -) - -func readyLocalRecordingTerminal(t *testing.T) (*Store, LocalCallTerminal) { - t.Helper() - st, _ := readyLocalOrigination(t) - if err := st.ClaimLocalOrigination("d-1", "execution-a", "agent-1", localTestOriginationBinding(t, st, "execution-a"), localTerminalFixture().StartedAt); err != nil { - t.Fatal(err) - } - fact := localTerminalFixture() - fact.RecordingExpected = true - fact.Recording = &LocalRecordingManifest{ - RecordingID: "rec-a", UploadID: "upload-a", Format: "wav", Channels: 1, - SampleRateHz: 8000, DurationMs: 60000, - } - if err := st.RecordLocalCallTerminal(fact, nil); err != nil { - t.Fatal(err) - } - return st, fact -} - -func TestLocalUploadedRecordingFactCannotBeReplacedOrRePUT(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - assertLocalTerminalQuota(t, st, 0, 0, "released") - fact := LocalRecordingOutcome{ - Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", - SizeBytes: 128000, ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ObservedAt: terminal.EndedAt.Add(time.Second), - } - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, fact); err != nil { - t.Fatal(err) - } - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, fact); err != nil { - t.Fatalf("identical uploaded fact replay: %v", err) - } - changed := fact - changed.ObjectKey = "tenant/call/different.wav" - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, changed); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("changed upload fact accepted: %v", err) - } - loaded, exists, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) - if err != nil || !exists || loaded.ObjectKey != fact.ObjectKey { - t.Fatalf("uploaded fact lost or changed: fact=%+v exists=%t err=%v", loaded, exists, err) - } - event := localFinalEventFixture() - if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, event); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, changed); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("uploaded asset replaced after final result: %v", err) - } -} - -func TestLocalRecordingDeadlineClosesUnavailableWithoutLateAsset(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - unavailable := LocalRecordingOutcome{ - Status: "unavailable", ErrorCode: "upload_timeout", ObservedAt: terminal.EndedAt.Add(LocalRecordingDeadline), - } - tooEarly := unavailable - tooEarly.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline - time.Nanosecond) - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, tooEarly); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("premature upload timeout accepted: %v", err) - } - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, unavailable); err != nil { - t.Fatal(err) - } - lateUpload := LocalRecordingOutcome{ - Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/late.wav", - ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - ObservedAt: unavailable.ObservedAt.Add(time.Nanosecond), - } - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, lateUpload); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("late upload replaced timeout: %v", err) - } - fact, exists, err := st.LoadLocalRecordingOutcome(terminal.ExecutionID) - if err != nil || !exists || fact.ErrorCode != "upload_timeout" { - t.Fatalf("timeout fact changed: fact=%+v exists=%t err=%v", fact, exists, err) - } -} diff --git a/internal/store/local_refusal_terminal_test.go b/internal/store/local_refusal_terminal_test.go deleted file mode 100644 index 9b17eb5..0000000 --- a/internal/store/local_refusal_terminal_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package store - -import ( - "testing" - "time" -) - -func TestLocalRefusedExecutionClosesOnceWithoutAgentAttempt(t *testing.T) { - st, _ := readyLocalOrigination(t) - at := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) - if err := st.RefuseLocalOrigination("d-1", "execution-a", "policy_denied", at); err != nil { - t.Fatal(err) - } - ids, err := st.ListLocalRefusedWithoutTerminal("d-1", 0, 256) - if err != nil || len(ids) != 1 || ids[0] != "execution-a" { - t.Fatalf("refused decision unavailable to recover: ids=%v err=%v", ids, err) - } - candidate, decidedAt, reason, err := st.LoadLocalRefusedOrigination("d-1", "execution-a") - if err != nil || candidate.ExecutionID != "execution-a" || reason != "policy_denied" || !decidedAt.Equal(at) { - t.Fatalf("persisted refusal lost: candidate=%+v at=%v reason=%q err=%v", candidate, decidedAt, reason, err) - } - fact := LocalCallTerminal{ - ExecutionID: "execution-a", CallID: "execution-a", Source: "dispatcher_refusal", - StartedAt: at, EndedAt: at, Outcome: "failed", ReasonCode: reason, - } - event := localFinalEventFixture() - if err := st.RecordLocalCallTerminal(fact, &event); err != nil { - t.Fatal(err) - } - assertLocalTerminalQuota(t, st, 0, 0, "released") - if err := st.RecordLocalCallTerminal(fact, &event); err != nil { - t.Fatalf("refusal replay changed terminal fact: %v", err) - } - ids, err = st.ListLocalRefusedWithoutTerminal("d-1", 0, 256) - if err != nil || len(ids) != 0 { - t.Fatalf("closed refusal scheduled twice: ids=%v err=%v", ids, err) - } -} diff --git a/internal/store/local_v01.go b/internal/store/local_v01.go deleted file mode 100644 index b1d4499..0000000 --- a/internal/store/local_v01.go +++ /dev/null @@ -1,326 +0,0 @@ -package store - -import ( - "bytes" - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" -) - -type LocalCommandRecord struct { - DispatcherID string - TaskID string - TenantID string - TenantKey string - CommandID string - CommandType string - Body []byte - ReceiptID string - Exchange string - RoutingKey string - ReceiptBody []byte - CapacityRejectedReceiptBody []byte - Status string - Admission *LocalCallAdmission -} - -type LocalCallAdmission struct { - DispatcherID string - Task Task - ReservationID string - QuotaScopes []string - Snapshot LocalExecutionConfigSnapshot -} - -// LocalExecutionConfigSnapshot binds an accepted call to the exact raw -// configuration responses used for its admission decision. -type LocalExecutionConfigSnapshot struct { - SchemaVersion string `json:"schema_version"` - DispatcherID string `json:"dispatcher_id"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - TaskID string `json:"task_id"` - SelectedTrunkID string `json:"selected_trunk_id"` - SIPRevision int64 `json:"sip_revision"` - TaskRevision int64 `json:"task_revision"` - QuotaRevision int64 `json:"quota_revision"` - SIP json.RawMessage `json:"sip"` - Task json.RawMessage `json:"task"` - TenantQuota json.RawMessage `json:"tenant_quota"` -} - -type LocalEventRecord struct { - EventID string - TenantKey string - Exchange string - RoutingKey string - Body []byte -} - -// PersistLocalCommand stores the inbound command, quota reservation, bound -// config snapshot, and durable receipt in one transaction. -func (s *Store) PersistLocalCommand(record LocalCommandRecord) (bool, error) { - if record.TenantID == "" || record.TenantKey == "" || record.CommandID == "" || record.CommandType == "" || len(record.Body) == 0 || record.ReceiptID == "" || record.Exchange == "" || record.RoutingKey == "" || len(record.ReceiptBody) == 0 { - return false, errors.New("local command persistence fields are required") - } - if record.Status != "persisted" && record.Status != "rejected" { - return false, fmt.Errorf("unsupported local inbox status %q", record.Status) - } - if record.CommandType == "call.execute" && (record.DispatcherID == "" || record.TaskID == "") { - return false, errors.New("call.execute requires a Dispatcher and task identity") - } - if record.CommandType == "call.execute" && record.Status == "persisted" && record.Admission == nil { - return false, errors.New("accepted call.execute requires a durable quota admission") - } - if record.Admission == nil && len(record.CapacityRejectedReceiptBody) != 0 { - return false, errors.New("quota rejection receipt requires an admission") - } - var configBody []byte - if record.Admission != nil { - var err error - configBody, err = validateLocalAdmission(record) - if err != nil { - return false, err - } - } - bodyHash := sha256.Sum256(record.Body) - now := s.now().UTC().Format(time.RFC3339Nano) - - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return false, err - } - defer tx.Rollback() - - var oldTenantID, oldTenantKey, oldBodyHash string - err = tx.QueryRow(`SELECT tenant_id,tenant_key,body_hash FROM inbox WHERE command_id=?`, record.CommandID).Scan(&oldTenantID, &oldTenantKey, &oldBodyHash) - if err == nil { - if oldTenantID != record.TenantID || oldTenantKey != record.TenantKey || oldBodyHash != hex.EncodeToString(bodyHash[:]) { - return false, ErrCommandConflict - } - var receiptID string - if err := tx.QueryRow(`SELECT response_id FROM mq_command_receipts WHERE tenant_id=? AND command_id=?`, record.TenantID, record.CommandID).Scan(&receiptID); err != nil { - return false, fmt.Errorf("load persisted local command receipt: %w", err) - } - if _, err := tx.Exec(`UPDATE outbox SET status='pending', published_at=NULL, last_error=NULL WHERE event_id=? AND status='published'`, receiptID); err != nil { - return false, err - } - if err := tx.Commit(); err != nil { - return false, err - } - return true, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return false, err - } - if record.CommandType == "call.execute" { - if err := requireLocalTaskRunningTx(tx, record.DispatcherID, record.TenantID, record.TenantKey, record.TaskID); err != nil { - return false, err - } - } - - if _, err := tx.Exec(`INSERT INTO inbox(command_id,tenant_id,tenant_key,command_type,body_hash,body,status,received_at) - VALUES(?,?,?,?,?,?,'received',?)`, record.CommandID, record.TenantID, record.TenantKey, record.CommandType, - hex.EncodeToString(bodyHash[:]), record.Body, now); err != nil { - return false, err - } - - finalStatus := record.Status - finalReceiptBody := record.ReceiptBody - if record.Admission != nil { - available, err := localQuotaScopesAvailable(tx, record.Admission.QuotaScopes) - if err != nil { - return false, err - } - if !available { - finalStatus = "rejected" - finalReceiptBody = record.CapacityRejectedReceiptBody - } else { - if err := insertLocalTask(tx, record.Admission.Task, now); err != nil { - return false, err - } - digest := sha256.Sum256(configBody) - if _, err := tx.Exec(`INSERT INTO local_v01_execution_configs(execution_id,content_sha256,body,stored_at) VALUES(?,?,?,?)`, - record.Admission.Task.ExecutionID, hex.EncodeToString(digest[:]), configBody, now); err != nil { - return false, err - } - if err := reserveLocalCallTx(tx, *record.Admission, now); err != nil { - return false, err - } - } - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) - VALUES(?,?,?,?,?,'pending',?)`, record.ReceiptID, record.TenantKey, record.Exchange, record.RoutingKey, finalReceiptBody, now); err != nil { - return false, err - } - if _, err := tx.Exec(`INSERT INTO mq_command_receipts(tenant_id,command_id,response_id) VALUES(?,?,?)`, record.TenantID, record.CommandID, record.ReceiptID); err != nil { - return false, err - } - if _, err := tx.Exec(`UPDATE inbox SET status=?,persisted_at=? WHERE command_id=?`, finalStatus, now, record.CommandID); err != nil { - return false, err - } - if err := tx.Commit(); err != nil { - return false, err - } - return false, nil -} - -func validateLocalAdmission(record LocalCommandRecord) ([]byte, error) { - admission := record.Admission - if admission == nil { - return nil, errors.New("local call admission is required") - } - task := admission.Task - snapshot := admission.Snapshot - if record.CommandType != "call.execute" || record.Status != "persisted" || len(record.CapacityRejectedReceiptBody) == 0 || !json.Valid(record.CapacityRejectedReceiptBody) { - return nil, errors.New("invalid accepted call admission receipt") - } - if admission.DispatcherID == "" || record.DispatcherID != admission.DispatcherID || record.TaskID != task.TaskID || admission.ReservationID == "" || task.ExecutionID == "" || task.TaskID == "" || task.TenantID == "" || task.TenantKey == "" || task.TaskItemID == "" || task.TraceID == "" || - task.TenantID != record.TenantID || task.TenantKey != record.TenantKey || task.TaskItemID != record.CommandID || task.Status != "accepted" || - task.TaskRevision <= 0 || task.Callee == "" || task.RoutePolicyID == "" || task.CallerProfileID == "" || task.AgentVersionID == "" || task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || - admission.ReservationID != task.ExecutionID { - return nil, errors.New("local call admission task identity or limits are invalid") - } - if len(admission.QuotaScopes) != 2 || admission.QuotaScopes[0] != LocalTenantQuotaScope(admission.DispatcherID, task.TenantID) || admission.QuotaScopes[1] != LocalTaskQuotaScope(admission.DispatcherID, task.TenantID, task.TaskID) { - return nil, errors.New("local call admission quota scopes are invalid") - } - if snapshot.SchemaVersion != "execution-config-snapshot.v0.3" || snapshot.SelectedTrunkID == "" || snapshot.DispatcherID != admission.DispatcherID || snapshot.TenantID != task.TenantID || snapshot.TenantKey != task.TenantKey || snapshot.TaskID != task.TaskID || - snapshot.TaskRevision != task.TaskRevision || snapshot.SIPRevision <= 0 || snapshot.QuotaRevision <= 0 || - !validLocalJSONResponse(snapshot.SIP) || !validLocalJSONResponse(snapshot.Task) || !validLocalJSONResponse(snapshot.TenantQuota) { - return nil, errors.New("local call admission config snapshot identity or contents are invalid") - } - body, err := json.Marshal(snapshot) - if err != nil { - return nil, fmt.Errorf("marshal local execution config snapshot: %w", err) - } - return body, nil -} - -func validLocalJSONResponse(body json.RawMessage) bool { - trimmed := bytes.TrimSpace(body) - return len(trimmed) > 0 && trimmed[0] == '{' && json.Valid(trimmed) -} - -func localQuotaScopesAvailable(tx *sql.Tx, scopes []string) (bool, error) { - if len(scopes) == 0 { - return false, ErrNoCapacity - } - for _, scope := range scopes { - var limit, reserved, unknown int64 - if err := tx.QueryRow(`SELECT limit_value,reserved_value,unknown_value FROM quotas WHERE scope=?`, scope).Scan(&limit, &reserved, &unknown); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope) - } - return false, err - } - if limit <= 0 || reserved >= limit || unknown >= limit-reserved { - return false, nil - } - } - return true, nil -} - -func insertLocalTask(tx *sql.Tx, task Task, now string) error { - variables := task.Variables - if variables == nil { - variables = map[string]any{} - } - variablesJSON, err := json.Marshal(variables) - if err != nil { - return fmt.Errorf("marshal local task variables: %w", err) - } - _, err = tx.Exec(`INSERT INTO tasks(execution_id,tenant_key,tenant_id,task_id,task_item_id,task_revision,trace_id,callee, - route_policy_id,caller_profile_id,agent_version_id,variables,ring_timeout_ms,max_call_duration_ms,status,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'accepted',?,?)`, - task.ExecutionID, task.TenantKey, task.TenantID, task.TaskID, task.TaskItemID, task.TaskRevision, task.TraceID, task.Callee, - task.RoutePolicyID, task.CallerProfileID, task.AgentVersionID, variablesJSON, task.RingTimeoutMS, task.MaxCallDurationMS, now, now) - return err -} - -func reserveLocalCallTx(tx *sql.Tx, admission LocalCallAdmission, now string) error { - result, err := tx.Exec(`UPDATE tasks SET status='reserved',updated_at=? WHERE execution_id=? AND tenant_key=? AND status='accepted'`, - now, admission.Task.ExecutionID, admission.Task.TenantKey) - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - scopesJSON, err := json.Marshal(admission.QuotaScopes) - if err != nil { - return err - } - if _, err := tx.Exec(`INSERT INTO reservations(reservation_id,execution_id,tenant_key,scopes,state,created_at) - VALUES(?,?,?,?, 'held', ?)`, admission.ReservationID, admission.Task.ExecutionID, admission.Task.TenantKey, scopesJSON, now); err != nil { - return err - } - for _, scope := range admission.QuotaScopes { - result, err := tx.Exec(`UPDATE quotas SET reserved_value=reserved_value+1,updated_at=? - WHERE scope=? AND reserved_value+unknown_value assignment.TaskRevision { - if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,updated_at=? WHERE dispatcher_id=? AND task_id=?`, - taskRevision, observedStatus, now, dispatcherID, taskID); err != nil { - return err - } - } - return tx.Commit() -} - -// PersistLocalTaskControlResult applies the final local state and writes its -// command.result outbox event in the same SQLite transaction. -func (s *Store) PersistLocalTaskControlResult(result LocalTaskControlResultRecord) (LocalTaskAssignment, bool, error) { - if err := validateLocalTaskControlResult(result); err != nil { - return LocalTaskAssignment{}, false, err - } - now := s.now().UTC().Format(time.RFC3339Nano) - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return LocalTaskAssignment{}, false, err - } - defer tx.Rollback() - assignment, err := scanLocalTaskAssignment(tx.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, - result.DispatcherID, result.TaskID)) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return LocalTaskAssignment{}, false, ErrLocalTaskUnassigned - } - return LocalTaskAssignment{}, false, err - } - if assignment.TenantID != result.TenantID || assignment.TenantKey != result.TenantKey { - return LocalTaskAssignment{}, false, ErrTenantBindingConflict - } - if result.ObservedStatus != "" { - if result.TaskRevision < assignment.TaskRevision { - return LocalTaskAssignment{}, false, ErrConfigRevisionRollback - } - if result.TaskRevision == assignment.TaskRevision && result.ObservedStatus != assignment.Status { - return LocalTaskAssignment{}, false, ErrConfigRevisionConflict - } - assignment.Status = result.ObservedStatus - assignment.TaskRevision = result.TaskRevision - } - state, err := controlResultAdmissionState(assignment.AdmissionState, result) - if err != nil { - return LocalTaskAssignment{}, false, err - } - assignment.AdmissionState = state - assignment.UpdatedAt = now - if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,admission_state=?,updated_at=? WHERE dispatcher_id=? AND task_id=?`, - assignment.TaskRevision, assignment.Status, assignment.AdmissionState, now, result.DispatcherID, result.TaskID); err != nil { - return LocalTaskAssignment{}, false, err - } - duplicate, err := enqueueLocalEventTx(tx, result.Event, now) - if err != nil { - return LocalTaskAssignment{}, false, err - } - if err := tx.Commit(); err != nil { - return LocalTaskAssignment{}, false, err - } - return assignment, duplicate, nil -} - -func validateLocalTaskControlResult(result LocalTaskControlResultRecord) error { - if result.DispatcherID == "" || result.TaskID == "" || result.TenantID == "" || result.TenantKey == "" { - return errors.New("task-control result identity is required") - } - switch result.Action { - case "pause", "resume", "stop": - default: - return fmt.Errorf("unsupported task-control action %q", result.Action) - } - switch result.ResultStatus { - case "applied", "rejected": - default: - return fmt.Errorf("unsupported task-control result status %q", result.ResultStatus) - } - switch result.AdmissionState { - case "running", "paused", "stopped", "finished": - default: - return fmt.Errorf("unsupported task-control result state %q", result.AdmissionState) - } - if result.ObservedStatus != "" { - switch result.ObservedStatus { - case "running", "paused", "stopped", "finished": - default: - return fmt.Errorf("unsupported observed task status %q", result.ObservedStatus) - } - if result.TaskRevision <= 0 { - return errors.New("observed task status requires a positive revision") - } - } else if result.TaskRevision != 0 { - return errors.New("task revision requires an observed task status") - } - return validateLocalEventRecord(result.Event) -} - -func controlResultAdmissionState(current string, result LocalTaskControlResultRecord) (string, error) { - terminal := current == "stopped" || current == "finished" || current == "removed" - if terminal { - if current == "removed" && result.AdmissionState == "stopped" && result.ResultStatus == "rejected" { - return current, nil - } - if result.AdmissionState != current { - return "", ErrLocalTaskStopped - } - } - if result.ResultStatus == "applied" { - switch result.Action { - case "pause": - if result.ObservedStatus != "paused" || result.AdmissionState != "paused" { - return "", errors.New("applied pause requires authoritative paused state") - } - case "resume": - if result.ObservedStatus != "running" || result.AdmissionState != "running" || terminal { - return "", ErrLocalTaskStopped - } - case "stop": - if result.ObservedStatus != "stopped" || result.AdmissionState != "stopped" { - return "", errors.New("applied stop requires authoritative stopped state") - } - } - return result.AdmissionState, nil - } - if result.AdmissionState == current { - return current, nil - } - if current == "running" && result.AdmissionState == "paused" { - return "paused", nil - } - return "", errors.New("rejected task-control result cannot reopen admission") -} - -func validateLocalEventRecord(event LocalEventRecord) error { - if event.EventID == "" || event.TenantKey == "" || event.Exchange == "" || event.RoutingKey == "" || len(event.Body) == 0 { - return errors.New("local event identity, route, and body are required") - } - return nil -} diff --git a/internal/store/local_v01_control_test.go b/internal/store/local_v01_control_test.go deleted file mode 100644 index fb5e3ab..0000000 --- a/internal/store/local_v01_control_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package store - -import ( - "errors" - "testing" - - "git.ipao.vip/rogee/go-sip/internal/mq" -) - -func TestRecordLocalTaskStatusObservationIsDurableWithoutReceipt(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - seedLocalDiscovery(t, st) - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { - t.Fatal(err) - } - if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped", 2); err != nil { - t.Fatal(err) - } - assignment, err := st.LocalTaskAssignment("d-1", "task-a") - if err != nil || assignment.Status != "stopped" || assignment.TaskRevision != 2 || assignment.AdmissionState != "stopped" { - t.Fatalf("observed task state=%+v err=%v", assignment, err) - } - var outboxCount int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil || outboxCount != 0 { - t.Fatalf("status observation wrote a receipt: count=%d err=%v", outboxCount, err) - } - if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped", 2); err != nil { - t.Fatalf("idempotent status observation: %v", err) - } - if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 2); !errors.Is(err, ErrConfigRevisionConflict) { - t.Fatalf("same-revision status change=%v, want conflict", err) - } - if err := st.RecordLocalTaskStatusObservation("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 1); !errors.Is(err, ErrConfigRevisionRollback) { - t.Fatalf("revision rollback=%v, want rollback error", err) - } -} - -func TestLocalTaskControlBarrierAndResultCommitAtomically(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - seedLocalDiscovery(t, st) - - assignment, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused") - if err != nil || assignment.AdmissionState != "paused" { - t.Fatalf("barrier assignment=%+v err=%v", assignment, err) - } - bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, st.now()) - if _, err := st.PersistLocalCommand(localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a")); !errors.Is(err, ErrLocalTaskPaused) { - t.Fatalf("call admission after pause = %v, want ErrLocalTaskPaused", err) - } - - result := LocalTaskControlResultRecord{ - DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", - Action: "pause", ResultStatus: "applied", ObservedStatus: "paused", TaskRevision: 2, - AdmissionState: "paused", Event: localControlResultEvent("event-pause-1", "tenant-key-a"), - } - assignment, duplicate, err := st.PersistLocalTaskControlResult(result) - if err != nil || duplicate || assignment.Status != "paused" || assignment.TaskRevision != 2 || assignment.AdmissionState != "paused" { - t.Fatalf("control assignment=%+v duplicate=%v err=%v", assignment, duplicate, err) - } - assignment, duplicate, err = st.PersistLocalTaskControlResult(result) - if err != nil || !duplicate || assignment.TaskRevision != 2 { - t.Fatalf("duplicate control result assignment=%+v duplicate=%v err=%v", assignment, duplicate, err) - } - - conflict := result - conflict.Event.Body = []byte(`{"event_id":"event-pause-1","different":true}`) - if _, _, err := st.PersistLocalTaskControlResult(conflict); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("conflicting event ID error=%v", err) - } - assignment, err = st.LocalTaskAssignment("d-1", "task-a") - if err != nil || assignment.Status != "paused" || assignment.TaskRevision != 2 || assignment.AdmissionState != "paused" { - t.Fatalf("failed result transaction changed task state: %+v err=%v", assignment, err) - } - var outboxCount int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id='event-pause-1'`).Scan(&outboxCount); err != nil || outboxCount != 1 { - t.Fatalf("outbox rows=%d err=%v", outboxCount, err) - } -} - -func TestLocalTaskStopBarrierCannotBeReopenedByResume(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - seedLocalDiscovery(t, st) - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { - t.Fatal(err) - } - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { - t.Fatal(err) - } - stop := LocalTaskControlResultRecord{ - DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", - Action: "stop", ResultStatus: "applied", ObservedStatus: "stopped", TaskRevision: 2, - AdmissionState: "stopped", Event: localControlResultEvent("event-stop-1", "tenant-key-a"), - } - assignment, _, err := st.PersistLocalTaskControlResult(stop) - if err != nil || assignment.AdmissionState != "stopped" { - t.Fatalf("stop assignment=%+v err=%v", assignment, err) - } - assignment, err = st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused") - if err != nil || assignment.AdmissionState != "stopped" { - t.Fatalf("pause reopened stopped task: %+v err=%v", assignment, err) - } - - resumeRejected := LocalTaskControlResultRecord{ - DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", - Action: "resume", ResultStatus: "rejected", ObservedStatus: "running", TaskRevision: 3, - AdmissionState: "stopped", Event: localControlResultEvent("event-resume-rejected-1", "tenant-key-a"), - } - assignment, _, err = st.PersistLocalTaskControlResult(resumeRejected) - if err != nil || assignment.Status != "running" || assignment.TaskRevision != 3 || assignment.AdmissionState != "stopped" { - t.Fatalf("rejected resume assignment=%+v err=%v", assignment, err) - } -} - -func TestLocalTaskResumeBarrierReopensPausedButNotStopped(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - seedLocalDiscovery(t, st) - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "paused"); err != nil { - t.Fatal(err) - } - assignment, err := st.ResumeLocalTaskAdmission("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 2) - if err != nil || assignment.AdmissionState != "running" || assignment.Status != "running" || assignment.TaskRevision != 2 { - t.Fatalf("resume assignment=%+v err=%v", assignment, err) - } - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "task-a", "tenant-a", "tenant-key-a", "stopped"); err != nil { - t.Fatal(err) - } - assignment, err = st.ResumeLocalTaskAdmission("d-1", "task-a", "tenant-a", "tenant-key-a", "running", 3) - if !errors.Is(err, ErrLocalTaskStopped) || assignment.AdmissionState != "stopped" { - t.Fatalf("resume reopened terminal task: %+v err=%v", assignment, err) - } -} - -func localControlResultEvent(eventID, tenantKey string) LocalEventRecord { - return LocalEventRecord{ - EventID: eventID, TenantKey: tenantKey, Exchange: mq.ResultsExchangeV3, - RoutingKey: "d.d-1.out", Body: []byte(`{"event_id":"` + eventID + `"}`), - } -} diff --git a/internal/store/local_v01_discovery.go b/internal/store/local_v01_discovery.go deleted file mode 100644 index dc19985..0000000 --- a/internal/store/local_v01_discovery.go +++ /dev/null @@ -1,231 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/mq" -) - -var ( - ErrLocalDiscoveryUnavailable = errors.New("task discovery has no fresh complete response") - ErrLocalDiscoveryTaskLimit = errors.New("task discovery exceeds the 256-task per-Dispatcher limit") - ErrLocalDiscoveryTaskMissing = errors.New("local task-discovery update targets an unknown task") - ErrLocalTaskRemoved = errors.New("local task assignment has been removed") - ErrLocalTaskUnassigned = errors.New("task is not assigned to this Dispatcher") - ErrLocalTaskPaused = errors.New("task admission is paused") - ErrLocalTaskStopped = errors.New("task admission is terminal") -) - -type LocalDiscoveredTask struct { - TaskID string - TenantID string - TenantKey string - Status string - TaskRevision int64 -} - -type LocalTaskQueue struct { - Exchange string - RoutingKey string - BindingKey string - QueueName string -} - -type LocalTaskAssignment struct { - DispatcherID string - TaskID string - TenantID string - TenantKey string - TaskRevision int64 - Status string - AdmissionState string - Removed bool - Queue LocalTaskQueue - UpdatedAt string - DiscoveryObservedAt time.Time -} - -func validateLocalDiscoveredTask(dispatcherID string, task LocalDiscoveredTask) error { - if dispatcherID == "" || !validLocalTaskID(task.TaskID) || task.TenantID == "" || task.TenantKey == "" || task.TaskRevision <= 0 || len([]byte(task.TenantKey)) > 196 { - return errors.New("incomplete task-discovery assignment") - } - switch task.Status { - case "running", "paused", "stopped", "finished", "removed": - default: - return fmt.Errorf("unsupported discovered task status %q", task.Status) - } - return nil -} - -func validLocalTaskID(taskID string) bool { - if len(taskID) == 0 || len(taskID) > 128 { - return false - } - for _, b := range []byte(taskID) { - if (b < 'a' || b > 'z') && (b < 'A' || b > 'Z') && (b < '0' || b > '9') && b != '_' && b != '-' { - return false - } - } - return true -} - -func derivedLocalTaskQueue(dispatcherID, taskID string) LocalTaskQueue { - route := fmt.Sprintf("d.%s.task.%s.in", dispatcherID, taskID) - return LocalTaskQueue{Exchange: mq.CommandsExchangeV3, RoutingKey: route, BindingKey: route, - QueueName: fmt.Sprintf("agent-call.d.%s.task.%s.v3", dispatcherID, taskID)} -} - -func applyLocalDiscoveredTask(tx *sql.Tx, dispatcherID string, task LocalDiscoveredTask, now string) error { - queue := derivedLocalTaskQueue(dispatcherID, task.TaskID) - var old LocalTaskAssignment - var removed int - err := tx.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, - dispatcherID, task.TaskID).Scan(&old.DispatcherID, &old.TaskID, &old.TenantID, &old.TenantKey, &old.TaskRevision, - &old.Status, &old.AdmissionState, &removed, &old.Queue.Exchange, &old.Queue.RoutingKey, &old.Queue.BindingKey, &old.Queue.QueueName, &old.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - removed := 0 - if task.Status == "removed" { - removed = 1 - } - _, err = tx.Exec(`INSERT INTO local_v01_task_assignments(dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, - dispatcherID, task.TaskID, task.TenantID, task.TenantKey, task.TaskRevision, task.Status, task.Status, removed, - queue.Exchange, queue.RoutingKey, queue.BindingKey, queue.QueueName, now) - return err - } - if err != nil { - return err - } - old.Removed = removed != 0 - if old.TenantID != task.TenantID || old.TenantKey != task.TenantKey { - return ErrTenantBindingConflict - } - if task.TaskRevision < old.TaskRevision { - return ErrConfigRevisionRollback - } - if old.Removed { - if task.Status != "removed" { - return ErrLocalTaskRemoved - } - return nil - } - if old.Queue != queue { - return ErrConfigRevisionConflict - } - if task.TaskRevision == old.TaskRevision && task.Status == old.Status { - return nil - } - state := mergeLocalTaskState(old.AdmissionState, task.Status) - removed = 0 - if task.Status == "removed" { - state, removed = "removed", 1 - } - _, err = tx.Exec(`UPDATE local_v01_task_assignments SET task_revision=?,saas_status=?,admission_state=?,removed=?,queue_exchange=?,routing_key=?,binding_key=?,queue_name=?,updated_at=? - WHERE dispatcher_id=? AND task_id=?`, task.TaskRevision, task.Status, state, removed, queue.Exchange, queue.RoutingKey, - queue.BindingKey, queue.QueueName, now, dispatcherID, task.TaskID) - return err -} - -func mergeLocalTaskState(current, incoming string) string { - if current == "removed" || current == "finished" { - return current - } - if current == "stopped" { - if incoming == "finished" { - return "finished" - } - return current - } - switch incoming { - case "running": - // Discovery records the new SaaS status, but only MQ resume with a fresh task read can clear a persisted pause. - return current - case "paused", "stopped", "finished": - return incoming - default: - return current - } -} - -func (s *Store) LocalTaskAssignments(dispatcherID string) ([]LocalTaskAssignment, error) { - s.mu.Lock() - defer s.mu.Unlock() - rows, err := s.db.Query(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? ORDER BY task_id`, dispatcherID) - if err != nil { - return nil, err - } - defer rows.Close() - var assignments []LocalTaskAssignment - for rows.Next() { - assignment, err := scanLocalTaskAssignment(rows) - if err != nil { - return nil, err - } - assignments = append(assignments, assignment) - } - if err := rows.Err(); err != nil { - return nil, err - } - return assignments, nil -} - -func (s *Store) LocalTaskAssignment(dispatcherID, taskID string) (LocalTaskAssignment, error) { - s.mu.Lock() - defer s.mu.Unlock() - return scanLocalTaskAssignment(s.db.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, dispatcherID, taskID)) -} - -func scanLocalTaskAssignment(row interface{ Scan(...any) error }) (LocalTaskAssignment, error) { - var assignment LocalTaskAssignment - var removed int - err := row.Scan(&assignment.DispatcherID, &assignment.TaskID, &assignment.TenantID, &assignment.TenantKey, &assignment.TaskRevision, - &assignment.Status, &assignment.AdmissionState, &removed, &assignment.Queue.Exchange, &assignment.Queue.RoutingKey, - &assignment.Queue.BindingKey, &assignment.Queue.QueueName, &assignment.UpdatedAt) - if err != nil { - return LocalTaskAssignment{}, err - } - assignment.Removed = removed != 0 - return assignment, nil -} - -func requireLocalTaskRunningTx(tx *sql.Tx, dispatcherID, tenantID, tenantKey, taskID string) error { - var ready int - if err := tx.QueryRow(`SELECT ready FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&ready); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrLocalDiscoveryUnavailable - } - return err - } - if ready != 1 { - return ErrLocalDiscoveryUnavailable - } - var assignedTenantID, assignedTenantKey, admissionState string - var removed int - err := tx.QueryRow(`SELECT tenant_id,tenant_key,admission_state,removed FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, - dispatcherID, taskID).Scan(&assignedTenantID, &assignedTenantKey, &admissionState, &removed) - if errors.Is(err, sql.ErrNoRows) { - return ErrLocalTaskUnassigned - } - if err != nil { - return err - } - if assignedTenantID != tenantID || assignedTenantKey != tenantKey { - return ErrTenantBindingConflict - } - if removed != 0 || admissionState == "stopped" || admissionState == "finished" || admissionState == "removed" { - return ErrLocalTaskStopped - } - if admissionState == "paused" { - return ErrLocalTaskPaused - } - if admissionState != "running" { - return fmt.Errorf("unsupported local task admission state %q", admissionState) - } - return nil -} diff --git a/internal/store/local_v01_test.go b/internal/store/local_v01_test.go deleted file mode 100644 index f4425aa..0000000 --- a/internal/store/local_v01_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package store - -import ( - "errors" - "path/filepath" - "testing" - "time" -) - -func TestPersistLocalCommandIsAtomicAndIdempotent(t *testing.T) { - st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) - if err != nil { - t.Fatal(err) - } - defer st.Close() - - bundle := localConfigBundle("dispatcher-1", "tenant-id-1", "tenant-key-1", "task-1", 1, 1, 1, 2, 2, time.Now().UTC()) - if err := st.SaveLocalConfigBundle(bundle); err != nil { - t.Fatal(err) - } - record := localAdmissionCommand("command-1", "execution-1", "task-1", bundle, "dispatcher-1", "tenant-id-1", "tenant-key-1") - seedLocalCommandAssignments(t, st, record) - record.Exchange = "agent-call.saas.v3" - record.RoutingKey = "d.dispatcher-1.out" - duplicate, err := st.PersistLocalCommand(record) - if err != nil { - t.Fatal(err) - } - if duplicate { - t.Fatal("first command was reported as a duplicate") - } - - var inboxStatus string - var receiptCount, outboxCount int - if err := st.db.QueryRow(`SELECT status FROM inbox WHERE tenant_id=? AND command_id=?`, record.TenantID, record.CommandID).Scan(&inboxStatus); err != nil { - t.Fatal(err) - } - if inboxStatus != "persisted" { - t.Fatalf("inbox status = %q, want persisted", inboxStatus) - } - if err := st.db.QueryRow(`SELECT COUNT(*) FROM mq_command_receipts WHERE tenant_id=? AND command_id=? AND response_id=?`, record.TenantID, record.CommandID, record.ReceiptID).Scan(&receiptCount); err != nil { - t.Fatal(err) - } - if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=? AND status='pending'`, record.ReceiptID).Scan(&outboxCount); err != nil { - t.Fatal(err) - } - if receiptCount != 1 || outboxCount != 1 { - t.Fatalf("receipt links=%d outbox rows=%d, want 1 each", receiptCount, outboxCount) - } - - first, err := st.ClaimOutbox(1) - if err != nil || len(first) != 1 { - t.Fatalf("claim first receipt: records=%d err=%v", len(first), err) - } - if err := st.MarkOutboxPublished(first[0].ID); err != nil { - t.Fatal(err) - } - duplicate, err = st.PersistLocalCommand(record) - if err != nil { - t.Fatal(err) - } - if !duplicate { - t.Fatal("redelivered command was not identified as a duplicate") - } - second, err := st.ClaimOutbox(1) - if err != nil || len(second) != 1 { - t.Fatalf("claim recovered receipt: records=%d err=%v", len(second), err) - } - if second[0].EventID != first[0].EventID || string(second[0].Body) != string(first[0].Body) { - t.Fatalf("duplicate changed receipt: first=%+v second=%+v", first[0], second[0]) - } - - record.Body = []byte(`{"command_id":"command-1","callee":"different"}`) - if _, err := st.PersistLocalCommand(record); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("same command ID with different body error = %v, want ErrCommandConflict", err) - } -} - -func TestEnqueueLocalEventIsIdempotentAndRejectsContentConflict(t *testing.T) { - st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) - if err != nil { - t.Fatal(err) - } - defer st.Close() - - event := LocalEventRecord{ - EventID: "call-result-1", TenantKey: "tenant-key-1", Exchange: "agent-call.saas.v3", - RoutingKey: "d.dispatcher-1.out", Body: []byte(`{"event_id":"call-result-1"}`), - } - duplicate, err := st.EnqueueLocalEvent(event) - if err != nil || duplicate { - t.Fatalf("first enqueue duplicate=%v err=%v", duplicate, err) - } - duplicate, err = st.EnqueueLocalEvent(event) - if err != nil || !duplicate { - t.Fatalf("same event enqueue duplicate=%v err=%v", duplicate, err) - } - var count int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id=?`, event.EventID).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("outbox rows = %d, want 1", count) - } - event.Body = []byte(`{"event_id":"call-result-1","different":true}`) - if _, err := st.EnqueueLocalEvent(event); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("same event ID with different body error = %v, want ErrCommandConflict", err) - } -} - -func TestVerifyLocalAcceptedCommandBindsCallResultToReceipt(t *testing.T) { - st, err := Open(filepath.Join(t.TempDir(), "dispatcher.db")) - if err != nil { - t.Fatal(err) - } - defer st.Close() - - bundle := localConfigBundle("dispatcher-a", "tenant-a", "tenant-a", "task-a", 1, 1, 1, 2, 2, time.Now().UTC()) - if err := st.SaveLocalConfigBundle(bundle); err != nil { - t.Fatal(err) - } - record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "dispatcher-a", "tenant-a", "tenant-a") - seedLocalCommandAssignments(t, st, record) - record.Body = []byte(`{"command_id":"command-a","command_type":"call.execute","tenant_id":"tenant-a","tenant_key":"tenant-a","payload":{"task_id":"task-a"}}`) - record.ReceiptID = "receipt-a" - record.Exchange = "agent-call.saas.v3" - record.RoutingKey = "d.dispatcher-a.out" - record.ReceiptBody = []byte(`{"event_type":"command.result","tenant_id":"tenant-a","tenant_key":"tenant-a","payload":{"command_id":"command-a","command_type":"call.execute","status":"accepted","execution_id":"execution-a"}}`) - _, err = st.PersistLocalCommand(record) - if err != nil { - t.Fatal(err) - } - if err := st.VerifyLocalAcceptedCommand("tenant-a", "tenant-a", "command-a", "execution-a", "task-a"); err != nil { - t.Fatalf("valid result binding: %v", err) - } - for _, tc := range []struct { - tenantID, tenantKey, commandID, executionID, taskID string - }{ - {"tenant-b", "tenant-a", "command-a", "execution-a", "task-a"}, - {"tenant-a", "tenant-b", "command-a", "execution-a", "task-a"}, - {"tenant-a", "tenant-a", "command-a", "execution-b", "task-a"}, - {"tenant-a", "tenant-a", "command-a", "execution-a", "task-b"}, - } { - if err := st.VerifyLocalAcceptedCommand(tc.tenantID, tc.tenantKey, tc.commandID, tc.executionID, tc.taskID); !errors.Is(err, ErrCommandConflict) { - t.Fatalf("invalid result binding %+v error = %v, want ErrCommandConflict", tc, err) - } - } -} diff --git a/internal/store/local_v01_verify.go b/internal/store/local_v01_verify.go deleted file mode 100644 index 182c15c..0000000 --- a/internal/store/local_v01_verify.go +++ /dev/null @@ -1,66 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/json" - "errors" -) - -// VerifyLocalAcceptedCommand binds a final call result to the exact accepted -// command and execution recorded in the durable inbox and receipt outbox. -func (s *Store) VerifyLocalAcceptedCommand(tenantID, tenantKey, commandID, executionID, taskID string) error { - if tenantID == "" || tenantKey == "" || commandID == "" || executionID == "" || taskID == "" { - return ErrCommandConflict - } - s.mu.Lock() - defer s.mu.Unlock() - var storedTenantKey, status string - var commandBody, receiptBody []byte - err := s.db.QueryRow(`SELECT i.tenant_key, i.status, i.body, o.body - FROM inbox i - JOIN mq_command_receipts r ON r.tenant_id=i.tenant_id AND r.command_id=i.command_id - JOIN outbox o ON o.event_id=r.response_id - WHERE i.tenant_id=? AND i.command_id=?`, tenantID, commandID).Scan(&storedTenantKey, &status, &commandBody, &receiptBody) - if errors.Is(err, sql.ErrNoRows) { - return ErrCommandConflict - } - if err != nil { - return err - } - if storedTenantKey != tenantKey || status != "persisted" { - return ErrCommandConflict - } - var command struct { - CommandID string `json:"command_id"` - CommandType string `json:"command_type"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - Payload struct { - TaskID string `json:"task_id"` - } `json:"payload"` - } - if err := json.Unmarshal(commandBody, &command); err != nil { - return ErrCommandConflict - } - var receipt struct { - EventType string `json:"event_type"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - Payload struct { - CommandID string `json:"command_id"` - CommandType string `json:"command_type"` - Status string `json:"status"` - ExecutionID string `json:"execution_id"` - } `json:"payload"` - } - if err := json.Unmarshal(receiptBody, &receipt); err != nil { - return ErrCommandConflict - } - if command.CommandID != commandID || command.CommandType != "call.execute" || command.TenantID != tenantID || - command.TenantKey != tenantKey || command.Payload.TaskID != taskID || receipt.EventType != "command.result" || - receipt.TenantID != tenantID || receipt.TenantKey != tenantKey || receipt.Payload.CommandID != commandID || - receipt.Payload.CommandType != "call.execute" || receipt.Payload.Status != "accepted" || receipt.Payload.ExecutionID != executionID { - return ErrCommandConflict - } - return nil -} diff --git a/internal/store/local_v03_admission_test.go b/internal/store/local_v03_admission_test.go deleted file mode 100644 index 77edd25..0000000 --- a/internal/store/local_v03_admission_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package store - -import ( - "errors" - "testing" - "time" -) - -func TestSnapshotAdmissionRequiresRunningTask(t *testing.T) { - for _, tc := range []struct { - name string - discovered bool - status string - wantErr error - }{ - {name: "paused", discovered: true, status: "paused", wantErr: ErrLocalTaskPaused}, - {name: "stopped", discovered: true, status: "stopped", wantErr: ErrLocalTaskStopped}, - {name: "removed", discovered: true, status: "removed", wantErr: ErrLocalTaskStopped}, - {name: "no discovery", wantErr: ErrLocalDiscoveryUnavailable}, - } { - t.Run(tc.name, func(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - if tc.discovered { - initialStatus := tc.status - if initialStatus == "removed" { - initialStatus = "running" - } - seedLocalSnapshotTasks(t, st, "d-1", eventTask("task-a", initialStatus, 1)) - if tc.status == "removed" { - if err := st.ApplyLocalTaskChanges("d-1", []LocalDiscoveredTask{eventTask("task-a", "removed", 2)}, time.Now().UTC()); err != nil { - t.Fatal(err) - } - } - } - now := time.Date(2026, 9, 21, 2, 0, 0, 0, time.UTC) - bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 1, 1, now) - record := localAdmissionCommand("command-a", "execution-a", "task-a", bundle, "d-1", "tenant-a", "tenant-key-a") - if _, err := st.PersistLocalCommand(record); !errors.Is(err, tc.wantErr) { - t.Fatalf("PersistLocalCommand error=%v, want %v", err, tc.wantErr) - } - for _, table := range []string{"inbox", "outbox", "tasks", "reservations"} { - var count int - if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil || count != 0 { - t.Fatalf("table %s rows=%d err=%v after blocked admission", table, count, err) - } - } - }) - } -} diff --git a/internal/store/local_v03_config_gate_test.go b/internal/store/local_v03_config_gate_test.go deleted file mode 100644 index 0ba2cca..0000000 --- a/internal/store/local_v03_config_gate_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package store - -import ( - "errors" - "testing" - "time" -) - -func TestSnapshotConfigReadRequiresControlCatchupAndTenantBinding(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - now := time.Now().UTC() - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("task-a", "running", 1)}, now); err != nil { - t.Fatal(err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "task-a", "tenant-a"); !errors.Is(err, ErrLocalDiscoveryUnavailable) { - t.Fatalf("snapshot without control catch-up admitted config read: %v", err) - } - if err := st.MarkLocalTaskDiscoveryReadyV04("d-1", now); err != nil { - t.Fatal(err) - } - assignment, err := st.LocalDiscoveredTaskForConfig("d-1", "task-a", "tenant-a") - if err != nil || assignment.Status != "running" || assignment.TenantKey != "tenant-key-a" { - t.Fatalf("assignment=%+v err=%v", assignment, err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "task-a", "another-tenant"); !errors.Is(err, ErrTenantBindingConflict) { - t.Fatalf("cross-tenant config read: %v", err) - } - if err := st.ApplyLocalTaskChanges("d-1", []LocalDiscoveredTask{eventTask("task-a", "removed", 2)}, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "task-a", "tenant-a"); !errors.Is(err, ErrLocalTaskRemoved) { - t.Fatalf("removed task config read: %v", err) - } -} diff --git a/internal/store/local_v04_cleanup.go b/internal/store/local_v04_cleanup.go deleted file mode 100644 index 82172c6..0000000 --- a/internal/store/local_v04_cleanup.go +++ /dev/null @@ -1,90 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "log" -) - -// LocalTaskConfigEviction reports a narrowly scoped cache eviction. BlockedBy -// is a durable reason to retry later, not permission to discard recovery data. -type LocalTaskConfigEviction struct { - Deleted int64 - BlockedBy string -} - -// EvictTerminalTaskConfig explicitly removes only a terminal task's derived -// HTTP task-config copy. It never removes command identities, assignments, -// execution snapshots, reservations, uploads, terminal facts or outbox rows: -// publisher confirm is not SaaS application receipt. There is no automatic -// migration or sweep of existing data. -func (s *Store) EvictTerminalTaskConfig(dispatcherID, taskID string) (LocalTaskConfigEviction, error) { - if dispatcherID == "" || taskID == "" { - return LocalTaskConfigEviction{}, ErrLocalTaskUnassigned - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return LocalTaskConfigEviction{}, err - } - defer tx.Rollback() - - var tenantID, status, admission string - var removed int - err = tx.QueryRow(`SELECT tenant_id,saas_status,admission_state,removed FROM local_v01_task_assignments - WHERE dispatcher_id=? AND task_id=?`, dispatcherID, taskID).Scan(&tenantID, &status, &admission, &removed) - if errors.Is(err, sql.ErrNoRows) { - return LocalTaskConfigEviction{}, ErrLocalTaskUnassigned - } - if err != nil { - return LocalTaskConfigEviction{}, fmt.Errorf("load task before cache eviction: %w", err) - } - terminal := func(value string) bool { return value == "stopped" || value == "finished" || value == "removed" } - if !terminal(status) || !terminal(admission) || (removed != 0 && status != "removed") { - log.Printf("task config eviction blocked dispatcher_id=%s task_id=%s reason=task_active", dispatcherID, taskID) - return LocalTaskConfigEviction{BlockedBy: "task_active"}, nil - } - checks := []struct { - reason string - query string - args []any - }{ - {"execution_unconfirmed", `SELECT EXISTS(SELECT 1 FROM tasks t - LEFT JOIN reservations r ON r.execution_id=t.execution_id - LEFT JOIN local_v01_call_terminals c ON c.execution_id=t.execution_id - LEFT JOIN outbox o ON o.event_id=c.result_event_id - WHERE t.tenant_id=? AND t.task_id=? AND - (t.status!='finished' OR r.reservation_id IS NULL OR r.state!='released' OR - c.execution_id IS NULL OR c.result_event_id IS NULL OR o.status IS NULL OR o.status!='published'))`, []any{tenantID, taskID}}, - {"quota_occupied", `SELECT EXISTS(SELECT 1 FROM quotas WHERE scope IN (?,?) AND (reserved_value!=0 OR unknown_value!=0))`, - []any{LocalTaskQuotaScope(dispatcherID, tenantID, taskID), LocalTenantQuotaScope(dispatcherID, tenantID)}}, - {"upload_incomplete", `SELECT EXISTS(SELECT 1 FROM uploads WHERE state='granted')`, nil}, - {"outbox_pending", `SELECT EXISTS(SELECT 1 FROM outbox WHERE status!='published')`, nil}, - } - for _, check := range checks { - var blocked int - if err := tx.QueryRow(check.query, check.args...).Scan(&blocked); err != nil { - return LocalTaskConfigEviction{}, fmt.Errorf("check %s before task cache eviction: %w", check.reason, err) - } - if blocked != 0 { - log.Printf("task config eviction blocked dispatcher_id=%s task_id=%s reason=%s", dispatcherID, taskID, check.reason) - return LocalTaskConfigEviction{BlockedBy: check.reason}, nil - } - } - result, err := tx.Exec(`DELETE FROM local_v01_config_snapshots WHERE dispatcher_id=? AND resource='task' AND tenant_id=? AND task_id=?`, - dispatcherID, tenantID, taskID) - if err != nil { - return LocalTaskConfigEviction{}, fmt.Errorf("evict terminal task config: %w", err) - } - deleted, err := result.RowsAffected() - if err != nil || deleted > 1 { - return LocalTaskConfigEviction{}, fmt.Errorf("terminal task config rows=%d: %w", deleted, errors.Join(err, ErrCommandConflict)) - } - if err := tx.Commit(); err != nil { - return LocalTaskConfigEviction{}, fmt.Errorf("commit terminal task config eviction: %w", err) - } - log.Printf("task config eviction completed dispatcher_id=%s task_id=%s deleted=%d", dispatcherID, taskID, deleted) - return LocalTaskConfigEviction{Deleted: deleted}, nil -} diff --git a/internal/store/local_v04_cleanup_test.go b/internal/store/local_v04_cleanup_test.go deleted file mode 100644 index 8a1fa8b..0000000 --- a/internal/store/local_v04_cleanup_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "testing" - "time" -) - -func cleanupTestStore(t *testing.T) *Store { - t.Helper() - st := openLocalDiscoveryTestStore(t) - seedLocalSnapshotTasks(t, st, "d-1", eventTask("task-a", "running", 1)) - bundle := localConfigBundle("d-1", "tenant-a", "tenant-key-a", "task-a", 1, 1, 1, 3, 3, time.Now().UTC()) - if err := st.SaveLocalConfigBundle(bundle); err != nil { - t.Fatal(err) - } - return st -} - -func stopCleanupTestTask(t *testing.T, st *Store) { - t.Helper() - if _, err := st.db.Exec(`UPDATE local_v01_task_assignments SET saas_status='stopped',admission_state='stopped' WHERE dispatcher_id='d-1' AND task_id='task-a'`); err != nil { - t.Fatal(err) - } -} - -func expectCleanupTaskCache(t *testing.T, st *Store, present bool) { - t.Helper() - _, err := st.LocalConfigSnapshot("d-1", "task", "tenant-a", "task-a") - if present && err != nil || !present && !errors.Is(err, sql.ErrNoRows) { - t.Fatalf("task config present=%v error=%v", present, err) - } -} - -func TestTerminalTaskCacheEvictionPreservesRecoveryAndDedupRecords(t *testing.T) { - st := cleanupTestStore(t) - command := LocalCommandRecord{ - DispatcherID: "d-1", TaskID: "task-a", TenantID: "tenant-a", TenantKey: "tenant-key-a", - CommandID: "original-command", CommandType: "call.execute", Body: []byte(`{"task_id":"task-a"}`), - ReceiptID: "result-event", Exchange: "events", RoutingKey: "d-1", ReceiptBody: []byte(`{}`), Status: "rejected", - } - if duplicate, err := st.PersistLocalCommand(command); err != nil || duplicate { - t.Fatalf("seed rejected command: duplicate=%v err=%v", duplicate, err) - } - if _, err := st.db.Exec(`UPDATE outbox SET status='published',published_at='2026-09-22T10:01:00Z' WHERE event_id='result-event'`); err != nil { - t.Fatal(err) - } - result, err := st.EvictTerminalTaskConfig("d-1", "task-a") - if err != nil || result.Deleted != 0 || result.BlockedBy != "task_active" { - t.Fatalf("active task cleanup=%+v err=%v", result, err) - } - stopCleanupTestTask(t, st) - result, err = st.EvictTerminalTaskConfig("d-1", "task-a") - if err != nil || result.Deleted != 1 || result.BlockedBy != "" { - t.Fatalf("safe terminal config cleanup=%+v err=%v", result, err) - } - expectCleanupTaskCache(t, st, false) - for _, scope := range []struct{ resource, tenantID string }{{"sip", ""}, {"tenant_quota", "tenant-a"}} { - if _, err := st.LocalConfigSnapshot("d-1", scope.resource, scope.tenantID, ""); err != nil { - t.Fatalf("cleaned shared %s: %v", scope.resource, err) - } - } - for table := range map[string]bool{"inbox": true, "outbox": true, "local_v01_task_assignments": true, "local_v01_tenant_bindings": true} { - var count int - if err := st.db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&count); err != nil || count != 1 { - t.Fatalf("lost durable %s: count=%d err=%v", table, count, err) - } - } - result, err = st.EvictTerminalTaskConfig("d-1", "task-a") - if err != nil || result.Deleted != 0 || result.BlockedBy != "" { - t.Fatalf("repeat cleanup should be idempotent: %+v err=%v", result, err) - } - if duplicate, err := st.PersistLocalCommand(command); err != nil || !duplicate { - t.Fatalf("old command after config eviction lost original identity: duplicate=%v err=%v", duplicate, err) - } - var status string - if err := st.db.QueryRow(`SELECT status FROM outbox WHERE event_id='result-event'`).Scan(&status); err != nil || status != "pending" { - t.Fatalf("old command receipt not redelivered with original event id: %q err=%v", status, err) - } - var tasks int - if err := st.db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil || tasks != 0 { - t.Fatalf("old command re-reserved after cleanup: tasks=%d err=%v", tasks, err) - } - expectCleanupTaskCache(t, st, false) -} - -func TestTerminalTaskCacheEvictionFailClosedWhileWorkOrDeliveryIsUnknown(t *testing.T) { - cases := []struct { - name string - seed string - params []any - want string - }{ - {"pending outbox", `INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES('event','tenant-key-a','events','call.result',x'7b7d','pending','2026-09-22T10:00:00Z')`, nil, "outbox_pending"}, - {"inflight upload", `INSERT INTO uploads(upload_id,binding,asset,grant,object_key,state,created_at) VALUES('upload-1',x'7b7d',x'7b7d',x'7b7d','path','granted','2026-09-22T10:00:00Z')`, nil, "upload_incomplete"}, - {"unknown quota", `UPDATE quotas SET unknown_value=1 WHERE scope=?`, []any{LocalTaskQuotaScope("d-1", "tenant-a", "task-a")}, "quota_occupied"}, - {"unknown call", `INSERT INTO tasks(execution_id,tenant_key,tenant_id,task_id,task_item_id,task_revision,trace_id,callee,route_policy_id,caller_profile_id,agent_version_id,variables,ring_timeout_ms,max_call_duration_ms,status,created_at,updated_at) VALUES('exe-1','tenant-key-a','tenant-a','task-a','item-1',1,'trace','15003164745','route','caller','agent',x'7b7d',1000,2000,'unknown','2026-09-22T10:00:00Z','2026-09-22T10:00:00Z')`, nil, "execution_unconfirmed"}, - {"finished call without final result", `INSERT INTO tasks(execution_id,tenant_key,tenant_id,task_id,task_item_id,task_revision,trace_id,callee,route_policy_id,caller_profile_id,agent_version_id,variables,ring_timeout_ms,max_call_duration_ms,status,created_at,updated_at) VALUES('exe-2','tenant-key-a','tenant-a','task-a','item-2',1,'trace','15003164745','route','caller','agent',x'7b7d',1000,2000,'finished','2026-09-22T10:00:00Z','2026-09-22T10:00:00Z')`, nil, "execution_unconfirmed"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - st := cleanupTestStore(t) - stopCleanupTestTask(t, st) - if _, err := st.db.Exec(tc.seed, tc.params...); err != nil { - t.Fatal(err) - } - result, err := st.EvictTerminalTaskConfig("d-1", "task-a") - if err != nil || result.Deleted != 0 || result.BlockedBy != tc.want { - t.Fatalf("unsafe cleanup=%+v err=%v want blocker=%s", result, err, tc.want) - } - expectCleanupTaskCache(t, st, true) - }) - } -} - -func TestTerminalTaskCacheEvictionDatabaseFailureRetainsSnapshot(t *testing.T) { - st := cleanupTestStore(t) - stopCleanupTestTask(t, st) - if _, err := st.db.Exec(`CREATE TRIGGER cleanup_failure BEFORE DELETE ON local_v01_config_snapshots - WHEN OLD.resource='task' BEGIN SELECT RAISE(ABORT, 'test database write failure'); END`); err != nil { - t.Fatal(err) - } - if _, err := st.EvictTerminalTaskConfig("d-1", "task-a"); err == nil { - t.Fatal("SQLite delete failure swallowed") - } - expectCleanupTaskCache(t, st, true) -} diff --git a/internal/store/local_v04_discovery.go b/internal/store/local_v04_discovery.go deleted file mode 100644 index 051efb6..0000000 --- a/internal/store/local_v04_discovery.go +++ /dev/null @@ -1,177 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -// CloseLocalTaskDiscoveryAdmissionV04 fails closed without persisting a live cursor. -func (s *Store) CloseLocalTaskDiscoveryAdmissionV04(dispatcherID string) error { - s.mu.Lock() - defer s.mu.Unlock() - _, err := s.db.Exec(`INSERT INTO local_v04_task_discovery_state(dispatcher_id,ready,updated_at) VALUES(?,0,?) - ON CONFLICT(dispatcher_id) DO UPDATE SET ready=0,updated_at=excluded.updated_at`, dispatcherID, time.Now().UTC().Format(time.RFC3339Nano)) - return err -} - -// LocalTaskDiscoveryReadyV04 reports whether a complete snapshot and control -// backlog have been applied. An absent row is not ready on a fresh host. -func (s *Store) LocalTaskDiscoveryReadyV04(dispatcherID string) (bool, error) { - if dispatcherID == "" { - return false, ErrLocalDiscoveryUnavailable - } - s.mu.Lock() - defer s.mu.Unlock() - var ready int - if err := s.db.QueryRow(`SELECT ready FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&ready); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return false, err - } - return ready == 1, nil -} - -// MarkLocalTaskDiscoveryReadyV04 opens admission after the live change stream is caught up. -func (s *Store) MarkLocalTaskDiscoveryReadyV04(dispatcherID string, observedAt time.Time) error { - if dispatcherID == "" || observedAt.IsZero() { - return ErrLocalDiscoveryUnavailable - } - s.mu.Lock() - defer s.mu.Unlock() - result, err := s.db.Exec(`UPDATE local_v04_task_discovery_state SET ready=1,updated_at=? WHERE dispatcher_id=?`, observedAt.UTC().Format(time.RFC3339Nano), dispatcherID) - if err != nil { - return err - } - if n, err := result.RowsAffected(); err != nil || n != 1 { - return ErrLocalDiscoveryUnavailable - } - return nil -} - -// ApplyLocalTaskSnapshot replaces membership only after every page of one snapshot -// has been verified by the caller. Control barriers and execution records survive. -func (s *Store) ApplyLocalTaskSnapshot(dispatcherID string, tasks []LocalDiscoveredTask, observedAt time.Time) error { - if dispatcherID == "" || observedAt.IsZero() || len(tasks) > 256 { - return ErrLocalDiscoveryUnavailable - } - s.mu.Lock() - defer s.mu.Unlock() - if err := validateV04AssignmentSet(dispatcherID, tasks); err != nil { - return err - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - now := observedAt.UTC().Format(time.RFC3339Nano) - seen := make(map[string]bool, len(tasks)) - for _, task := range tasks { - if task.Status == "removed" { - return ErrLocalDiscoveryUnavailable - } - seen[task.TaskID] = true - if err := applyLocalDiscoveredTask(tx, dispatcherID, task, now); err != nil { - return err - } - } - rows, err := tx.Query(`SELECT task_id FROM local_v01_task_assignments WHERE dispatcher_id=? AND removed=0`, dispatcherID) - if err != nil { - return err - } - var absent []string - for rows.Next() { - var taskID string - if err := rows.Scan(&taskID); err != nil { - rows.Close() - return err - } - if !seen[taskID] { - absent = append(absent, taskID) - } - } - if err := rows.Err(); err != nil { - rows.Close() - return err - } - rows.Close() - for _, taskID := range absent { - if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET saas_status='removed',admission_state='removed',removed=1,updated_at=? WHERE dispatcher_id=? AND task_id=?`, now, dispatcherID, taskID); err != nil { - return err - } - } - if _, err := tx.Exec(`INSERT INTO local_v04_task_discovery_state(dispatcher_id,ready,updated_at) VALUES(?,0,?) - ON CONFLICT(dispatcher_id) DO UPDATE SET ready=0,updated_at=excluded.updated_at`, dispatcherID, now); err != nil { - return err - } - return tx.Commit() -} - -// ApplyLocalTaskChanges updates task membership. An existing task's status is -// controlled by MQ; contradictory HTTP status fails admission instead of racing it. -func (s *Store) ApplyLocalTaskChanges(dispatcherID string, tasks []LocalDiscoveredTask, observedAt time.Time) error { - if dispatcherID == "" || observedAt.IsZero() || len(tasks) > 256 { - return ErrLocalDiscoveryUnavailable - } - s.mu.Lock() - defer s.mu.Unlock() - if err := validateV04AssignmentSet(dispatcherID, tasks); err != nil { - return err - } - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - for _, task := range tasks { - var existingStatus string - err := tx.QueryRow(`SELECT saas_status FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, dispatcherID, task.TaskID).Scan(&existingStatus) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return err - } - if err == nil && task.Status != "removed" && task.Status != existingStatus { - if err := tx.Rollback(); err != nil { - return err - } - if _, err := s.db.Exec(`UPDATE local_v04_task_discovery_state SET ready=0 WHERE dispatcher_id=?`, dispatcherID); err != nil { - return err - } - return fmt.Errorf("task %s: HTTP status %s conflicts with MQ-applied %s: %w", task.TaskID, task.Status, existingStatus, ErrLocalDiscoveryUnavailable) - } - } - now := observedAt.UTC().Format(time.RFC3339Nano) - for _, task := range tasks { - if err := applyLocalDiscoveredTask(tx, dispatcherID, task, now); err != nil { - return err - } - } - if _, err := tx.Exec(`UPDATE local_v04_task_discovery_state SET updated_at=? WHERE dispatcher_id=?`, now, dispatcherID); err != nil { - return err - } - return tx.Commit() -} - -func validateV04AssignmentSet(dispatcherID string, tasks []LocalDiscoveredTask) error { - seen := make(map[string]bool, len(tasks)) - ids, keys := make(map[string]string), make(map[string]string) - for _, task := range tasks { - if err := validateLocalDiscoveredTask(dispatcherID, task); err != nil { - return err - } - if seen[task.TaskID] { - return fmt.Errorf("duplicate discovered task %s: %w", task.TaskID, ErrLocalDiscoveryUnavailable) - } - seen[task.TaskID] = true - if key, ok := ids[task.TenantID]; ok && key != task.TenantKey { - return ErrTenantBindingConflict - } - if id, ok := keys[task.TenantKey]; ok && id != task.TenantID { - return ErrTenantBindingConflict - } - ids[task.TenantID], keys[task.TenantKey] = task.TenantKey, task.TenantID - } - return nil -} diff --git a/internal/store/local_v04_discovery_test.go b/internal/store/local_v04_discovery_test.go deleted file mode 100644 index 1f0977e..0000000 --- a/internal/store/local_v04_discovery_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package store - -import ( - "errors" - "testing" - "time" -) - -func eventTask(id, status string, revision int64) LocalDiscoveredTask { - return LocalDiscoveredTask{TaskID: id, TenantID: "tenant-a", TenantKey: "tenant-key-a", Status: status, TaskRevision: revision} -} - -func TestV04CompleteSnapshotReplacesMembershipWithoutReopeningPause(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - now := time.Now().UTC() - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("a", "running", 1), eventTask("b", "running", 1)}, now); err != nil { - t.Fatal(err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "a", "tenant-a"); !errors.Is(err, ErrLocalDiscoveryUnavailable) { - t.Fatalf("snapshot allowed admission before control backlog: %v", err) - } - if err := st.MarkLocalTaskDiscoveryReadyV04("d-1", now); err != nil { - t.Fatal(err) - } - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "a", "tenant-a", "tenant-key-a", "paused"); err != nil { - t.Fatal(err) - } - if err := st.CloseLocalTaskDiscoveryAdmissionV04("d-1"); err != nil { - t.Fatal(err) - } - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("a", "paused", 2)}, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - a, err := st.LocalTaskAssignment("d-1", "a") - if err != nil || a.AdmissionState != "paused" || a.TaskRevision != 2 { - t.Fatalf("snapshot reopened paused task: %+v %v", a, err) - } - b, err := st.LocalTaskAssignment("d-1", "b") - if err != nil || !b.Removed || b.AdmissionState != "removed" { - t.Fatalf("snapshot failed to revoke missing task: %+v %v", b, err) - } - if err := st.MarkLocalTaskDiscoveryReadyV04("d-1", now.Add(time.Second)); err != nil { - t.Fatal(err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "a", "tenant-a"); err != nil { - t.Fatalf("completed snapshot+control catch-up not ready: %v", err) - } -} - -func TestV04InvalidSnapshotDoesNotPartiallyCommitOrOpenAdmission(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - now := time.Now().UTC() - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("a", "running", 1)}, now); err != nil { - t.Fatal(err) - } - if err := st.CloseLocalTaskDiscoveryAdmissionV04("d-1"); err != nil { - t.Fatal(err) - } - bad := eventTask("b", "running", 1) - bad.TenantKey = "changed-binding" - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("a", "running", 2), bad}, now.Add(time.Second)); err == nil { - t.Fatal("conflicting tenant binding accepted") - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "a", "tenant-a"); !errors.Is(err, ErrLocalDiscoveryUnavailable) { - t.Fatalf("failed snapshot opened admission: %v", err) - } - if _, err := st.LocalTaskAssignment("d-1", "b"); err == nil { - t.Fatal("failed snapshot partially inserted task b") - } - a, err := st.LocalTaskAssignment("d-1", "a") - if err != nil || a.TaskRevision != 1 { - t.Fatalf("failed snapshot changed a: %+v %v", a, err) - } -} - -func TestV04LiveChangesOnlyManageMembershipNotExistingStatus(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - now := time.Now().UTC() - if err := st.ApplyLocalTaskSnapshot("d-1", []LocalDiscoveredTask{eventTask("a", "running", 1), eventTask("b", "running", 1)}, now); err != nil { - t.Fatal(err) - } - if _, err := st.SetLocalTaskAdmissionBarrier("d-1", "a", "tenant-a", "tenant-key-a", "paused"); err != nil { - t.Fatal(err) - } - if err := st.ApplyLocalTaskChanges("d-1", []LocalDiscoveredTask{eventTask("a", "running", 2), eventTask("b", "removed", 2), eventTask("c", "running", 1)}, now.Add(time.Second)); err != nil { - t.Fatal(err) - } - for taskID, want := range map[string]string{"a": "paused", "b": "removed", "c": "running"} { - row, err := st.LocalTaskAssignment("d-1", taskID) - if err != nil || row.AdmissionState != want { - t.Fatalf("task %s state=%+v err=%v", taskID, row, err) - } - } - if err := st.ApplyLocalTaskChanges("d-1", []LocalDiscoveredTask{eventTask("a", "stopped", 3)}, now.Add(2*time.Second)); !errors.Is(err, ErrLocalDiscoveryUnavailable) { - t.Fatalf("incremental status switched without MQ: %v", err) - } - if _, err := st.LocalDiscoveredTaskForConfig("d-1", "a", "tenant-a"); !errors.Is(err, ErrLocalDiscoveryUnavailable) { - t.Fatalf("status conflict should close admission: %v", err) - } -} diff --git a/internal/store/local_v04_lookup.go b/internal/store/local_v04_lookup.go deleted file mode 100644 index f665158..0000000 --- a/internal/store/local_v04_lookup.go +++ /dev/null @@ -1,49 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -// LocalDiscoveredTaskForConfig binds a config read to a fully caught-up, -// durable task assignment. A partial page cannot authorize configuration. -func (s *Store) LocalDiscoveredTaskForConfig(dispatcherID, taskID, tenantID string) (LocalTaskAssignment, error) { - s.mu.Lock() - defer s.mu.Unlock() - var ready int - var observedAt string - err := s.db.QueryRow(`SELECT ready,updated_at FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&ready, &observedAt) - if errors.Is(err, sql.ErrNoRows) || (err == nil && ready != 1) { - return LocalTaskAssignment{}, ErrLocalDiscoveryUnavailable - } - if err != nil { - return LocalTaskAssignment{}, err - } - var assignment LocalTaskAssignment - var removed int - err = s.db.QueryRow(`SELECT dispatcher_id,task_id,tenant_id,tenant_key,task_revision,saas_status,admission_state,removed, - queue_exchange,routing_key,binding_key,queue_name,updated_at FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, - dispatcherID, taskID).Scan(&assignment.DispatcherID, &assignment.TaskID, &assignment.TenantID, &assignment.TenantKey, - &assignment.TaskRevision, &assignment.Status, &assignment.AdmissionState, &removed, &assignment.Queue.Exchange, - &assignment.Queue.RoutingKey, &assignment.Queue.BindingKey, &assignment.Queue.QueueName, &assignment.UpdatedAt) - if errors.Is(err, sql.ErrNoRows) { - return LocalTaskAssignment{}, ErrLocalDiscoveryTaskMissing - } - if err != nil { - return LocalTaskAssignment{}, err - } - if assignment.TenantID != tenantID { - return LocalTaskAssignment{}, ErrTenantBindingConflict - } - assignment.Removed = removed != 0 - if assignment.Removed { - return LocalTaskAssignment{}, ErrLocalTaskRemoved - } - assignment.DiscoveryObservedAt, err = time.Parse(time.RFC3339Nano, observedAt) - if err != nil { - return LocalTaskAssignment{}, fmt.Errorf("invalid durable discovery observation time: %w", err) - } - return assignment, nil -} diff --git a/internal/store/local_v04_lookup_test.go b/internal/store/local_v04_lookup_test.go deleted file mode 100644 index 86c0e67..0000000 --- a/internal/store/local_v04_lookup_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package store - -import ( - "fmt" - "strings" - "testing" - "time" -) - -func TestSingleTaskControlLookupUsesIndexedIdentityWithGrowingHistory(t *testing.T) { - st := openLocalDiscoveryTestStore(t) - for batch := 0; batch < 3; batch++ { - tasks := make([]LocalDiscoveredTask, 0, 200) - for i := batch * 200; i < (batch+1)*200; i++ { - tasks = append(tasks, eventTask(fmt.Sprintf("hist-%d", i), "running", 1)) - } - if err := st.ApplyLocalTaskSnapshot("d-1", tasks, time.Now().UTC()); err != nil { - t.Fatal(err) - } - } - if err := st.MarkLocalTaskDiscoveryReadyV04("d-1", time.Now().UTC()); err != nil { - t.Fatal(err) - } - row, err := st.LocalTaskAssignment("d-1", "hist-599") - if err != nil || row.Removed || row.TaskID != "hist-599" { - t.Fatalf("active assignment lookup: %+v err=%v", row, err) - } - row, err = st.LocalTaskAssignment("d-1", "hist-0") - if err != nil || !row.Removed { - t.Fatalf("historical assignment lost: %+v err=%v", row, err) - } - plan, err := st.db.Query(`EXPLAIN QUERY PLAN SELECT tenant_id,tenant_key,saas_status,admission_state,task_revision,removed - FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, "d-1", "hist-599") - if err != nil { - t.Fatal(err) - } - defer plan.Close() - var indexed bool - for plan.Next() { - var id, parent, unused int - var detail string - if err := plan.Scan(&id, &parent, &unused, &detail); err != nil { - t.Fatal(err) - } - if strings.Contains(detail, "SCAN local_v01_task_assignments") { - t.Fatalf("single task control query scans historical assignments: %s", detail) - } - indexed = indexed || strings.Contains(detail, "SEARCH local_v01_task_assignments USING INDEX") - } - if err := plan.Err(); err != nil { - t.Fatal(err) - } - if !indexed { - t.Fatal("single task query did not use dispatcher/task identity index") - } -} diff --git a/internal/store/local_v3_upload_state_test.go b/internal/store/local_v3_upload_state_test.go deleted file mode 100644 index 2e8b045..0000000 --- a/internal/store/local_v3_upload_state_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package store - -import ( - "errors" - "testing" -) - -func TestLocalFinalResultOwnsUploadDeliveryWithoutSplitNotification(t *testing.T) { - for _, tc := range []struct { - name string - outcome LocalRecordingOutcome - finalState string - }{ - {name: "uploaded", outcome: LocalRecordingOutcome{ - Status: "uploaded", Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", SizeBytes: 128000, - ChecksumSHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - }, finalState: "completed"}, - {name: "unavailable", outcome: LocalRecordingOutcome{Status: "unavailable", ErrorCode: "upload_timeout"}, finalState: "failed"}, - } { - t.Run(tc.name, func(t *testing.T) { - st, terminal := readyLocalRecordingTerminal(t) - grant := UploadRecord{ - UploadID: terminal.Recording.UploadID, Binding: []byte("bound-agent"), Asset: []byte("bound-recording"), - Grant: []byte("one-put-only"), Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav", - State: "granted", CreatedAt: terminal.EndedAt, - } - if _, err := st.IssueUploadGrant(grant, "op-a", "request-a"); err != nil { - t.Fatal(err) - } - tc.outcome.ObservedAt = terminal.EndedAt.Add(LocalRecordingDeadline) - if err := st.RecordLocalRecordingOutcome(terminal.ExecutionID, tc.outcome); err != nil { - t.Fatal(err) - } - loaded, err := st.LoadUpload(grant.UploadID) - if err != nil || loaded.State != "uploaded" { - t.Fatalf("completed upload still offered a second PUT: state=%q err=%v", loaded.State, err) - } - if _, err := st.IssueUploadGrant(grant, "op-b", "request-b"); !errors.Is(err, ErrUploadMismatch) { - t.Fatalf("second PUT grant issued after durable outcome: %v", err) - } - event := localFinalEventFixture() - if err := st.EnqueueLocalFinalEvent(terminal.ExecutionID, terminal.CallID, event); err != nil { - t.Fatal(err) - } - messages, err := st.ClaimOutbox(10) - if err != nil { - t.Fatal(err) - } - found := false - for _, message := range messages { - if message.EventID == event.EventID { - found = true - if err := st.MarkOutboxPublished(message.ID); err != nil { - t.Fatal(err) - } - } - } - if !found { - t.Fatal("single final result was not queued") - } - loaded, err = st.LoadUpload(grant.UploadID) - if err != nil || loaded.State != tc.finalState { - t.Fatalf("upload delivery was marked before/without the final result: state=%q want=%q err=%v", loaded.State, tc.finalState, err) - } - var splitEvents int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM upload_notifications WHERE upload_id=?`, grant.UploadID).Scan(&splitEvents); err != nil || splitEvents != 0 { - t.Fatalf("obsolete recording.uploaded notification persisted: count=%d err=%v", splitEvents, err) - } - }) - } -} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql deleted file mode 100644 index 3421f3d..0000000 --- a/internal/store/migrations/001_init.sql +++ /dev/null @@ -1,101 +0,0 @@ -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS inbox ( - command_id TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - command_type TEXT NOT NULL, - body_hash TEXT NOT NULL, - body BLOB NOT NULL, - status TEXT NOT NULL CHECK (status IN ('received', 'persisted', 'rejected')), - received_at TEXT NOT NULL, - persisted_at TEXT -); - -CREATE TABLE IF NOT EXISTS tasks ( - execution_id TEXT PRIMARY KEY, - tenant_key TEXT NOT NULL, - tenant_id TEXT NOT NULL, - task_id TEXT NOT NULL, - task_item_id TEXT NOT NULL, - task_revision INTEGER NOT NULL, - trace_id TEXT NOT NULL, - callee TEXT NOT NULL, - route_policy_id TEXT NOT NULL, - caller_profile_id TEXT NOT NULL, - agent_version_id TEXT NOT NULL, - variables BLOB NOT NULL, - ring_timeout_ms INTEGER NOT NULL, - max_call_duration_ms INTEGER NOT NULL, - status TEXT NOT NULL CHECK (status IN ('accepted', 'reserved', 'running', 'draining', 'paused', 'stopped', 'finished', 'unknown')), - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (tenant_key, task_id, task_item_id, task_revision) -); -CREATE INDEX IF NOT EXISTS tasks_pending_idx ON tasks (status, created_at); -CREATE INDEX IF NOT EXISTS tasks_tenant_idx ON tasks (tenant_key, status, created_at); - -CREATE TABLE IF NOT EXISTS quotas ( - scope TEXT PRIMARY KEY, - limit_value INTEGER NOT NULL CHECK (limit_value >= 0), - reserved_value INTEGER NOT NULL DEFAULT 0 CHECK (reserved_value >= 0), - unknown_value INTEGER NOT NULL DEFAULT 0 CHECK (unknown_value >= 0), - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS reservations ( - reservation_id TEXT PRIMARY KEY, - execution_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - scopes BLOB NOT NULL, - state TEXT NOT NULL CHECK (state IN ('held', 'released', 'unknown')), - created_at TEXT NOT NULL, - released_at TEXT, - UNIQUE (execution_id) -); -CREATE INDEX IF NOT EXISTS reservations_tenant_idx ON reservations (tenant_key, state); - -CREATE TABLE IF NOT EXISTS outbox ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL UNIQUE, - tenant_key TEXT NOT NULL, - exchange TEXT NOT NULL, - routing_key TEXT NOT NULL, - body BLOB NOT NULL, - status TEXT NOT NULL CHECK (status IN ('pending', 'dispatching', 'published', 'retry')), - attempts INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - created_at TEXT NOT NULL, - published_at TEXT -); -CREATE INDEX IF NOT EXISTS outbox_pending_idx ON outbox (status, id); - -CREATE TABLE IF NOT EXISTS controls ( - execution_id TEXT PRIMARY KEY, - task_revision INTEGER NOT NULL, - action TEXT NOT NULL CHECK (action IN ('pause', 'resume', 'drain', 'stop', 'hangup')), - active_call_policy TEXT, - reason TEXT, - idempotency_key TEXT, - applied INTEGER NOT NULL DEFAULT 0, - requested_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS replays ( - idempotency_key TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL, - source_command_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - reason TEXT NOT NULL, - created_at TEXT NOT NULL, - UNIQUE (idempotency_key, source_command_id) -); - -CREATE TABLE IF NOT EXISTS leases ( - lease_id TEXT PRIMARY KEY, - scope TEXT NOT NULL, - holder_id TEXT NOT NULL, - expires_at TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('active', 'expired', 'released')) -); -CREATE INDEX IF NOT EXISTS leases_scope_idx ON leases (scope, state, expires_at); diff --git a/internal/store/migrations/002_scheduler_state.sql b/internal/store/migrations/002_scheduler_state.sql deleted file mode 100644 index 1d6e86b..0000000 --- a/internal/store/migrations/002_scheduler_state.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS scheduler_state ( - scope TEXT PRIMARY KEY, - tenants_json BLOB NOT NULL, - cursor INTEGER NOT NULL CHECK (cursor >= 0), - updated_at TEXT NOT NULL -); diff --git a/internal/store/migrations/003_uploads.sql b/internal/store/migrations/003_uploads.sql deleted file mode 100644 index 8859ffc..0000000 --- a/internal/store/migrations/003_uploads.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS uploads ( - upload_id TEXT PRIMARY KEY, - binding BLOB NOT NULL, - asset BLOB NOT NULL, - grant BLOB NOT NULL, - object_key TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('granted', 'completed', 'failed')), - created_at TEXT NOT NULL, - completed_at TEXT -); -CREATE INDEX IF NOT EXISTS uploads_state_idx ON uploads (state, created_at); diff --git a/internal/store/migrations/004_execution_facts.sql b/internal/store/migrations/004_execution_facts.sql deleted file mode 100644 index 00d1e13..0000000 --- a/internal/store/migrations/004_execution_facts.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS execution_facts ( - fact_id TEXT PRIMARY KEY, - tenant_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - execution_id TEXT NOT NULL, - content_sha256 TEXT NOT NULL, - kind INTEGER NOT NULL, - binding_json BLOB NOT NULL, - payload_json BLOB NOT NULL, - observed_at TEXT NOT NULL, - source_boot_id TEXT NOT NULL, - source_sequence INTEGER NOT NULL, - event_id TEXT NOT NULL, - event_type TEXT NOT NULL, - aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, - aggregate_version INTEGER NOT NULL, - received_at TEXT NOT NULL -); -CREATE INDEX IF NOT EXISTS execution_facts_execution_idx ON execution_facts (execution_id, observed_at); -CREATE INDEX IF NOT EXISTS execution_facts_event_idx ON execution_facts (event_id); diff --git a/internal/store/migrations/005_dispatcher_identity.sql b/internal/store/migrations/005_dispatcher_identity.sql deleted file mode 100644 index 1b3cdc4..0000000 --- a/internal/store/migrations/005_dispatcher_identity.sql +++ /dev/null @@ -1,7 +0,0 @@ --- One immutable logical owner per Dispatcher database. Broker ownership is a --- separate live-instance guard; an epoch never replaces this identity. -CREATE TABLE IF NOT EXISTS dispatcher_identity ( - singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - dispatcher_id TEXT NOT NULL UNIQUE CHECK (length(dispatcher_id) = 36), - created_at TEXT NOT NULL -); diff --git a/internal/store/migrations/006_mq_queries.sql b/internal/store/migrations/006_mq_queries.sql deleted file mode 100644 index 8517be2..0000000 --- a/internal/store/migrations/006_mq_queries.sql +++ /dev/null @@ -1,16 +0,0 @@ -CREATE TABLE IF NOT EXISTS tenant_bindings ( - tenant_id TEXT PRIMARY KEY, - tenant_key TEXT NOT NULL UNIQUE -); - -CREATE TABLE IF NOT EXISTS mq_query_inbox ( - tenant_id TEXT NOT NULL, - message_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - dispatcher_id TEXT NOT NULL, - body_hash TEXT NOT NULL, - response_id TEXT NOT NULL UNIQUE, - received_at TEXT NOT NULL, - PRIMARY KEY (tenant_id, message_id), - FOREIGN KEY (response_id) REFERENCES outbox(event_id) -); diff --git a/internal/store/migrations/007_mq_command_receipts.sql b/internal/store/migrations/007_mq_command_receipts.sql deleted file mode 100644 index 516c6b3..0000000 --- a/internal/store/migrations/007_mq_command_receipts.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE UNIQUE INDEX IF NOT EXISTS inbox_tenant_command_idx ON inbox(tenant_id, command_id); - -CREATE TABLE IF NOT EXISTS mq_command_receipts ( - tenant_id TEXT NOT NULL, - command_id TEXT NOT NULL, - response_id TEXT NOT NULL UNIQUE, - PRIMARY KEY (tenant_id, command_id), - FOREIGN KEY (tenant_id, command_id) REFERENCES inbox(tenant_id, command_id), - FOREIGN KEY (response_id) REFERENCES outbox(event_id) -); diff --git a/internal/store/migrations/008_upload_notifications.sql b/internal/store/migrations/008_upload_notifications.sql deleted file mode 100644 index bb103d5..0000000 --- a/internal/store/migrations/008_upload_notifications.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS upload_notifications ( - upload_id TEXT PRIMARY KEY REFERENCES uploads(upload_id), - event_id TEXT NOT NULL UNIQUE REFERENCES outbox(event_id), - uploaded_at TEXT NOT NULL -); diff --git a/internal/store/migrations/009_ai_mq.sql b/internal/store/migrations/009_ai_mq.sql deleted file mode 100644 index c4dd4b4..0000000 --- a/internal/store/migrations/009_ai_mq.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS ai_mq_requests ( - tenant_id TEXT NOT NULL, - message_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - dispatcher_id TEXT NOT NULL, - agent_version_id TEXT NOT NULL, - body BLOB NOT NULL, - body_hash TEXT NOT NULL, - response_id TEXT, - response_hash TEXT, - response BLOB, - PRIMARY KEY (tenant_id, message_id), - FOREIGN KEY (message_id) REFERENCES outbox(event_id) -); - -CREATE TABLE IF NOT EXISTS ai_config_snapshots ( - tenant_id TEXT NOT NULL, - agent_version_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - config_sha256 TEXT NOT NULL, - config BLOB NOT NULL, - PRIMARY KEY (tenant_id, agent_version_id) -); diff --git a/internal/store/migrations/010_upload_grant_requests.sql b/internal/store/migrations/010_upload_grant_requests.sql deleted file mode 100644 index 6083fc7..0000000 --- a/internal/store/migrations/010_upload_grant_requests.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS upload_grant_requests ( - upload_id TEXT NOT NULL REFERENCES uploads(upload_id), - operation_id TEXT NOT NULL, - request_hash TEXT NOT NULL, - grant BLOB NOT NULL, - PRIMARY KEY (upload_id, operation_id) -); diff --git a/internal/store/migrations/011_upload_destinations.sql b/internal/store/migrations/011_upload_destinations.sql deleted file mode 100644 index b6704a1..0000000 --- a/internal/store/migrations/011_upload_destinations.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE IF NOT EXISTS upload_destinations ( - upload_id TEXT PRIMARY KEY REFERENCES uploads(upload_id), - bucket TEXT NOT NULL CHECK (length(bucket) > 0) -); diff --git a/internal/store/migrations/012_mq_task_controls.sql b/internal/store/migrations/012_mq_task_controls.sql deleted file mode 100644 index 1c1bedd..0000000 --- a/internal/store/migrations/012_mq_task_controls.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS mq_task_controls ( - tenant_id TEXT NOT NULL, - command_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - task_id TEXT NOT NULL, - expected_revision INTEGER NOT NULL, - action TEXT NOT NULL, - active_call_policy TEXT NOT NULL, - state TEXT NOT NULL CHECK (state IN ('pending', 'applied')), - PRIMARY KEY (tenant_id, command_id), - FOREIGN KEY (tenant_id, command_id) REFERENCES inbox(tenant_id, command_id) -); -CREATE TABLE IF NOT EXISTS mq_task_control_targets ( - tenant_id TEXT NOT NULL, - command_id TEXT NOT NULL, - execution_id TEXT NOT NULL REFERENCES tasks(execution_id), - original_status TEXT NOT NULL, - applied INTEGER NOT NULL CHECK (applied IN (0,1)), - PRIMARY KEY (tenant_id, command_id, execution_id), - FOREIGN KEY (tenant_id, command_id) REFERENCES mq_task_controls(tenant_id, command_id) -); diff --git a/internal/store/migrations/013_execution_agents.sql b/internal/store/migrations/013_execution_agents.sql deleted file mode 100644 index a5c714a..0000000 --- a/internal/store/migrations/013_execution_agents.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE IF NOT EXISTS execution_agents ( - execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), - agent_id TEXT NOT NULL, - binding BLOB NOT NULL -); diff --git a/internal/store/migrations/014_local_v01_config_snapshots.sql b/internal/store/migrations/014_local_v01_config_snapshots.sql deleted file mode 100644 index 33fc126..0000000 --- a/internal/store/migrations/014_local_v01_config_snapshots.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS local_v01_config_snapshots ( - dispatcher_id TEXT NOT NULL, - resource TEXT NOT NULL CHECK (resource IN ('sip', 'tasks', 'task', 'tenant_quota')), - tenant_id TEXT NOT NULL DEFAULT '', - task_id TEXT NOT NULL DEFAULT '', - revision INTEGER NOT NULL DEFAULT 0 CHECK (revision >= 0), - version TEXT NOT NULL, - content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), - fetched_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - body BLOB NOT NULL, - PRIMARY KEY (dispatcher_id, resource, tenant_id, task_id) -); - -CREATE TABLE IF NOT EXISTS local_v01_tenant_bindings ( - dispatcher_id TEXT NOT NULL, - tenant_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY (dispatcher_id, tenant_id), - UNIQUE (dispatcher_id, tenant_key) -); - -CREATE TABLE IF NOT EXISTS local_v01_execution_configs ( - execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), - content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), - body BLOB NOT NULL, - stored_at TEXT NOT NULL -); diff --git a/internal/store/migrations/015_local_v02_task_discovery.sql b/internal/store/migrations/015_local_v02_task_discovery.sql deleted file mode 100644 index b54158d..0000000 --- a/internal/store/migrations/015_local_v02_task_discovery.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Old paginated cursors cannot authorize the single-response discovery contract. --- Keep task assignments and execution recovery; require a fresh snapshot. -DROP TABLE IF EXISTS local_v01_task_discovery_pages; -DROP TABLE IF EXISTS local_v01_task_discovery_state; - -CREATE TABLE IF NOT EXISTS local_v02_task_discovery_state ( - dispatcher_id TEXT PRIMARY KEY, - cursor TEXT NOT NULL, - last_mode TEXT NOT NULL CHECK (last_mode IN ('snapshot', 'changes')), - ready INTEGER NOT NULL CHECK (ready IN (0, 1)), - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS local_v02_task_discovery_response ( - dispatcher_id TEXT PRIMARY KEY, - content_sha256 TEXT NOT NULL CHECK (length(content_sha256) = 64), - body BLOB NOT NULL, - stored_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS local_v01_task_assignments ( - dispatcher_id TEXT NOT NULL, - task_id TEXT NOT NULL, - tenant_id TEXT NOT NULL, - tenant_key TEXT NOT NULL, - task_revision INTEGER NOT NULL CHECK (task_revision >= 0), - saas_status TEXT NOT NULL CHECK (saas_status IN ('running', 'paused', 'stopped', 'finished', 'removed')), - admission_state TEXT NOT NULL CHECK (admission_state IN ('running', 'paused', 'stopped', 'finished', 'removed')), - removed INTEGER NOT NULL DEFAULT 0 CHECK (removed IN (0, 1)), - queue_exchange TEXT NOT NULL, - routing_key TEXT NOT NULL, - binding_key TEXT NOT NULL, - queue_name TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (dispatcher_id, task_id) -); - -CREATE INDEX IF NOT EXISTS local_v01_task_assignments_admission - ON local_v01_task_assignments(dispatcher_id, removed, admission_state); diff --git a/internal/store/migrations/016_local_v02_origination_decisions.sql b/internal/store/migrations/016_local_v02_origination_decisions.sql deleted file mode 100644 index a6c8d4d..0000000 --- a/internal/store/migrations/016_local_v02_origination_decisions.sql +++ /dev/null @@ -1,11 +0,0 @@ --- A quota reservation is held at acceptance. A terminal local dial decision --- is durable and one-shot whether it issues an Agent instruction or refuses it. --- No later MQ duplicate, clock change or process restart can revive a refusal. -CREATE TABLE IF NOT EXISTS local_v02_origination_decisions ( - execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), - dispatcher_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK (decision IN ('issued', 'refused')), - reason TEXT NOT NULL CHECK (reason IN ('', 'policy_denied', 'control_closed', 'invalid_binding')), - decided_at TEXT NOT NULL, - CHECK ((decision = 'issued' AND reason = '') OR (decision = 'refused' AND reason <> '')) -); diff --git a/internal/store/migrations/017_local_v01_call_terminals.sql b/internal/store/migrations/017_local_v01_call_terminals.sql deleted file mode 100644 index f06dce8..0000000 --- a/internal/store/migrations/017_local_v01_call_terminals.sql +++ /dev/null @@ -1,29 +0,0 @@ --- One immutable terminal fact per accepted local execution. Releasing the --- reservation and recording the fact share a transaction; the final result --- may be enqueued immediately or later after the recording outcome is known. -CREATE TABLE IF NOT EXISTS local_v01_call_terminals ( - execution_id TEXT PRIMARY KEY REFERENCES tasks(execution_id), - call_id TEXT NOT NULL UNIQUE, - source TEXT NOT NULL CHECK (source IN ('mock_agent', 'dispatcher_refusal', 'mock_agent_deadline', 'dispatcher_deadline')), - started_at TEXT NOT NULL, - ended_at TEXT NOT NULL, - outcome TEXT NOT NULL, - reason_code TEXT NOT NULL, - recording_expected INTEGER NOT NULL CHECK (recording_expected IN (0, 1)), - recording_id TEXT UNIQUE, - upload_id TEXT UNIQUE, - recording_manifest BLOB, - recording_outcome BLOB, - recording_outcome_sha256 TEXT, - recording_status TEXT CHECK (recording_status IN ('uploaded', 'unavailable')), - fact_sha256 TEXT NOT NULL, - result_event_id TEXT UNIQUE, - result_sha256 TEXT, - CHECK ((result_event_id IS NULL) = (result_sha256 IS NULL)), - CHECK ((recording_expected = 0 AND recording_id IS NULL AND upload_id IS NULL AND recording_manifest IS NULL AND recording_outcome IS NULL) - OR (recording_expected = 1 AND recording_id IS NOT NULL AND upload_id IS NOT NULL AND recording_manifest IS NOT NULL)), - CHECK ((recording_outcome IS NULL) = (recording_outcome_sha256 IS NULL)), - CHECK ((recording_outcome IS NULL) = (recording_status IS NULL)) -); -CREATE INDEX IF NOT EXISTS local_v01_call_terminals_pending_idx - ON local_v01_call_terminals (result_event_id, ended_at); diff --git a/internal/store/migrations/018_local_v03_task_discovery.sql b/internal/store/migrations/018_local_v03_task_discovery.sql deleted file mode 100644 index 240ecc6..0000000 --- a/internal/store/migrations/018_local_v03_task_discovery.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Separate event-cursor checkpoint: never reinterpret a v0.2 snapshot/change cursor. --- The older tables remain untouched for controlled drain/diagnosis, not fallback. -CREATE TABLE IF NOT EXISTS local_v03_task_discovery_state ( - dispatcher_id TEXT PRIMARY KEY, - cursor TEXT NOT NULL, - ready INTEGER NOT NULL CHECK (ready IN (0, 1)), - body BLOB NOT NULL, - content_sha256 TEXT NOT NULL, - updated_at TEXT NOT NULL -); diff --git a/internal/store/migrations/019_local_v04_discovery.sql b/internal/store/migrations/019_local_v04_discovery.sql deleted file mode 100644 index 0d59ba4..0000000 --- a/internal/store/migrations/019_local_v04_discovery.sql +++ /dev/null @@ -1,6 +0,0 @@ --- v0.4 stores only the current process's discovery readiness; the live event cursor remains in memory. -CREATE TABLE IF NOT EXISTS local_v04_task_discovery_state ( - dispatcher_id TEXT PRIMARY KEY, - ready INTEGER NOT NULL CHECK(ready IN (0, 1)), - updated_at TEXT NOT NULL -); diff --git a/internal/store/mq_scope.go b/internal/store/mq_scope.go deleted file mode 100644 index 6843b9f..0000000 --- a/internal/store/mq_scope.go +++ /dev/null @@ -1,40 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" -) - -// bindMQScope checks the configured database owner and the immutable tenant -// mapping inside the caller's transaction, before committing any input ACK. -func bindMQScope(tx *sql.Tx, dispatcherID, tenantID, tenantKey string) error { - var owner string - if err := tx.QueryRow(`SELECT dispatcher_id FROM dispatcher_identity WHERE singleton=1`).Scan(&owner); err != nil { - return fmt.Errorf("read MQ owner: %w", err) - } - if owner != dispatcherID { - return ErrMessageScope - } - var conflicting int - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM inbox WHERE (tenant_id=? AND tenant_key<>?) OR (tenant_key=? AND tenant_id<>?))`, tenantID, tenantKey, tenantKey, tenantID).Scan(&conflicting); err != nil { - return err - } - if conflicting != 0 { - return ErrMessageScope - } - if _, err := tx.Exec(`INSERT INTO tenant_bindings(tenant_id,tenant_key) VALUES(?,?) ON CONFLICT DO NOTHING`, tenantID, tenantKey); err != nil { - return err - } - var key string - if err := tx.QueryRow(`SELECT tenant_key FROM tenant_bindings WHERE tenant_id=?`, tenantID).Scan(&key); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrMessageScope - } - return err - } - if key != tenantKey { - return ErrMessageScope - } - return nil -} diff --git a/internal/store/query.go b/internal/store/query.go deleted file mode 100644 index eadbec2..0000000 --- a/internal/store/query.go +++ /dev/null @@ -1,188 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" -) - -var ErrIdempotencyConflict = errors.New("message identity reused with different content") -var ErrMessageScope = errors.New("message dispatcher or tenant binding mismatch") - -// HandleQuery saves the query identity and its immutable response in one -// transaction. Returning successfully is sufficient for an input ACK, not for -// claiming that the response has been delivered or consumed. -func (s *Store) HandleQuery(raw []byte, routingKey string) (string, bool, error) { - request, err := contract.DecodeService(raw) - if err != nil { - return "", false, err - } - if request.MessageType != "command.query" && request.MessageType != "call.query" { - return "", false, errors.New("expected command.query or call.query") - } - route, err := tenant.NewDispatcherRoute(request.DispatcherID, request.TenantKey) - if err != nil { - return "", false, err - } - if route.InboundKey != routingKey { - return "", false, fmt.Errorf("%w: routing mismatch", ErrMessageScope) - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return "", false, err - } - defer tx.Rollback() - var owner string - if err := tx.QueryRow(`SELECT dispatcher_id FROM dispatcher_identity WHERE singleton=1`).Scan(&owner); err != nil { - return "", false, fmt.Errorf("read query owner: %w", err) - } - if owner != request.DispatcherID { - return "", false, ErrMessageScope - } - var conflicting int - if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM inbox WHERE (tenant_id=? AND tenant_key<>?) OR (tenant_key=? AND tenant_id<>?))`, request.TenantID, request.TenantKey, request.TenantKey, request.TenantID).Scan(&conflicting); err != nil { - return "", false, err - } - if conflicting != 0 { - return "", false, ErrMessageScope - } - if _, err := tx.Exec(`INSERT INTO tenant_bindings(tenant_id,tenant_key) VALUES(?,?) ON CONFLICT DO NOTHING`, request.TenantID, request.TenantKey); err != nil { - return "", false, err - } - var boundKey string - if err := tx.QueryRow(`SELECT tenant_key FROM tenant_bindings WHERE tenant_id=?`, request.TenantID).Scan(&boundKey); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return "", false, ErrMessageScope - } - return "", false, err - } - if boundKey != request.TenantKey { - return "", false, ErrMessageScope - } - hash := sha256.Sum256(raw) - digest := hex.EncodeToString(hash[:]) - var priorHash, responseID string - err = tx.QueryRow(`SELECT body_hash,response_id FROM mq_query_inbox WHERE tenant_id=? AND message_id=?`, request.TenantID, request.MessageID).Scan(&priorHash, &responseID) - if err == nil { - if priorHash != digest { - return "", false, ErrIdempotencyConflict - } - // Re-deliver the persisted reply under its original identity. Never - // recompute its snapshot or reset an in-flight publication. - if _, err := tx.Exec(`UPDATE outbox SET status='pending', published_at=NULL WHERE event_id=? AND status='published'`, responseID); err != nil { - return "", false, err - } - if err := tx.Commit(); err != nil { - return "", false, err - } - return responseID, true, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", false, err - } - var target struct { - CommandID string `json:"command_id"` - CallID string `json:"call_id"` - } - if err := json.Unmarshal(request.Payload, &target); err != nil { - return "", false, err - } - now := s.now().UTC() - deadline, err := time.Parse(time.RFC3339, request.NotAfter) - if err != nil { - return "", false, err - } - response := contract.ServiceMessage{SchemaVersion: "2.0", MessageType: request.MessageType + ".result", MessageID: uuid.NewString(), DispatcherID: owner, TenantID: request.TenantID, TenantKey: request.TenantKey, TraceID: request.TraceID, IssuedAt: now.Format(time.RFC3339Nano), CorrelationID: request.MessageID, Status: "ok", ReasonCode: "ok"} - var payload any - if !now.Before(deadline) { - response.Status, response.ReasonCode = "rejected", "expired" - payload = map[string]any{"detail": "query deadline expired", "retryable": false} - } else { - if request.MessageType == "command.query" { - payload, err = commandQuerySnapshot(tx, request.TenantID, request.TenantKey, target.CommandID) - } else { - payload, err = callQuerySnapshot(tx, request, target.CallID, now) - } - if errors.Is(err, sql.ErrNoRows) { - response.Status, response.ReasonCode = "rejected", "not_found" - payload = map[string]any{"detail": "query target not found", "retryable": false} - } else if errors.Is(err, ErrQuerySnapshotTooLarge) { - response.Status, response.ReasonCode = "rejected", "unavailable" - payload = map[string]any{"detail": "query snapshot exceeds MQ message budget; partial results are not returned", "retryable": false} - } else if err != nil { - return "", false, err - } - } - response.Payload, err = json.Marshal(payload) - if err != nil { - return "", false, err - } - body, err := json.Marshal(response) - if err != nil { - return "", false, err - } - if len(body) > mq.MaxMessageBytes { - response.Status, response.ReasonCode = "rejected", "unavailable" - response.Payload = json.RawMessage(`{"detail":"query snapshot exceeds MQ message budget; partial results are not returned","retryable":false}`) - body, err = json.Marshal(response) - if err != nil { - return "", false, err - } - } - if _, err := contract.DecodeService(body); err != nil { - return "", false, fmt.Errorf("validate query response: %w", err) - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, response.MessageID, request.TenantKey, mq.EventExchange, route.OutboundKey, body, now.Format(time.RFC3339Nano)); err != nil { - return "", false, err - } - if _, err := tx.Exec(`INSERT INTO mq_query_inbox(tenant_id,message_id,tenant_key,dispatcher_id,body_hash,response_id,received_at) VALUES(?,?,?,?,?,?,?)`, request.TenantID, request.MessageID, request.TenantKey, owner, digest, response.MessageID, now.Format(time.RFC3339Nano)); err != nil { - return "", false, err - } - if err := tx.Commit(); err != nil { - return "", false, err - } - return response.MessageID, false, nil -} - -func commandQuerySnapshot(tx *sql.Tx, tenantID, tenantKey, commandID string) (map[string]any, error) { - var kind, status, received string - var persisted sql.NullString - if err := tx.QueryRow(`SELECT command_type,status,received_at,persisted_at FROM inbox WHERE tenant_id=? AND tenant_key=? AND command_id=?`, tenantID, tenantKey, commandID).Scan(&kind, &status, &received, &persisted); err != nil { - return nil, err - } - var version sql.NullInt64 - var reason sql.NullString - if err := tx.QueryRow(`SELECT json_extract(body,'$.aggregate_version'), json_extract(body,'$.payload.status'), - json_extract(body,'$.occurred_at'), json_extract(body,'$.payload.reason_code') - FROM outbox WHERE tenant_key=? AND json_extract(body,'$.tenant_id')=? - AND json_extract(body,'$.event_type')='command.result' - AND json_extract(body,'$.aggregate_type')='command' AND json_extract(body,'$.aggregate_id')=? - ORDER BY json_extract(body,'$.aggregate_version') DESC LIMIT 1`, tenantKey, tenantID, commandID).Scan(&version, &status, &received, &reason); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, errors.New("persisted command has no aggregate-state evidence") - } - return nil, err - } - if !version.Valid || version.Int64 < 1 || status == "" { - return nil, errors.New("persisted command has no aggregate-state evidence") - } - result := map[string]any{"command_id": commandID, "command_type": kind, "tenant_id": tenantID, "tenant_key": tenantKey, "status": status, "aggregate_version": version.Int64, "updated_at": received} - if reason.Valid { - result["reason_code"] = reason.String - } - if persisted.Valid { - result["accepted_at"] = persisted.String - } - return result, nil -} diff --git a/internal/store/query_test.go b/internal/store/query_test.go deleted file mode 100644 index a58e08e..0000000 --- a/internal/store/query_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package store - -import ( - "encoding/json" - "errors" - "path/filepath" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestCommandQueryUsesRecordedAggregateVersion(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/command-query.json") - if err != nil { - t.Fatal(err) - } - request, err := contract.DecodeService(raw) - if err != nil { - t.Fatal(err) - } - var target struct { - CommandID string `json:"command_id"` - } - if err := json.Unmarshal(request.Payload, &target); err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at,persisted_at) VALUES(?,?,?,'call.execute','fixture','{}','persisted',?,?)`, request.TenantID, target.CommandID, request.TenantKey, request.IssuedAt, request.IssuedAt); err != nil { - t.Fatal(err) - } - eventRaw, err := contracts.Files.ReadFile("upstream/v1/examples/event-command-result.json") - if err != nil { - t.Fatal(err) - } - var event map[string]any - if err := json.Unmarshal(eventRaw, &event); err != nil { - t.Fatal(err) - } - event["aggregate_id"], event["aggregate_version"] = target.CommandID, 7 - event["payload"].(map[string]any)["command_id"] = target.CommandID - eventRaw, err = json.Marshal(event) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, request.TenantKey) - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES('fact',?,'agent-call.saas.v2',?,?,'published',?)`, request.TenantKey, route.OutboundKey, eventRaw, request.IssuedAt); err != nil { - t.Fatal(err) - } - id, _, err := s.HandleQuery(raw, route.InboundKey) - if err != nil { - t.Fatal(err) - } - var responseRaw []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, id).Scan(&responseRaw); err != nil { - t.Fatal(err) - } - response, err := contract.DecodeService(responseRaw) - if err != nil { - t.Fatal(err) - } - var snapshot struct { - AggregateVersion int64 `json:"aggregate_version"` - CommandID string `json:"command_id"` - Status string `json:"status"` - } - if err := json.Unmarshal(response.Payload, &snapshot); err != nil { - t.Fatal(err) - } - if response.Status != "ok" || snapshot.AggregateVersion != 7 || snapshot.CommandID != target.CommandID || snapshot.Status != "accepted" { - t.Fatalf("snapshot was fabricated or bound incorrectly: %+v", snapshot) - } - if _, err := s.DB().Exec(`DELETE FROM outbox WHERE event_id='fact'`); err != nil { - t.Fatal(err) - } - // Missing aggregate evidence must not be replaced by a hard-coded version. - var next map[string]any - if err := json.Unmarshal(raw, &next); err != nil { - t.Fatal(err) - } - next["message_id"] = "query-missing-evidence" - raw, err = json.Marshal(next) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleQuery(raw, route.InboundKey); err == nil { - t.Fatal("missing aggregate evidence silently replaced") - } -} - -func TestCommandQueryRecoveryAndAtomicFailure(t *testing.T) { - filename := filepath.Join(t.TempDir(), "queries.db") - s, err := Open(filename) - if err != nil { - t.Fatal(err) - } - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/command-query.json") - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`CREATE TRIGGER reject_query_response BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT,'injected storage failure'); END`); err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleQuery(raw, route.InboundKey); err == nil { - t.Fatal("storage failure hidden") - } - for _, table := range []string{"outbox", "mq_query_inbox", "tenant_bindings"} { - var n int - if err := s.DB().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil { - t.Fatal(err) - } - if n != 0 { - t.Fatalf("partial commit in %s", table) - } - } - if _, err := s.DB().Exec(`DROP TRIGGER reject_query_response`); err != nil { - t.Fatal(err) - } - id, _, err := s.HandleQuery(raw, route.InboundKey) - if err != nil { - t.Fatal(err) - } - if err := s.Close(); err != nil { - t.Fatal(err) - } - s, err = Open(filename) - if err != nil { - t.Fatal(err) - } - defer s.Close() - // A later duplicate returns the original decision, even after its deadline. - s.now = func() time.Time { return time.Date(2026, 9, 22, 0, 0, 0, 0, time.UTC) } - got, duplicate, err := s.HandleQuery(raw, route.InboundKey) - if err != nil || !duplicate || got != id { - t.Fatalf("restart lost response identity: %s %v %v", got, duplicate, err) - } - var changed map[string]any - if err := json.Unmarshal(raw, &changed); err != nil { - t.Fatal(err) - } - changed["message_id"] = "expired-query" - raw, err = json.Marshal(changed) - if err != nil { - t.Fatal(err) - } - expired, _, err := s.HandleQuery(raw, route.InboundKey) - if err != nil { - t.Fatal(err) - } - var body []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, expired).Scan(&body); err != nil { - t.Fatal(err) - } - response, err := contract.DecodeService(body) - if err != nil || response.ReasonCode != "expired" { - t.Fatalf("late query not rejected: %v %v", response, err) - } -} - -func TestCommandQueryPersistsOneCorrelatedResponse(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/command-query.json") - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - id, duplicate, err := s.HandleQuery(raw, route.InboundKey) - if err != nil || duplicate || id == "" { - t.Fatalf("first query: %s %v %v", id, duplicate, err) - } - if _, err := s.DB().Exec(`UPDATE outbox SET status='published' WHERE event_id=?`, id); err != nil { - t.Fatal(err) - } - second, duplicate, err := s.HandleQuery(raw, route.InboundKey) - if err != nil || !duplicate || second != id { - t.Fatalf("duplicate query: %s %v %v", second, duplicate, err) - } - var delivery string - if err := s.DB().QueryRow(`SELECT status FROM outbox WHERE event_id=?`, id).Scan(&delivery); err != nil { - t.Fatal(err) - } - if delivery != "pending" { - t.Fatalf("duplicate cannot recover original reply: %s", delivery) - } - var body []byte - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("duplicate produced %d responses", count) - } - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, id).Scan(&body); err != nil { - t.Fatal(err) - } - response, err := contract.DecodeService(body) - if err != nil { - t.Fatal(err) - } - if response.MessageType != "command.query.result" || response.CorrelationID != "command.query-a" || response.ReasonCode != "not_found" || response.DispatcherID != identityA { - t.Fatalf("wrong response identity/status: %+v", response) - } - var changed map[string]any - if err := json.Unmarshal(raw, &changed); err != nil { - t.Fatal(err) - } - changed["payload"] = map[string]any{"command_id": "different"} - changedRaw, _ := json.Marshal(changed) - if _, _, err := s.HandleQuery(changedRaw, route.InboundKey); !errors.Is(err, ErrIdempotencyConflict) { - t.Fatalf("different content accepted: %v", err) - } - other, _ := tenant.NewDispatcherRoute(identityB, "tenant-a") - if _, _, err := s.HandleQuery(raw, other.InboundKey); err == nil { - t.Fatal("foreign route accepted") - } -} diff --git a/internal/store/replay_mq.go b/internal/store/replay_mq.go deleted file mode 100644 index bb22b9a..0000000 --- a/internal/store/replay_mq.go +++ /dev/null @@ -1,184 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" -) - -// HandleReplay changes delivery state only. It never creates an execution or -// asset and never rewrites the identity or body of a business event. -func (s *Store) HandleReplay(raw []byte, routingKey string) (string, bool, error) { - command, err := contract.DecodeMQCommand(raw) - if err != nil { - return "", false, err - } - if command.CommandType != "call.replay" && command.CommandType != "command.replay" { - return "", false, errors.New("expected a replay command") - } - route, err := tenant.NewDispatcherRoute(command.DispatcherID, command.TenantKey) - if err != nil { - return "", false, err - } - if route.InboundKey != routingKey { - return "", false, ErrMessageScope - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return "", false, err - } - defer tx.Rollback() - if err := bindMQScope(tx, command.DispatcherID, command.TenantID, command.TenantKey); err != nil { - return "", false, err - } - hash := sha256.Sum256(raw) - digest := hex.EncodeToString(hash[:]) - var previous string - err = tx.QueryRow(`SELECT body_hash FROM inbox WHERE tenant_id=? AND command_id=?`, command.TenantID, command.CommandID).Scan(&previous) - if err == nil { - if previous != digest { - return "", false, ErrIdempotencyConflict - } - var responseID string - if err := tx.QueryRow(`SELECT response_id FROM mq_command_receipts WHERE tenant_id=? AND command_id=?`, command.TenantID, command.CommandID).Scan(&responseID); err != nil { - return "", false, fmt.Errorf("read persisted replay receipt: %w", err) - } - if _, err := tx.Exec(`UPDATE outbox SET status='pending',published_at=NULL WHERE event_id=? AND status='published'`, responseID); err != nil { - return "", false, err - } - if err := tx.Commit(); err != nil { - return "", false, err - } - return responseID, true, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return "", false, err - } - now := s.now().UTC() - stamp := now.Format(time.RFC3339Nano) - expired, err := contract.NotAfterExpired(command.NotAfter, now) - if err != nil { - return "", false, err - } - status, reason := "applied", "replayed" - if expired { - status, reason = "rejected", "expired" - } else { - ids, err := replayBusinessEventIDs(tx, command) - if err != nil { - return "", false, err - } - if len(ids) == 0 { - status, reason = "rejected", "not_found" - } else { - for _, id := range ids { - if _, err := tx.Exec(`UPDATE outbox SET status='pending',published_at=NULL WHERE event_id=? AND status IN ('published','retry')`, id); err != nil { - return "", false, err - } - } - } - } - inboxStatus := "persisted" - if status == "rejected" { - inboxStatus = "rejected" - } - if _, err := tx.Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at,persisted_at) VALUES(?,?,?,?,?,?,?,?,?)`, command.TenantID, command.CommandID, command.TenantKey, command.CommandType, digest, raw, inboxStatus, stamp, stamp); err != nil { - return "", false, err - } - responseID := uuid.NewString() - response := map[string]any{"schema_version": "2.0", "dispatcher_id": command.DispatcherID, "event_id": responseID, "event_type": "command.result", "tenant_id": command.TenantID, "tenant_key": command.TenantKey, "trace_id": command.TraceID, "occurred_at": stamp, "aggregate_type": "command", "aggregate_id": command.CommandID, "aggregate_version": 1, "payload": map[string]any{"command_id": command.CommandID, "command_type": command.CommandType, "status": status, "reason_code": reason}} - body, err := json.Marshal(response) - if err != nil { - return "", false, err - } - if err := contract.ValidateMQMessage(body); err != nil { - return "", false, fmt.Errorf("invalid replay receipt: %w", err) - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, responseID, command.TenantKey, mq.EventExchange, route.OutboundKey, body, stamp); err != nil { - return "", false, err - } - if _, err := tx.Exec(`INSERT INTO mq_command_receipts(tenant_id,command_id,response_id) VALUES(?,?,?)`, command.TenantID, command.CommandID, responseID); err != nil { - return "", false, err - } - if err := tx.Commit(); err != nil { - return "", false, err - } - return responseID, false, nil -} - -func replayBusinessEventIDs(tx *sql.Tx, command contract.CommandEnvelope) ([]string, error) { - var target struct { - CallID string `json:"call_id"` - SourceCommandID string `json:"source_command_id"` - } - if err := json.Unmarshal(command.Payload, &target); err != nil { - return nil, err - } - executionID := "" - if command.CommandType == "command.replay" { - var source []byte - err := tx.QueryRow(`SELECT body FROM inbox WHERE tenant_id=? AND tenant_key=? AND command_id=?`, command.TenantID, command.TenantKey, target.SourceCommandID).Scan(&source) - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - original, err := contract.DecodeMQCommand(source) - if err != nil { - return nil, fmt.Errorf("source command is not a valid current command: %w", err) - } - if original.DispatcherID != command.DispatcherID { - return nil, ErrMessageScope - } - if original.CommandType == "call.replay" || original.CommandType == "command.replay" { - return nil, nil - } - var payload struct { - ExecutionID string `json:"execution_id"` - } - if err := json.Unmarshal(original.Payload, &payload); err != nil { - return nil, err - } - executionID = payload.ExecutionID - } - rows, err := tx.Query(`SELECT event_id FROM outbox WHERE tenant_key=? - AND json_extract(body,'$.schema_version')='2.0' - AND json_extract(body,'$.dispatcher_id')=? AND json_extract(body,'$.tenant_id')=? - AND json_extract(body,'$.event_type') IS NOT NULL - AND COALESCE(json_extract(body,'$.payload.command_type'),'') NOT IN ('call.replay','command.replay') - AND ((?<>'' AND json_extract(body,'$.payload.call_id')=?) - OR (?<>'' AND ((json_extract(body,'$.aggregate_type')='command' AND json_extract(body,'$.aggregate_id')=?) OR json_extract(body,'$.payload.source_command_id')=?)) - OR (?<>'' AND json_extract(body,'$.payload.execution_id')=?) - OR (?<>'' AND json_extract(body,'$.payload.call_id') IN ( - SELECT json_extract(f.body,'$.payload.call_id') FROM outbox f - WHERE f.tenant_key=? AND json_extract(f.body,'$.dispatcher_id')=? - AND json_extract(f.body,'$.tenant_id')=? AND json_extract(f.body,'$.schema_version')='2.0' - AND json_extract(f.body,'$.event_type') IN ('call.status','call.finished') - AND json_extract(f.body,'$.payload.execution_id')=? - )))`, command.TenantKey, command.DispatcherID, command.TenantID, target.CallID, target.CallID, target.SourceCommandID, target.SourceCommandID, target.SourceCommandID, executionID, executionID, executionID, command.TenantKey, command.DispatcherID, command.TenantID, executionID) - if err != nil { - return nil, err - } - defer rows.Close() - var ids []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - return ids, rows.Err() -} diff --git a/internal/store/replay_mq_test.go b/internal/store/replay_mq_test.go deleted file mode 100644 index 42d9c57..0000000 --- a/internal/store/replay_mq_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package store - -import ( - "encoding/json" - "errors" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestMQReplayOnlyRequeuesOriginalBusinessFacts(t *testing.T) { - for _, kind := range []string{"call-replay", "command-replay"} { - t.Run(kind, func(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - original, err := contracts.Files.ReadFile("upstream/v1/examples/call-execute.json") - if err != nil { - t.Fatal(err) - } - var command map[string]any - if err := json.Unmarshal(original, &command); err != nil { - t.Fatal(err) - } - command["command_id"] = "command-a" - command["payload"].(map[string]any)["execution_id"] = "execution-1" - original, err = json.Marshal(command) - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at) VALUES('tenant-a','command-a','tenant-a','call.execute','fixture',?,'persisted','2026-09-21T00:00:00Z')`, original); err != nil { - t.Fatal(err) - } - before := map[string]string{} - for _, name := range []string{"call-status", "transcript-updated", "recording-uploaded"} { - raw, err := contracts.Files.ReadFile("upstream/v1/examples/event-" + name + ".json") - if err != nil { - t.Fatal(err) - } - var event map[string]any - if err := json.Unmarshal(raw, &event); err != nil { - t.Fatal(err) - } - id := event["event_id"].(string) - before[id] = string(raw) - if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,'tenant-a','agent-call.saas.v2',?,?,'published','2026-09-21T00:00:00Z')`, id, route.OutboundKey, raw); err != nil { - t.Fatal(err) - } - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/" + kind + ".json") - if err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`CREATE TRIGGER reject_replay_receipt BEFORE INSERT ON mq_command_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END`); err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleReplay(raw, route.InboundKey); err == nil { - t.Fatal("receipt storage failure hidden") - } - var changed int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE status<>'published'`).Scan(&changed); err != nil { - t.Fatal(err) - } - if changed != 0 { - t.Fatal("replay side effects escaped a failed transaction") - } - if _, err := s.DB().Exec(`DROP TRIGGER reject_replay_receipt`); err != nil { - t.Fatal(err) - } - foreign, err := tenant.NewDispatcherRoute(identityB, "tenant-a") - if err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleReplay(raw, foreign.InboundKey); !errors.Is(err, ErrMessageScope) { - t.Fatalf("wrong route accepted: %v", err) - } - responseID, duplicate, err := s.HandleReplay(raw, route.InboundKey) - if err != nil || duplicate { - t.Fatalf("replay failed: %v", err) - } - for id, original := range before { - var body []byte - var status string - if err := s.DB().QueryRow(`SELECT body,status FROM outbox WHERE event_id=?`, id).Scan(&body, &status); err != nil { - t.Fatal(err) - } - if string(body) != original || status != "pending" { - t.Fatal("original fact identity/body changed or not replayed") - } - } - var response []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, responseID).Scan(&response); err != nil { - t.Fatal(err) - } - if err := contract.ValidateMQMessage(response); err != nil { - t.Fatal(err) - } - if _, err := s.DB().Exec(`UPDATE outbox SET status='published'`); err != nil { - t.Fatal(err) - } - again, duplicate, err := s.HandleReplay(raw, route.InboundKey) - if err != nil || !duplicate || again != responseID { - t.Fatalf("duplicate lost decision: %v", err) - } - var pending int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE status='pending'`).Scan(&pending); err != nil { - t.Fatal(err) - } - if pending != 1 { - t.Fatal("duplicate replay executed its side effect again instead of recovering only its receipt") - } - var conflict map[string]any - if err := json.Unmarshal(raw, &conflict); err != nil { - t.Fatal(err) - } - conflict["payload"].(map[string]any)["reason"] = "different" - conflictRaw, err := json.Marshal(conflict) - if err != nil { - t.Fatal(err) - } - if _, _, err := s.HandleReplay(conflictRaw, route.InboundKey); !errors.Is(err, ErrIdempotencyConflict) { - t.Fatalf("different command content accepted: %v", err) - } - conflict["command_id"] = "expired-replay" - s.now = func() time.Time { return time.Date(2026, 9, 22, 0, 0, 0, 0, time.UTC) } - conflictRaw, err = json.Marshal(conflict) - if err != nil { - t.Fatal(err) - } - expiredID, _, err := s.HandleReplay(conflictRaw, route.InboundKey) - if err != nil { - t.Fatal(err) - } - var reason string - if err := s.DB().QueryRow(`SELECT json_extract(body,'$.payload.reason_code') FROM outbox WHERE event_id=?`, expiredID).Scan(&reason); err != nil { - t.Fatal(err) - } - if reason != "expired" { - t.Fatal("expired replay was not refused") - } - for id := range before { - var status string - if err := s.DB().QueryRow(`SELECT status FROM outbox WHERE event_id=?`, id).Scan(&status); err != nil { - t.Fatal(err) - } - if status != "published" { - t.Fatal("expired replay changed original delivery state") - } - } - var tasks int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&tasks); err != nil { - t.Fatal(err) - } - if tasks != 0 { - t.Fatal("replay created a dialing task") - } - }) - } -} diff --git a/internal/store/reservation_control_test.go b/internal/store/reservation_control_test.go deleted file mode 100644 index e40dfab..0000000 --- a/internal/store/reservation_control_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package store - -import ( - "errors" - "testing" - - "git.ipao.vip/rogee/go-sip/internal/testfixture" -) - -func TestReservationCannotBypassTaskControlBarrier(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - if err := s.SetQuota("global", 2); err != nil { - t.Fatal(err) - } - for _, state := range []string{"paused", "draining", "stopped", "unknown", "finished"} { - if _, err := s.DB().Exec(`UPDATE tasks SET status=?`, state); err != nil { - t.Fatal(err) - } - if err := s.Reserve("reservation-"+state, "exec_demo_001", "tenant-demo-key", []string{"global"}); !errors.Is(err, ErrCASConflict) { - t.Fatalf("%s task reserved: %v", state, err) - } - } - if _, err := s.DB().Exec(`UPDATE tasks SET status='accepted'`); err != nil { - t.Fatal(err) - } - if err := s.Reserve("wrong-tenant", "exec_demo_001", "other-tenant", []string{"global"}); !errors.Is(err, ErrCASConflict) { - t.Fatalf("foreign tenant reserved task: %v", err) - } - if err := s.Reserve("valid", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil { - t.Fatal(err) - } - var state string - if err := s.DB().QueryRow(`SELECT status FROM tasks`).Scan(&state); err != nil { - t.Fatal(err) - } - if state != "reserved" { - t.Fatal("quota and reserved state were not committed together") - } -} diff --git a/internal/store/store.go b/internal/store/store.go deleted file mode 100644 index 96602b5..0000000 --- a/internal/store/store.go +++ /dev/null @@ -1,878 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "embed" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - iofs "io/fs" - "sort" - "strings" - "sync" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -//go:embed migrations/*.sql -var migrations embed.FS - -var ( - ErrDuplicateCommand = errors.New("duplicate command") - ErrNoCapacity = errors.New("quota capacity unavailable") - ErrCASConflict = errors.New("control revision conflict") - ErrLeaseHeld = errors.New("active lease held by another dispatcher") -) - -type Store struct { - db *sql.DB - now func() time.Time - mu sync.Mutex -} - -func Open(path string) (*Store, error) { - if path == "" { - path = "file:dispatcher.db?_pragma=busy_timeout(5000)" - } - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, fmt.Errorf("open sqlite: %w", err) - } - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - store := &Store{db: db, now: time.Now} - if err := store.migrate(); err != nil { - _ = db.Close() - return nil, err - } - return store, nil -} - -func New(db *sql.DB, now func() time.Time) (*Store, error) { - if db == nil { - return nil, errors.New("nil database") - } - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - if now == nil { - now = time.Now - } - store := &Store{db: db, now: now} - if err := store.migrate(); err != nil { - return nil, err - } - return store, nil -} - -func (s *Store) migrate() error { - files, err := iofs.Glob(migrations, "migrations/*.sql") - if err != nil { - return err - } - sort.Strings(files) - for _, file := range files { - data, err := migrations.ReadFile(file) - if err != nil { - return err - } - if _, err := s.db.Exec(string(data)); err != nil { - return fmt.Errorf("migrate sqlite %s: %w", file, err) - } - } - return nil -} - -func (s *Store) RecoverOutbox() error { - _, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = 'recovered_after_restart' WHERE status = 'dispatching'`) - return err -} - -func (s *Store) Close() error { return s.db.Close() } - -func (s *Store) DB() *sql.DB { return s.db } - -type IngestResult struct { - CommandID string - ExecutionID string - Duplicate bool - PersistedAt time.Time -} - -func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, error) { - envelope, payload, err := contract.DecodeExecute(raw) - if err != nil { - return IngestResult{}, err - } - route, err := tenant.NewDispatcherRoute(envelope.DispatcherID, envelope.TenantKey) - if err != nil { - return IngestResult{}, err - } - if routingKey != route.InboundKey { - return IngestResult{}, fmt.Errorf("%w: execution command route mismatch", ErrMessageScope) - } - now := s.now().UTC() - expired, err := contract.NotAfterExpired(envelope.NotAfter, now) - if err != nil { - return IngestResult{}, err - } - hash := sha256.Sum256(raw) - bodyHash := hex.EncodeToString(hash[:]) - - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return IngestResult{}, fmt.Errorf("begin ingest: %w", err) - } - defer tx.Rollback() - if err := bindMQScope(tx, envelope.DispatcherID, envelope.TenantID, envelope.TenantKey); err != nil { - return IngestResult{}, err - } - - var existingHash, status string - err = tx.QueryRow(`SELECT body_hash, status FROM inbox WHERE command_id = ?`, envelope.CommandID).Scan(&existingHash, &status) - switch { - case err == nil: - if existingHash != bodyHash { - return IngestResult{}, fmt.Errorf("%w: %s", ErrCommandConflict, envelope.CommandID) - } - if _, err := tx.Exec(`UPDATE outbox SET status='pending', published_at=NULL WHERE event_id=? AND status='published'`, envelope.CommandID+"-result"); err != nil { - return IngestResult{}, err - } - if err := tx.Commit(); err != nil { - return IngestResult{}, err - } - return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil - case !errors.Is(err, sql.ErrNoRows): - return IngestResult{}, fmt.Errorf("lookup inbox: %w", err) - } - - if _, err := tx.Exec(`INSERT INTO inbox(command_id, tenant_id, tenant_key, command_type, body_hash, body, status, received_at) - VALUES(?, ?, ?, ?, ?, ?, 'received', ?)`, envelope.CommandID, envelope.TenantID, envelope.TenantKey, envelope.CommandType, bodyHash, raw, now.Format(time.RFC3339Nano)); err != nil { - return IngestResult{}, fmt.Errorf("persist inbox: %w", err) - } - - admissionStatus, admissionReason := "accepted", "accepted" - businessDuplicate := false - if expired { - admissionStatus, admissionReason = "rejected", "expired" - } else { - variables, err := json.Marshal(payload.Variables) - if err != nil { - return IngestResult{}, fmt.Errorf("encode variables: %w", err) - } - result, err := tx.Exec(`INSERT INTO tasks( - execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, trace_id, - callee, route_policy_id, caller_profile_id, agent_version_id, variables, - ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at) - VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?) - ON CONFLICT(tenant_key, task_id, task_item_id, task_revision) DO NOTHING`, - payload.ExecutionID, envelope.TenantKey, envelope.TenantID, payload.TaskID, payload.TaskItemID, payload.TaskRevision, - envelope.TraceID, payload.Callee, payload.RoutePolicyID, payload.CallerProfileID, payload.AgentVersionID, - variables, payload.RingTimeoutMS, payload.MaxCallDurationMS, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) - if err != nil { - return IngestResult{}, fmt.Errorf("persist task: %w", err) - } - inserted, err := result.RowsAffected() - if err != nil { - return IngestResult{}, fmt.Errorf("inspect task insert: %w", err) - } - if inserted == 0 { - existing, err := scanTask(tx.QueryRow(`SELECT execution_id,tenant_key,tenant_id,task_id,task_item_id,task_revision, - trace_id,callee,route_policy_id,caller_profile_id,agent_version_id,variables, - ring_timeout_ms,max_call_duration_ms,status,created_at,updated_at - FROM tasks WHERE tenant_key=? AND task_id=? AND task_item_id=? AND task_revision=?`, envelope.TenantKey, payload.TaskID, payload.TaskItemID, payload.TaskRevision)) - if err != nil { - return IngestResult{}, err - } - existingVariables, err := json.Marshal(existing.Variables) - if err != nil { - return IngestResult{}, err - } - if existing.ExecutionID != payload.ExecutionID || existing.Callee != payload.Callee || - existing.RoutePolicyID != payload.RoutePolicyID || existing.CallerProfileID != payload.CallerProfileID || - existing.AgentVersionID != payload.AgentVersionID || existing.RingTimeoutMS != payload.RingTimeoutMS || - existing.MaxCallDurationMS != payload.MaxCallDurationMS || string(existingVariables) != string(variables) { - return IngestResult{}, fmt.Errorf("%w: business key already belongs to different execution content", ErrCommandConflict) - } - businessDuplicate = true - } - } - - event, err := (contract.EventBuilder{ - TenantID: envelope.TenantID, TenantKey: envelope.TenantKey, TraceID: envelope.TraceID, - EventType: "command.result", Aggregate: "command", AggregateID: envelope.CommandID, Version: 1, - Payload: map[string]any{ - "command_id": envelope.CommandID, "command_type": envelope.CommandType, - "execution_id": payload.ExecutionID, - "status": admissionStatus, "reason_code": admissionReason, - "requested_task_revision": payload.TaskRevision, - }, - }).MarshalMQ(envelope.DispatcherID, now, envelope.CommandID+"-result") - if err != nil { - return IngestResult{}, fmt.Errorf("build command.result: %w", err) - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) - VALUES(?, ?, ?, ?, ?, 'pending', ?)`, envelope.CommandID+"-result", envelope.TenantKey, mq.EventExchange, route.OutboundKey, event, now.Format(time.RFC3339Nano)); err != nil { - return IngestResult{}, fmt.Errorf("persist outbox: %w", err) - } - if _, err := tx.Exec(`UPDATE inbox SET status = 'persisted', persisted_at = ? WHERE command_id = ?`, now.Format(time.RFC3339Nano), envelope.CommandID); err != nil { - return IngestResult{}, fmt.Errorf("mark inbox persisted: %w", err) - } - if err := tx.Commit(); err != nil { - return IngestResult{}, fmt.Errorf("commit ingest: %w", err) - } - return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: businessDuplicate, PersistedAt: now}, nil -} - -func verifyRouting(tenantKey, routingKey string) error { - want, err := tenant.CommandRoutingKey(tenantKey) - if err != nil { - return err - } - if want != routingKey { - return fmt.Errorf("tenant routing mismatch: expected %q got %q", want, routingKey) - } - return nil -} - -const MaxOutboxPayloadBytes = 8 * 1024 * 1024 -const OutboxPayloadBlockedReason = "blocked_payload_too_large" - -type OutboxRecord struct { - ID int64 - EventID string - TenantKey string - Exchange string - RoutingKey string - Body []byte - Attempts int -} - -func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) { - if limit <= 0 { - return nil, errors.New("outbox limit must be positive") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - rows, err := tx.Query(`SELECT id, event_id, tenant_key, exchange, routing_key, body, attempts - FROM outbox WHERE status='pending' OR (status='retry' AND COALESCE(last_error,'') != ?) - ORDER BY id LIMIT ?`, OutboxPayloadBlockedReason, limit) - if err != nil { - return nil, err - } - var records []OutboxRecord - for rows.Next() { - var r OutboxRecord - if err := rows.Scan(&r.ID, &r.EventID, &r.TenantKey, &r.Exchange, &r.RoutingKey, &r.Body, &r.Attempts); err != nil { - rows.Close() - return nil, err - } - records = append(records, r) - } - if err := rows.Close(); err != nil { - return nil, err - } - for _, r := range records { - if _, err := tx.Exec(`UPDATE outbox SET status = 'dispatching', attempts = attempts + 1 WHERE id = ?`, r.ID); err != nil { - return nil, err - } - } - if err := tx.Commit(); err != nil { - return nil, err - } - return records, nil -} - -// BlockOutboxPayload retains an oversized message intact and prevents another -// publish attempt across restarts. The durable reason, not a transient process -// flag, distinguishes a blocked row from a retryable one. -func (s *Store) BlockOutboxPayload(id int64, size int) error { - if id <= 0 || size <= MaxOutboxPayloadBytes { - return errors.New("outbox payload block requires a claimed oversized message") - } - result, err := s.db.Exec(`UPDATE outbox SET status='retry', last_error=? - WHERE id=? AND status='dispatching'`, OutboxPayloadBlockedReason, id) - if err != nil { - return fmt.Errorf("block oversized outbox message: %w", err) - } - rows, err := result.RowsAffected() - if err != nil { - return err - } - if rows != 1 { - return fmt.Errorf("block oversized outbox message %d: %w", id, ErrCommandConflict) - } - return nil -} - -func (s *Store) MarkOutboxPublished(id int64) error { - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - now := s.now().UTC().Format(time.RFC3339Nano) - if _, err := tx.Exec(`UPDATE outbox SET status='published', published_at=?, last_error=NULL WHERE id=?`, now, id); err != nil { - return err - } - if _, err := tx.Exec(`UPDATE uploads SET state='completed', completed_at=? WHERE upload_id IN - (SELECT n.upload_id FROM upload_notifications n JOIN outbox o ON o.event_id=n.event_id WHERE o.id=? AND o.status='published')`, now, id); err != nil { - return err - } - if _, err := tx.Exec(`UPDATE uploads SET state='completed', completed_at=? WHERE state='granted' AND upload_id IN - (SELECT f.upload_id FROM local_v01_call_terminals f JOIN outbox o ON o.event_id=f.result_event_id - WHERE o.id=? AND o.status='published' AND f.recording_status='uploaded')`, now, id); err != nil { - return err - } - if _, err := tx.Exec(`UPDATE uploads SET state='failed' WHERE state='granted' AND upload_id IN - (SELECT f.upload_id FROM local_v01_call_terminals f JOIN outbox o ON o.event_id=f.result_event_id - WHERE o.id=? AND o.status='published' AND f.recording_status='unavailable')`, id); err != nil { - return err - } - return tx.Commit() -} - -func (s *Store) MarkOutboxRetry(id int64, cause error) error { - message := "retry" - if cause != nil { - message = cause.Error() - } - result, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = ? WHERE id = ? AND status='dispatching'`, message, id) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil { - return err - } - if rows != 1 { - return fmt.Errorf("retry outbox message %d: %w", id, ErrCommandConflict) - } - return nil -} - -type Task struct { - ExecutionID string - TenantKey string - TenantID string - TaskID string - TaskItemID string - TaskRevision int64 - TraceID string - Callee string - RoutePolicyID string - CallerProfileID string - AgentVersionID string - Variables map[string]any - RingTimeoutMS int64 - MaxCallDurationMS int64 - Status string - CreatedAt time.Time - UpdatedAt time.Time -} - -func (s *Store) FindTask(tenantID, taskID string) (Task, error) { - if tenantID == "" || taskID == "" { - return Task{}, errors.New("tenant id and task id are required") - } - s.mu.Lock() - defer s.mu.Unlock() - row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, - trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables, - ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at - FROM tasks WHERE tenant_id = ? AND task_id = ? ORDER BY task_revision DESC LIMIT 1`, tenantID, taskID) - return scanTask(row) -} - -func (s *Store) NextTask(tenantKey string) (Task, error) { - if strings.TrimSpace(tenantKey) == "" { - return Task{}, errors.New("tenant key is required") - } - s.mu.Lock() - defer s.mu.Unlock() - row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, - trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables, - ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at - FROM tasks WHERE tenant_key = ? AND status = 'accepted' ORDER BY created_at, execution_id LIMIT 1`, tenantKey) - return scanTask(row) -} - -func (s *Store) MarkTaskRunning(executionID string) error { - if executionID == "" { - return errors.New("execution id is required") - } - s.mu.Lock() - defer s.mu.Unlock() - result, err := s.db.Exec(`UPDATE tasks SET status = 'running', updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, s.now().UTC().Format(time.RFC3339Nano), executionID) - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - return nil -} - -// FinalizeReservation releases a failed remote attempt and updates its task in -// the same SQLite transaction. Unknown attempts stay counted in unknown_value; -// only a failure proven before remote submission is requeued as accepted. -func (s *Store) FinalizeReservation(reservationID, executionID string, unknown bool) error { - if reservationID == "" || executionID == "" { - return errors.New("reservation and execution ids are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := s.finalizeReservationTx(tx, reservationID, executionID, unknown); err != nil { - return err - } - return tx.Commit() -} - -func (s *Store) finalizeReservationTx(tx *sql.Tx, reservationID, executionID string, unknown bool) error { - var state, storedExecutionID string - var scopesJSON []byte - if err := tx.QueryRow(`SELECT state, execution_id, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &storedExecutionID, &scopesJSON); err != nil { - return err - } - if storedExecutionID != executionID { - return fmt.Errorf("reservation execution mismatch: got %q, want %q", storedExecutionID, executionID) - } - if state != "held" { - return nil - } - var scopes []string - if err := json.Unmarshal(scopesJSON, &scopes); err != nil { - return fmt.Errorf("decode reservation scopes: %w", err) - } - if len(scopes) == 0 { - return errors.New("quota scopes are required to finalize a reservation") - } - now := s.now().UTC().Format(time.RFC3339Nano) - for _, scope := range scopes { - var result sql.Result - var err error - if unknown { - result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope) - } else { - result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope) - } - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - } - newState := "released" - newTaskStatus := "accepted" - if unknown { - newState = "unknown" - newTaskStatus = "unknown" - } - if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ? AND state = 'held'`, newState, now, reservationID); err != nil { - return err - } - result, err := tx.Exec(`UPDATE tasks SET status = CASE WHEN status='reserved' THEN ? ELSE status END, updated_at = ? WHERE execution_id = ? AND status IN ('reserved','paused','draining','stopped')`, newTaskStatus, now, executionID) - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - return nil -} - -type CommandRecord struct { - CommandID string - TenantID string - TenantKey string - CommandType string - Status string - Body []byte - ReceivedAt time.Time - PersistedAt *time.Time -} - -func (s *Store) GetCommand(tenantID, commandID string) (CommandRecord, error) { - if tenantID == "" || commandID == "" { - return CommandRecord{}, errors.New("tenant id and command id are required") - } - var record CommandRecord - var received, persisted sql.NullString - if err := s.db.QueryRow(`SELECT command_id, tenant_id, tenant_key, command_type, status, body, received_at, persisted_at - FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, commandID).Scan(&record.CommandID, &record.TenantID, &record.TenantKey, &record.CommandType, &record.Status, &record.Body, &received, &persisted); err != nil { - return CommandRecord{}, err - } - var err error - record.ReceivedAt, err = time.Parse(time.RFC3339Nano, received.String) - if err != nil { - return CommandRecord{}, err - } - if persisted.Valid { - value, err := time.Parse(time.RFC3339Nano, persisted.String) - if err != nil { - return CommandRecord{}, err - } - record.PersistedAt = &value - } - return record, nil -} - -func (s *Store) ReplayCommand(idempotencyKey, tenantID, sourceCommandID, reason string) error { - if idempotencyKey == "" || tenantID == "" || sourceCommandID == "" || strings.TrimSpace(reason) == "" { - return errors.New("replay identity and reason are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var tenantKey string - var body []byte - if err := tx.QueryRow(`SELECT tenant_key, body FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, sourceCommandID).Scan(&tenantKey, &body); err != nil { - return err - } - var existing string - if err := tx.QueryRow(`SELECT idempotency_key FROM replays WHERE idempotency_key = ?`, idempotencyKey).Scan(&existing); err == nil { - return tx.Commit() - } else if !errors.Is(err, sql.ErrNoRows) { - return err - } - now := s.now().UTC().Format(time.RFC3339Nano) - if _, err := tx.Exec(`INSERT INTO replays(idempotency_key, tenant_id, source_command_id, tenant_key, reason, created_at) VALUES(?, ?, ?, ?, ?, ?)`, idempotencyKey, tenantID, sourceCommandID, tenantKey, reason, now); err != nil { - return err - } - routingKey := "agent-call.tenant." + tenantKey + ".call.execute" - eventID := "replay-" + idempotencyKey - if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, tenant.CommandExchange, routingKey, body, now); err != nil { - return err - } - return tx.Commit() -} - -func scanTask(row *sql.Row) (Task, error) { - var t Task - var variables []byte - var created, updated string - if err := row.Scan(&t.ExecutionID, &t.TenantKey, &t.TenantID, &t.TaskID, &t.TaskItemID, &t.TaskRevision, - &t.TraceID, &t.Callee, &t.RoutePolicyID, &t.CallerProfileID, &t.AgentVersionID, &variables, - &t.RingTimeoutMS, &t.MaxCallDurationMS, &t.Status, &created, &updated); err != nil { - return Task{}, err - } - if err := json.Unmarshal(variables, &t.Variables); err != nil { - return Task{}, fmt.Errorf("decode task variables: %w", err) - } - var err error - t.CreatedAt, err = time.Parse(time.RFC3339Nano, created) - if err != nil { - return Task{}, err - } - t.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated) - if err != nil { - return Task{}, err - } - return t, nil -} - -func (s *Store) SetQuota(scope string, limit int64) error { - if scope == "" || limit < 0 { - return errors.New("invalid quota") - } - _, err := s.db.Exec(`INSERT INTO quotas(scope, limit_value, updated_at) VALUES(?, ?, ?) - ON CONFLICT(scope) DO UPDATE SET limit_value = excluded.limit_value, updated_at = excluded.updated_at`, scope, limit, s.now().UTC().Format(time.RFC3339Nano)) - return err -} - -func (s *Store) Reserve(reservationID, executionID, tenantKey string, scopes []string) error { - if reservationID == "" || executionID == "" || tenantKey == "" || len(scopes) == 0 { - return errors.New("reservation identity and scopes are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - now := s.now().UTC().Format(time.RFC3339Nano) - scopesJSON, err := json.Marshal(scopes) - if err != nil { - return err - } - result, err := tx.Exec(`UPDATE tasks SET status='reserved',updated_at=? WHERE execution_id=? AND tenant_key=? AND status='accepted'`, now, executionID, tenantKey) - if err != nil { - return err - } - count, err := result.RowsAffected() - if err != nil { - return err - } - if count != 1 { - return ErrCASConflict - } - for _, scope := range scopes { - var limit, reserved, unknown int64 - if err := tx.QueryRow(`SELECT limit_value, reserved_value, unknown_value FROM quotas WHERE scope = ?`, scope).Scan(&limit, &reserved, &unknown); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope) - } - return err - } - if reserved+unknown >= limit { - return fmt.Errorf("%w: %s", ErrNoCapacity, scope) - } - } - if _, err := tx.Exec(`INSERT INTO reservations(reservation_id, execution_id, tenant_key, scopes, state, created_at) VALUES(?, ?, ?, ?, 'held', ?)`, reservationID, executionID, tenantKey, scopesJSON, now); err != nil { - return err - } - for _, scope := range scopes { - if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value + 1, updated_at = ? WHERE scope = ?`, now, scope); err != nil { - return err - } - } - if err := tx.Commit(); err != nil { - return err - } - return nil -} - -func (s *Store) ReleaseReservation(reservationID string, unknown bool) error { - if reservationID == "" { - return errors.New("reservation id is required") - } - return s.releaseReservation(reservationID, unknown, nil) -} - -func (s *Store) ReleaseReservationWithScopes(reservationID string, scopes []string, unknown bool) error { - return s.releaseReservation(reservationID, unknown, scopes) -} - -func (s *Store) releaseReservation(reservationID string, unknown bool, scopes []string) error { - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := releaseReservationTx(tx, reservationID, unknown, scopes, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - return tx.Commit() -} - -// releaseReservationTx is shared by explicit release and confirmed terminal -// calls. Unknown consumption remains held until a confirmed outcome arrives. -func releaseReservationTx(tx *sql.Tx, reservationID string, unknown bool, scopes []string, now string) error { - var state string - var scopesJSON []byte - if err := tx.QueryRow(`SELECT state, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &scopesJSON); err != nil { - return err - } - if state == "released" || state == "unknown" && unknown { - return nil - } - var persisted []string - if err := json.Unmarshal(scopesJSON, &persisted); err != nil { - return fmt.Errorf("decode reservation scopes: %w", err) - } - if len(persisted) == 0 { - return errors.New("quota scopes are required to release a reservation") - } - if len(scopes) > 0 { - selected := make(map[string]int, len(scopes)) - for _, scope := range scopes { - selected[scope]++ - } - for _, scope := range persisted { - selected[scope]-- - } - if len(scopes) != len(persisted) { - return ErrCommandConflict - } - for _, difference := range selected { - if difference != 0 { - return ErrCommandConflict - } - } - } - for _, scope := range persisted { - if scope == "" { - return errors.New("empty persisted quota scope") - } - column := "reserved_value" - if state == "unknown" { - column = "unknown_value" - } - query := `UPDATE quotas SET ` + column + ` = ` + column + ` - 1, updated_at = ? WHERE scope = ? AND ` + column + ` > 0` - if state == "held" && unknown { - query = `UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0` - } - result, err := tx.Exec(query, now, scope) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil || rows != 1 { - return fmt.Errorf("release quota scope %s (rows=%d): %w", scope, rows, errors.Join(err, ErrCommandConflict)) - } - } - newState := "released" - if unknown { - newState = "unknown" - } - result, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ? AND state = ?`, newState, now, reservationID, state) - if err != nil { - return err - } - rows, err := result.RowsAffected() - if err != nil || rows != 1 { - return fmt.Errorf("release reservation %s (rows=%d): %w", reservationID, rows, errors.Join(err, ErrCommandConflict)) - } - return nil -} - -type Lease struct { - LeaseID string - Scope string - HolderID string - ExpiresAt time.Time - State string -} - -func (s *Store) AcquireLease(leaseID, scope, holderID string, ttl time.Duration) (Lease, error) { - if leaseID == "" || scope == "" || holderID == "" || ttl <= 0 { - return Lease{}, errors.New("lease id, scope, holder, and positive ttl are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return Lease{}, err - } - defer tx.Rollback() - now := s.now().UTC() - var existingID, existingHolder, existingExpiry string - err = tx.QueryRow(`SELECT lease_id, holder_id, expires_at FROM leases WHERE scope = ? AND state = 'active' LIMIT 1`, scope).Scan(&existingID, &existingHolder, &existingExpiry) - if err == nil { - expires, parseErr := time.Parse(time.RFC3339Nano, existingExpiry) - if parseErr != nil { - return Lease{}, parseErr - } - if expires.After(now) && existingHolder != holderID { - return Lease{}, fmt.Errorf("%w: scope %s", ErrLeaseHeld, scope) - } - if expires.After(now) && existingHolder == holderID && existingID != leaseID { - return Lease{}, fmt.Errorf("%w: holder already owns scope %s", ErrLeaseHeld, scope) - } - if !expires.After(now) { - if _, err := tx.Exec(`UPDATE leases SET state = 'expired' WHERE lease_id = ?`, existingID); err != nil { - return Lease{}, err - } - } - } else if !errors.Is(err, sql.ErrNoRows) { - return Lease{}, err - } - expires := now.Add(ttl) - if _, err := tx.Exec(`INSERT INTO leases(lease_id, scope, holder_id, expires_at, state) VALUES(?, ?, ?, ?, 'active') - ON CONFLICT(lease_id) DO UPDATE SET scope = excluded.scope, holder_id = excluded.holder_id, expires_at = excluded.expires_at, state = 'active'`, leaseID, scope, holderID, expires.Format(time.RFC3339Nano)); err != nil { - return Lease{}, err - } - if err := tx.Commit(); err != nil { - return Lease{}, err - } - return Lease{LeaseID: leaseID, Scope: scope, HolderID: holderID, ExpiresAt: expires, State: "active"}, nil -} - -func (s *Store) ReleaseLease(leaseID string) error { - if leaseID == "" { - return errors.New("lease id is required") - } - _, err := s.db.Exec(`UPDATE leases SET state = 'released' WHERE lease_id = ? AND state = 'active'`, leaseID) - return err -} - -func (s *Store) ApplyControl(executionID string, expectedRevision int64, action string) error { - return s.ApplyControlDetailed(executionID, expectedRevision, action, "", "", "") -} - -func (s *Store) ApplyControlDetailed(executionID string, expectedRevision int64, action, activeCallPolicy, reason, idempotencyKey string) error { - if executionID == "" || expectedRevision < 1 { - return errors.New("execution id and expected revision are required") - } - if action != "pause" && action != "resume" && action != "drain" && action != "stop" && action != "hangup" { - return fmt.Errorf("unsupported control action %q", action) - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - var status string - if err := tx.QueryRow(`SELECT status FROM tasks WHERE execution_id = ? AND task_revision = ?`, executionID, expectedRevision).Scan(&status); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrCASConflict - } - return err - } - if status == "stopped" && action != "stop" { - return fmt.Errorf("%w: stopped execution cannot resume", ErrCASConflict) - } - if _, err := tx.Exec(`INSERT INTO controls(execution_id, task_revision, action, active_call_policy, reason, idempotency_key, applied, requested_at) - VALUES(?, ?, ?, ?, ?, ?, 0, ?) - ON CONFLICT(execution_id) DO UPDATE SET task_revision = excluded.task_revision, action = excluded.action, active_call_policy = excluded.active_call_policy, reason = excluded.reason, idempotency_key = excluded.idempotency_key, applied = 0, requested_at = excluded.requested_at`, executionID, expectedRevision, action, activeCallPolicy, reason, idempotencyKey, s.now().UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - newStatus := status - switch action { - case "pause": - newStatus = "paused" - case "resume": - if status != "paused" { - return fmt.Errorf("%w: execution is %s", ErrCASConflict, status) - } - newStatus = "accepted" - case "drain": - newStatus = "draining" - case "stop", "hangup": - newStatus = "stopped" - } - if _, err := tx.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE execution_id = ? AND task_revision = ?`, newStatus, s.now().UTC().Format(time.RFC3339Nano), executionID, expectedRevision); err != nil { - return err - } - return tx.Commit() -} diff --git a/internal/store/store_test.go b/internal/store/store_test.go deleted file mode 100644 index 4cfb1f4..0000000 --- a/internal/store/store_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/json" - "errors" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/internal/testfixture" - _ "modernc.org/sqlite" -) - -func testStore(t *testing.T) *Store { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) - s, err := New(db, func() time.Time { return now }) - if err != nil { - t.Fatal(err) - } - if err := s.BindDispatcherID(testfixture.DispatcherID); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = s.Close() }) - return s -} - -func TestIngestIsDurableAndIdempotent(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - result, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")) - if err != nil { - t.Fatal(err) - } - if result.Duplicate { - t.Fatal("first command marked duplicate") - } - second, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")) - if err != nil { - t.Fatal(err) - } - if !second.Duplicate { - t.Fatal("second command was not idempotent") - } - outbox, err := s.ClaimOutbox(10) - if err != nil { - t.Fatal(err) - } - if len(outbox) != 1 { - t.Fatalf("outbox rows = %d, want 1", len(outbox)) - } -} - -func TestIngestRejectsRoutingMismatch(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, "agent-call.tenant.other.call.execute"); err == nil { - t.Fatal("expected routing mismatch") - } -} - -func TestQuotaIsAtomicAndUnknownIsNotReleased(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - for _, id := range []string{"e1", "e2", "e3"} { - var command map[string]any - if err := json.Unmarshal(raw, &command); err != nil { - t.Fatal(err) - } - command["command_id"] = "command-" + id - payload := command["payload"].(map[string]any) - payload["execution_id"], payload["task_item_id"] = id, id - encoded, err := json.Marshal(command) - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(encoded, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - } - for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-1"} { - if err := s.SetQuota(scope, 1); err != nil { - t.Fatal(err) - } - } - if err := s.Reserve("r1", "e1", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); err != nil { - t.Fatal(err) - } - if err := s.Reserve("r2", "e2", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) { - t.Fatalf("reserve error = %v, want ErrNoCapacity", err) - } - if err := s.ReleaseReservationWithScopes("r1", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}, true); err != nil { - t.Fatal(err) - } - if err := s.Reserve("r3", "e3", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) { - t.Fatalf("unknown reservation released capacity: %v", err) - } -} - -func TestFinalizeReservationRequeuesBeforeRemoteSubmission(t *testing.T) { - s := testStore(t) - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - if err := s.Reserve("r-requeue", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil { - t.Fatal(err) - } - if err := s.FinalizeReservation("r-requeue", "exec_demo_001", false); err != nil { - t.Fatal(err) - } - var taskStatus string - if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil { - t.Fatal(err) - } - if taskStatus != "accepted" { - t.Fatalf("task status=%q, want accepted", taskStatus) - } - var reserved, unknown int64 - if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil { - t.Fatal(err) - } - if reserved != 0 || unknown != 0 { - t.Fatalf("quota reserved=%d unknown=%d, want 0/0", reserved, unknown) - } -} - -func TestFinalizeReservationKeepsUnknownCounted(t *testing.T) { - s := testStore(t) - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - if err := s.Reserve("r-unknown", "exec_demo_001", "tenant-demo-key", []string{"global"}); err != nil { - t.Fatal(err) - } - if err := s.FinalizeReservation("r-unknown", "exec_demo_001", true); err != nil { - t.Fatal(err) - } - var taskStatus string - if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil { - t.Fatal(err) - } - if taskStatus != "unknown" { - t.Fatalf("task status=%q, want unknown", taskStatus) - } - var reserved, unknown int64 - if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil { - t.Fatal(err) - } - if reserved != 0 || unknown != 1 { - t.Fatalf("quota reserved=%d unknown=%d, want 0/1", reserved, unknown) - } -} - -func TestControlCASAndStopBarrier(t *testing.T) { - s := testStore(t) - raw, err := testfixture.Execute() - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { - t.Fatal(err) - } - if err := s.ApplyControl("exec_demo_001", 1, "pause"); err != nil { - t.Fatal(err) - } - if err := s.ApplyControl("exec_demo_001", 99, "resume"); !errors.Is(err, ErrCASConflict) { - t.Fatalf("revision mismatch error = %v", err) - } - if err := s.ApplyControl("exec_demo_001", 1, "stop"); err != nil { - t.Fatal(err) - } - if err := s.ApplyControl("exec_demo_001", 1, "resume"); !errors.Is(err, ErrCASConflict) { - t.Fatalf("stopped resume error = %v", err) - } -} diff --git a/internal/store/task_control_mq.go b/internal/store/task_control_mq.go deleted file mode 100644 index ccf8eb1..0000000 --- a/internal/store/task_control_mq.go +++ /dev/null @@ -1,216 +0,0 @@ -package store - -import ( - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "github.com/google/uuid" -) - -type taskControlPayload struct { - TaskID string `json:"task_id"` - Action string `json:"action"` - ExpectedRevision int64 `json:"expected_task_revision"` - Policy string `json:"active_call_policy"` -} - -type controlTarget struct { - executionID, status string - revision int64 - active bool -} - -// HandleTaskControl persists the admission barrier, required remote work and -// original receipt atomically. Active calls remain accepted, never falsely -// reported applied before their Agent acknowledges the control. -func (s *Store) HandleTaskControl(raw []byte, routingKey string) (string, bool, error) { - command, err := contract.DecodeMQCommand(raw) - if err != nil { - return "", false, err - } - if command.CommandType != "task.control" { - return "", false, errors.New("expected task.control") - } - route, err := tenant.NewDispatcherRoute(command.DispatcherID, command.TenantKey) - if err != nil { - return "", false, err - } - if routingKey != route.InboundKey { - return "", false, ErrMessageScope - } - var payload taskControlPayload - if err := json.Unmarshal(command.Payload, &payload); err != nil { - return "", false, err - } - digest := sha256.Sum256(raw) - bodyHash := hex.EncodeToString(digest[:]) - now := s.now().UTC() - stamp := now.Format(time.RFC3339Nano) - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return "", false, err - } - defer tx.Rollback() - if err := bindMQScope(tx, command.DispatcherID, command.TenantID, command.TenantKey); err != nil { - return "", false, err - } - var oldHash, responseID string - err = tx.QueryRow(`SELECT i.body_hash,r.response_id FROM inbox i JOIN mq_command_receipts r ON r.tenant_id=i.tenant_id AND r.command_id=i.command_id WHERE i.tenant_id=? AND i.command_id=?`, command.TenantID, command.CommandID).Scan(&oldHash, &responseID) - if err == nil { - if oldHash != bodyHash { - return "", false, ErrIdempotencyConflict - } - if _, err := tx.Exec(`UPDATE outbox SET status='pending',published_at=NULL WHERE event_id=? AND status='published'`, responseID); err != nil { - return "", false, err - } - return responseID, true, tx.Commit() - } - if !errors.Is(err, sql.ErrNoRows) { - return "", false, err - } - if _, err := tx.Exec(`INSERT INTO inbox(command_id,tenant_id,tenant_key,command_type,body_hash,body,status,received_at,persisted_at) VALUES(?,?,?,?,?,?,'persisted',?,?)`, command.CommandID, command.TenantID, command.TenantKey, command.CommandType, bodyHash, raw, stamp, stamp); err != nil { - return "", false, err - } - status, reason := "applied", "applied" - expired, err := contract.NotAfterExpired(command.NotAfter, now) - if err != nil { - return "", false, err - } - var targets []controlTarget - if expired { - status, reason = "rejected", "expired" - } else { - rows, err := tx.Query(`SELECT execution_id,status,task_revision, - (status IN ('running','unknown') - OR EXISTS(SELECT 1 FROM reservations r WHERE r.execution_id=tasks.execution_id AND r.state='unknown') - OR (EXISTS(SELECT 1 FROM execution_agents a WHERE a.execution_id=tasks.execution_id) - AND (status='reserved' OR EXISTS(SELECT 1 FROM reservations r WHERE r.execution_id=tasks.execution_id AND r.state='held')))) - FROM tasks WHERE tenant_id=? AND tenant_key=? AND task_id=?`, command.TenantID, command.TenantKey, payload.TaskID) - if err != nil { - return "", false, err - } - for rows.Next() { - var target controlTarget - if err := rows.Scan(&target.executionID, &target.status, &target.revision, &target.active); err != nil { - rows.Close() - return "", false, err - } - targets = append(targets, target) - } - rowErr := rows.Err() - closeErr := rows.Close() - if err := errors.Join(rowErr, closeErr); err != nil { - return "", false, err - } - if len(targets) == 0 { - status, reason = "rejected", "not_found" - } - var pending int - if err := tx.QueryRow(`SELECT COUNT(*) FROM mq_task_controls WHERE tenant_id=? AND task_id=? AND state='pending'`, command.TenantID, payload.TaskID).Scan(&pending); err != nil { - return "", false, err - } - if pending != 0 { - status, reason = "rejected", "control_pending" - } - for _, target := range targets { - if target.revision != payload.ExpectedRevision { - status, reason = "rejected", "revision_conflict" - break - } - if (target.status == "stopped" && payload.Action != "stop") || (payload.Action == "resume" && target.status != "paused") { - status, reason = "rejected", "invalid_state" - break - } - } - } - revision := payload.ExpectedRevision - if status != "rejected" { - hasActive := false - for _, target := range targets { - hasActive = hasActive || target.active - } - state := "applied" - if hasActive { - state = "pending" - status, reason = "accepted", "accepted" - } else { - revision++ - } - if _, err := tx.Exec(`INSERT INTO mq_task_controls(tenant_id,command_id,tenant_key,task_id,expected_revision,action,active_call_policy,state) VALUES(?,?,?,?,?,?,?,?)`, command.TenantID, command.CommandID, command.TenantKey, payload.TaskID, payload.ExpectedRevision, payload.Action, payload.Policy, state); err != nil { - return "", false, err - } - for _, target := range targets { - next := target.status - switch payload.Action { - case "pause": - next = "paused" - case "resume": - next = "accepted" - case "stop": - next = "stopped" - default: - return "", false, errors.New("unsupported task control action") - } - if target.status == "finished" { - next = target.status - } - if _, err := tx.Exec(`INSERT INTO mq_task_control_targets(tenant_id,command_id,execution_id,original_status,applied) VALUES(?,?,?,?,?)`, command.TenantID, command.CommandID, target.executionID, target.status, !target.active); err != nil { - return "", false, err - } - if _, err := tx.Exec(`UPDATE tasks SET status=?,task_revision=?,updated_at=? WHERE execution_id=? AND tenant_id=? AND task_revision=?`, next, revision, stamp, target.executionID, command.TenantID, payload.ExpectedRevision); err != nil { - return "", false, err - } - if !target.active && target.status == "reserved" { - // No Agent assignment exists, so no remote execution was submitted. - // Release these local reservations in the same control transaction. - rows, err := tx.Query(`SELECT reservation_id FROM reservations WHERE execution_id=? AND state='held'`, target.executionID) - if err != nil { - return "", false, err - } - var reservations []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - rows.Close() - return "", false, err - } - reservations = append(reservations, id) - } - if err := errors.Join(rows.Err(), rows.Close()); err != nil { - return "", false, err - } - for _, id := range reservations { - if err := s.finalizeReservationTx(tx, id, target.executionID, false); err != nil { - return "", false, err - } - } - } - } - } - responseID = uuid.NewSHA1(uuid.NameSpaceURL, []byte("task.control:"+command.DispatcherID+":"+command.TenantID+":"+command.CommandID)).String() - result := map[string]any{"command_id": command.CommandID, "command_type": command.CommandType, "status": status, "reason_code": reason, "task_id": payload.TaskID, "requested_task_revision": payload.ExpectedRevision} - if status == "applied" { - result["applied_task_revision"] = revision - } - body, err := (contract.EventBuilder{TenantID: command.TenantID, TenantKey: command.TenantKey, TraceID: command.TraceID, EventType: "command.result", Aggregate: "command", AggregateID: command.CommandID, Version: 1, Payload: result}).MarshalMQ(command.DispatcherID, now, responseID) - if err != nil { - return "", false, fmt.Errorf("control receipt: %w", err) - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, responseID, command.TenantKey, mq.EventExchange, route.OutboundKey, body, stamp); err != nil { - return "", false, err - } - if _, err := tx.Exec(`INSERT INTO mq_command_receipts(tenant_id,command_id,response_id) VALUES(?,?,?)`, command.TenantID, command.CommandID, responseID); err != nil { - return "", false, err - } - return responseID, false, tx.Commit() -} diff --git a/internal/store/task_control_mq_test.go b/internal/store/task_control_mq_test.go deleted file mode 100644 index 29b41b8..0000000 --- a/internal/store/task_control_mq_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package store - -import ( - "encoding/json" - "testing" - "time" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -func TestMQControlAppliesQueuedTasksButKeepsActiveTargetsPending(t *testing.T) { - for _, initialState := range []string{"accepted", "reserved", "running"} { - active := initialState == "running" - t.Run(initialState, func(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - base := "upstream/" + contract.MQSourceCommit + "/examples/" - execute, err := contracts.Files.ReadFile(base + "call-execute.json") - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(execute, route.InboundKey); err != nil { - t.Fatal(err) - } - if initialState == "reserved" { - _, payload, err := contract.DecodeExecute(execute) - if err != nil { - t.Fatal(err) - } - if err := s.SetQuota("global", 1); err != nil { - t.Fatal(err) - } - if err := s.Reserve("not-yet-assigned", payload.ExecutionID, "tenant-a", []string{"global"}); err != nil { - t.Fatal(err) - } - } else if initialState == "running" { - if _, err := s.DB().Exec(`UPDATE tasks SET status='running'`); err != nil { - t.Fatal(err) - } - } - raw, err := contracts.Files.ReadFile(base + "task-control.json") - if err != nil { - t.Fatal(err) - } - receipt, duplicate, err := s.HandleTaskControl(raw, route.InboundKey) - if err != nil || duplicate { - t.Fatalf("control: %v duplicate=%v", err, duplicate) - } - again, duplicate, err := s.HandleTaskControl(raw, route.InboundKey) - if err != nil || !duplicate || again != receipt { - t.Fatal("duplicate control did not preserve original receipt") - } - var body []byte - if err := s.DB().QueryRow(`SELECT body FROM outbox WHERE event_id=?`, receipt).Scan(&body); err != nil { - t.Fatal(err) - } - if err := contract.ValidateMQMessage(body); err != nil { - t.Fatal(err) - } - var event struct { - Payload struct { - Status string `json:"status"` - } `json:"payload"` - } - if err := json.Unmarshal(body, &event); err != nil { - t.Fatal(err) - } - expected := "applied" - if active { - expected = "accepted" - } - if event.Payload.Status != expected { - t.Fatalf("status=%s want=%s", event.Payload.Status, expected) - } - var taskState string - var revision int - if err := s.DB().QueryRow(`SELECT status,task_revision FROM tasks`).Scan(&taskState, &revision); err != nil { - t.Fatal(err) - } - if taskState != "paused" { - t.Fatal("pause failed to close local admission") - } - if initialState == "reserved" { - var reservationState string - var reserved int - if err := s.DB().QueryRow(`SELECT state FROM reservations WHERE reservation_id='not-yet-assigned'`).Scan(&reservationState); err != nil { - t.Fatal(err) - } - if err := s.DB().QueryRow(`SELECT reserved_value FROM quotas WHERE scope='global'`).Scan(&reserved); err != nil { - t.Fatal(err) - } - if reservationState != "released" || reserved != 0 { - t.Fatal("never-dispatched reservation leaked after control") - } - } - if (!active && revision != 2) || (active && revision != 1) { - t.Fatal("revision pretended remote application") - } - }) - } -} diff --git a/internal/store/upload_grants.go b/internal/store/upload_grants.go deleted file mode 100644 index 1af65d3..0000000 --- a/internal/store/upload_grants.go +++ /dev/null @@ -1,103 +0,0 @@ -package store - -import ( - "bytes" - "database/sql" - "errors" - "time" -) - -func (s *Store) LoadUploadGrantRequest(uploadID, operationID, digest string) ([]byte, error) { - var previous string - var grant []byte - if err := s.db.QueryRow(`SELECT request_hash,grant FROM upload_grant_requests WHERE upload_id=? AND operation_id=?`, uploadID, operationID).Scan(&previous, &grant); err != nil { - return nil, err - } - if previous != digest { - return nil, ErrIdempotencyConflict - } - return grant, nil -} - -// IssueUploadGrant atomically binds one explicit request identity to one grant. -// Re-delivery returns the original grant, including its original expiry. -func (s *Store) IssueUploadGrant(record UploadRecord, operationID, digest string) ([]byte, error) { - if operationID == "" || digest == "" || record.UploadID == "" || record.State != "granted" || len(record.Grant) == 0 || len(record.Binding) == 0 || len(record.Asset) == 0 || record.ObjectKey == "" || record.Bucket == "" { - return nil, errors.New("complete upload and grant request identity are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return nil, err - } - defer tx.Rollback() - // Grant replay and first issuance must both stop once the recording has - // an immutable outcome. Keep this check inside the same SQLite transaction - // as the grant write so a concurrent outcome cannot authorize a late PUT. - var recorded sql.NullString - err = tx.QueryRow(`SELECT recording_status FROM local_v01_call_terminals WHERE upload_id=?`, record.UploadID).Scan(&recorded) - if err == nil && recorded.Valid { - return nil, ErrUploadMismatch - } - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - var previous string - var grant []byte - err = tx.QueryRow(`SELECT request_hash,grant FROM upload_grant_requests WHERE upload_id=? AND operation_id=?`, record.UploadID, operationID).Scan(&previous, &grant) - if err == nil { - if previous != digest { - return nil, ErrIdempotencyConflict - } - return grant, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - var binding, asset []byte - var objectKey, state, bucket string - err = tx.QueryRow(`SELECT binding,asset,object_key,state FROM uploads WHERE upload_id=?`, record.UploadID).Scan(&binding, &asset, &objectKey, &state) - if errors.Is(err, sql.ErrNoRows) { - if record.CreatedAt.IsZero() { - record.CreatedAt = s.now() - } - if _, err := tx.Exec(`INSERT INTO uploads(upload_id,binding,asset,grant,object_key,state,created_at) VALUES(?,?,?,?,?,'granted',?)`, record.UploadID, record.Binding, record.Asset, record.Grant, record.ObjectKey, record.CreatedAt.UTC().Format(time.RFC3339Nano)); err != nil { - return nil, err - } - if _, err := tx.Exec(`INSERT INTO upload_destinations(upload_id,bucket) VALUES(?,?)`, record.UploadID, record.Bucket); err != nil { - return nil, err - } - } else { - if err != nil { - return nil, err - } - if err := tx.QueryRow(`SELECT bucket FROM upload_destinations WHERE upload_id=?`, record.UploadID).Scan(&bucket); err != nil { - return nil, err - } - if bucket != record.Bucket || state != "granted" || objectKey != record.ObjectKey || !bytes.Equal(binding, record.Binding) || !bytes.Equal(asset, record.Asset) { - return nil, ErrUploadMismatch - } - result, err := tx.Exec(`UPDATE uploads SET grant=? WHERE upload_id=? - AND NOT EXISTS(SELECT 1 FROM upload_notifications WHERE upload_id=?) - AND NOT EXISTS(SELECT 1 FROM local_v01_call_terminals f WHERE f.upload_id=? AND f.recording_status IS NOT NULL)`, - record.Grant, record.UploadID, record.UploadID, record.UploadID) - if err != nil { - return nil, err - } - changed, err := result.RowsAffected() - if err != nil { - return nil, err - } - if changed != 1 { - return nil, ErrUploadMismatch - } - } - if _, err := tx.Exec(`INSERT INTO upload_grant_requests(upload_id,operation_id,request_hash,grant) VALUES(?,?,?,?)`, record.UploadID, operationID, digest, record.Grant); err != nil { - return nil, err - } - if err := tx.Commit(); err != nil { - return nil, err - } - return append([]byte(nil), record.Grant...), nil -} diff --git a/internal/store/upload_notification.go b/internal/store/upload_notification.go deleted file mode 100644 index a13e064..0000000 --- a/internal/store/upload_notification.go +++ /dev/null @@ -1,103 +0,0 @@ -package store - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "reflect" - "time" - - "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/mq" - "git.ipao.vip/rogee/go-sip/internal/tenant" -) - -var ErrUploadMismatch = errors.New("upload notification does not match its persisted identity or object") - -type uploadNotice struct { - EventID string `json:"event_id"` - EventType string `json:"event_type"` - DispatcherID string `json:"dispatcher_id"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - AggregateID string `json:"aggregate_id"` - Payload map[string]any `json:"payload"` -} - -// RecordUploadNotification commits the reported upload fact and its original -// notification together. It does not mark delivery complete before publication. -func (s *Store) RecordUploadNotification(uploadID, eventID, tenantKey, routingKey string, body []byte, uploadedAt time.Time) error { - if uploadID == "" || eventID == "" || tenantKey == "" || routingKey == "" { - return errors.New("upload notification identity and route are required") - } - if err := contract.ValidateMQMessage(body); err != nil { - return err - } - var notice uploadNotice - if err := json.Unmarshal(body, ¬ice); err != nil { - return err - } - if notice.EventID != eventID || notice.EventType != "recording.uploaded" || notice.TenantKey != tenantKey || notice.Payload["upload_id"] != uploadID { - return ErrUploadMismatch - } - route, err := tenant.NewDispatcherRoute(notice.DispatcherID, tenantKey) - if err != nil { - return err - } - if route.OutboundKey != routingKey { - return ErrMessageScope - } - if uploadedAt.IsZero() { - return errors.New("upload fact time is required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - if err := bindMQScope(tx, notice.DispatcherID, notice.TenantID, tenantKey); err != nil { - return err - } - var existing string - err = tx.QueryRow(`SELECT event_id FROM upload_notifications WHERE upload_id=?`, uploadID).Scan(&existing) - if err == nil { - if existing != eventID { - return ErrIdempotencyConflict - } - var previousBody []byte - if err := tx.QueryRow(`SELECT body FROM outbox WHERE event_id=?`, existing).Scan(&previousBody); err != nil { - return err - } - var previous uploadNotice - if err := json.Unmarshal(previousBody, &previous); err != nil { - return err - } - if !reflect.DeepEqual(previous, notice) { - return ErrIdempotencyConflict - } - return nil - } - if !errors.Is(err, sql.ErrNoRows) { - return err - } - var state, objectKey, bucket string - if err := tx.QueryRow(`SELECT state,object_key,d.bucket FROM uploads u JOIN upload_destinations d ON d.upload_id=u.upload_id WHERE u.upload_id=?`, uploadID).Scan(&state, &objectKey, &bucket); err != nil { - return err - } - if notice.Payload["object_key"] != objectKey || notice.Payload["bucket"] != bucket { - return ErrUploadMismatch - } - if state != "granted" { - return fmt.Errorf("upload cannot report new facts in state %q", state) - } - if _, err := tx.Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,?,?,?,'pending',?)`, eventID, tenantKey, mq.EventExchange, routingKey, body, uploadedAt.UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - if _, err := tx.Exec(`INSERT INTO upload_notifications(upload_id,event_id,uploaded_at) VALUES(?,?,?)`, uploadID, eventID, uploadedAt.UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - return tx.Commit() -} diff --git a/internal/store/upload_notification_test.go b/internal/store/upload_notification_test.go deleted file mode 100644 index 21e2882..0000000 --- a/internal/store/upload_notification_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package store - -import ( - "encoding/json" - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/tenant" - "testing" - "time" -) - -func TestUploadCompletionRequiresPublishedOriginalNotice(t *testing.T) { - s, err := Open(":memory:") - if err != nil { - t.Fatal(err) - } - defer s.Close() - now := time.Now().UTC() - if err := s.BindDispatcherID(identityA); err != nil { - t.Fatal(err) - } - if _, err := s.IssueUploadGrant(UploadRecord{UploadID: "upload-a", Binding: []byte("binding"), Asset: []byte("asset"), Grant: []byte("grant"), ObjectKey: "recordings/recording-a.wav", Bucket: "example-bucket", State: "granted", CreatedAt: now}, "request-a", "request-hash-a"); err != nil { - t.Fatal(err) - } - raw, err := contracts.Files.ReadFile("upstream/v1/examples/event-recording-uploaded.json") - if err != nil { - t.Fatal(err) - } - var event map[string]any - if err := json.Unmarshal(raw, &event); err != nil { - t.Fatal(err) - } - event["event_id"] = "notice-a" - raw, err = json.Marshal(event) - if err != nil { - t.Fatal(err) - } - route, err := tenant.NewDispatcherRoute(identityA, "tenant-a") - if err != nil { - t.Fatal(err) - } - if err := s.RecordUploadNotification("upload-a", "different-event", "tenant-a", route.OutboundKey, raw, now); err == nil { - t.Fatal("mismatched event identity accepted") - } - for n := 0; n < 2; n++ { - if err := s.RecordUploadNotification("upload-a", "notice-a", "tenant-a", route.OutboundKey, raw, now); err != nil { - t.Fatal(err) - } - } - record, err := s.LoadUpload("upload-a") - if err != nil { - t.Fatal(err) - } - if record.State != "uploaded" || record.CompletedAt != nil { - t.Fatalf("outbox incorrectly counted as complete: %s", record.State) - } - replacement := record - replacement.State = "granted" - replacement.Grant = []byte("another grant") - if _, err := s.IssueUploadGrant(replacement, "explicit-after-upload", "new-request-hash"); err == nil { - t.Fatal("already-uploaded file was reauthorized") - } - var id int64 - var count int - if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatal("duplicate completion created another event") - } - if err := s.DB().QueryRow(`SELECT id FROM outbox WHERE event_id='notice-a'`).Scan(&id); err != nil { - t.Fatal(err) - } - if err := s.MarkOutboxPublished(id); err != nil { - t.Fatal(err) - } - record, err = s.LoadUpload("upload-a") - if err != nil { - t.Fatal(err) - } - if record.State != "completed" || record.CompletedAt == nil { - t.Fatal("confirmed notification did not complete upload delivery") - } -} diff --git a/internal/store/uploads.go b/internal/store/uploads.go deleted file mode 100644 index 00a7e1e..0000000 --- a/internal/store/uploads.go +++ /dev/null @@ -1,52 +0,0 @@ -package store - -import ( - "database/sql" - "errors" - "fmt" - "time" -) - -type UploadRecord struct { - UploadID string - Binding []byte - Asset []byte - Grant []byte - ObjectKey string - Bucket string - State string - CreatedAt time.Time - CompletedAt *time.Time -} - -func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) { - if uploadID == "" { - return UploadRecord{}, errors.New("upload ID is required") - } - s.mu.Lock() - defer s.mu.Unlock() - var record UploadRecord - var createdAt string - var completedAt sql.NullString - err := s.db.QueryRow(`SELECT uploads.upload_id,binding,asset,grant,object_key,d.bucket, - CASE WHEN state='granted' AND ( - EXISTS(SELECT 1 FROM upload_notifications n WHERE n.upload_id=uploads.upload_id) - OR EXISTS(SELECT 1 FROM local_v01_call_terminals f WHERE f.upload_id=uploads.upload_id AND f.recording_status IS NOT NULL) - ) THEN 'uploaded' ELSE state END, - created_at,completed_at FROM uploads JOIN upload_destinations d ON d.upload_id=uploads.upload_id WHERE uploads.upload_id=?`, uploadID).Scan(&record.UploadID, &record.Binding, &record.Asset, &record.Grant, &record.ObjectKey, &record.Bucket, &record.State, &createdAt, &completedAt) - if err != nil { - return UploadRecord{}, fmt.Errorf("load upload: %w", err) - } - record.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) - if err != nil { - return UploadRecord{}, fmt.Errorf("parse upload created_at: %w", err) - } - if completedAt.Valid { - parsed, err := time.Parse(time.RFC3339Nano, completedAt.String) - if err != nil { - return UploadRecord{}, fmt.Errorf("parse upload completed_at: %w", err) - } - record.CompletedAt = &parsed - } - return record, nil -}