diff --git a/AGENTS.md b/AGENTS.md index 252be4c..b74dc3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ ## 当前范围 - 后续Agent必须先读 `docs/plan-0918.md`:映射W/子任务,按索引读取需求/契约正文,确认I/M/G前置、授权及写入边界后实施;并行时按§9认领,子Agent只写独占模块/证据,§8总台账、公共文件及合并状态由集成负责人单写。计划不替代权威Schema或验收,不重复审批已确认方向,不自动授权真实云/付费/拨号。 +- 当前MQ-only/OSS调整目标已获用户明确批准由当前Agent独立执行,**不启动子Agent**,不得再以子Agent模型/fast环境阻塞本目标;此前按任务类型自动分工规则在本目标不适用。若未来另获启动授权,仍遵守下条模型要求。 - 用户指定:若启动开发及配套审查子Agent,固定 **gpt-5.6-luna、max思考、fast模式**。启动前查询精确provider/model和runner支持并显式配置(当前工具用模型`:max`后缀及`fast: true`,不继承默认);不可用/不支持/无法核验则报告阻塞,不静默换模型、降思考档、关fast或换CLI。此为后续执行约束,本轮仅修复计划,未启动开发子Agent。 - 并行开发须先有获授权的可追溯Git/契约基线、一lane一工作区/测试资源、无交叠写集合及每批合并后回归;当前子项目尚未跟踪的文件不能假定存在于HEAD/worktree。不得自动提交、暂存或清理父项目无关改动;详情见计划§9。 - 当前已获授权进行本项目开发:W01 项目内契约基线和 W02 Proto/stubs 已建立;仍不能把设计、Mock、Proto或88项测试清单写成真实供应商/生产验收已通过。入口和权威依据仍为 `docs/Go重写方案_v0.3.md`、`docs/验证与切换验收_v0.3.md`、`docs/通信与事件数据交互_v0.1.md`、`docs/OpenAPI与MQ字段索引_v0.1.md`、`docs/开源组件选型与复用清单_v0.2.md`。 @@ -126,7 +127,7 @@ - ASR-only和ASR+LLM+TTS均按批准的不可变AI配置在本地/隔离链路验收;不擅自加MQ模式字段,不复用旧LLM/TTS。真实供应商未联调时必须明确标记为第二阶段,不能把 Mock 写成真实供应商通过。 - P1使用管理平台批准的静态单 Cell 快照和受控维护窗口,不做在线发布/回滚编排;静态配置必须关准入、排空、核验实际加载,旧直写通道不得并行。 - P1保留 tenant_key 原值、租户独立队列、复合幂等键、有界窗口及单租户配额/控制边界;不开发或验收双租户公平、第二 Cell 汇总配额和多实例协调。 -- `upload-session/complete/verified`、RabbitMQ ACL/TLS 和 application receipt 本阶段按版本化契约、Schema、正反例 fixture、状态机和本地隔离测试验收;真实 SaaS/MQ 联调延期第二阶段。 +- 本阶段上传按固定15分钟授权、单次PUT、原事实持久化及recording.uploaded可靠入队进行本地隔离验收;不申请SaaS上传会话、不等待complete/verified或OSS ID。RabbitMQ ACL/TLS和application receipt仅按适用版本化契约/隔离测试验证,不将通知入队称为SaaS已处理;真实SaaS/MQ联调延期第二阶段。 - 88项验收为跨阶段基线,当前只签收单节点/单 Cell/单租户适用子场景;双节点、第二 Cell、第二租户、真实 ECS/生产联调、容量/N+1及切换均不作为本轮门禁。 ## 语言与工程 @@ -160,11 +161,12 @@ - 本项目设计/运行/验收文档只在自身 `docs/` 维护。上游共享接口有唯一权威来源;导入带版本、来源和哈希的不可变契约包,再生成类型/校验,不维护重复手写 Schema。 - 新内部消息/许可/fencing 协议需先获批;不擅自改变 SaaS 路径、字段、状态、路由或控制语义。 -- **SaaS↔Dispatcher的全部交互唯一经RabbitMQ专用Topic订阅完成,禁止双方任何HTTP请求/回调/兼容通道或故障回退**,包括执行、控制、查询、整体补传、AI配置/授权、录音业务会话/complete/verified及响应。OSS配置/TOKEN不来自SaaS:Agent领取及显式重申请TOKEN只经D↔A Unary;本规则不禁止Agent→OSS、ARI、AI供应商HTTP(S)或gRPC的HTTP/2。 +- **SaaS↔Dispatcher的全部交互唯一经RabbitMQ专用Topic订阅完成,禁止双方任何HTTP请求/回调/兼容通道或故障回退**,包括执行、控制、查询、整体补传、AI配置/授权及recording.uploaded上传事实通知;上传不申请SaaS会话或等待verified/OSS ID回复。OSS配置/TOKEN不来自SaaS:Agent领取及显式重申请TOKEN只经D↔A Unary;本规则不禁止Agent→OSS、ARI、AI供应商HTTP(S)或gRPC的HTTP/2。 - **每个Dispatcher必须有独立、全局唯一且不重复的ID及独立接收Topic/队列**;指定D的任务/配置/上传结果不能由其它D抢收,也不能广播后仅靠正文过滤。身份与tenant/Agent/Cell ID、dispatcher_epoch分开;保留租户独立队列及原值tenant_key,完整新路由长度预算须重验。具体ID生成/持久化、Topic/绑定、消息字段/关联/错误/期限须随W01新版本冻结,不凭本文给旧严格Schema添加字段。 - **OSS相关配置存于Dispatcher配置文件,Agent向Dispatcher领取临时上传TOKEN后直传OSS,不保存长期凭据;SaaS不再下发OSS配置/TOKEN。** D复用官方SDK提供受限TOKEN/目标信息,配置缺失/无效明确失败;不在样例、源码、日志或证据中保存实际密钥/完整TOKEN。过期只允许A显式向D重新申请,不自动续期或向SaaS申请TOKEN;精确配置格式/TOKEN形态/UploadGrant映射另行核验,不猜字段。 -- D不接收/转发文件;SaaS业务会话/upload_id及complete/verified仍经MQ,**上传完成后的SaaS独立校验职责不变**。A经R13报元信息,D仅在持久校验SaaS verified后发recording.ready/OSS ID。保留D现有签发能力,不误删为旧路径;但D本地HEAD不能替代SaaS verified。业务会话/complete的异步MQ与R12/R13衔接仍须核验,不无限阻塞或伪造完成。 -- 本次MQ-only纠正只修改计划、契约及相关设计文档;旧不可变包、生成索引和历史证据原样保留,不代表新合同已发布。受影响W01/W02/W04/W05/W07/W08/W11/W12/W13/W14按plan§8.2重新验证。全局唯一D身份/专用Topic及本地D1/D2隔离fixture为当前合同要求,不授权双D业务运行、HA或共享额度。 +- **用户已修订目标:上传仅负责Agent直传及D可靠通知MQ,SaaS后续处理不属本项目职责。** D保留签发能力、不转发文件;R13持久保存事实与recording.uploaded outbox,消息为persistent、进入指定durable队列/绑定、mandatory无return且publisher confirm成功后才记交付完成。只写本地outbox不算入队;不申请SaaS会话、不等待verified/OSS ID、不新增VERIFYING、不伪造SaaS结果。 +- 新版recording.uploaded取代本项目recording.ready,字段为call_id/recording_id/upload_id/bucket/object_key/format/channels/sample_rate_hz/duration_ms/size_bytes/checksum_sha256;不含TOKEN/密钥/签名URL。MQ失败/确认丢失/重启只恢复原消息身份的交付,不重新PUT或新建资产。AI/控制等必要请求响应不受此收缩影响。 +- 前一轮MQ-only文档纠正已结束;当前目标已获批准修改本项目契约/代码/配置/测试。精确方案见已确认的 `docs/contracts/mq-only-v2-freeze-proposal.md`:纯Topic精确绑定、拒绝独立通配词段、tenant_key预算196 UTF-8字节、稳定UUID v4、严格JSON配置及15分钟SDK预签名PUT;不得重开已批准方向。旧不可变包/历史证据原样保留,新版须完成Schema/正反例/哈希验证后发布,不把方案确认当实现完成。受影响W01/W02/W04/W05/W07/W08/W11/W12/W13/W14按plan§8.2重新验证。全局唯一D身份/专用Topic及本地D1/D2隔离fixture为当前合同要求,不授权双D业务运行、HA或共享额度。 - 文本实时事件准确名为transcript.updated,不新增call.transcript别名;OSS文本归档不能替代实时文字/opt-out,缺少专用资产授权接口时明确未启用,不能伪装recording.ready。 - 现有MQ信封command_type/command_id、event_type/aggregate_*与正文已对齐;事件payload专属约束尚需补齐,不把通用object校验当完整验收。字段索引只读生成,不手改成第二套Schema。 - `tenant_key` 原值一对一绑定,不清洗、编码或截断;旧布局224个UTF-8字节预算不能在加入D身份后直接照搬,W01须冻结并验证完整routing key/queue预算及分隔符/通配符边界;超限停止发布并保留源任务。 diff --git a/Makefile b/Makefile index b2a5825..56b3773 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: fmt test test-race vet build security release mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check +.PHONY: fmt test test-race coverage vet build security release mq-integration-local mq-only-acceptance-local proto-lint proto-generate proto-check contract-check acceptance-local check GO ?= go BINARY ?= dist/sip-go-agent @@ -12,6 +12,9 @@ test: test-race: $(GO) test -race ./... +coverage: + ./scripts/coverage.sh + vet: $(GO) vet ./... @@ -29,6 +32,9 @@ release: mq-integration-local: ./scripts/mq-integration-local.sh +mq-only-acceptance-local: + ./scripts/mq-only-acceptance-local.sh + proto-lint: buf lint diff --git a/README.md b/README.md index c8fa21b..cbf2a93 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向生产的Go SIP调度与执行项目:同一module/二进制通过Cobra提供 `dispatcher`、`agent` 两个业务子命令,分阶段替换Python,保留既有业务语义,SaaS交互统一为MQ-only。 -> **当前状态:旧W01–W14单节点本地验收证据保留;本轮SaaS↔Dispatcher MQ-only修订的文档已纠正,受影响契约、实现与验收重新列为待验证,见计划§8.2。** 真实SaaS/MQ、供应商/ECS、生产切换及第二Cell/第二租户仍属第二阶段。 +> **当前状态:本轮单节点/单Dispatcher/单Agent/单Cell/单租户的 MQ-only 本地范围已完成。** 控制、AI配置/授权、查询/补传、上传通知及恢复均有 loopback RabbitMQ 证据;业务源码覆盖率为65.8%。真实SaaS/MQ、供应商/ECS、生产切换及第二Cell/第二租户仍属第二阶段;当前开发主机缺少 Asterisk/tcpdump,部署 preflight 已按要求 fail-closed,未冒充 mixed/real 通过。 > 本项目已独立拆仓运营;源码、配置、依赖、迁移、测试、部署和文档均在此目录内维护。远程仓库为 `git.ipao.vip/rogee/go-sip`,本地 Git 默认分支为 `main`;真实外呼仍受逐次授权、capture-first、白名单和时间门禁约束。 ## 已确认的范围 @@ -17,22 +17,22 @@ - 重写完整 Agent:调度控制面、Cell 外呼执行、ARI/RTP/录音、AI 流式适配、Cell 配置接收。 - 分阶段迁移,最终构建、测试和运行不依赖 Python、父仓库目录或其它业务项目内部代码。 - Asterisk 继续负责 SIP;独立 SIP 管理平台继续拥有配置管理权,均不纳入重写。 -- **SaaS↔Dispatcher全部请求、响应和事件只走RabbitMQ,双方禁止任何HTTP。每个D都有全局唯一ID及独立接收Topic/队列,不能共享队列抢收或广播后过滤。** 控制/查询/补传、AI配置/授权、上传会话/complete/verified均在内;精确新消息/拓扑待冻结,P1不扩为多D协调/HA。 -- **OSS配置存于Dispatcher配置文件,Agent向Dispatcher领取临时上传TOKEN后直传OSS,不保存长期凭据。** SaaS不下发OSS配置/TOKEN;D保留SDK签发能力,不转发文件。SaaS业务会话及最终verified仍经MQ,D本地对象验证不能替代SaaS校验。实时文字仍为 `transcript.updated`,归档不能替代实时事件。 +- **SaaS↔Dispatcher全部请求、响应和事件只走RabbitMQ,双方禁止任何HTTP。每个D都有全局唯一ID及独立接收Topic/队列,不能共享队列抢收或广播后过滤。** 执行/控制/查询/补传、AI配置/授权及 `recording.uploaded` 均在内;本地契约已冻结,P1不扩为多D协调/HA。 +- **OSS配置存于Dispatcher配置文件,Agent向Dispatcher领取临时上传TOKEN后直传OSS,不保存长期凭据。** SaaS不下发OSS配置/TOKEN;D保留SDK签发能力,不转发文件。上传不申请SaaS会话、不等待verified/OSS ID或业务处理回复;D仅在原上传事实可靠进入指定持久MQ队列后记录交付完成。实时文字仍为 `transcript.updated`,归档不能替代实时事件。 - 本次必须支持 **ASR-only** 与 **ASR + LLM + TTS** 两种模式;本阶段已完成本地/协议隔离双模式验收。百炼/火山ASR、OpenAI兼容LLM、火山TTS的真实供应商能力和生产参数联调仍标第二阶段/未启用,不能将 Mock 写成真实供应商通过;禁止复用旧LLM/TTS。 - **AI业务配置/授权由D按任务agent_version_id经MQ向SaaS取得,经本D专用Topic收响应、校验并持久绑定后交付Agent;旧AI GET已废弃。** 模型、提示词、音色/语速、识别、超时/打断等参数不写死;新版本用于新任务,无需重启,在途通话固定快照。静态SIP发布不代表AI配置静态硬编码。 - 本轮验收范围收敛为单节点、单 Agent、单 Cell、单租户;保留 tenant_key 原值、独立队列、复合幂等和有界窗口。双节点、第二 Cell、双租户公平/背压/恢复不在本轮开发或验收范围,作为后续阶段。 -- `upload-session/complete/verified`的全MQ版本及R12/R13异步衔接须重新冻结/实现/本地验证;旧契约/fixture不覆盖本次修订。真实SaaS/MQ及既有ACL/TLS/application receipt联调仍第二阶段,不新增生产授权。 +- 上传链路仅包含R12临时授权、Agent直传、R13报告及 `recording.uploaded` 可靠入队;SaaS后续资产处理不属于本项目。真实SaaS/MQ联调仍第二阶段,不新增生产授权。 ## 当前本地实现 -以下保留修订前实现事实,本轮未修改代码;旧租户拓扑、HTTP控制、启动AI注入和D本地验证直接发ready不符合新接入目标;D签发TOKEN的职责保留,配置文件接线和授权约束仍需核验,须按计划§8.2纠正,不能当作MQ-only已完成。 +MQ身份隔离、严格配置文件、执行接收/回执、查询/补传、任务控制、AI配置/授权及上传通知已有 loopback RabbitMQ 本地往返;旧HTTP业务入口已删除。最终质量检查通过,部署 preflight 在缺失 Asterisk/tcpdump 时明确失败并保留日志;这不等同真实SaaS、供应商或生产验收。阶段证据见 `docs/evidence/20260922-mq-only-local-final.md`。 - `contracts/upstream/` 嵌入项目内自包含 W01 基线,记录父源 commit、dirty 继承和 SHA-256;运行时代码读取该包,不读取父目录。 - `internal/store/` 提供 SQLite inbox、任务、租户/跨 Cell 配额、控制 CAS、replay 和 outbox 事务。 -- `internal/agent/` 只使用文件保存执行状态、transcript、录音/资产和崩溃恢复信息;boot 不释放未知占用;录音通过 Dispatcher 授权的短期 Alibaba OSS presigned PUT 直传,源文件在 verified handoff 前保留。 +- `internal/agent/` 只使用文件保存执行状态、transcript、录音/资产和崩溃恢复信息;boot 不释放未知占用;录音通过 Dispatcher 授权的短期 Alibaba OSS presigned PUT 直传,上传尝试和通知恢复分别持久记录,失败/过期保留源文件,不自动续期或重新PUT。 - `internal/ai/` 对固定 AI Schema 做不可变快照校验,支持项目内显式 `full_ai`/`asr_only` 分支;`mock_pipeline` 只用于隔离协议/取消/参数测试,不宣称真实供应商已启用。 -- `internal/control/` 仍有旧查询/控制/replay HTTP实现;已列为删除项,不再作为SaaS接入合同或并行兼容通道。 +- 旧 `internal/control/` HTTP业务实现、启动入口、CLI参数及环境配置已删除,不保留兼容通道。 - `internal/rpc/` 提供 `agent.v1` Unary handlers、mTLS TLS1.3 配置、会话世代/fencing、CAS、permit/fact/upload metadata 边界;Agent CLI 可选启动受证书保护的 gRPC listener,并校验静态 Cell 制品与 AI 授权边界。`internal/calllog/` 提供按手机号 HMAC 关联、掩码和 allow-list JSONL 外呼业务日志,不写原始号码、凭据、音频、转写或 prompt。 - `internal/health/` 使用 gopsutil 采样主机/进程资源;媒体端口和 AI 配额未接入时明确报告 unknown。`internal/mq/` 默认 bounded prefetch=1、per-tenant DLQ 和 publisher confirm;`make mq-integration-local` 只启动 disposable RabbitMQ。 - `make acceptance-local` 会校验固定契约、跑 race/vet/build、执行 mock Agent/Dispatcher smoke,并确认 real mode 无凭据时拒绝;Alibaba OSS 实际授权测试由 `AGENT_CALL_OSS_INTEGRATION=1` 门控,使用受控环境变量,不把凭据写入仓库;`make release` 生成带 dirty-source/哈希的 local-development manifest;证据见 [`docs/evidence/20260918-local-development.json`](docs/evidence/20260918-local-development.json)。 @@ -40,6 +40,18 @@ 这些证据只签收旧基线的单节点/单Cell/单租户P1适用范围,不覆盖本次MQ-only修订,也不等于真实供应商、生产SaaS/MQ receipt、生产切换或第二阶段拓扑通过。范围和延期项见 [`docs/evidence/20260920-scope-amendment.md`](docs/evidence/20260920-scope-amendment.md)。 +## 显式重新申请上传授权 + +仅对已有失败或结果未知的上传使用: + +```sh +sip-go-agent agent upload-retry --spool /path/to/agent-spool \ + --upload-id '<原上传ID>' --request-id '<新的小写UUID v4>' \ + --file '/path/to/retained-recording.wav' +``` + +该命令使用既有Dispatcher端点/mTLS部署配置,不发起呼叫。请求ID必须由调用方显式提供;每个ID最多一次PUT,已成功上传的文件只恢复通知,不能再次PUT。重复旧请求不会获得续期TOKEN。`completed` 只表示指定持久MQ队列已接收原通知,不表示SaaS已处理。使用前仍需满足对应环境的统一部署与诊断要求。 + ## 文档 后续Agent先读 [项目开发计划与需求阅读索引](docs/plan-0918.md),按W/子任务确认I/M/G前置并阅读详细设计/权威契约。并行开发按§9登记单写范围、隔离工作区/测试资源和合并回归,由集成负责人统一维护总台账;开发子Agent固定使用 **gpt-5.6-luna+max+fast**,不可用时报告阻塞,不静默降级。本轮未启动开发子Agent。 @@ -64,14 +76,14 @@ - 当前设计文档以本目录 `docs/` 为唯一维护位置,不再向父项目另存一份。 - 对接协议的现有权威来源仍属于上游;后续在本项目导入带来源、版本和 SHA-256 的不可变契约发布包,不能另写一套同名 Schema。 - 后续发行包、CI、数据库和运行配置独立。不能靠 `../agent_call`、`../management`、`../sip_mock_server` 或父项目环境文件运行。 -- 业务代码范围仅包括 SIP Agent/Dispatcher 与 Asterisk;RabbitMQ、OSS、AI 供应商及 SaaS API 是 SaaS 提供的基础设施,不在本项目生产包中部署。独立集成测试使用自有隔离数据库、RabbitMQ 和契约 fixture;OSS数据面复用官方SDK/标准HTTP,D按自身配置文件提供临时TOKEN,A直传且不持有长期AK/SK;SaaS业务会话/最终verified仍经MQ,不作为OSS配置或TOKEN来源;外部 SIP Mock 只能以固定镜像及版本化协议接入,不导入其源码。 +- 业务代码范围仅包括 SIP Agent/Dispatcher 与 Asterisk;RabbitMQ、OSS、AI 供应商及 SaaS API 是 SaaS 提供的基础设施,不在本项目生产包中部署。独立集成测试使用自有隔离数据库、RabbitMQ 和契约 fixture;OSS数据面复用官方SDK/标准HTTP,D按自身配置文件提供临时TOKEN,A直传且不持有长期AK/SK;不申请SaaS上传会话,不等待SaaS校验或业务处理;外部 SIP Mock 只能以固定镜像及版本化协议接入,不导入其源码。 - 真实外呼、云创建、供应商调用和消费授权均是独立门禁,不能由测试成功或本文档自动授权。 ## 阶段与下一步 -1. **P0:先完成MQ-only新合同(GAP-10)。** 冻结D唯一ID/生命周期、独立Topic/队列/绑定、全部请求响应/关联/错误/期限及Unary异步衔接;旧包原样保留。其余已有基线按受影响范围重新验证,包括8种事件payload、双AI模式(GAP-08)、SaaS任务AI配置读取/调参(GAP-09)、首发Unary职责、单 Cell 静态快照来源/加载回执、D配置文件/临时上传TOKEN及SaaS最终verified和至少3家SIP trunk的配置/协议 fixture。只核验实际采用的SDK;火山TTS参数覆盖、精确版本/许可证未核验前不宣布锁库,不等待未来动态发布/文本OSS归档合同。 +1. **P0:先完成MQ-only新合同(GAP-10)。** 冻结D唯一ID/生命周期、独立Topic/队列/绑定、全部请求响应/关联/错误/期限及Unary异步衔接;旧包原样保留。其余已有基线按受影响范围重新验证,包括8种事件payload、双AI模式(GAP-08)、SaaS任务AI配置读取/调参(GAP-09)、首发Unary职责、单 Cell 静态快照来源/加载回执、D配置文件/临时上传TOKEN及上传通知可靠入队和至少3家SIP trunk的配置/协议 fixture。只核验实际采用的SDK;火山TTS参数覆盖、精确版本/许可证未核验前不宣布锁库,不等待未来动态发布/文本OSS归档合同。 2. **P1:本次单节点内测上线。** 完成单 Cell、单租户、双模式、幂等/控制/配额/恢复/录音安全的本地/隔离闭环;真实 ECS、真实外呼、生产 SaaS/MQ 联调不作为本阶段前置。 -3. **第二阶段:** 真实 SaaS/MQ upload-session/complete/verified、RabbitMQ ACL/TLS/application receipt、真实供应商/ECS 联调,以及双节点、第二 Cell、第二租户公平调度。 +3. **第二阶段:** 真实 SaaS/MQ 对接、RabbitMQ ACL/TLS及适用的业务回执、真实供应商/ECS 联调,以及双节点、第二 Cell、第二租户公平调度。 4. **后续另立项:** 在线动态发布、自动跨供应商FALLBACK、多Dispatcher HA/分布式配额、权重借用、文本OSS归档、1000路完整AI/N+1。既有call/command整体补传不是通用回放平台,当前单节点首发仍保留。 -阶段目标详见主方案§1/§10,首发验收见验收方案§1.1–§1.2;文件名保持不变。旧单节点/单Cell/单租户本地P1曾签收,本次MQ-only受影响门禁以计划§8.2待验证为准;真实依赖、供应商、云/拨号、生产receipt、容量和切换仍属第二阶段,旧证据见 `docs/evidence/20260920-local-p1-acceptance.md`。 +阶段目标详见主方案§1/§10,首发验收见验收方案§1.1–§1.2;文件名保持不变。本次MQ-only本地门禁以计划§8.2和`docs/evidence/20260922-mq-only-local-final.md`为准;真实依赖、供应商、云/拨号、生产receipt、容量和切换仍属第二阶段,旧证据见 `docs/evidence/20260920-local-p1-acceptance.md`。 diff --git a/cmd/sip-go-agent/control_worker.go b/cmd/sip-go-agent/control_worker.go new file mode 100644 index 0000000..c2913a8 --- /dev/null +++ b/cmd/sip-go-agent/control_worker.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "log/slog" + "time" + + "git.ipao.vip/rogee/go-sip/internal/dispatcher" +) + +func runDispatcherControls(ctx context.Context, d *dispatcher.Dispatcher, controller dispatcher.TaskController, batch int) { + ticker := time.NewTicker(dispatcherOutboxFlushInterval) + defer ticker.Stop() + for { + if ctx.Err() != nil { + return + } + if _, err := d.ProcessTaskControls(ctx, controller, batch); err != nil && ctx.Err() == nil { + slog.Error("process Dispatcher task controls", "error", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} diff --git a/cmd/sip-go-agent/dispatcher_config_test.go b/cmd/sip-go-agent/dispatcher_config_test.go new file mode 100644 index 0000000..b9a25d4 --- /dev/null +++ b/cmd/sip-go-agent/dispatcher_config_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestDispatcherHasNoBusinessHTTPFlags(t *testing.T) { + command := newDispatcherCommand() + for _, name := range []string{"control-listen", "control-token"} { + if command.Flags().Lookup(name) != nil { + t.Fatalf("obsolete HTTP flag remains: --%s", name) + } + } +} + +func TestDispatcherRequiresFileBeforeOpeningDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "untouched.db") + root := newRootCommand() + root.SetArgs([]string{"dispatcher", "--mode", "mock", "--db", path}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--config") { + t.Fatalf("missing file did not fail early: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("database was touched before configuration validation: %v", err) + } +} + +func TestDispatcherStoreRejectsChangedIdentityBeforeRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "identity.db") + cfg := config.Config{DBPath: path, DispatcherID: "c046b893-8628-4589-ae50-619d049248a6"} + st, err := openDispatcherStore(cfg) + if err != nil { + t.Fatal(err) + } + // A claimed notice must not be reset by a process presenting another ID. + _, err = st.DB().Exec(`INSERT INTO outbox (event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES ('notice','key','exchange','route','{}','dispatching','2026-09-21T00:00:00Z')`) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + cfg.DispatcherID = "a50b1569-aa17-4503-bfc1-cf55c10a1c24" + if other, err := openDispatcherStore(cfg); !errors.Is(err, store.ErrDispatcherIdentityMismatch) { + if other != nil { + other.Close() + } + t.Fatalf("wrong identity accepted: %v", err) + } + check, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer check.Close() + var status string + if err := check.DB().QueryRow(`SELECT status FROM outbox WHERE event_id='notice'`).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "dispatching" { + t.Fatalf("foreign identity changed recovery state: %s", status) + } +} diff --git a/cmd/sip-go-agent/identity_watch.go b/cmd/sip-go-agent/identity_watch.go new file mode 100644 index 0000000..3f7521d --- /dev/null +++ b/cmd/sip-go-agent/identity_watch.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "errors" + "fmt" + + amqp "github.com/rabbitmq/amqp091-go" +) + +var errDispatcherIdentityLost = errors.New("Dispatcher MQ identity ownership lost") + +func dispatcherIdentityContext(parent context.Context, done <-chan *amqp.Error) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancelCause(parent) + go func() { + select { + case <-ctx.Done(): + case err := <-done: + if err != nil { + cancel(fmt.Errorf("%w: %v", errDispatcherIdentityLost, err)) + } else { + cancel(errDispatcherIdentityLost) + } + } + }() + return ctx, func() { cancel(context.Canceled) } +} diff --git a/cmd/sip-go-agent/identity_watch_test.go b/cmd/sip-go-agent/identity_watch_test.go new file mode 100644 index 0000000..777f2f3 --- /dev/null +++ b/cmd/sip-go-agent/identity_watch_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "errors" + "testing" + "time" + + amqp "github.com/rabbitmq/amqp091-go" +) + +func TestDispatcherIdentityLossCancelsAdmissionContext(t *testing.T) { + for _, closed := range []bool{false, true} { + done := make(chan *amqp.Error, 1) + ctx, cancel := dispatcherIdentityContext(context.Background(), done) + if closed { + close(done) + } else { + done <- &amqp.Error{Code: 320, Reason: "local test disconnect"} + } + select { + case <-ctx.Done(): + case <-time.After(time.Second): + t.Fatal("identity loss did not stop admission") + } + if !errors.Is(context.Cause(ctx), errDispatcherIdentityLost) { + t.Fatalf("lost cause: %v", context.Cause(ctx)) + } + cancel() + } +} + +func TestDispatcherIdentityWatchStopsWithParent(t *testing.T) { + parent, cancelParent := context.WithCancel(context.Background()) + ctx, cancel := dispatcherIdentityContext(parent, make(chan *amqp.Error)) + defer cancel() + cancelParent() + <-ctx.Done() + if !errors.Is(context.Cause(ctx), context.Canceled) { + t.Fatal("normal cancellation reported identity loss") + } +} diff --git a/cmd/sip-go-agent/main.go b/cmd/sip-go-agent/main.go index 8bbbbb4..950546d 100644 --- a/cmd/sip-go-agent/main.go +++ b/cmd/sip-go-agent/main.go @@ -7,7 +7,6 @@ import ( "fmt" "log/slog" "net" - "net/http" "os" "os/signal" "path/filepath" @@ -24,7 +23,6 @@ import ( "git.ipao.vip/rogee/go-sip/internal/callwindow" "git.ipao.vip/rogee/go-sip/internal/config" "git.ipao.vip/rogee/go-sip/internal/contract" - "git.ipao.vip/rogee/go-sip/internal/control" "git.ipao.vip/rogee/go-sip/internal/dispatcher" "git.ipao.vip/rogee/go-sip/internal/health" "git.ipao.vip/rogee/go-sip/internal/mq" @@ -33,7 +31,9 @@ import ( "git.ipao.vip/rogee/go-sip/internal/store" "github.com/spf13/cobra" "google.golang.org/grpc" + "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" ) func main() { @@ -98,6 +98,7 @@ func newAgentCommand() *cobra.Command { cmd.Flags().StringVar(&cfg.CallAISnapshotPath, "call-ai-snapshot", cfg.CallAISnapshotPath, "immutable AI snapshot path") cmd.Flags().IntVar(&cfg.CallMediaPort, "call-media-port", cfg.CallMediaPort, "ExternalMedia UDP port") cmd.Flags().StringVar(&cfg.CallRecordingDirectory, "call-recording-dir", cfg.CallRecordingDirectory, "local recording directory") + cmd.AddCommand(newUploadRetryCommand()) return cmd } @@ -379,6 +380,11 @@ func serveAgentRPC(cfg config.Config, spool *agent.Spool, report agent.RecoveryR agentv1.RegisterAgentControlServiceServer(grpcServer, handler) serveCtx, cancel := signalContext() defer cancel() + stopUploadRecovery, err := startUploadNotificationRecovery(serveCtx, cfg, spool) + if err != nil { + return err + } + defer stopUploadRecovery() go func() { <-serveCtx.Done() grpcServer.GracefulStop() @@ -473,8 +479,23 @@ func flushDispatcherOutbox(ctx context.Context, d *dispatcher.Dispatcher, batch } } +func openDispatcherStore(cfg config.Config) (*store.Store, error) { + st, err := store.Open(cfg.DBPath) + if err != nil { + return nil, err + } + if err := st.BindDispatcherID(cfg.DispatcherID); err != nil { + return nil, errors.Join(err, st.Close()) + } + if err := st.RecoverOutbox(); err != nil { + return nil, errors.Join(err, st.Close()) + } + return st, nil +} + func newDispatcherCommand() *cobra.Command { cfg := config.FromEnv() + var configFile string var once bool var consume bool var tenantKey string @@ -482,16 +503,42 @@ func newDispatcherCommand() *cobra.Command { Use: "dispatcher", Short: "run the single-active Dispatcher process", RunE: func(cmd *cobra.Command, _ []string) error { + if err := cfg.LoadDispatcherFile(configFile); err != nil { + return err + } if err := cfg.Validate("dispatcher"); err != nil { return err } - st, err := store.Open(cfg.DBPath) + var publisher mq.Publisher + var broker *mq.Broker + if cfg.RabbitURL != "" { + var err error + broker, err = mq.Open(cfg.RabbitURL, cfg.DispatcherID) + if err != nil { + return err + } + defer broker.Close() + if tenantKey == "" { + return errors.New("--tenant-key is required with RabbitMQ") + } + if _, err := broker.DeclareTenantQueue(tenantKey); err != nil { + return err + } + publisher = broker + } + st, err := openDispatcherStore(cfg) if err != nil { return err } defer st.Close() - leaseCtx, cancelLease := signalContext() - defer cancelLease() + signalCtx, cancelSignal := signalContext() + defer cancelSignal() + leaseCtx := signalCtx + if broker != nil { + var cancelIdentity context.CancelFunc + leaseCtx, cancelIdentity = dispatcherIdentityContext(signalCtx, broker.Done()) + defer cancelIdentity() + } lease, err := dispatcher.StartLease(leaseCtx, st, "dispatcher-active-"+cfg.DispatcherID, "dispatcher", cfg.DispatcherID, 30*time.Second) if err != nil { return err @@ -511,16 +558,6 @@ func newDispatcherCommand() *cobra.Command { } }() } - var publisher mq.Publisher - var broker *mq.Broker - if cfg.RabbitURL != "" { - broker, err = mq.Open(cfg.RabbitURL, cfg.Exchange) - if err != nil { - return err - } - defer broker.Close() - publisher = broker - } d, err := dispatcher.New(st, publisher, nil) if err != nil { return err @@ -566,7 +603,12 @@ func newDispatcherCommand() *cobra.Command { if err != nil { return fmt.Errorf("listen Dispatcher gRPC: %w", err) } - dispatcherGRPC = grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + dispatcherGRPC = grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)), grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if leaseCtx.Err() != nil { + return nil, status.Error(codes.Unavailable, "Dispatcher ownership is no longer active") + } + return handler(ctx, req) + })) agentv1.RegisterAgentControlServiceServer(dispatcherGRPC, dispatcherHandler) go func() { if serveErr := dispatcherGRPC.Serve(dispatcherListener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { @@ -595,13 +637,7 @@ func newDispatcherCommand() *cobra.Command { if once && consume { return errors.New("--once cannot be combined with --consume") } - if consume { - if broker == nil || tenantKey == "" { - return errors.New("--consume requires --rabbit-url and --tenant-key") - } - if cfg.ControlListen != "" { - return errors.New("--consume cannot be combined with --control-listen") - } + if publisher != nil && !once && (consume || dispatcherGRPC != nil) { flushCtx, cancelFlush := context.WithCancel(leaseCtx) flushDone := make(chan struct{}) go func() { @@ -612,9 +648,26 @@ func newDispatcherCommand() *cobra.Command { cancelFlush() <-flushDone }() + } + if connectedAgents != nil && !once && (consume || dispatcherGRPC != nil) { + controlCtx, cancelControl := context.WithCancel(leaseCtx) + controlDone := make(chan struct{}) + go func() { + defer close(controlDone) + runDispatcherControls(controlCtx, d, connectedAgents.coordinator, cfg.OutboxBatch) + }() + defer func() { cancelControl(); <-controlDone }() + } + if consume { + if broker == nil || tenantKey == "" { + return errors.New("--consume requires --rabbit-url and --tenant-key") + } if err := d.ConsumeTenant(leaseCtx, broker, tenantKey); err != nil && !errors.Is(err, context.Canceled) { return err } + if errors.Is(context.Cause(leaseCtx), errDispatcherIdentityLost) { + return context.Cause(leaseCtx) + } return nil } if once { @@ -627,49 +680,25 @@ func newDispatcherCommand() *cobra.Command { } result["published"] = count } - if cfg.ControlListen == "" { - if dispatcherGRPC == nil { - return writeResult(result) - } - select { - case <-leaseCtx.Done(): - return nil - case err := <-lease.Lost(): - return fmt.Errorf("dispatcher lease lost: %w", err) - } - } - if once { - return errors.New("--once cannot be combined with --control-listen") - } - server := &http.Server{Addr: cfg.ControlListen, Handler: control.Handler{Store: st, BearerToken: cfg.ControlToken}} - go func() { - select { - case <-leaseCtx.Done(): - case <-lease.Lost(): - } - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer shutdownCancel() - _ = server.Shutdown(shutdownCtx) - }() - if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { - return err + if dispatcherGRPC == nil { + return writeResult(result) } select { + case <-leaseCtx.Done(): + if errors.Is(context.Cause(leaseCtx), errDispatcherIdentityLost) { + return context.Cause(leaseCtx) + } + return nil case err := <-lease.Lost(): return fmt.Errorf("dispatcher lease lost: %w", err) - default: - return nil } }, } cmd.Flags().StringVar(&cfg.Mode, "mode", cfg.Mode, "mock, mixed, or real") cmd.Flags().StringVar(&cfg.DBPath, "db", cfg.DBPath, "Dispatcher SQLite path") - cmd.Flags().StringVar(&cfg.DispatcherID, "dispatcher-id", cfg.DispatcherID, "single-active Dispatcher holder identity") + cmd.Flags().StringVar(&configFile, "config", "", "required strict Dispatcher JSON configuration file") cmd.Flags().StringVar(&cfg.RabbitURL, "rabbit-url", cfg.RabbitURL, "RabbitMQ URL") - cmd.Flags().StringVar(&cfg.Exchange, "exchange", cfg.Exchange, "durable command exchange") cmd.Flags().IntVar(&cfg.OutboxBatch, "outbox-batch", cfg.OutboxBatch, "maximum outbox messages per run") - cmd.Flags().StringVar(&cfg.ControlListen, "control-listen", cfg.ControlListen, "internal control HTTP listen address; empty disables server") - cmd.Flags().StringVar(&cfg.ControlToken, "control-token", cfg.ControlToken, "bearer token for internal control HTTP") cmd.Flags().StringVar(&cfg.AgentEndpointsFile, "agent-endpoints-file", cfg.AgentEndpointsFile, "strict JSON file of Dispatcher-owned Agent endpoints") cmd.Flags().StringVar(&tenantKey, "tenant-key", "", "tenant key to consume from its command queue") cmd.Flags().BoolVar(&consume, "consume", false, "consume one tenant command queue") diff --git a/cmd/sip-go-agent/main_test.go b/cmd/sip-go-agent/main_test.go index 721ec1f..0fc31d1 100644 --- a/cmd/sip-go-agent/main_test.go +++ b/cmd/sip-go-agent/main_test.go @@ -10,6 +10,7 @@ import ( "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/dispatcher" "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestRootHasExplicitRoles(t *testing.T) { @@ -86,16 +87,19 @@ func TestFlushDispatcherOutboxPublishesPending(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } d, err := dispatcher.New(st, recordingPublisher{}, time.Now) if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } diff --git a/cmd/sip-go-agent/upload.go b/cmd/sip-go-agent/upload.go index a83b170..f454cb7 100644 --- a/cmd/sip-go-agent/upload.go +++ b/cmd/sip-go-agent/upload.go @@ -6,7 +6,6 @@ import ( "encoding/hex" "errors" "fmt" - "net/url" "path/filepath" "strings" "time" @@ -55,6 +54,10 @@ func uploadCallRecordings(ctx context.Context, cfg config.Config, result callrun return nil, fmt.Errorf("dial Dispatcher gRPC service: %w", err) } defer client.Close() + spool, err := agent.NewSpool(cfg.SpoolRoot, time.Now) + if err != nil { + return nil, err + } uploader := agent.UploadClient{Now: time.Now} facts := append(append([]callruntime.RecordingFact(nil), result.InboundRecordings...), result.OutboundRecordings...) if len(facts) == 0 { @@ -76,46 +79,16 @@ func uploadCallRecordings(ctx context.Context, cfg config.Config, result callrun ChecksumSha256: recording.SHA256, Channels: 1, SampleRateHz: 16000, + DurationMs: recording.DurationMS, } - uploadID := stableUploadID(binding, asset) - meta := uploadMeta(cfg, "request", uploadID) - grantResponse, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: uploadID}) + record, err := uploadRecording(ctx, cfg, client, uploader, spool, binding, asset, recording.Path) if err != nil { - return nil, fmt.Errorf("request upload %s: %w", uploadID, err) - } - if grantResponse == nil || grantResponse.Grant == nil || grantResponse.Receipt == nil || grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { - return nil, fmt.Errorf("request upload %s was rejected", uploadID) - } - parsed, err := url.Parse(grantResponse.Grant.TargetUrl) - if err != nil || parsed.Host == "" { - return nil, fmt.Errorf("upload %s returned invalid target URL", uploadID) - } - uploader.AllowedHosts = map[string]struct{}{strings.ToLower(parsed.Host): {}} - uploadResult, err := uploader.UploadFile(ctx, grantResponse.Grant, recording.Path) - if err != nil { - return nil, fmt.Errorf("upload %s data plane: %w", uploadID, err) - } - if uploadResult.SizeBytes != asset.SizeBytes || !strings.EqualFold(uploadResult.SHA256, asset.ChecksumSha256) { - return nil, fmt.Errorf("upload %s local result does not match asset", uploadID) - } - completeResponse, err := client.CompleteUpload(ctx, &agentv1.CompleteUploadRequest{ - Meta: uploadMeta(cfg, "complete", uploadID), - Binding: binding, - Asset: asset, - UploadId: uploadID, - UploadedSizeBytes: uploadResult.SizeBytes, - UploadedChecksumSha256: uploadResult.SHA256, - }) - if err != nil { - return nil, fmt.Errorf("complete upload %s: %w", uploadID, err) - } - if completeResponse == nil || completeResponse.Receipt == nil || completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" { - return nil, fmt.Errorf("complete upload %s was not verified", uploadID) + return nil, err } uploaded = append(uploaded, map[string]any{ - "upload_id": uploadID, "asset_id": asset.AssetId, "object_key": grantResponse.Grant.ObjectKey, - "oss_id": completeResponse.OssId, "bytes": uploadResult.SizeBytes, "sha256": uploadResult.SHA256, - "status_code": uploadResult.StatusCode, + "upload_id": record.UploadID, "asset_id": asset.AssetId, "object_key": record.ObjectKey, + "bytes": record.Result.SizeBytes, "sha256": record.Result.SHA256, + "status_code": record.Result.StatusCode, "notification_state": record.State, }) } return uploaded, nil diff --git a/cmd/sip-go-agent/upload_attempt.go b/cmd/sip-go-agent/upload_attempt.go new file mode 100644 index 0000000..1f2fe9a --- /dev/null +++ b/cmd/sip-go-agent/upload_attempt.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "strings" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" + "google.golang.org/protobuf/proto" +) + +type recordingUploadRPC interface { + RequestUpload(context.Context, *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) + CompleteUpload(context.Context, *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) +} + +func uploadRecording(ctx context.Context, cfg config.Config, client recordingUploadRPC, uploader agent.UploadClient, spool *agent.Spool, binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor, path string) (returned agent.UploadAttempt, returnErr error) { + if binding == nil || asset == nil { + return returned, errors.New("upload binding and asset are required") + } + id := stableUploadID(binding, asset) + lock, err := spool.LockUpload(id) + if err != nil { + return returned, err + } + defer func() { returnErr = errors.Join(returnErr, lock.Close()) }() + identityBytes, err := (proto.MarshalOptions{Deterministic: true}).Marshal(&agentv1.RequestUploadRequest{Binding: binding, Asset: asset, UploadId: id}) + if err != nil { + return agent.UploadAttempt{}, err + } + digest := sha256.Sum256(identityBytes) + identity := hex.EncodeToString(digest[:]) + record, err := spool.LoadUploadAttempt(id) + if errors.Is(err, os.ErrNotExist) { + response, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: uploadMeta(cfg, "request", id), Binding: binding, Asset: asset, UploadId: id}) + if err != nil { + return record, fmt.Errorf("request upload %s: %w", id, err) + } + if response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || response.GetGrant() == nil { + return record, fmt.Errorf("request upload %s has no accepted grant", id) + } + grant := response.Grant + if grant.UploadId != id { + return record, errors.New("upload grant identity mismatch") + } + parsed, err := url.Parse(grant.TargetUrl) + if err != nil || parsed.Host == "" { + return record, errors.New("upload grant has invalid target URL") + } + record = agent.UploadAttempt{UploadID: id, Identity: identity, State: "attempted", ObjectKey: grant.ObjectKey, Binding: binding, Asset: asset, RequestID: uploadMeta(cfg, "request", id).OperationId} + // Durable exclusive claim must precede any network PUT, including an attempt + // that ends in an ambiguous transport failure. + if err := spool.ClaimUpload(record); err != nil { + return record, err + } + uploader.AllowedHosts = map[string]struct{}{strings.ToLower(parsed.Host): {}} + result, err := uploader.UploadFile(ctx, grant, path) + if err != nil { + return record, fmt.Errorf("upload %s data plane: %w", id, err) + } + if result.SizeBytes != asset.SizeBytes || !strings.EqualFold(result.SHA256, asset.ChecksumSha256) { + return record, errors.New("upload result does not match asset") + } + if err := spool.RecordUploadResult(id, result); err != nil { + return record, err + } + record.State, record.Result = "uploaded", result + } else if err != nil { + return record, err + } else if record.Identity != identity { + return record, errors.New("persisted upload binding or asset mismatch") + } else if record.State == "attempted" { + return record, errors.New("prior PUT outcome unknown or failed; automatic re-upload is forbidden") + } + if record.State == "completed" { + return record, nil + } + return notifyUploadedRecordingLocked(ctx, cfg, client, spool, record) +} + +func notifyUploadedRecording(ctx context.Context, cfg config.Config, client recordingUploadRPC, spool *agent.Spool, record agent.UploadAttempt) (returned agent.UploadAttempt, returnErr error) { + lock, err := spool.LockUpload(record.UploadID) + if err != nil { + return returned, err + } + defer func() { returnErr = errors.Join(returnErr, lock.Close()) }() + current, err := spool.LoadUploadAttempt(record.UploadID) + if err != nil { + return returned, err + } + if current.State == "completed" { + return current, nil + } + return notifyUploadedRecordingLocked(ctx, cfg, client, spool, current) +} + +func notifyUploadedRecordingLocked(ctx context.Context, cfg config.Config, client recordingUploadRPC, spool *agent.Spool, record agent.UploadAttempt) (agent.UploadAttempt, error) { + if record.State != "uploaded" || record.Binding == nil || record.Asset == nil { + return record, errors.New("successful upload and original notification metadata are required") + } + id := record.UploadID + response, err := client.CompleteUpload(ctx, &agentv1.CompleteUploadRequest{Meta: uploadMeta(cfg, "complete", id), Binding: record.Binding, Asset: record.Asset, UploadId: id, UploadedSizeBytes: record.Result.SizeBytes, UploadedChecksumSha256: record.Result.SHA256}) + if err != nil { + return record, fmt.Errorf("notify upload %s: %w", id, err) + } + if response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || response.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED { + return record, errors.New("upload notification has not completed MQ delivery") + } + if err := spool.CompleteUploadNotification(id); err != nil { + return record, err + } + record.State = "completed" + return record, nil +} diff --git a/cmd/sip-go-agent/upload_attempt_test.go b/cmd/sip-go-agent/upload_attempt_test.go new file mode 100644 index 0000000..38350a2 --- /dev/null +++ b/cmd/sip-go-agent/upload_attempt_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" +) + +type uploadRPCStub struct { + grant *agentv1.UploadGrant + requests, notifications int + pending bool +} + +func (s *uploadRPCStub) RequestUpload(_ context.Context, r *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) { + s.requests++ + s.grant.UploadId = r.UploadId + return &agentv1.RequestUploadResponse{Grant: s.grant, Receipt: &agentv1.OperationReceipt{Result: agentv1.ResultCode_RESULT_CODE_ACCEPTED}}, nil +} +func (s *uploadRPCStub) CompleteUpload(_ context.Context, _ *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) { + s.notifications++ + if s.pending { + return nil, errors.New("notification pending") + } + return &agentv1.CompleteUploadResponse{State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, Receipt: &agentv1.OperationReceipt{Result: agentv1.ResultCode_RESULT_CODE_ACCEPTED}}, nil +} + +func TestRecordingNotificationRecoveryDoesNotPUTAgain(t *testing.T) { + puts := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { puts++; w.WriteHeader(http.StatusOK) })) + defer server.Close() + root := t.TempDir() + path := filepath.Join(root, "audio.wav") + if err := os.WriteFile(path, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("audio")) + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{AssetId: "recording-a", SizeBytes: 5, ChecksumSha256: hex.EncodeToString(sum[:])} + remote := &uploadRPCStub{pending: true, grant: &agentv1.UploadGrant{ObjectKey: "recording-a", TargetUrl: server.URL, MaxBytes: 5, RequiredChecksumSha256: asset.ChecksumSha256, ExpiresAtUnixMs: time.Now().Add(time.Minute).UnixMilli()}} + spool, err := agent.NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + uploader := agent.UploadClient{HTTPClient: server.Client()} + if _, err := uploadRecording(context.Background(), config.Config{}, remote, uploader, spool, binding, asset, path); err == nil { + t.Fatal("pending MQ notification reported complete") + } + remote.pending = false + restarted, err := agent.NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + if err := recoverUploadNotifications(context.Background(), config.Config{}, remote, restarted); err != nil { + t.Fatal(err) + } + record, err := restarted.LoadUploadAttempt(stableUploadID(binding, asset)) + if err != nil { + t.Fatal(err) + } + if record.State != "completed" || puts != 1 || remote.requests != 1 || remote.notifications != 2 { + t.Fatalf("state=%s PUT=%d grant=%d notification=%d", record.State, puts, remote.requests, remote.notifications) + } + if _, err := uploadRecording(context.Background(), config.Config{}, remote, uploader, restarted, binding, asset, path); err != nil { + t.Fatal(err) + } + if puts != 1 || remote.notifications != 2 { + t.Fatal("completed upload repeated side effects") + } + if _, err := os.Stat(path); err != nil { + t.Fatal("source recording removed") + } +} diff --git a/cmd/sip-go-agent/upload_recovery.go b/cmd/sip-go-agent/upload_recovery.go new file mode 100644 index 0000000..7c81feb --- /dev/null +++ b/cmd/sip-go-agent/upload_recovery.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/rpc" +) + +func recoverUploadNotifications(ctx context.Context, cfg config.Config, client recordingUploadRPC, spool *agent.Spool) error { + pending, err := spool.PendingUploadNotifications() + if err != nil { + return err + } + var failures []error + for _, record := range pending { + if _, err := notifyUploadedRecording(ctx, cfg, client, spool, record); err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + +// startUploadNotificationRecovery never requests a token or opens a source file. +// The worker owns only the durable metadata-to-Dispatcher notification path. +func startUploadNotificationRecovery(ctx context.Context, cfg config.Config, spool *agent.Spool) (func(), error) { + pending, err := spool.PendingUploadNotifications() + if err != nil { + return nil, fmt.Errorf("read upload recovery journal: %w", err) + } + if cfg.DispatcherGRPCEndpoint == "" { + if len(pending) > 0 { + return nil, errors.New("pending upload notifications require Dispatcher gRPC endpoint") + } + return func() {}, nil + } + client, err := rpc.DialFromFiles(cfg.DispatcherGRPCEndpoint, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, cfg.DispatcherGRPCServerName) + if err != nil { + return nil, err + } + workerCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + attemptCtx, finish := context.WithTimeout(workerCtx, 10*time.Second) + err := recoverUploadNotifications(attemptCtx, cfg, client, spool) + finish() + if err != nil && workerCtx.Err() == nil { + slog.Error("upload notification recovery failed; original facts retained", "error", err) + } + select { + case <-workerCtx.Done(): + return + case <-ticker.C: + } + } + }() + return func() { + cancel() + <-done + if err := client.Close(); err != nil { + slog.Error("close upload notification client", "error", err) + } + }, nil +} diff --git a/cmd/sip-go-agent/upload_retry.go b/cmd/sip-go-agent/upload_retry.go new file mode 100644 index 0000000..ad50fcb --- /dev/null +++ b/cmd/sip-go-agent/upload_retry.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/url" + "strings" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" + "github.com/google/uuid" + "google.golang.org/protobuf/proto" +) + +// retryUploadRecording is reachable only through an explicit operator command. +// A supplied request ID is stable across redelivery and can authorize one PUT. +func retryUploadRecording(ctx context.Context, cfg config.Config, client recordingUploadRPC, uploader agent.UploadClient, spool *agent.Spool, uploadID, requestID, path string) (returned agent.UploadAttempt, returnErr error) { + parsedID, err := uuid.Parse(requestID) + if err != nil || parsedID.Version() != 4 || parsedID.Variant() != uuid.RFC4122 || parsedID.String() != requestID { + return returned, errors.New("explicit request ID must be a canonical UUID v4") + } + lock, err := spool.LockUpload(uploadID) + if err != nil { + return returned, err + } + defer func() { returnErr = errors.Join(returnErr, lock.Close()) }() + record, err := spool.LoadUploadAttempt(uploadID) + if err != nil { + return record, err + } + if record.State != "attempted" || record.Binding == nil || record.Asset == nil || record.RequestID == "" || record.RequestID == requestID { + return record, errors.New("only an unsuccessful upload may explicitly request a new grant") + } + raw, err := (proto.MarshalOptions{Deterministic: true}).Marshal(&agentv1.RequestUploadRequest{Binding: record.Binding, Asset: record.Asset, UploadId: uploadID}) + if err != nil { + return record, err + } + sum := sha256.Sum256(raw) + if record.Identity != hex.EncodeToString(sum[:]) || stableUploadID(record.Binding, record.Asset) != uploadID { + return record, errors.New("persisted upload identity mismatch") + } + meta := uploadMeta(cfg, "request", uploadID) + meta.RequestId = requestID + meta.OperationId = requestID + meta.IdempotencyKey = requestID + meta.TraceId = requestID + response, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: record.Binding, Asset: record.Asset, UploadId: uploadID}) + if err != nil { + return record, err + } + if response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_ACCEPTED || response.GetGrant() == nil { + return record, errors.New("explicit upload request was not granted") + } + grant := response.Grant + if grant.UploadId != uploadID || grant.ObjectKey != record.ObjectKey || grant.RequiredChecksumSha256 != record.Asset.ChecksumSha256 || grant.MaxBytes < record.Asset.SizeBytes { + return record, errors.New("replacement grant does not match the original asset") + } + target, err := url.Parse(grant.TargetUrl) + if err != nil || target.Host == "" { + return record, errors.New("replacement grant has invalid target") + } + if err := spool.ReserveUploadRetry(uploadID, requestID); err != nil { + return record, err + } + uploader.AllowedHosts = map[string]struct{}{strings.ToLower(target.Host): {}} + result, err := uploader.UploadFile(ctx, grant, path) + if err != nil { + return record, err + } + if result.SizeBytes != record.Asset.SizeBytes || result.SHA256 != record.Asset.ChecksumSha256 { + return record, errors.New("replacement upload result does not match original asset") + } + if err := spool.RecordUploadResult(uploadID, result); err != nil { + return record, err + } + record.RequestID = requestID + record.State = "uploaded" + record.Result = result + return notifyUploadedRecordingLocked(ctx, cfg, client, spool, record) +} diff --git a/cmd/sip-go-agent/upload_retry_command.go b/cmd/sip-go-agent/upload_retry_command.go new file mode 100644 index 0000000..94a19fa --- /dev/null +++ b/cmd/sip-go-agent/upload_retry_command.go @@ -0,0 +1,50 @@ +package main + +import ( + "errors" + "time" + + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/rpc" + "github.com/spf13/cobra" +) + +func newUploadRetryCommand() *cobra.Command { + cfg := config.FromEnv() + var uploadID, requestID, path string + cmd := &cobra.Command{ + Use: "upload-retry", Short: "explicitly request one new grant for an unsuccessful upload; never dial", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) (returnErr error) { + if uploadID == "" || requestID == "" || path == "" { + return errors.New("--upload-id, --request-id and --file are required") + } + if err := cfg.Validate("agent"); err != nil { + return err + } + if cfg.DispatcherGRPCEndpoint == "" || cfg.DispatcherGRPCServerName == "" { + return errors.New("Dispatcher gRPC endpoint and server name are required") + } + spool, err := agent.NewSpool(cfg.SpoolRoot, time.Now) + if err != nil { + return err + } + client, err := rpc.DialFromFiles(cfg.DispatcherGRPCEndpoint, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, cfg.DispatcherGRPCServerName) + if err != nil { + return err + } + defer func() { returnErr = errors.Join(returnErr, client.Close()) }() + record, err := retryUploadRecording(cmd.Context(), cfg, client, agent.UploadClient{Now: time.Now}, spool, uploadID, requestID, path) + if err != nil { + return err + } + return writeResult(map[string]any{"upload_id": record.UploadID, "notification_state": record.State, "bytes": record.Result.SizeBytes, "sha256": record.Result.SHA256}) + }, + } + cmd.Flags().StringVar(&cfg.SpoolRoot, "spool", cfg.SpoolRoot, "existing Agent spool root") + cmd.Flags().StringVar(&uploadID, "upload-id", "", "existing failed or uncertain upload identity") + cmd.Flags().StringVar(&requestID, "request-id", "", "explicit new canonical UUID v4; never reuse a consumed request") + cmd.Flags().StringVar(&path, "file", "", "retained original recording; content must match its persisted identity") + return cmd +} diff --git a/cmd/sip-go-agent/upload_retry_test.go b/cmd/sip-go-agent/upload_retry_test.go new file mode 100644 index 0000000..d70497b --- /dev/null +++ b/cmd/sip-go-agent/upload_retry_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/config" +) + +func TestExplicitUploadRetryIsOneNewRequestAndOnePUT(t *testing.T) { + puts := 0 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + puts++ + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + root := t.TempDir() + path := filepath.Join(root, "audio.wav") + if err := os.WriteFile(path, []byte("audio"), 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("audio")) + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{AssetId: "recording-a", SizeBytes: 5, ChecksumSha256: hex.EncodeToString(sum[:])} + remote := &uploadRPCStub{grant: &agentv1.UploadGrant{ObjectKey: "recording-a", TargetUrl: server.URL, MaxBytes: 5, RequiredChecksumSha256: asset.ChecksumSha256, ExpiresAtUnixMs: time.Now().Add(-time.Minute).UnixMilli()}} + spool, err := agent.NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + uploader := agent.UploadClient{HTTPClient: server.Client()} + if _, err := uploadRecording(context.Background(), config.Config{}, remote, uploader, spool, binding, asset, path); err == nil { + t.Fatal("expired token accepted") + } + if puts != 0 || remote.requests != 1 { + t.Fatal("expired grant caused PUT or auto renewal") + } + remote.grant.ExpiresAtUnixMs = time.Now().Add(15 * time.Minute).UnixMilli() + id := stableUploadID(binding, asset) + if _, err := uploadRecording(context.Background(), config.Config{}, remote, uploader, spool, binding, asset, path); err == nil { + t.Fatal("ordinary recovery retried a failed attempt") + } + if _, err := retryUploadRecording(context.Background(), config.Config{}, remote, uploader, spool, id, "11111111-1111-4111-8111-111111111111", path); err != nil { + t.Fatal(err) + } + if puts != 1 || remote.requests != 2 || remote.notifications != 1 { + t.Fatalf("PUT=%d grants=%d notifications=%d", puts, remote.requests, remote.notifications) + } + if _, err := retryUploadRecording(context.Background(), config.Config{}, remote, uploader, spool, id, "22222222-2222-4222-8222-222222222222", path); err == nil { + t.Fatal("completed upload retried") + } + if puts != 1 || remote.requests != 2 { + t.Fatal("duplicate retry repeated effects") + } + if _, err := os.Stat(path); err != nil { + t.Fatal("retry removed source file") + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/README.md b/contracts/upstream/2026-09-21-p1-v2/README.md new file mode 100644 index 0000000..193e12a --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/README.md @@ -0,0 +1,11 @@ +# Project-local MQ-only v2 + +Approved design: docs/contracts/mq-only-v2-freeze-proposal.md, amended by the enqueue-only upload goal. +This is a local project contract, not external SaaS acceptance. Old v1 files are untouched. +Publisher: scripts/publish-mq-v2.py. Validator: go test ./contracts -run TestV2. + +Upload notification: recording.uploaded, no recording.ready, upload session or verified reply. Completion means persistent delivery to the designated durable MQ queue, mandatory routing and publisher confirm; not SaaS consumption. +Payload: call_id, recording_id, upload_id, bucket, object_key, format, channels, sample_rate_hz, duration_ms, size_bytes, checksum_sha256. No TOKEN, credentials, signed URL or SaaS OSS ID. + +Control/replay command_id belongs solely to the envelope. Query nested structures reuse typed events and delivery counts. AI receipt/config composition is flattened without widening fields. +Schema limits characters; runtime also checks UTF-8 bytes and aggregate response size. Oversized snapshots fail explicitly, never silently truncate. diff --git a/contracts/upstream/2026-09-21-p1-v2/ai-authorization.schema.json b/contracts/upstream/2026-09-21-p1-v2/ai-authorization.schema.json new file mode 100644 index 0000000..4b6d10e --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/ai-authorization.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/ai-authorization.schema.json", + "title": "Dispatcher to Agent immutable AI authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_id", + "tenant_id", + "tenant_key", + "agent_version_id", + "config_sha256", + "mode", + "issued_at", + "expires_at", + "source", + "revoked" + ], + "properties": { + "authorization_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 224 + }, + "agent_version_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "enum": [ + "saas", + "mock-saas" + ] + }, + "credential_refs": { + "type": "object", + "additionalProperties": false, + "properties": { + "asr": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "llm": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tts": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "allowed_egress_pool_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "revoked": { + "type": "boolean" + }, + "revocation_reason": { + "type": "string", + "maxLength": 256 + } + }, + "allOf": [ + { + "if": { + "properties": { + "revoked": { + "const": true + } + } + }, + "then": { + "required": [ + "revocation_reason" + ] + } + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v2/ai-config.schema.json b/contracts/upstream/2026-09-21-p1-v2/ai-config.schema.json new file mode 100644 index 0000000..87c24c3 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/ai-config.schema.json @@ -0,0 +1,323 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": [ + "agent_version_id", + "immutable", + "asr", + "conversation" + ], + "properties": { + "agent_version_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "immutable": { + "const": true + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "llm": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "model" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + } + } + }, + "prompt": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "allowed_variables" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "allowed_variables": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 32768 + } + } + }, + "tts": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "model", + "voice", + "format" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "voice": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "speed": { + "type": "number", + "minimum": 0.25, + "maximum": 3 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "format": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels" + ], + "properties": { + "encoding": { + "enum": [ + "pcm_s16le", + "pcma" + ] + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "channels": { + "const": 1 + } + } + } + } + }, + "asr": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "language", + "input" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "language": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "interim": { + "type": "boolean" + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels", + "sample_width_bytes" + ], + "properties": { + "encoding": { + "const": "pcm_s16le" + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "channels": { + "const": 1 + }, + "sample_width_bytes": { + "const": 2 + } + } + } + } + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": [ + "allow_interrupt", + "silence_timeout_ms", + "max_duration_ms", + "max_turns", + "sentence_max_chars", + "max_pending_audio_chunks" + ], + "properties": { + "opening": { + "type": "string", + "maxLength": 32768 + }, + "allow_interrupt": { + "type": "boolean" + }, + "silence_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_duration_ms": { + "type": "integer", + "minimum": 1, + "maximum": 3600000 + }, + "max_turns": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "sentence_max_chars": { + "type": "integer", + "minimum": 1 + }, + "max_pending_audio_chunks": { + "type": "integer", + "minimum": 1 + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "oneOf": [ + { + "title": "Full AI", + "required": [ + "llm", + "prompt", + "tts" + ], + "properties": { + "mode": { + "enum": [ + "full_ai" + ] + }, + "conversation": { + "required": [ + "opening" + ] + } + } + }, + { + "title": "ASR only", + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "asr_only" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "llm" + ] + }, + { + "required": [ + "prompt" + ] + }, + { + "required": [ + "tts" + ] + } + ] + } + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v2/dispatcher-config.schema.json b/contracts/upstream/2026-09-21-p1-v2/dispatcher-config.schema.json new file mode 100644 index 0000000..60f7d9d --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/dispatcher-config.schema.json @@ -0,0 +1,62 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "dispatcher_id", + "oss" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "dispatcher_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "oss": { + "type": "object", + "additionalProperties": false, + "required": [ + "endpoint", + "region", + "bucket", + "object_prefix", + "access_key_id_env", + "access_key_secret_env" + ], + "properties": { + "endpoint": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https?://" + }, + "region": { + "type": "string", + "minLength": 1 + }, + "bucket": { + "type": "string", + "minLength": 1 + }, + "object_prefix": { + "type": "string", + "minLength": 1 + }, + "access_key_id_env": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "access_key_secret_env": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/dispatcher-config.schema.json" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/event-payloads.schema.json b/contracts/upstream/2026-09-21-p1-v2/event-payloads.schema.json new file mode 100644 index 0000000..360f8f1 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/event-payloads.schema.json @@ -0,0 +1,646 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/event-payloads.schema.json", + "title": "Agent-call versioned event envelopes and payloads", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_id", + "event_type", + "tenant_id", + "tenant_key", + "trace_id", + "occurred_at", + "aggregate_type", + "aggregate_id", + "aggregate_version", + "payload", + "dispatcher_id" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "event_id": { + "$ref": "#/$defs/id" + }, + "event_type": { + "type": "string" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "not": { + "pattern": "(^|\\.)[*#](\\.|$)" + }, + "$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget." + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "aggregate_type": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "aggregate_id": { + "$ref": "#/$defs/id" + }, + "aggregate_version": { + "type": "integer", + "minimum": 1 + }, + "payload": { + "type": "object" + }, + "dispatcher_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "oneOf": [ + { + "properties": { + "event_type": { + "const": "command.result" + }, + "payload": { + "$ref": "#/$defs/command_result" + } + } + }, + { + "properties": { + "event_type": { + "const": "call.status" + }, + "payload": { + "$ref": "#/$defs/call_status" + } + } + }, + { + "properties": { + "event_type": { + "const": "transcript.updated" + }, + "payload": { + "$ref": "#/$defs/transcript_updated" + } + } + }, + { + "properties": { + "event_type": { + "const": "call.finished" + }, + "payload": { + "$ref": "#/$defs/call_finished" + } + } + }, + { + "properties": { + "event_type": { + "const": "recording.uploaded" + }, + "payload": { + "$ref": "#/$defs/recording_uploaded" + } + } + }, + { + "properties": { + "event_type": { + "const": "recording.failed" + }, + "payload": { + "$ref": "#/$defs/recording_failed" + } + } + }, + { + "properties": { + "event_type": { + "const": "transcript.failed" + }, + "payload": { + "$ref": "#/$defs/transcript_failed" + } + } + }, + { + "properties": { + "event_type": { + "const": "contact.opt_out" + }, + "payload": { + "$ref": "#/$defs/contact_opt_out" + } + } + } + ], + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "command_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "command_id", + "command_type", + "status", + "reason_code" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/id" + }, + "command_type": { + "enum": [ + "call.execute", + "task.control", + "call.replay", + "command.replay" + ] + }, + "status": { + "enum": [ + "accepted", + "waiting", + "applied", + "rejected", + "failed", + "unknown" + ] + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "call_id": { + "$ref": "#/$defs/id" + }, + "requested_task_revision": { + "type": "integer", + "minimum": 0 + }, + "applied_task_revision": { + "type": "integer", + "minimum": 0 + }, + "admission_state": { + "enum": [ + "open", + "closed", + "draining", + "quarantined", + "unknown" + ] + }, + "resource_reservation_id": { + "$ref": "#/$defs/id" + }, + "permit_id": { + "$ref": "#/$defs/id" + } + } + }, + "call_status": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "execution_id", + "call_state", + "call_version", + "attempt_id", + "attempt_state" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "call_state": { + "enum": [ + "queued", + "dialing", + "ringing", + "answered", + "ended" + ] + }, + "call_version": { + "type": "integer", + "minimum": 1 + }, + "attempt_id": { + "$ref": "#/$defs/id" + }, + "attempt_state": { + "enum": [ + "pending", + "active", + "ended", + "unknown" + ] + }, + "route_policy_id": { + "$ref": "#/$defs/id" + }, + "caller_profile_id": { + "$ref": "#/$defs/id" + }, + "trunk_id": { + "$ref": "#/$defs/id" + }, + "cell_id": { + "$ref": "#/$defs/id" + }, + "egress_pool_id": { + "$ref": "#/$defs/id" + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "reason_code": { + "type": "string", + "maxLength": 128 + } + } + }, + "transcript_updated": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "turn_id", + "segment_id", + "role", + "revision", + "text", + "is_final", + "start_ms", + "end_ms", + "playback_state" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "turn_id": { + "$ref": "#/$defs/id" + }, + "segment_id": { + "$ref": "#/$defs/id" + }, + "role": { + "enum": [ + "customer", + "agent", + "system" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "text": { + "type": "string", + "maxLength": 32768 + }, + "is_final": { + "type": "boolean" + }, + "start_ms": { + "type": "integer", + "minimum": 0 + }, + "end_ms": { + "type": "integer", + "minimum": 0 + }, + "playback_state": { + "enum": [ + "not_applicable", + "generated", + "sent", + "playback_confirmed", + "cancelled", + "unknown" + ] + } + } + }, + "call_finished": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "execution_id", + "call_version", + "outcome", + "started_at", + "ended_at", + "duration_ms", + "reason_code" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "call_version": { + "type": "integer", + "minimum": 1 + }, + "outcome": { + "enum": [ + "answered", + "no_answer", + "busy", + "failed", + "opt_out", + "cancelled", + "unknown" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "attempt_summary": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/attempt_summary" + } + }, + "asset_state": { + "enum": [ + "pending", + "complete", + "failed", + "unknown" + ] + } + } + }, + "attempt_summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "attempt_id", + "state" + ], + "properties": { + "attempt_id": { + "$ref": "#/$defs/id" + }, + "state": { + "enum": [ + "pending", + "active", + "ended", + "unknown" + ] + }, + "trunk_id": { + "$ref": "#/$defs/id" + }, + "cell_id": { + "$ref": "#/$defs/id" + }, + "reason_code": { + "type": "string", + "maxLength": 128 + } + } + }, + "recording_failed": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "recording_id", + "stage", + "reason_code", + "retryable" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "recording_id": { + "$ref": "#/$defs/id" + }, + "stage": { + "enum": [ + "seal", + "request", + "upload", + "complete", + "verify", + "cleanup" + ] + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "retryable": { + "type": "boolean" + }, + "next_retry_at": { + "type": "string", + "format": "date-time" + } + } + }, + "transcript_failed": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "reason_code", + "retryable" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "retryable": { + "type": "boolean" + }, + "segment_id": { + "$ref": "#/$defs/id" + }, + "affected_segments": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/id" + } + } + } + }, + "contact_opt_out": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "task_id", + "task_item_id", + "requested_at" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "requested_at": { + "type": "string", + "format": "date-time" + }, + "turn_id": { + "$ref": "#/$defs/id" + }, + "segment_id": { + "$ref": "#/$defs/id" + } + } + }, + "recording_uploaded": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "recording_id", + "format", + "channels", + "sample_rate_hz", + "duration_ms", + "size_bytes", + "checksum_sha256", + "upload_id", + "bucket", + "object_key" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "recording_id": { + "$ref": "#/$defs/id" + }, + "format": { + "enum": [ + "wav", + "raw_pcm", + "pcma" + ] + }, + "channels": { + "const": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "upload_id": { + "$ref": "#/$defs/id" + }, + "bucket": { + "type": "string", + "minLength": 1, + "maxLength": 63 + }, + "object_key": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-request.json b/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-request.json new file mode 100644 index 0000000..e80178b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-request.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "ai.config.request-a", + "message_type": "ai.config.request", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "agent_version_id": "version-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-result.json b/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-result.json new file mode 100644 index 0000000..a7fda1f --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/ai-config-result.json @@ -0,0 +1,90 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "ai-config-reply-a", + "message_type": "ai.config.result", + "correlation_id": "ai.config.request-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "snapshot": { + "tenant_id": "tenant-a", + "agent_version_id": "version-a", + "status": "published", + "immutable": true, + "content_sha256": "5f7a7531839338c0a0a9b8f8f5329c2f5d1aaa6e42d11bdd25446294b119ee8e", + "config": { + "agent_version_id": "version-a", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000 + }, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "opening": "", + "allow_interrupt": true, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } + } + }, + "authorization": { + "authorization_id": "auth-1", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "agent_version_id": "version-a", + "config_sha256": "5f7a7531839338c0a0a9b8f8f5329c2f5d1aaa6e42d11bdd25446294b119ee8e", + "mode": "full_ai", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "allowed_egress_pool_ids": [ + "egress-mock" + ], + "revoked": false + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/call-execute.json b/contracts/upstream/2026-09-21-p1-v2/examples/call-execute.json new file mode 100644 index 0000000..1adfb12 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/call-execute.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/call-query-result.json b/contracts/upstream/2026-09-21-p1-v2/examples/call-query-result.json new file mode 100644 index 0000000..163514b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/call-query-result.json @@ -0,0 +1,157 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "call-query-reply-a", + "message_type": "call.query.result", + "correlation_id": "call.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "call_state": "answered", + "call_version": 1, + "attempts": [ + { + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "transcript": { + "events": [ + { + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ] + }, + "recordings": [ + { + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "delivery": { + "pending": 1, + "retry": 0, + "dispatching": 0, + "published": 0 + }, + "snapshot_at": "2026-09-21T00:00:00Z" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/call-query.json b/contracts/upstream/2026-09-21-p1-v2/examples/call-query.json new file mode 100644 index 0000000..5859a0e --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/call-query.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "call.query-a", + "message_type": "call.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/call-replay.json b/contracts/upstream/2026-09-21-p1-v2/examples/call-replay.json new file mode 100644 index 0000000..04e9eec --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/call-replay.json @@ -0,0 +1,15 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "call.replay-a", + "command_type": "call.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/command-query-result.json b/contracts/upstream/2026-09-21-p1-v2/examples/command-query-result.json new file mode 100644 index 0000000..3c0daaf --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/command-query-result.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "accepted", + "aggregate_version": 1 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/command-query.json b/contracts/upstream/2026-09-21-p1-v2/examples/command-query.json new file mode 100644 index 0000000..365fde9 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/command-query.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "command.query-a", + "message_type": "command.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "command_id": "command-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/command-replay.json b/contracts/upstream/2026-09-21-p1-v2/examples/command-replay.json new file mode 100644 index 0000000..aa6f503 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/command-replay.json @@ -0,0 +1,15 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command.replay-a", + "command_type": "command.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "source_command_id": "command-a", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/dispatcher-config.json b/contracts/upstream/2026-09-21-p1-v2/examples/dispatcher-config.json new file mode 100644 index 0000000..f60820c --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/dispatcher-config.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "oss": { + "endpoint": "https://oss.example.invalid", + "region": "example-region", + "bucket": "example-bucket", + "object_prefix": "recordings", + "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", + "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-call-finished.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-call-finished.json new file mode 100644 index 0000000..c1b6ac7 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-call-finished.json @@ -0,0 +1,34 @@ +{ + "schema_version": "2.0", + "event_id": "call.finished-event-a", + "event_type": "call.finished", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:03Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 2, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_version": 1, + "outcome": "answered", + "started_at": "2026-09-18T00:00:00Z", + "ended_at": "2026-09-18T00:00:03Z", + "duration_ms": 3000, + "reason_code": "normal_clearing", + "asset_state": "complete", + "attempt_summary": [ + { + "attempt_id": "attempt-1", + "state": "ended", + "trunk_id": "trunk-1", + "cell_id": "cell-1" + } + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-call-status.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-call-status.json new file mode 100644 index 0000000..3c6ba28 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-call-status.json @@ -0,0 +1,29 @@ +{ + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-command-result.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-command-result.json new file mode 100644 index 0000000..3f6c279 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-command-result.json @@ -0,0 +1,20 @@ +{ + "schema_version": "2.0", + "event_id": "command.result-event-a", + "event_type": "command.result", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "command", + "aggregate_id": "command-1", + "aggregate_version": 1, + "payload": { + "command_id": "command-1", + "command_type": "call.execute", + "status": "accepted", + "reason_code": "accepted", + "execution_id": "execution-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-contact-opt-out.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-contact-opt-out.json new file mode 100644 index 0000000..a0bc4cd --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-contact-opt-out.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "event_id": "contact.opt_out-event-a", + "event_type": "contact.opt_out", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:06Z", + "aggregate_type": "contact", + "aggregate_id": "contact-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "task_id": "task-1", + "task_item_id": "item-1", + "requested_at": "2026-09-18T00:00:06Z", + "turn_id": "turn-1", + "segment_id": "segment-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-failed.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-failed.json new file mode 100644 index 0000000..8bbd601 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-failed.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-uploaded.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-uploaded.json new file mode 100644 index 0000000..38aff36 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-recording-uploaded.json @@ -0,0 +1,26 @@ +{ + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-failed.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-failed.json new file mode 100644 index 0000000..4f8084c --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-failed.json @@ -0,0 +1,22 @@ +{ + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-updated.json b/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-updated.json new file mode 100644 index 0000000..b08f673 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/event-transcript-updated.json @@ -0,0 +1,25 @@ +{ + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/invalid-call-query-extra.json b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-call-query-extra.json new file mode 100644 index 0000000..36b1de9 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-call-query-extra.json @@ -0,0 +1,158 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "call.query.result", + "correlation_id": "command.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "call_state": "answered", + "call_version": 1, + "attempts": [ + { + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "transcript": { + "events": [ + { + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ] + }, + "recordings": [ + { + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "delivery": { + "pending": 1, + "retry": 0, + "dispatching": 0, + "published": 0, + "raw": {} + }, + "snapshot_at": "2026-09-21T00:00:00Z" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/invalid-correlation.json b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-correlation.json new file mode 100644 index 0000000..4c9d94f --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-correlation.json @@ -0,0 +1,20 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "status": "ok", + "reason_code": "ok", + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "accepted", + "aggregate_version": 1 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/invalid-dispatcher.json b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-dispatcher.json new file mode 100644 index 0000000..f60819b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-dispatcher.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "dispatcher-a", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/invalid-v1.json b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-v1.json new file mode 100644 index 0000000..7757e3d --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-v1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/invalid-wildcard.json b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-wildcard.json new file mode 100644 index 0000000..b31d0f0 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/invalid-wildcard.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant.#", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/query-pending.json b/contracts/upstream/2026-09-21-p1-v2/examples/query-pending.json new file mode 100644 index 0000000..7e7afb3 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/query-pending.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "pending", + "reason_code": "waiting", + "payload": {} +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/query-rejected.json b/contracts/upstream/2026-09-21-p1-v2/examples/query-rejected.json new file mode 100644 index 0000000..15dc9d8 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/query-rejected.json @@ -0,0 +1,17 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "rejected", + "reason_code": "not_found", + "payload": { + "detail": "command not found", + "retryable": false + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/examples/task-control.json b/contracts/upstream/2026-09-21-p1-v2/examples/task-control.json new file mode 100644 index 0000000..63bf5a6 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/examples/task-control.json @@ -0,0 +1,18 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "task.control-a", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "pause", + "expected_task_revision": 1, + "active_call_policy": "drain", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/fixtures.json b/contracts/upstream/2026-09-21-p1-v2/fixtures.json new file mode 100644 index 0000000..aaa94cc --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/fixtures.json @@ -0,0 +1,132 @@ +[ + { + "file": "examples/call-execute.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/task-control.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/call-replay.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/command-replay.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/command-query.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/call-query.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/ai-config-request.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/query-pending.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/query-rejected.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/dispatcher-config.json", + "schema": "dispatcher-config.schema.json", + "valid": true + }, + { + "file": "examples/invalid-v1.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/invalid-wildcard.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/invalid-dispatcher.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/event-call-finished.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-call-status.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-command-result.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-contact-opt-out.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-recording-failed.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-recording-uploaded.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-transcript-failed.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-transcript-updated.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/command-query-result.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/invalid-correlation.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/call-query-result.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/invalid-call-query-extra.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/ai-config-result.json", + "schema": "mq.schema.json", + "valid": true + } +] diff --git a/contracts/upstream/2026-09-21-p1-v2/manifest.json b/contracts/upstream/2026-09-21-p1-v2/manifest.json new file mode 100644 index 0000000..11160d1 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/manifest.json @@ -0,0 +1,86 @@ +{ + "version": "2026-09-21-p1-v2", + "source": "project-approved-mq-only-v2", + "derived_from": "2026-09-19-p1-v1", + "source_files": { + "README.md": "3332e4c71021189b03f3031106c08b3c9c08eb2f9bb1a4f4b0a96fe1598ceaba", + "SNAPSHOT.json": "a41adc5ec329a9010cc45d08f309eb3620e0d6311a5c565a9f26a35ffc4ecb42", + "ai-authorization.schema.json": "5d6bbd369bd6b406abe9b9f8619e6f299b544dacfdd4473a08257a366fd95d20", + "ai-config.openapi.yaml": "d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a", + "ai-config.schema.json": "dc915bff70e71408fcacbaad47d3554bbfa7a22a51f2f53e127c6bd8bc8ba5a6", + "cell-agent.openapi.yaml": "79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41", + "event-payloads.schema.json": "5091ded520d692f458699327b13adddeccb0c4b4d4a72ef7cdfebcd5d496eade", + "examples/README.md": "a3514c7ef89b4324685bc9025139488000fa42330de72495484add042a6897cf", + "examples/agent-version-asr-only.json": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b", + "examples/agent-version-full-explicit.json": "e590148448885a1e88c473ca032d5efd719270689c3cff7aa44ca9c6ffa2b321", + "examples/agent-version-full-production-v1.json": "6a5f35f8cc4256c048d4f1ca567a3544ea7bc92bb835cfab39a000d22b73102b", + "examples/agent-version.json": "d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e", + "examples/ai-authorization.json": "b1c0f723f083d1be45035792482f6f74bd3bca34486cc6408d4bcfe3772bc432", + "examples/call.execute.json": "0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742", + "examples/event-call-finished.json": "fab56aff9c5d4059b8fa65d24f2afa424148e7a07025238fc7fd30e9c5ee63f4", + "examples/event-call-status.json": "5c673ceb4a98ce8c90d976356b4c5ffa6c7d96d045b5b3312513a9be93d585ba", + "examples/event-command-result.json": "64fe71719573378b0caac2f7c5476a27ba7073df8c346d4748a6b0d534d4a087", + "examples/event-contact-opt-out.json": "b661f65f0a195e59fe226b12ebae2feb5a9733a9a779c835245be27319aaf8f2", + "examples/event-recording-failed.json": "4f5f46422d2281ffae65c99e947bc7dd44effb5e94eacc12e41b3250ac645c34", + "examples/event-recording-ready.json": "2b6d731a8d7993cbbe07a5ba8c7413ec56bae8a1538476c0f1740133a488d5b4", + "examples/event-transcript-failed.json": "58d000ce27de6e67054f66d4ded34ffafa983bdcced12613051f670d1eefeed8", + "examples/event-transcript-updated.json": "115f276522631d7c4decee71ee238aa0d00b4256adedfc9a6a0c5f9dc89f64f6", + "examples/invalid-ai-authorization-revoked.json": "0b946b43c1f773391791dfb60a2b2a03c8a69e64e5e9ea30af8591850c34b8f3", + "examples/invalid-asr-only-with-llm.json": "7343ce8d6ce63e22128ab7bc721544d681a5dd9eccfc4ab4f9d6181c5748824c", + "examples/invalid-event-unknown-type.json": "80c3f89dc775deec9d1a3b85c81ea4b2fd1b030bc4deba1a4dcd2823e4eb8024", + "examples/invalid-oss-upload-http.json": "8e4fb4344c4bec51b47ff9b00a63afdc84d09ab827e1b6823c4e576bf6b6a3f1", + "examples/oss-upload-grant.json": "268751a1a0d238636c04c004a635e3bda702bc2ef90ade0e5846b47a3c263b5d", + "examples/static-cell-artifact-real-v1.json": "b4a001457e747a039677870a3ddeccc902b8f87819c3ce2d2e37ae6a14f1ffe0", + "examples/static-cell-artifact.json": "129821e4de654ef12d3e7f155e9301ecddf798f9ca3399316a20a55273fbc61f", + "executor.openapi.yaml": "b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329", + "mock-profile.json": "4d43097602fed9a68821e765117580706896ac82d4bce361a80a4cea544ab638", + "mq-topology.md": "a85b26596e1b1db7405dcabc967df56d5eb6713cc9908bc05ecaa2075ff1092f", + "mq.schema.json": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a", + "oss-upload.schema.json": "d3f6ffc5e004fba8acbb6a18495be508a63ec45766bab1ef946ece58bbad84de", + "p1-development-profile.json": "2aa7cd3f4fa07f7e1a3037107fa7a56183f28370ea06a2a9b8dc18c8790eee2d", + "p1-development-profile.schema.json": "68e1b46194a1f5fa5bc763ba424411f48ada1ab52d39e6f800315a832692794b", + "release-manifest.json": "251127dfbb8fe60da58312c9785bc7eafa4135ef91f241a8e07c3868a30e123a", + "saas.openapi.yaml": "368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb", + "sip-management.openapi.yaml": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281", + "static-cell-artifact.schema.json": "46333f6a161ebbfd42f4a326e2509d562164fe836e6d1c88368428795362138d" + }, + "files": { + "README.md": "3ca0606a76cd63d411e4ad4ec9b3334b7e62f9bda63653620584e29e57894979", + "ai-authorization.schema.json": "d954218b1d8c6f3a8d83e959425ed6cab0f63593d18b4876a5cd374c9cf1ded6", + "ai-config.schema.json": "62a5205b00c287e9ea14b9ce2e681d7945a2cd1f5fb0d4809c2d0ec76a009486", + "dispatcher-config.schema.json": "37bf6d4636005e49bb60a016c1045325ae9799b8ce58d2a601f889961a78a73c", + "event-payloads.schema.json": "e24869dfc946e20847d37e6f13c5ce1312dc3db8c5c7e1b1794422358df87e65", + "examples/ai-config-request.json": "d6fba0382ccbac1e1585052326e9a393d941ee7d1e96cce19ece462a92c7e374", + "examples/ai-config-result.json": "e93474304ef5d7785dd56ac83823d45846f94d54c7047ee8672190eccf8cb90d", + "examples/call-execute.json": "b96a7ab2eed238b0c8c1d9534a8932955819e32b7e5d0e554fad076c3c3a4b7a", + "examples/call-query-result.json": "07bfe067e56138964f2708c1860ab57c22b06567a8d633d21d6057d2a0004004", + "examples/call-query.json": "3a1f2af373a8504da32f3a113ae7a033eaeeb50dbcee5836d2042026ffc63902", + "examples/call-replay.json": "74bdc281b27bb6513f8199455149545a9ed446a59cba1e3463db2e58884cbe0d", + "examples/command-query-result.json": "34e533bd6f07c8f72f79964f7637dc39052f8ba0dc16f3b4dcf11aeff4f898b3", + "examples/command-query.json": "76392b74382d8dfb02b987c8b2a6e8b9a56c5311557557579756e507ddb48d04", + "examples/command-replay.json": "a885310aebddc6c0e579e32e328690795668ecfcf6a26990ab1f09b89e25bc5a", + "examples/dispatcher-config.json": "0432e3e4b758c9d049f40ed52581d48422a9e1a87c8f5a95590a26f8d7dae872", + "examples/event-call-finished.json": "206c9c95273012720800ec41ac5b4347ade4288b3c086c9f782d62e96b1eaef5", + "examples/event-call-status.json": "ed68df5d40efd8b71bc0cc00fd67c5e65d68a2580361ff3d464cdaf44af09013", + "examples/event-command-result.json": "21d86e11db96374f393126eb7bb1f7188609292d446bf322f521c15c8719a5b4", + "examples/event-contact-opt-out.json": "6b396965c33f5e284363d612bb5bf53dcdb3ec4b56eea26d6c89778ba471f57b", + "examples/event-recording-failed.json": "911086c4954474a5494b7054660629f22d1f10a21fe129091ec0609cc16616ef", + "examples/event-recording-uploaded.json": "3189da1e9966931b92aa5b9e13dab1f70d9abda1822e8c7772bcf04f24aa09f4", + "examples/event-transcript-failed.json": "56385f45b43394a6061853343600e4ebf5aa01d7b6d0ff23ea79628ebfecb308", + "examples/event-transcript-updated.json": "4747266786b1cbfdfe483e5c3c7c36c587eab710fd77eb7152b8172f1620ca3e", + "examples/invalid-call-query-extra.json": "d5baeaacf73f4fbe4d57b1fcb6a3ca8c82603d763ccd2882298a412f00cdeb1a", + "examples/invalid-correlation.json": "79051aeecb5b4726d3a392b72e2cf6d9b90a873e3a1c5065879721655a859d4d", + "examples/invalid-dispatcher.json": "1a98856bf3d50a783eab538b37b1b84bd98ac5acca57a4c271244f5fa90b50e9", + "examples/invalid-v1.json": "dc795727448254a97781ae163583a0bb776a6ebcc589b1330d018083886a4a09", + "examples/invalid-wildcard.json": "becd89c7d9ccfc2343417efa9d7502a629ca3a33ee8dae419a121dfa01cb7257", + "examples/query-pending.json": "a3c4102102d4652d2c1ffaefec88d7ea50f694ad37811d82a1dfbd0cd985ca11", + "examples/query-rejected.json": "6260c6dbb5748a8140106930ac03a5a59c4d2103a30c66239c234e39f4800a11", + "examples/task-control.json": "f5b5df31c7a5d332412472db26f99d49b1d3a9aabdeb05e8a6dea9c4c85e4089", + "fixtures.json": "103ff072caafeb8a0399dccb27bc0619068e9b35baf738054fdfb3c225135c78", + "mq-topology.json": "ade0f2dcf4247351db82ca7498941b925c832ef1ee6d546c5bf34a4921011c68", + "mq.schema.json": "dc212fa183efc90843e6180af49482502ad4445d2be234b910c3b2570010deb7", + "oss-upload.schema.json": "7e80b414d27d98e44cf755faeacc30e51612cf45c6edfa1d1e55f675b6bcd50c", + "p1-development-profile.schema.json": "e1a5504dd38cf7f7503fa59b894eac65a50e32f7ddac13694f7a92ef633a0bcd", + "static-cell-artifact.schema.json": "7ff58fcfc3d5911ea7444a6e86557410e94d8b395501940c379ef80040048630" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/mq-topology.json b/contracts/upstream/2026-09-21-p1-v2/mq-topology.json new file mode 100644 index 0000000..c7344d1 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/mq-topology.json @@ -0,0 +1,32 @@ +{ + "version": "2.0", + "exchanges": { + "agent-call.dispatchers.v2": { + "type": "topic", + "durable": true + }, + "agent-call.saas.v2": { + "type": "topic", + "durable": true + }, + "agent-call.dead-letter.v2": { + "type": "topic", + "durable": true + } + }, + "inbox_queue": "agent-call.d..t..v2", + "dead_letter_queue": "agent-call.d..t..dlq.v2", + "inbound_key": "d..t..in", + "outbound_key": "d..t..out", + "saas_queue": "agent-call.saas.events.v2", + "owner_queue": "agent-call.d..owner.v2", + "owner_exclusive": true, + "business_queues_durable": true, + "message_persistent": true, + "publish_mandatory": true, + "publisher_confirms": true, + "tenant_key_max_utf8_bytes": 196, + "message_max_bytes": 262144, + "service_request_deadline_seconds": 30, + "upload_token_seconds": 900 +} diff --git a/contracts/upstream/2026-09-21-p1-v2/mq.schema.json b/contracts/upstream/2026-09-21-p1-v2/mq.schema.json new file mode 100644 index 0000000..3081590 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/mq.schema.json @@ -0,0 +1,956 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [ + { + "$ref": "#/$defs/command" + }, + { + "$ref": "#/$defs/event" + }, + { + "$ref": "#/$defs/request" + }, + { + "$ref": "#/$defs/response" + } + ], + "$defs": { + "aiConfigResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "snapshot", + "authorization" + ], + "properties": { + "snapshot": { + "$ref": "#/$defs/ai_AgentVersion" + }, + "authorization": { + "$ref": "ai-authorization.schema.json" + } + } + }, + "ai_AgentVersion": { + "type": "object", + "required": [ + "tenant_id", + "agent_version_id", + "status", + "immutable", + "content_sha256", + "config" + ], + "properties": { + "tenant_id": { + "type": "string" + }, + "agent_version_id": { + "type": "string" + }, + "status": { + "enum": [ + "published", + "reused" + ] + }, + "immutable": { + "const": true + }, + "content_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "config": { + "$ref": "ai-config.schema.json" + } + } + }, + "callReplay": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "call_id" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "call_id": { + "$ref": "#/$defs/id" + } + } + }, + "command": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "command_type", + "command_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload", + "dispatcher_id" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "command_type": { + "enum": [ + "call.execute", + "task.control", + "call.replay", + "command.replay" + ] + }, + "command_id": { + "$ref": "#/$defs/id" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "$ref": "#/$defs/time" + }, + "not_after": { + "$ref": "#/$defs/time" + }, + "payload": { + "type": "object" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + } + }, + "allOf": [ + { + "if": { + "properties": { + "command_type": { + "const": "call.execute" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/executePayload" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "task.control" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/taskControl" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "call.replay" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/callReplay" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "command.replay" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/commandReplay" + } + } + } + } + ] + }, + "commandReplay": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "source_command_id" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "source_command_id": { + "$ref": "#/$defs/id" + } + } + }, + "dispatcherId": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "event": { + "$ref": "event-payloads.schema.json" + }, + "executePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "execution_id", + "task_id", + "task_item_id", + "task_revision", + "callee", + "route_policy_id", + "caller_profile_id", + "agent_version_id", + "variables", + "ring_timeout_ms", + "max_call_duration_ms" + ], + "properties": { + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "task_revision": { + "type": "integer", + "minimum": 1 + }, + "callee": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "route_policy_id": { + "$ref": "#/$defs/id" + }, + "caller_profile_id": { + "$ref": "#/$defs/id" + }, + "agent_version_id": { + "$ref": "#/$defs/id" + }, + "variables": { + "type": "object", + "additionalProperties": true + }, + "ring_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_call_duration_ms": { + "type": "integer", + "minimum": 1 + } + } + }, + "executor_Call": { + "type": "object", + "required": [ + "call_id", + "execution_id", + "call_state", + "call_version", + "attempts", + "transcript", + "recordings", + "delivery", + "snapshot_at" + ], + "properties": { + "call_id": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "task_item_id": { + "type": "string" + }, + "call_state": { + "type": "string" + }, + "call_version": { + "type": "integer" + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "ended_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "duration_ms": { + "type": [ + "integer", + "null" + ] + }, + "attempts": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "call.status" + ] + } + } + } + ] + } + }, + "transcript": { + "type": "object", + "additionalProperties": false, + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "transcript.updated", + "transcript.failed" + ] + } + } + } + ] + } + } + } + }, + "recordings": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "recording.uploaded", + "recording.failed" + ] + } + } + } + ] + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "pending", + "retry", + "dispatching", + "published" + ], + "properties": { + "pending": { + "type": "integer", + "minimum": 0 + }, + "retry": { + "type": "integer", + "minimum": 0 + }, + "dispatching": { + "type": "integer", + "minimum": 0 + }, + "published": { + "type": "integer", + "minimum": 0 + } + } + }, + "snapshot_at": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "executor_Command": { + "type": "object", + "required": [ + "command_id", + "command_type", + "tenant_id", + "tenant_key", + "status", + "aggregate_version" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/executor_Id" + }, + "command_type": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "task_id": { + "type": [ + "string", + "null" + ] + }, + "execution_id": { + "type": [ + "string", + "null" + ] + }, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "wait_reason_code": { + "type": [ + "string", + "null" + ] + }, + "accepted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "admission_deadline": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "requested_task_revision": { + "type": [ + "integer", + "null" + ] + }, + "applied_task_revision": { + "type": [ + "integer", + "null" + ] + }, + "task_state": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "aggregate_version": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + }, + "executor_Id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s/\\\\]+$" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s/\\\\]+$" + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "message_type", + "message_id", + "dispatcher_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "message_type": { + "enum": [ + "command.query", + "call.query", + "ai.config.request" + ] + }, + "message_id": { + "$ref": "#/$defs/id" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "not_after": { + "type": "string", + "format": "date-time" + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "message_type": { + "const": "command.query" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "command_id" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + }, + { + "if": { + "properties": { + "message_type": { + "const": "call.query" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + }, + { + "if": { + "properties": { + "message_type": { + "const": "ai.config.request" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "agent_version_id" + ], + "properties": { + "agent_version_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + } + ] + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "message_type", + "message_id", + "dispatcher_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "correlation_id", + "status", + "reason_code", + "payload" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "message_type": { + "enum": [ + "command.query.result", + "call.query.result", + "ai.config.result" + ] + }, + "message_id": { + "$ref": "#/$defs/id" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "correlation_id": { + "$ref": "#/$defs/id" + }, + "status": { + "enum": [ + "ok", + "pending", + "rejected" + ] + }, + "reason_code": { + "type": "string" + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "pending" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "waiting" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": {} + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "reason_code": { + "enum": [ + "invalid_request", + "not_found", + "conflict", + "expired", + "not_authorized", + "unavailable", + "unsupported" + ] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "detail", + "retryable" + ], + "properties": { + "detail": { + "type": "string", + "maxLength": 1024 + }, + "retryable": { + "type": "boolean" + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "command.query.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/executor_Command" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "call.query.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/executor_Call" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "ai.config.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/aiConfigResult" + } + } + } + } + ] + }, + "taskControl": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "expected_task_revision", + "reason", + "task_id" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "pause", + "resume", + "stop" + ] + }, + "expected_task_revision": { + "type": "integer", + "minimum": 1 + }, + "active_call_policy": { + "type": "string", + "enum": [ + "drain", + "hangup" + ] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "task_id": { + "$ref": "#/$defs/id" + } + } + }, + "tenantKey": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "not": { + "pattern": "(^|\\.)[*#](\\.|$)" + }, + "$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget." + }, + "time": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/oss-upload.schema.json b/contracts/upstream/2026-09-21-p1-v2/oss-upload.schema.json new file mode 100644 index 0000000..8b2ada1 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/oss-upload.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/oss-upload.schema.json", + "title": "Recording upload control-plane messages", + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string" + } + }, + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "request" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "call_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "format": { + "enum": [ + "wav", + "raw_pcm", + "pcma" + ] + }, + "channels": { + "const": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000 + }, + "duration_ms": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "upload_id", + "recording_id", + "call_id", + "size_bytes", + "checksum_sha256", + "format", + "channels", + "sample_rate_hz", + "duration_ms" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "grant" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "upload_url": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "required_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "max_bytes": { + "type": "integer", + "minimum": 1 + }, + "object_binding": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "upload_id", + "recording_id", + "upload_url", + "required_headers", + "expires_at", + "max_bytes", + "object_binding" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "complete" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "etag": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "required": [ + "upload_id", + "recording_id", + "size_bytes", + "checksum_sha256", + "etag" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "verified" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "oss_id": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "verified_at": { + "type": "string", + "format": "date-time" + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "upload_id", + "recording_id", + "oss_id", + "verified_at", + "checksum_sha256" + ] + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v2/p1-development-profile.schema.json b/contracts/upstream/2026-09-21-p1-v2/p1-development-profile.schema.json new file mode 100644 index 0000000..1f3526b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/p1-development-profile.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/p1-development-profile.schema.json", + "title": "P1 isolated development profile", + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "profile_version", + "environment", + "external_calls", + "real_authorization", + "topology", + "limits", + "modes", + "retention", + "status" + ], + "properties": { + "profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "profile_version": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "environment": { + "const": "mock" + }, + "external_calls": { + "const": false + }, + "real_authorization": { + "const": "blocked" + }, + "topology": { + "type": "object", + "additionalProperties": false, + "required": [ + "dispatcher_count", + "agent_count", + "cell_count", + "tenant_count" + ], + "properties": { + "dispatcher_count": { + "const": 1 + }, + "agent_count": { + "const": 2 + }, + "cell_count": { + "const": 2 + }, + "tenant_count": { + "type": "integer", + "minimum": 1 + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "global_concurrency", + "global_cps", + "tenant_concurrency", + "tenant_cps", + "pending_window_global", + "pending_window_per_tenant", + "lease_ttl_ms", + "final_permit_ttl_ms" + ], + "properties": { + "global_concurrency": { + "type": "integer", + "minimum": 1 + }, + "global_cps": { + "type": "integer", + "minimum": 1 + }, + "tenant_concurrency": { + "type": "integer", + "minimum": 1 + }, + "tenant_cps": { + "type": "integer", + "minimum": 1 + }, + "pending_window_global": { + "type": "integer", + "minimum": 1 + }, + "pending_window_per_tenant": { + "type": "integer", + "minimum": 1 + }, + "lease_ttl_ms": { + "const": 10000 + }, + "final_permit_ttl_ms": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + } + }, + "modes": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "enum": [ + "full_ai", + "asr_only" + ] + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": [ + "keep_unverified_assets", + "delete_only_after_verified", + "backup_kind" + ], + "properties": { + "keep_unverified_assets": { + "const": true + }, + "delete_only_after_verified": { + "const": true + }, + "backup_kind": { + "enum": [ + "local-sqlite-wal-consistent", + "not-configured" + ] + } + } + }, + "status": { + "const": "development-only-not-production-approval" + }, + "real_budget": { + "const": "unknown" + }, + "operator_approval": { + "const": "not-granted" + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v2/static-cell-artifact.schema.json b/contracts/upstream/2026-09-21-p1-v2/static-cell-artifact.schema.json new file mode 100644 index 0000000..0dba3eb --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v2/static-cell-artifact.schema.json @@ -0,0 +1,369 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v2/static-cell-artifact.schema.json", + "title": "Project-owned v1 static Cell/SIP hand-off artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "source_release", + "source_digest", + "approval_reference", + "cell_id", + "revision", + "config_sha256", + "mode", + "allowed_targets", + "trunks" + ], + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_release": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "approval_reference": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "cell_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "mock", + "mixed", + "real" + ] + }, + "allowed_targets": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9]{11,15}$" + } + }, + "trunks": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "provider_id", + "egress_pool_id", + "codec", + "caller_profile_ids", + "dial_prefix", + "enabled", + "media_profile_id" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "egress_pool_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "codec": { + "const": "PCMA" + }, + "caller_profile_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "dial_prefix": { + "type": "string", + "maxLength": 32 + }, + "enabled": { + "type": "boolean" + }, + "sip_endpoint_ref": { + "type": "string", + "maxLength": 128 + }, + "credential_ref": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "media_profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "ari": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_url", + "websocket_url", + "application", + "credential_ref" + ], + "properties": { + "base_url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "websocket_url": { + "type": "string", + "format": "uri", + "pattern": "^wss?://" + }, + "application": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "media_profiles": { + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "alaw" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 8000 + }, + "payload_type": { + "const": 8 + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "slin16" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 16000 + }, + "payload_type": { + "type": "integer", + "minimum": 96, + "maximum": 127 + } + } + } + } + ] + } + }, + "media": { + "type": "object", + "additionalProperties": false, + "required": [ + "bind_address", + "port", + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "bind_address": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1024, + "maximum": 65535 + }, + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + } + }, + "recording": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "format", + "directory", + "max_bytes" + ], + "properties": { + "enabled": { + "const": true + }, + "format": { + "const": "wav" + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "max_bytes": { + "type": "integer", + "minimum": 16000, + "maximum": 1073741824 + } + } + }, + "load_evidence": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "asterisk_config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "loaded_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "not-yet-loaded", + "loaded" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "enum": [ + "mixed", + "real" + ] + } + } + }, + "then": { + "required": [ + "ari", + "media", + "media_profiles", + "recording" + ] + } + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v3/README.md b/contracts/upstream/2026-09-21-p1-v3/README.md new file mode 100644 index 0000000..37871db --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/README.md @@ -0,0 +1,7 @@ +# 2026-09-21-p1-v3 + +Project-local JCS/SHA-256 revision approved by the user. Derived from 2026-09-21-p1-v2; older packages are unchanged. Wire schema_version remains 2.0. + +AI digests use RFC 8785 canonical UTF-8 bytes followed by SHA-256, lowercase hexadecimal. This does not change file checksums, upload facts, command-body identity, or other hashes. Numbers follow JCS IEEE-754 rules; no Unicode normalization is performed. Explicit zero/false remain distinct from absent fields. No legacy digest fallback. + +Local agreement and generated fixtures are not external SaaS acceptance. diff --git a/contracts/upstream/2026-09-21-p1-v3/ai-authorization.schema.json b/contracts/upstream/2026-09-21-p1-v3/ai-authorization.schema.json new file mode 100644 index 0000000..df72a49 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/ai-authorization.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/ai-authorization.schema.json", + "title": "Dispatcher to Agent immutable AI authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_id", + "tenant_id", + "tenant_key", + "agent_version_id", + "config_sha256", + "mode", + "issued_at", + "expires_at", + "source", + "revoked" + ], + "properties": { + "authorization_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 224 + }, + "agent_version_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "enum": [ + "saas", + "mock-saas" + ] + }, + "credential_refs": { + "type": "object", + "additionalProperties": false, + "properties": { + "asr": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "llm": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tts": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "allowed_egress_pool_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "revoked": { + "type": "boolean" + }, + "revocation_reason": { + "type": "string", + "maxLength": 256 + } + }, + "allOf": [ + { + "if": { + "properties": { + "revoked": { + "const": true + } + } + }, + "then": { + "required": [ + "revocation_reason" + ] + } + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v3/ai-config.schema.json b/contracts/upstream/2026-09-21-p1-v3/ai-config.schema.json new file mode 100644 index 0000000..1f011f6 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/ai-config.schema.json @@ -0,0 +1,323 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": [ + "agent_version_id", + "immutable", + "asr", + "conversation" + ], + "properties": { + "agent_version_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "immutable": { + "const": true + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "llm": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "model" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + } + } + }, + "prompt": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "allowed_variables" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "allowed_variables": { + "type": "array", + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 32768 + } + } + }, + "tts": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "model", + "voice", + "format" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "voice": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "speed": { + "type": "number", + "minimum": 0.25, + "maximum": 3 + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "format": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels" + ], + "properties": { + "encoding": { + "enum": [ + "pcm_s16le", + "pcma" + ] + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "channels": { + "const": 1 + } + } + } + } + }, + "asr": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_ref", + "language", + "input" + ], + "properties": { + "provider_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "model": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "language": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "interim": { + "type": "boolean" + }, + "timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "encoding", + "sample_rate_hz", + "channels", + "sample_width_bytes" + ], + "properties": { + "encoding": { + "const": "pcm_s16le" + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "channels": { + "const": 1 + }, + "sample_width_bytes": { + "const": 2 + } + } + } + } + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": [ + "allow_interrupt", + "silence_timeout_ms", + "max_duration_ms", + "max_turns", + "sentence_max_chars", + "max_pending_audio_chunks" + ], + "properties": { + "opening": { + "type": "string", + "maxLength": 32768 + }, + "allow_interrupt": { + "type": "boolean" + }, + "silence_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_duration_ms": { + "type": "integer", + "minimum": 1, + "maximum": 3600000 + }, + "max_turns": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "sentence_max_chars": { + "type": "integer", + "minimum": 1 + }, + "max_pending_audio_chunks": { + "type": "integer", + "minimum": 1 + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": true + } + }, + "oneOf": [ + { + "title": "Full AI", + "required": [ + "llm", + "prompt", + "tts" + ], + "properties": { + "mode": { + "enum": [ + "full_ai" + ] + }, + "conversation": { + "required": [ + "opening" + ] + } + } + }, + { + "title": "ASR only", + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "asr_only" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "llm" + ] + }, + { + "required": [ + "prompt" + ] + }, + { + "required": [ + "tts" + ] + } + ] + } + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v3/ai-digest.json b/contracts/upstream/2026-09-21-p1-v3/ai-digest.json new file mode 100644 index 0000000..05e9cc7 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/ai-digest.json @@ -0,0 +1,15 @@ +{ + "scope": "AI snapshot.config and authorization config_sha256 only", + "canonicalization": "RFC 8785 (JCS)", + "digest": "SHA-256", + "encoding": "lowercase hex", + "implementation": "github.com/cyberphone/json-canonicalization", + "revision": "v0.0.0-20241213102144-19d51d7fe467", + "external_acceptance": false, + "invariants": [ + "No Unicode normalization", + "Reject invalid UTF-8 and duplicate object keys", + "Preserve explicit zero/false versus absent fields", + "No old-digest fallback" + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v3/dispatcher-config.schema.json b/contracts/upstream/2026-09-21-p1-v3/dispatcher-config.schema.json new file mode 100644 index 0000000..7a89000 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/dispatcher-config.schema.json @@ -0,0 +1,62 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "dispatcher_id", + "oss" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "dispatcher_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "oss": { + "type": "object", + "additionalProperties": false, + "required": [ + "endpoint", + "region", + "bucket", + "object_prefix", + "access_key_id_env", + "access_key_secret_env" + ], + "properties": { + "endpoint": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https?://" + }, + "region": { + "type": "string", + "minLength": 1 + }, + "bucket": { + "type": "string", + "minLength": 1 + }, + "object_prefix": { + "type": "string", + "minLength": 1 + }, + "access_key_id_env": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + }, + "access_key_secret_env": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/dispatcher-config.schema.json" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/event-payloads.schema.json b/contracts/upstream/2026-09-21-p1-v3/event-payloads.schema.json new file mode 100644 index 0000000..e553037 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/event-payloads.schema.json @@ -0,0 +1,646 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/event-payloads.schema.json", + "title": "Agent-call versioned event envelopes and payloads", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "event_id", + "event_type", + "tenant_id", + "tenant_key", + "trace_id", + "occurred_at", + "aggregate_type", + "aggregate_id", + "aggregate_version", + "payload", + "dispatcher_id" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "event_id": { + "$ref": "#/$defs/id" + }, + "event_type": { + "type": "string" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "not": { + "pattern": "(^|\\.)[*#](\\.|$)" + }, + "$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget." + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "aggregate_type": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "aggregate_id": { + "$ref": "#/$defs/id" + }, + "aggregate_version": { + "type": "integer", + "minimum": 1 + }, + "payload": { + "type": "object" + }, + "dispatcher_id": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + } + }, + "oneOf": [ + { + "properties": { + "event_type": { + "const": "command.result" + }, + "payload": { + "$ref": "#/$defs/command_result" + } + } + }, + { + "properties": { + "event_type": { + "const": "call.status" + }, + "payload": { + "$ref": "#/$defs/call_status" + } + } + }, + { + "properties": { + "event_type": { + "const": "transcript.updated" + }, + "payload": { + "$ref": "#/$defs/transcript_updated" + } + } + }, + { + "properties": { + "event_type": { + "const": "call.finished" + }, + "payload": { + "$ref": "#/$defs/call_finished" + } + } + }, + { + "properties": { + "event_type": { + "const": "recording.uploaded" + }, + "payload": { + "$ref": "#/$defs/recording_uploaded" + } + } + }, + { + "properties": { + "event_type": { + "const": "recording.failed" + }, + "payload": { + "$ref": "#/$defs/recording_failed" + } + } + }, + { + "properties": { + "event_type": { + "const": "transcript.failed" + }, + "payload": { + "$ref": "#/$defs/transcript_failed" + } + } + }, + { + "properties": { + "event_type": { + "const": "contact.opt_out" + }, + "payload": { + "$ref": "#/$defs/contact_opt_out" + } + } + } + ], + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "command_result": { + "type": "object", + "additionalProperties": false, + "required": [ + "command_id", + "command_type", + "status", + "reason_code" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/id" + }, + "command_type": { + "enum": [ + "call.execute", + "task.control", + "call.replay", + "command.replay" + ] + }, + "status": { + "enum": [ + "accepted", + "waiting", + "applied", + "rejected", + "failed", + "unknown" + ] + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "call_id": { + "$ref": "#/$defs/id" + }, + "requested_task_revision": { + "type": "integer", + "minimum": 0 + }, + "applied_task_revision": { + "type": "integer", + "minimum": 0 + }, + "admission_state": { + "enum": [ + "open", + "closed", + "draining", + "quarantined", + "unknown" + ] + }, + "resource_reservation_id": { + "$ref": "#/$defs/id" + }, + "permit_id": { + "$ref": "#/$defs/id" + } + } + }, + "call_status": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "execution_id", + "call_state", + "call_version", + "attempt_id", + "attempt_state" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "call_state": { + "enum": [ + "queued", + "dialing", + "ringing", + "answered", + "ended" + ] + }, + "call_version": { + "type": "integer", + "minimum": 1 + }, + "attempt_id": { + "$ref": "#/$defs/id" + }, + "attempt_state": { + "enum": [ + "pending", + "active", + "ended", + "unknown" + ] + }, + "route_policy_id": { + "$ref": "#/$defs/id" + }, + "caller_profile_id": { + "$ref": "#/$defs/id" + }, + "trunk_id": { + "$ref": "#/$defs/id" + }, + "cell_id": { + "$ref": "#/$defs/id" + }, + "egress_pool_id": { + "$ref": "#/$defs/id" + }, + "observed_at": { + "type": "string", + "format": "date-time" + }, + "reason_code": { + "type": "string", + "maxLength": 128 + } + } + }, + "transcript_updated": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "turn_id", + "segment_id", + "role", + "revision", + "text", + "is_final", + "start_ms", + "end_ms", + "playback_state" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "turn_id": { + "$ref": "#/$defs/id" + }, + "segment_id": { + "$ref": "#/$defs/id" + }, + "role": { + "enum": [ + "customer", + "agent", + "system" + ] + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "text": { + "type": "string", + "maxLength": 32768 + }, + "is_final": { + "type": "boolean" + }, + "start_ms": { + "type": "integer", + "minimum": 0 + }, + "end_ms": { + "type": "integer", + "minimum": 0 + }, + "playback_state": { + "enum": [ + "not_applicable", + "generated", + "sent", + "playback_confirmed", + "cancelled", + "unknown" + ] + } + } + }, + "call_finished": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "execution_id", + "call_version", + "outcome", + "started_at", + "ended_at", + "duration_ms", + "reason_code" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "call_version": { + "type": "integer", + "minimum": 1 + }, + "outcome": { + "enum": [ + "answered", + "no_answer", + "busy", + "failed", + "opt_out", + "cancelled", + "unknown" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "ended_at": { + "type": "string", + "format": "date-time" + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "attempt_summary": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/attempt_summary" + } + }, + "asset_state": { + "enum": [ + "pending", + "complete", + "failed", + "unknown" + ] + } + } + }, + "attempt_summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "attempt_id", + "state" + ], + "properties": { + "attempt_id": { + "$ref": "#/$defs/id" + }, + "state": { + "enum": [ + "pending", + "active", + "ended", + "unknown" + ] + }, + "trunk_id": { + "$ref": "#/$defs/id" + }, + "cell_id": { + "$ref": "#/$defs/id" + }, + "reason_code": { + "type": "string", + "maxLength": 128 + } + } + }, + "recording_failed": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "recording_id", + "stage", + "reason_code", + "retryable" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "recording_id": { + "$ref": "#/$defs/id" + }, + "stage": { + "enum": [ + "seal", + "request", + "upload", + "complete", + "verify", + "cleanup" + ] + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "retryable": { + "type": "boolean" + }, + "next_retry_at": { + "type": "string", + "format": "date-time" + } + } + }, + "transcript_failed": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "reason_code", + "retryable" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "reason_code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "retryable": { + "type": "boolean" + }, + "segment_id": { + "$ref": "#/$defs/id" + }, + "affected_segments": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/id" + } + } + } + }, + "contact_opt_out": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "task_id", + "task_item_id", + "requested_at" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "requested_at": { + "type": "string", + "format": "date-time" + }, + "turn_id": { + "$ref": "#/$defs/id" + }, + "segment_id": { + "$ref": "#/$defs/id" + } + } + }, + "recording_uploaded": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id", + "recording_id", + "format", + "channels", + "sample_rate_hz", + "duration_ms", + "size_bytes", + "checksum_sha256", + "upload_id", + "bucket", + "object_key" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + }, + "recording_id": { + "$ref": "#/$defs/id" + }, + "format": { + "enum": [ + "wav", + "raw_pcm", + "pcma" + ] + }, + "channels": { + "const": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000, + "maximum": 48000 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "upload_id": { + "$ref": "#/$defs/id" + }, + "bucket": { + "type": "string", + "minLength": 1, + "maxLength": 63 + }, + "object_key": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-asr-only.json b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-asr-only.json new file mode 100644 index 0000000..7ecf915 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-asr-only.json @@ -0,0 +1,21 @@ +{ + "agent_version_id": "agent_asr_v1", + "immutable": true, + "mode": "asr_only", + "asr": { + "provider_ref": "mock", + "model": "mock-asr-v1", + "language": "zh-CN", + "input": {"encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2}, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "allow_interrupt": false, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-full-explicit.json b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-full-explicit.json new file mode 100644 index 0000000..8e0bc7f --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version-full-explicit.json @@ -0,0 +1,41 @@ +{ + "agent_version_id": "agent_full_v1", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "speed": 1.0, + "format": {"encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1}, + "timeout_ms": 5000 + }, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": {"encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2}, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "opening": "", + "allow_interrupt": true, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/agent-version.json b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/agent-version.json @@ -0,0 +1,49 @@ +{ + "agent_version_id": "agent_v1", + "immutable": true, + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000 + }, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "opening": "", + "allow_interrupt": true, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/ai-authorization.json b/contracts/upstream/2026-09-21-p1-v3/examples/ai-authorization.json new file mode 100644 index 0000000..4728fd6 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/ai-authorization.json @@ -0,0 +1,18 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "51a1f367066aaaaa7c5f5ce50b229eb8644d27ada57f8b5575c254dd4c9930d7", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "allowed_egress_pool_ids": [ + "egress-mock" + ], + "revoked": false +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-request.json b/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-request.json new file mode 100644 index 0000000..e80178b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-request.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "ai.config.request-a", + "message_type": "ai.config.request", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "agent_version_id": "version-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-result.json b/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-result.json new file mode 100644 index 0000000..71bc1f7 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/ai-config-result.json @@ -0,0 +1,90 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "ai-config-reply-a", + "message_type": "ai.config.result", + "correlation_id": "ai.config.request-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "snapshot": { + "tenant_id": "tenant-a", + "agent_version_id": "version-a", + "status": "published", + "immutable": true, + "content_sha256": "f300adadedbdedb40533134ca7d340528d88d70ee0494522e75f067e66a2e9a1", + "config": { + "agent_version_id": "version-a", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000 + }, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000 + }, + "conversation": { + "opening": "", + "allow_interrupt": true, + "silence_timeout_ms": 3000, + "max_duration_ms": 120000, + "max_turns": 20, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } + } + }, + "authorization": { + "authorization_id": "auth-1", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "agent_version_id": "version-a", + "config_sha256": "5f7a7531839338c0a0a9b8f8f5329c2f5d1aaa6e42d11bdd25446294b119ee8e", + "mode": "full_ai", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "allowed_egress_pool_ids": [ + "egress-mock" + ], + "revoked": false + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/call-execute.json b/contracts/upstream/2026-09-21-p1-v3/examples/call-execute.json new file mode 100644 index 0000000..1adfb12 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/call-execute.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/call-query-result.json b/contracts/upstream/2026-09-21-p1-v3/examples/call-query-result.json new file mode 100644 index 0000000..163514b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/call-query-result.json @@ -0,0 +1,157 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "call-query-reply-a", + "message_type": "call.query.result", + "correlation_id": "call.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "call_state": "answered", + "call_version": 1, + "attempts": [ + { + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "transcript": { + "events": [ + { + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ] + }, + "recordings": [ + { + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "delivery": { + "pending": 1, + "retry": 0, + "dispatching": 0, + "published": 0 + }, + "snapshot_at": "2026-09-21T00:00:00Z" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/call-query.json b/contracts/upstream/2026-09-21-p1-v3/examples/call-query.json new file mode 100644 index 0000000..5859a0e --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/call-query.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "call.query-a", + "message_type": "call.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/call-replay.json b/contracts/upstream/2026-09-21-p1-v3/examples/call-replay.json new file mode 100644 index 0000000..04e9eec --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/call-replay.json @@ -0,0 +1,15 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "call.replay-a", + "command_type": "call.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "call_id": "call-a", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/command-query-result.json b/contracts/upstream/2026-09-21-p1-v3/examples/command-query-result.json new file mode 100644 index 0000000..3c0daaf --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/command-query-result.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "accepted", + "aggregate_version": 1 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/command-query.json b/contracts/upstream/2026-09-21-p1-v3/examples/command-query.json new file mode 100644 index 0000000..365fde9 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/command-query.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "command.query-a", + "message_type": "command.query", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "command_id": "command-a" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/command-replay.json b/contracts/upstream/2026-09-21-p1-v3/examples/command-replay.json new file mode 100644 index 0000000..aa6f503 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/command-replay.json @@ -0,0 +1,15 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command.replay-a", + "command_type": "command.replay", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "source_command_id": "command-a", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/dispatcher-config.json b/contracts/upstream/2026-09-21-p1-v3/examples/dispatcher-config.json new file mode 100644 index 0000000..f60820c --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/dispatcher-config.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "oss": { + "endpoint": "https://oss.example.invalid", + "region": "example-region", + "bucket": "example-bucket", + "object_prefix": "recordings", + "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", + "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-call-finished.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-call-finished.json new file mode 100644 index 0000000..c1b6ac7 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-call-finished.json @@ -0,0 +1,34 @@ +{ + "schema_version": "2.0", + "event_id": "call.finished-event-a", + "event_type": "call.finished", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:03Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 2, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_version": 1, + "outcome": "answered", + "started_at": "2026-09-18T00:00:00Z", + "ended_at": "2026-09-18T00:00:03Z", + "duration_ms": 3000, + "reason_code": "normal_clearing", + "asset_state": "complete", + "attempt_summary": [ + { + "attempt_id": "attempt-1", + "state": "ended", + "trunk_id": "trunk-1", + "cell_id": "cell-1" + } + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-call-status.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-call-status.json new file mode 100644 index 0000000..3c6ba28 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-call-status.json @@ -0,0 +1,29 @@ +{ + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-command-result.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-command-result.json new file mode 100644 index 0000000..3f6c279 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-command-result.json @@ -0,0 +1,20 @@ +{ + "schema_version": "2.0", + "event_id": "command.result-event-a", + "event_type": "command.result", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "command", + "aggregate_id": "command-1", + "aggregate_version": 1, + "payload": { + "command_id": "command-1", + "command_type": "call.execute", + "status": "accepted", + "reason_code": "accepted", + "execution_id": "execution-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-contact-opt-out.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-contact-opt-out.json new file mode 100644 index 0000000..a0bc4cd --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-contact-opt-out.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "event_id": "contact.opt_out-event-a", + "event_type": "contact.opt_out", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:06Z", + "aggregate_type": "contact", + "aggregate_id": "contact-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "task_id": "task-1", + "task_item_id": "item-1", + "requested_at": "2026-09-18T00:00:06Z", + "turn_id": "turn-1", + "segment_id": "segment-1" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-failed.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-failed.json new file mode 100644 index 0000000..8bbd601 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-failed.json @@ -0,0 +1,21 @@ +{ + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-uploaded.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-uploaded.json new file mode 100644 index 0000000..38aff36 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-recording-uploaded.json @@ -0,0 +1,26 @@ +{ + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-failed.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-failed.json new file mode 100644 index 0000000..4f8084c --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-failed.json @@ -0,0 +1,22 @@ +{ + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-updated.json b/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-updated.json new file mode 100644 index 0000000..b08f673 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/event-transcript-updated.json @@ -0,0 +1,25 @@ +{ + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/invalid-call-query-extra.json b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-call-query-extra.json new file mode 100644 index 0000000..36b1de9 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-call-query-extra.json @@ -0,0 +1,158 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "call.query.result", + "correlation_id": "command.query-a", + "status": "ok", + "reason_code": "ok", + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "call_state": "answered", + "call_version": 1, + "attempts": [ + { + "schema_version": "2.0", + "event_id": "call.status-event-a", + "event_type": "call.status", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-a", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "execution_id": "execution-1", + "task_id": "task-1", + "task_item_id": "item-1", + "call_state": "answered", + "call_version": 1, + "attempt_id": "attempt-1", + "attempt_state": "active", + "route_policy_id": "route-1", + "caller_profile_id": "caller-1", + "trunk_id": "trunk-1", + "cell_id": "cell-1", + "egress_pool_id": "egress-1", + "observed_at": "2026-09-18T00:00:01Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "transcript": { + "events": [ + { + "schema_version": "2.0", + "event_id": "transcript.updated-event-a", + "event_type": "transcript.updated", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "transcript_segment", + "aggregate_id": "segment-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "turn_id": "turn-1", + "segment_id": "segment-1", + "role": "customer", + "revision": 1, + "text": "您好", + "is_final": true, + "start_ms": 0, + "end_ms": 600, + "playback_state": "not_applicable" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "transcript.failed-event-a", + "event_type": "transcript.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:05Z", + "aggregate_type": "transcript", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": [ + "segment-1" + ] + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ] + }, + "recordings": [ + { + "schema_version": "2.0", + "event_id": "recording.uploaded-event-a", + "event_type": "recording.uploaded", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:02Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "upload_id": "upload-a", + "bucket": "example-bucket", + "object_key": "recordings/recording-a.wav" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + }, + { + "schema_version": "2.0", + "event_id": "recording.failed-event-a", + "event_type": "recording.failed", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:04Z", + "aggregate_type": "recording", + "aggregate_id": "recording-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-a", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + }, + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6" + } + ], + "delivery": { + "pending": 1, + "retry": 0, + "dispatching": 0, + "published": 0, + "raw": {} + }, + "snapshot_at": "2026-09-21T00:00:00Z" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/invalid-correlation.json b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-correlation.json new file mode 100644 index 0000000..4c9d94f --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-correlation.json @@ -0,0 +1,20 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "status": "ok", + "reason_code": "ok", + "payload": { + "command_id": "command-a", + "command_type": "call.execute", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "status": "accepted", + "aggregate_version": 1 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/invalid-dispatcher.json b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-dispatcher.json new file mode 100644 index 0000000..f60819b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-dispatcher.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "dispatcher-a", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/invalid-v1.json b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-v1.json new file mode 100644 index 0000000..7757e3d --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-v1.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/invalid-wildcard.json b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-wildcard.json new file mode 100644 index 0000000..b31d0f0 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/invalid-wildcard.json @@ -0,0 +1,24 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant.#", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "command-a", + "command_type": "call.execute", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "execution_id": "execution-a", + "task_id": "task-a", + "task_item_id": "item-a", + "task_revision": 1, + "callee": "15003164745", + "route_policy_id": "route-a", + "caller_profile_id": "caller-a", + "agent_version_id": "version-a", + "variables": {}, + "ring_timeout_ms": 1000, + "max_call_duration_ms": 10000 + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/query-pending.json b/contracts/upstream/2026-09-21-p1-v3/examples/query-pending.json new file mode 100644 index 0000000..7e7afb3 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/query-pending.json @@ -0,0 +1,14 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "pending", + "reason_code": "waiting", + "payload": {} +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/query-rejected.json b/contracts/upstream/2026-09-21-p1-v3/examples/query-rejected.json new file mode 100644 index 0000000..15dc9d8 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/query-rejected.json @@ -0,0 +1,17 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "message_id": "query-reply-a", + "message_type": "command.query.result", + "correlation_id": "command.query-a", + "status": "rejected", + "reason_code": "not_found", + "payload": { + "detail": "command not found", + "retryable": false + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/examples/task-control.json b/contracts/upstream/2026-09-21-p1-v3/examples/task-control.json new file mode 100644 index 0000000..63bf5a6 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/examples/task-control.json @@ -0,0 +1,18 @@ +{ + "schema_version": "2.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "tenant_id": "tenant-a", + "tenant_key": "tenant-a", + "trace_id": "trace-v2", + "issued_at": "2026-09-21T00:00:00Z", + "command_id": "task.control-a", + "command_type": "task.control", + "not_after": "2026-09-21T00:00:30Z", + "payload": { + "task_id": "task-a", + "action": "pause", + "expected_task_revision": 1, + "active_call_policy": "drain", + "reason": "local-test" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/fixtures.json b/contracts/upstream/2026-09-21-p1-v3/fixtures.json new file mode 100644 index 0000000..aaa94cc --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/fixtures.json @@ -0,0 +1,132 @@ +[ + { + "file": "examples/call-execute.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/task-control.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/call-replay.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/command-replay.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/command-query.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/call-query.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/ai-config-request.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/query-pending.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/query-rejected.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/dispatcher-config.json", + "schema": "dispatcher-config.schema.json", + "valid": true + }, + { + "file": "examples/invalid-v1.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/invalid-wildcard.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/invalid-dispatcher.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/event-call-finished.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-call-status.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-command-result.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-contact-opt-out.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-recording-failed.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-recording-uploaded.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-transcript-failed.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/event-transcript-updated.json", + "schema": "event-payloads.schema.json", + "valid": true + }, + { + "file": "examples/command-query-result.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/invalid-correlation.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/call-query-result.json", + "schema": "mq.schema.json", + "valid": true + }, + { + "file": "examples/invalid-call-query-extra.json", + "schema": "mq.schema.json", + "valid": false + }, + { + "file": "examples/ai-config-result.json", + "schema": "mq.schema.json", + "valid": true + } +] diff --git a/contracts/upstream/2026-09-21-p1-v3/jcs-golden.json b/contracts/upstream/2026-09-21-p1-v3/jcs-golden.json new file mode 100644 index 0000000..685d6f8 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/jcs-golden.json @@ -0,0 +1,20 @@ +[ + { + "name": "numbers", + "input": "{\"b\":1.0,\"a\":[-0.0,1e30,4.50,2e-3]}", + "canonical": "{\"a\":[0,1e+30,4.5,0.002],\"b\":1}", + "sha256": "27b16876af192eae1396ce02a5d3039a686b683a52db168a5b3a05b67f98ab98" + }, + { + "name": "utf16-key-order", + "input": "{\"\\ue000\":1,\"\\ud800\\udc00\":2}", + "canonical": "{\"𐀀\":2,\"\":1}", + "sha256": "9d4cdc71dda603c42f9b21d88d0c2ffc31a76cd1bd461d7359406cf169845f1e" + }, + { + "name": "strings", + "input": "{\"s\":\"\\u20ac/\\n\",\"flag\":false,\"zero\":0}", + "canonical": "{\"flag\":false,\"s\":\"€/\\n\",\"zero\":0}", + "sha256": "718fd0f9b70d6c23dd9d8733dbf3289e9c56431eb65182d93bea2e4cd7742a46" + } +] diff --git a/contracts/upstream/2026-09-21-p1-v3/manifest.json b/contracts/upstream/2026-09-21-p1-v3/manifest.json new file mode 100644 index 0000000..238bb3b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/manifest.json @@ -0,0 +1,51 @@ +{ + "version": "2026-09-21-p1-v3", + "source": "project-approved-jcs-sha256", + "derived_from": "2026-09-21-p1-v2", + "source_manifest_sha256": "6ca4582ab5f0b5550508deaa9f0b3d6e6bac579f108b423be3a7c133a207ad33", + "files": { + "README.md": "47c062c0bd4a9062404b270f912c0d132d3807bf25e4aff5e811ed6cb400df6d", + "ai-authorization.schema.json": "3738ee6ffe4740c9f502c606647bfb5fcf4d1054c101a296ddaada715ee358f5", + "ai-config.schema.json": "3663ea0231455ea0e5898b0bbf27c35aa632f1967283037df5d092077d195848", + "ai-digest.json": "fdd0b66d070ec3c24a698eca0d7fd7d3b33d884c303e968e1152d3a7b3f1564a", + "dispatcher-config.schema.json": "970c26b2b3832b6c8787b52dba0ebd8de49feab30b7f70da8b43450412955580", + "event-payloads.schema.json": "9a991c81ba260857aa0ac0ac36a9b65e98ea2b0dfd78395270848e8ffdf842e0", + "examples/agent-version-asr-only.json": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b", + "examples/agent-version-full-explicit.json": "e590148448885a1e88c473ca032d5efd719270689c3cff7aa44ca9c6ffa2b321", + "examples/agent-version.json": "d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e", + "examples/ai-authorization.json": "2aacf5b3a8afbc5c457ad008323559f1d51ce31c85741631481b46c46118168d", + "examples/ai-config-request.json": "d6fba0382ccbac1e1585052326e9a393d941ee7d1e96cce19ece462a92c7e374", + "examples/ai-config-result.json": "c85fa2281eec78c6048bc8a1d8a866fd8ede08f870b7db7c7bc90c7dd5e0c63e", + "examples/call-execute.json": "b96a7ab2eed238b0c8c1d9534a8932955819e32b7e5d0e554fad076c3c3a4b7a", + "examples/call-query-result.json": "07bfe067e56138964f2708c1860ab57c22b06567a8d633d21d6057d2a0004004", + "examples/call-query.json": "3a1f2af373a8504da32f3a113ae7a033eaeeb50dbcee5836d2042026ffc63902", + "examples/call-replay.json": "74bdc281b27bb6513f8199455149545a9ed446a59cba1e3463db2e58884cbe0d", + "examples/command-query-result.json": "34e533bd6f07c8f72f79964f7637dc39052f8ba0dc16f3b4dcf11aeff4f898b3", + "examples/command-query.json": "76392b74382d8dfb02b987c8b2a6e8b9a56c5311557557579756e507ddb48d04", + "examples/command-replay.json": "a885310aebddc6c0e579e32e328690795668ecfcf6a26990ab1f09b89e25bc5a", + "examples/dispatcher-config.json": "0432e3e4b758c9d049f40ed52581d48422a9e1a87c8f5a95590a26f8d7dae872", + "examples/event-call-finished.json": "206c9c95273012720800ec41ac5b4347ade4288b3c086c9f782d62e96b1eaef5", + "examples/event-call-status.json": "ed68df5d40efd8b71bc0cc00fd67c5e65d68a2580361ff3d464cdaf44af09013", + "examples/event-command-result.json": "21d86e11db96374f393126eb7bb1f7188609292d446bf322f521c15c8719a5b4", + "examples/event-contact-opt-out.json": "6b396965c33f5e284363d612bb5bf53dcdb3ec4b56eea26d6c89778ba471f57b", + "examples/event-recording-failed.json": "911086c4954474a5494b7054660629f22d1f10a21fe129091ec0609cc16616ef", + "examples/event-recording-uploaded.json": "3189da1e9966931b92aa5b9e13dab1f70d9abda1822e8c7772bcf04f24aa09f4", + "examples/event-transcript-failed.json": "56385f45b43394a6061853343600e4ebf5aa01d7b6d0ff23ea79628ebfecb308", + "examples/event-transcript-updated.json": "4747266786b1cbfdfe483e5c3c7c36c587eab710fd77eb7152b8172f1620ca3e", + "examples/invalid-call-query-extra.json": "d5baeaacf73f4fbe4d57b1fcb6a3ca8c82603d763ccd2882298a412f00cdeb1a", + "examples/invalid-correlation.json": "79051aeecb5b4726d3a392b72e2cf6d9b90a873e3a1c5065879721655a859d4d", + "examples/invalid-dispatcher.json": "1a98856bf3d50a783eab538b37b1b84bd98ac5acca57a4c271244f5fa90b50e9", + "examples/invalid-v1.json": "dc795727448254a97781ae163583a0bb776a6ebcc589b1330d018083886a4a09", + "examples/invalid-wildcard.json": "becd89c7d9ccfc2343417efa9d7502a629ca3a33ee8dae419a121dfa01cb7257", + "examples/query-pending.json": "a3c4102102d4652d2c1ffaefec88d7ea50f694ad37811d82a1dfbd0cd985ca11", + "examples/query-rejected.json": "6260c6dbb5748a8140106930ac03a5a59c4d2103a30c66239c234e39f4800a11", + "examples/task-control.json": "f5b5df31c7a5d332412472db26f99d49b1d3a9aabdeb05e8a6dea9c4c85e4089", + "fixtures.json": "103ff072caafeb8a0399dccb27bc0619068e9b35baf738054fdfb3c225135c78", + "jcs-golden.json": "371ee1ddc2f8626215f90b9e64de79d932b757f79b84ad1a55ccdd713d9e1812", + "mq-topology.json": "ade0f2dcf4247351db82ca7498941b925c832ef1ee6d546c5bf34a4921011c68", + "mq.schema.json": "13cfb57e5783f9abe1a3853d11765cacffdd2e2c34a38bf5bb68eb4744e0b889", + "oss-upload.schema.json": "cd9f98476550c4716b9cbbf4abe329a4a132a42f633c4c3db7333bfed04ec13a", + "p1-development-profile.schema.json": "c9a69466f9aa1a23ef185e0b9e0a1b8b833239609f128e33661fd051f9a115a9", + "static-cell-artifact.schema.json": "21e872c3f932e09435b89d129568e5d59e341bef5c3bfed792d06f75afbaae1f" + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/mq-topology.json b/contracts/upstream/2026-09-21-p1-v3/mq-topology.json new file mode 100644 index 0000000..c7344d1 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/mq-topology.json @@ -0,0 +1,32 @@ +{ + "version": "2.0", + "exchanges": { + "agent-call.dispatchers.v2": { + "type": "topic", + "durable": true + }, + "agent-call.saas.v2": { + "type": "topic", + "durable": true + }, + "agent-call.dead-letter.v2": { + "type": "topic", + "durable": true + } + }, + "inbox_queue": "agent-call.d..t..v2", + "dead_letter_queue": "agent-call.d..t..dlq.v2", + "inbound_key": "d..t..in", + "outbound_key": "d..t..out", + "saas_queue": "agent-call.saas.events.v2", + "owner_queue": "agent-call.d..owner.v2", + "owner_exclusive": true, + "business_queues_durable": true, + "message_persistent": true, + "publish_mandatory": true, + "publisher_confirms": true, + "tenant_key_max_utf8_bytes": 196, + "message_max_bytes": 262144, + "service_request_deadline_seconds": 30, + "upload_token_seconds": 900 +} diff --git a/contracts/upstream/2026-09-21-p1-v3/mq.schema.json b/contracts/upstream/2026-09-21-p1-v3/mq.schema.json new file mode 100644 index 0000000..e2cf96b --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/mq.schema.json @@ -0,0 +1,956 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [ + { + "$ref": "#/$defs/command" + }, + { + "$ref": "#/$defs/event" + }, + { + "$ref": "#/$defs/request" + }, + { + "$ref": "#/$defs/response" + } + ], + "$defs": { + "aiConfigResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "snapshot", + "authorization" + ], + "properties": { + "snapshot": { + "$ref": "#/$defs/ai_AgentVersion" + }, + "authorization": { + "$ref": "ai-authorization.schema.json" + } + } + }, + "ai_AgentVersion": { + "type": "object", + "required": [ + "tenant_id", + "agent_version_id", + "status", + "immutable", + "content_sha256", + "config" + ], + "properties": { + "tenant_id": { + "type": "string" + }, + "agent_version_id": { + "type": "string" + }, + "status": { + "enum": [ + "published", + "reused" + ] + }, + "immutable": { + "const": true + }, + "content_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "config": { + "$ref": "ai-config.schema.json" + } + } + }, + "callReplay": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "call_id" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "call_id": { + "$ref": "#/$defs/id" + } + } + }, + "command": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "command_type", + "command_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload", + "dispatcher_id" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "command_type": { + "enum": [ + "call.execute", + "task.control", + "call.replay", + "command.replay" + ] + }, + "command_id": { + "$ref": "#/$defs/id" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "$ref": "#/$defs/time" + }, + "not_after": { + "$ref": "#/$defs/time" + }, + "payload": { + "type": "object" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + } + }, + "allOf": [ + { + "if": { + "properties": { + "command_type": { + "const": "call.execute" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/executePayload" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "task.control" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/taskControl" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "call.replay" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/callReplay" + } + } + } + }, + { + "if": { + "properties": { + "command_type": { + "const": "command.replay" + } + } + }, + "then": { + "properties": { + "payload": { + "$ref": "#/$defs/commandReplay" + } + } + } + } + ] + }, + "commandReplay": { + "type": "object", + "additionalProperties": false, + "required": [ + "reason", + "source_command_id" + ], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "source_command_id": { + "$ref": "#/$defs/id" + } + } + }, + "dispatcherId": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "event": { + "$ref": "event-payloads.schema.json" + }, + "executePayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "execution_id", + "task_id", + "task_item_id", + "task_revision", + "callee", + "route_policy_id", + "caller_profile_id", + "agent_version_id", + "variables", + "ring_timeout_ms", + "max_call_duration_ms" + ], + "properties": { + "execution_id": { + "$ref": "#/$defs/id" + }, + "task_id": { + "$ref": "#/$defs/id" + }, + "task_item_id": { + "$ref": "#/$defs/id" + }, + "task_revision": { + "type": "integer", + "minimum": 1 + }, + "callee": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "route_policy_id": { + "$ref": "#/$defs/id" + }, + "caller_profile_id": { + "$ref": "#/$defs/id" + }, + "agent_version_id": { + "$ref": "#/$defs/id" + }, + "variables": { + "type": "object", + "additionalProperties": true + }, + "ring_timeout_ms": { + "type": "integer", + "minimum": 1 + }, + "max_call_duration_ms": { + "type": "integer", + "minimum": 1 + } + } + }, + "executor_Call": { + "type": "object", + "required": [ + "call_id", + "execution_id", + "call_state", + "call_version", + "attempts", + "transcript", + "recordings", + "delivery", + "snapshot_at" + ], + "properties": { + "call_id": { + "type": "string" + }, + "execution_id": { + "type": "string" + }, + "task_id": { + "type": "string" + }, + "task_item_id": { + "type": "string" + }, + "call_state": { + "type": "string" + }, + "call_version": { + "type": "integer" + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "outcome": { + "type": [ + "string", + "null" + ] + }, + "started_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "ended_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "duration_ms": { + "type": [ + "integer", + "null" + ] + }, + "attempts": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "call.status" + ] + } + } + } + ] + } + }, + "transcript": { + "type": "object", + "additionalProperties": false, + "required": [ + "events" + ], + "properties": { + "events": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "transcript.updated", + "transcript.failed" + ] + } + } + } + ] + } + } + } + }, + "recordings": { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "event-payloads.schema.json" + }, + { + "properties": { + "event_type": { + "enum": [ + "recording.uploaded", + "recording.failed" + ] + } + } + } + ] + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "pending", + "retry", + "dispatching", + "published" + ], + "properties": { + "pending": { + "type": "integer", + "minimum": 0 + }, + "retry": { + "type": "integer", + "minimum": 0 + }, + "dispatching": { + "type": "integer", + "minimum": 0 + }, + "published": { + "type": "integer", + "minimum": 0 + } + } + }, + "snapshot_at": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "executor_Command": { + "type": "object", + "required": [ + "command_id", + "command_type", + "tenant_id", + "tenant_key", + "status", + "aggregate_version" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/executor_Id" + }, + "command_type": { + "type": "string" + }, + "tenant_id": { + "type": "string" + }, + "tenant_key": { + "type": "string" + }, + "task_id": { + "type": [ + "string", + "null" + ] + }, + "execution_id": { + "type": [ + "string", + "null" + ] + }, + "call_id": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "reason_code": { + "type": [ + "string", + "null" + ] + }, + "wait_reason_code": { + "type": [ + "string", + "null" + ] + }, + "accepted_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "waiting_since": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "admission_deadline": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "requested_task_revision": { + "type": [ + "integer", + "null" + ] + }, + "applied_task_revision": { + "type": [ + "integer", + "null" + ] + }, + "task_state": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "aggregate_version": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + }, + "executor_Id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s/\\\\]+$" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s/\\\\]+$" + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "message_type", + "message_id", + "dispatcher_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "message_type": { + "enum": [ + "command.query", + "call.query", + "ai.config.request" + ] + }, + "message_id": { + "$ref": "#/$defs/id" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "not_after": { + "type": "string", + "format": "date-time" + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "message_type": { + "const": "command.query" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "command_id" + ], + "properties": { + "command_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + }, + { + "if": { + "properties": { + "message_type": { + "const": "call.query" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "call_id" + ], + "properties": { + "call_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + }, + { + "if": { + "properties": { + "message_type": { + "const": "ai.config.request" + } + } + }, + "then": { + "properties": { + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "agent_version_id" + ], + "properties": { + "agent_version_id": { + "$ref": "#/$defs/id" + } + } + } + } + } + } + ] + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "message_type", + "message_id", + "dispatcher_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "correlation_id", + "status", + "reason_code", + "payload" + ], + "properties": { + "schema_version": { + "const": "2.0" + }, + "message_type": { + "enum": [ + "command.query.result", + "call.query.result", + "ai.config.result" + ] + }, + "message_id": { + "$ref": "#/$defs/id" + }, + "dispatcher_id": { + "$ref": "#/$defs/dispatcherId" + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "correlation_id": { + "$ref": "#/$defs/id" + }, + "status": { + "enum": [ + "ok", + "pending", + "rejected" + ] + }, + "reason_code": { + "type": "string" + }, + "payload": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "pending" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "waiting" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": {} + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "rejected" + } + } + }, + "then": { + "properties": { + "reason_code": { + "enum": [ + "invalid_request", + "not_found", + "conflict", + "expired", + "not_authorized", + "unavailable", + "unsupported" + ] + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "detail", + "retryable" + ], + "properties": { + "detail": { + "type": "string", + "maxLength": 1024 + }, + "retryable": { + "type": "boolean" + } + } + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "command.query.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/executor_Command" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "call.query.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/executor_Call" + } + } + } + }, + { + "if": { + "properties": { + "status": { + "const": "ok" + }, + "message_type": { + "const": "ai.config.result" + } + } + }, + "then": { + "properties": { + "reason_code": { + "const": "ok" + }, + "payload": { + "$ref": "#/$defs/aiConfigResult" + } + } + } + } + ] + }, + "taskControl": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "expected_task_revision", + "reason", + "task_id" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "pause", + "resume", + "stop" + ] + }, + "expected_task_revision": { + "type": "integer", + "minimum": 1 + }, + "active_call_policy": { + "type": "string", + "enum": [ + "drain", + "hangup" + ] + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "task_id": { + "$ref": "#/$defs/id" + } + } + }, + "tenantKey": { + "type": "string", + "minLength": 1, + "maxLength": 196, + "not": { + "pattern": "(^|\\.)[*#](\\.|$)" + }, + "$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget." + }, + "time": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/oss-upload.schema.json b/contracts/upstream/2026-09-21-p1-v3/oss-upload.schema.json new file mode 100644 index 0000000..3b45e38 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/oss-upload.schema.json @@ -0,0 +1,205 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/oss-upload.schema.json", + "title": "Recording upload control-plane messages", + "type": "object", + "required": [ + "kind" + ], + "properties": { + "kind": { + "type": "string" + } + }, + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "request" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "call_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "format": { + "enum": [ + "wav", + "raw_pcm", + "pcma" + ] + }, + "channels": { + "const": 1 + }, + "sample_rate_hz": { + "type": "integer", + "minimum": 8000 + }, + "duration_ms": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "upload_id", + "recording_id", + "call_id", + "size_bytes", + "checksum_sha256", + "format", + "channels", + "sample_rate_hz", + "duration_ms" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "grant" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "upload_url": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "required_headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "max_bytes": { + "type": "integer", + "minimum": 1 + }, + "object_binding": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": [ + "upload_id", + "recording_id", + "upload_url", + "required_headers", + "expires_at", + "max_bytes", + "object_binding" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "complete" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "size_bytes": { + "type": "integer", + "minimum": 1 + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "etag": { + "type": [ + "string", + "null" + ], + "maxLength": 256 + } + }, + "required": [ + "upload_id", + "recording_id", + "size_bytes", + "checksum_sha256", + "etag" + ] + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "verified" + }, + "upload_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "recording_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "oss_id": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "verified_at": { + "type": "string", + "format": "date-time" + }, + "checksum_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "upload_id", + "recording_id", + "oss_id", + "verified_at", + "checksum_sha256" + ] + } + ] +} diff --git a/contracts/upstream/2026-09-21-p1-v3/p1-development-profile.schema.json b/contracts/upstream/2026-09-21-p1-v3/p1-development-profile.schema.json new file mode 100644 index 0000000..d9e832a --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/p1-development-profile.schema.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/p1-development-profile.schema.json", + "title": "P1 isolated development profile", + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "profile_version", + "environment", + "external_calls", + "real_authorization", + "topology", + "limits", + "modes", + "retention", + "status" + ], + "properties": { + "profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "profile_version": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "environment": { + "const": "mock" + }, + "external_calls": { + "const": false + }, + "real_authorization": { + "const": "blocked" + }, + "topology": { + "type": "object", + "additionalProperties": false, + "required": [ + "dispatcher_count", + "agent_count", + "cell_count", + "tenant_count" + ], + "properties": { + "dispatcher_count": { + "const": 1 + }, + "agent_count": { + "const": 2 + }, + "cell_count": { + "const": 2 + }, + "tenant_count": { + "type": "integer", + "minimum": 1 + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "global_concurrency", + "global_cps", + "tenant_concurrency", + "tenant_cps", + "pending_window_global", + "pending_window_per_tenant", + "lease_ttl_ms", + "final_permit_ttl_ms" + ], + "properties": { + "global_concurrency": { + "type": "integer", + "minimum": 1 + }, + "global_cps": { + "type": "integer", + "minimum": 1 + }, + "tenant_concurrency": { + "type": "integer", + "minimum": 1 + }, + "tenant_cps": { + "type": "integer", + "minimum": 1 + }, + "pending_window_global": { + "type": "integer", + "minimum": 1 + }, + "pending_window_per_tenant": { + "type": "integer", + "minimum": 1 + }, + "lease_ttl_ms": { + "const": 10000 + }, + "final_permit_ttl_ms": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + } + }, + "modes": { + "type": "array", + "minItems": 2, + "uniqueItems": true, + "items": { + "enum": [ + "full_ai", + "asr_only" + ] + } + }, + "retention": { + "type": "object", + "additionalProperties": false, + "required": [ + "keep_unverified_assets", + "delete_only_after_verified", + "backup_kind" + ], + "properties": { + "keep_unverified_assets": { + "const": true + }, + "delete_only_after_verified": { + "const": true + }, + "backup_kind": { + "enum": [ + "local-sqlite-wal-consistent", + "not-configured" + ] + } + } + }, + "status": { + "const": "development-only-not-production-approval" + }, + "real_budget": { + "const": "unknown" + }, + "operator_approval": { + "const": "not-granted" + } + } +} diff --git a/contracts/upstream/2026-09-21-p1-v3/static-cell-artifact.schema.json b/contracts/upstream/2026-09-21-p1-v3/static-cell-artifact.schema.json new file mode 100644 index 0000000..d612ae2 --- /dev/null +++ b/contracts/upstream/2026-09-21-p1-v3/static-cell-artifact.schema.json @@ -0,0 +1,369 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/2026-09-21-p1-v3/static-cell-artifact.schema.json", + "title": "Project-owned v1 static Cell/SIP hand-off artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "source_release", + "source_digest", + "approval_reference", + "cell_id", + "revision", + "config_sha256", + "mode", + "allowed_targets", + "trunks" + ], + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_release": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "approval_reference": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "cell_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "mock", + "mixed", + "real" + ] + }, + "allowed_targets": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9]{11,15}$" + } + }, + "trunks": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "provider_id", + "egress_pool_id", + "codec", + "caller_profile_ids", + "dial_prefix", + "enabled", + "media_profile_id" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "egress_pool_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "codec": { + "const": "PCMA" + }, + "caller_profile_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "dial_prefix": { + "type": "string", + "maxLength": 32 + }, + "enabled": { + "type": "boolean" + }, + "sip_endpoint_ref": { + "type": "string", + "maxLength": 128 + }, + "credential_ref": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "media_profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "ari": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_url", + "websocket_url", + "application", + "credential_ref" + ], + "properties": { + "base_url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "websocket_url": { + "type": "string", + "format": "uri", + "pattern": "^wss?://" + }, + "application": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "media_profiles": { + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "alaw" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 8000 + }, + "payload_type": { + "const": 8 + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "slin16" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 16000 + }, + "payload_type": { + "type": "integer", + "minimum": 96, + "maximum": 127 + } + } + } + } + ] + } + }, + "media": { + "type": "object", + "additionalProperties": false, + "required": [ + "bind_address", + "port", + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "bind_address": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1024, + "maximum": 65535 + }, + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + } + }, + "recording": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "format", + "directory", + "max_bytes" + ], + "properties": { + "enabled": { + "const": true + }, + "format": { + "const": "wav" + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "max_bytes": { + "type": "integer", + "minimum": 16000, + "maximum": 1073741824 + } + } + }, + "load_evidence": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "asterisk_config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "loaded_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "not-yet-loaded", + "loaded" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "enum": [ + "mixed", + "real" + ] + } + } + }, + "then": { + "required": [ + "ari", + "media", + "media_profiles", + "recording" + ] + } + } + ] +} diff --git a/contracts/v2_test.go b/contracts/v2_test.go new file mode 100644 index 0000000..838ffcd --- /dev/null +++ b/contracts/v2_test.go @@ -0,0 +1,268 @@ +package contracts_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "git.ipao.vip/rogee/go-sip/internal/tenant" +) + +const v2Dir = "upstream/2026-09-21-p1-v2" +const v2Base = "https://go-sip.local/contracts/2026-09-21-p1-v2/" + +// Contract tests must never fetch an unregistered schema from the network. +type offlineLoader struct{} + +func (offlineLoader) Load(url string) (any, error) { + return nil, fmt.Errorf("schema is not in the immutable local bundle: %s", url) +} + +func compileV2(t *testing.T, name string) *jsonschema.Schema { + t.Helper() + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + compiler.UseLoader(offlineLoader{}) + paths, err := filepath.Glob(filepath.Join(v2Dir, "*.schema.json")) + if err != nil || len(paths) == 0 { + t.Fatalf("new contract bundle missing: %v", err) + } + for _, path := range paths { + var schema any + if err := json.Unmarshal(readV2(t, path), &schema); err != nil { + t.Fatal(err) + } + if err := compiler.AddResource(v2Base+filepath.Base(path), schema); err != nil { + t.Fatal(err) + } + } + schema, err := compiler.Compile(v2Base + name) + if err != nil { + t.Fatal(err) + } + return schema +} + +func readV2(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return raw +} + +func objectV2(t *testing.T, path string) map[string]any { + t.Helper() + var value map[string]any + if err := json.Unmarshal(readV2(t, path), &value); err != nil { + t.Fatal(err) + } + return value +} + +func TestV2BundleFixtures(t *testing.T) { + for _, name := range []string{"mq.schema.json", "event-payloads.schema.json", "dispatcher-config.schema.json"} { + t.Run(name, func(t *testing.T) { compileV2(t, name) }) + } + var cases []struct { + File string `json:"file"` + Schema string `json:"schema"` + Valid bool `json:"valid"` + } + if err := json.Unmarshal(readV2(t, filepath.Join(v2Dir, "fixtures.json")), &cases); err != nil { + t.Fatal(err) + } + if len(cases) < 16 { + t.Fatal("missing bounded positive and negative message/config fixtures") + } + for _, tc := range cases { + t.Run(tc.File, func(t *testing.T) { + schema := compileV2(t, tc.Schema) + value := objectV2(t, filepath.Join(v2Dir, tc.File)) + err := schema.Validate(value) + if (err == nil) != tc.Valid { + t.Fatalf("valid=%v, validation=%v", tc.Valid, err) + } + }) + } +} + +func TestV2ExecuteIdentityAndTenantConstraints(t *testing.T) { + schema := compileV2(t, "mq.schema.json") + for _, tc := range []struct { + name string + edit func(map[string]any) + }{ + {"missing dispatcher", func(m map[string]any) { delete(m, "dispatcher_id") }}, + {"non UUID dispatcher", func(m map[string]any) { m["dispatcher_id"] = "dispatcher-a" }}, + {"uppercase dispatcher", func(m map[string]any) { m["dispatcher_id"] = strings.ToUpper(m["dispatcher_id"].(string)) }}, + {"wildcard tenant", func(m map[string]any) { m["tenant_key"] = "tenant.#" }}, + {"old version", func(m map[string]any) { m["schema_version"] = "1.0" }}, + {"extra root", func(m map[string]any) { m["metadata"] = map[string]any{} }}, + {"extra payload", func(m map[string]any) { m["payload"].(map[string]any)["mode"] = "full_ai" }}, + } { + t.Run(tc.name, func(t *testing.T) { + value := objectV2(t, filepath.Join(v2Dir, "examples/call-execute.json")) + tc.edit(value) + if err := schema.Validate(value); err == nil { + t.Fatal("invalid envelope accepted") + } + }) + } +} + +func TestV2ResponseCorrelationAndResultBranches(t *testing.T) { + schema := compileV2(t, "mq.schema.json") + for _, tc := range []struct { + name string + edit func(map[string]any) + }{ + {"missing correlation", func(m map[string]any) { delete(m, "correlation_id") }}, + {"wrong result", func(m map[string]any) { m["status"] = "rejected"; m["reason_code"] = "ok" }}, + {"response deadline", func(m map[string]any) { m["not_after"] = "2026-09-21T00:00:30Z" }}, + {"missing command ID", func(m map[string]any) { delete(m["payload"].(map[string]any), "command_id") }}, + {"extra result", func(m map[string]any) { m["payload"].(map[string]any)["raw"] = map[string]any{} }}, + } { + t.Run(tc.name, func(t *testing.T) { + value := objectV2(t, filepath.Join(v2Dir, "examples/command-query-result.json")) + tc.edit(value) + if err := schema.Validate(value); err == nil { + t.Fatal("invalid query response accepted") + } + }) + } +} + +func TestV2UploadIsAFactNotASaaSHandshake(t *testing.T) { + schema := compileV2(t, "mq.schema.json") + value := objectV2(t, filepath.Join(v2Dir, "examples/event-recording-uploaded.json")) + if err := schema.Validate(value); err != nil { + t.Fatal(err) + } + for _, field := range []string{"oss_id", "verified", "token", "upload_url", "access_key_secret"} { + t.Run(field, func(t *testing.T) { + value := objectV2(t, filepath.Join(v2Dir, "examples/event-recording-uploaded.json")) + value["payload"].(map[string]any)[field] = "not-a-real-value" + if err := schema.Validate(value); err == nil { + t.Fatal("uploaded fact accepted a credential or SaaS processing claim") + } + }) + } + old := objectV2(t, "upstream/2026-09-19-p1-v1/examples/event-recording-ready.json") + old["schema_version"] = "2.0" + old["dispatcher_id"] = value["dispatcher_id"] + if err := schema.Validate(old); err == nil { + t.Fatal("v2 still accepts the superseded recording.ready notification") + } + for _, kind := range []string{"recording.upload-session.request", "recording.upload-session.result", "recording.complete", "recording.verified"} { + if strings.Contains(string(readV2(t, filepath.Join(v2Dir, "mq.schema.json"))), `"`+kind+`"`) { + t.Errorf("removed upload handshake is still in the published schema: %s", kind) + } + } +} + +func TestV2DispatcherConfigDoesNotAcceptSecretsOrTTLOverride(t *testing.T) { + schema := compileV2(t, "dispatcher-config.schema.json") + for _, field := range []string{"access_key_id", "access_key_secret", "grant_ttl_seconds", "token"} { + t.Run(field, func(t *testing.T) { + value := objectV2(t, filepath.Join(v2Dir, "examples/dispatcher-config.json")) + value["oss"].(map[string]any)[field] = "not-a-real-value" + if err := schema.Validate(value); err == nil { + t.Fatal("unexpected secret/override field accepted") + } + }) + } +} + +func TestV2TopologyMatchesDispatcherRoutes(t *testing.T) { + var topology struct { + Exchanges map[string]struct { + Type string `json:"type"` + Durable bool `json:"durable"` + } `json:"exchanges"` + Inbox string `json:"inbox_queue"` + Dead string `json:"dead_letter_queue"` + Inbound string `json:"inbound_key"` + Outbound string `json:"outbound_key"` + Durable bool `json:"business_queues_durable"` + Persistent bool `json:"message_persistent"` + Mandatory bool `json:"publish_mandatory"` + Confirm bool `json:"publisher_confirms"` + TenantBytes int `json:"tenant_key_max_utf8_bytes"` + TokenSeconds int `json:"upload_token_seconds"` + } + if err := json.Unmarshal(readV2(t, filepath.Join(v2Dir, "mq-topology.json")), &topology); err != nil { + t.Fatal(err) + } + if !topology.Durable || !topology.Persistent || !topology.Mandatory || !topology.Confirm || topology.TenantBytes != 196 || topology.TokenSeconds != 900 { + t.Fatal("topology weakens the approved queue/token boundary") + } + if len(topology.Exchanges) != 3 { + t.Fatal("unexpected exchange topology") + } + for _, name := range []string{"agent-call.dispatchers.v2", "agent-call.saas.v2", "agent-call.dead-letter.v2"} { + x, ok := topology.Exchanges[name] + if !ok || x.Type != "topic" || !x.Durable { + t.Fatalf("wrong exchange contract: %s", name) + } + } + id := "c046b893-8628-4589-ae50-619d049248a6" + for _, key := range []string{"tenant-a", "租户.甲", strings.Repeat("a", 196)} { + route, err := tenant.NewDispatcherRoute(id, key) + if err != nil { + t.Fatal(err) + } + r := strings.NewReplacer("", id, "", key) + if route.InboxQueue != r.Replace(topology.Inbox) || route.DeadLetterQueue != r.Replace(topology.Dead) || route.InboundKey != r.Replace(topology.Inbound) || route.OutboundKey != r.Replace(topology.Outbound) { + t.Fatal("implementation differs from the published route templates") + } + } +} + +func TestV2ManifestPinsEveryBundleFile(t *testing.T) { + var manifest struct { + Version string `json:"version"` + Source string `json:"source"` + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(readV2(t, filepath.Join(v2Dir, "manifest.json")), &manifest); err != nil { + t.Fatal(err) + } + if manifest.Version != "2026-09-21-p1-v2" || manifest.Source != "project-approved-mq-only-v2" || len(manifest.Files) == 0 { + t.Fatal("invalid project-local provenance") + } + err := filepath.WalkDir(v2Dir, func(path string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return err + } + rel, err := filepath.Rel(v2Dir, path) + if err != nil || rel == "manifest.json" { + return err + } + actual := sha256.Sum256(readV2(t, path)) + if manifest.Files[filepath.ToSlash(rel)] != hex.EncodeToString(actual[:]) { + t.Errorf("missing/incorrect manifest hash: %s", rel) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + for rel := range manifest.Files { + if !filepath.IsLocal(rel) { + t.Errorf("non-local manifest entry: %s", rel) + continue + } + if _, err := os.Stat(filepath.Join(v2Dir, rel)); err != nil { + t.Error(err) + } + } +} diff --git a/contracts/v3_test.go b/contracts/v3_test.go new file mode 100644 index 0000000..1a6b099 --- /dev/null +++ b/contracts/v3_test.go @@ -0,0 +1,104 @@ +package contracts_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestV3BundleHashesAndOfflineFixtures(t *testing.T) { + const dir = "upstream/2026-09-21-p1-v3" + var manifest struct { + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(readV2(t, dir+"/manifest.json"), &manifest); err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + compiler.UseLoader(offlineLoader{}) + for name, want := range manifest.Files { + raw := readV2(t, filepath.Join(dir, name)) + sum := sha256.Sum256(raw) + if hex.EncodeToString(sum[:]) != want { + t.Fatalf("hash mismatch: %s", name) + } + if filepath.Ext(name) == ".json" && len(name) >= 12 && name[len(name)-12:] == ".schema.json" { + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + if err := compiler.AddResource(value["$id"].(string), value); err != nil { + t.Fatal(err) + } + } + } + if err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + name, err := filepath.Rel(dir, path) + if err != nil { + return err + } + if name != "manifest.json" { + if _, ok := manifest.Files[name]; !ok { + t.Errorf("unhashed file %s", name) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + var fixtures []struct { + File, Schema string + Valid bool + } + if err := json.Unmarshal(readV2(t, dir+"/fixtures.json"), &fixtures); err != nil { + t.Fatal(err) + } + for _, fixture := range fixtures { + schema, err := compiler.Compile("https://go-sip.local/contracts/2026-09-21-p1-v3/" + fixture.Schema) + if err != nil { + t.Fatal(err) + } + var value any + if err := json.Unmarshal(readV2(t, dir+"/"+fixture.File), &value); err != nil { + t.Fatal(err) + } + if err := schema.Validate(value); (err == nil) != fixture.Valid { + t.Fatalf("%s: %v", fixture.File, err) + } + } +} + +func TestV3PythonGoJCSGolden(t *testing.T) { + var vectors []struct{ Name, Input, Canonical, SHA256 string } + if err := json.Unmarshal(readV2(t, "upstream/2026-09-21-p1-v3/jcs-golden.json"), &vectors); err != nil { + t.Fatal(err) + } + for _, vector := range vectors { + t.Run(vector.Name, func(t *testing.T) { + canonical, err := jsoncanonicalizer.Transform([]byte(vector.Input)) + if err != nil { + t.Fatal(err) + } + if string(canonical) != vector.Canonical { + t.Fatalf("cross-language canonical bytes differ: %s", canonical) + } + sum := sha256.Sum256(canonical) + if hex.EncodeToString(sum[:]) != vector.SHA256 { + t.Fatal("cross-language digest mismatch") + } + }) + } +} diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index fe3e36e..0000000 --- a/deploy/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Local deployment draft - -Production packaging is under [`../deploys/`](../deploys/). This directory contains only the standalone project's local/mock entry points. -It is not the W13 frozen production candidate: G0, external authority, real broker/OSS/SIP/AI -verification, and the two-Cell acceptance gates remain blocked. W01/W02 Proto and -local Agent RPC/mTLS tests are present in the project. - -## Mock smoke run - -From the project root: - -```sh -make check -SIP_GO_AGENT_MODE=mock AGENT_SPOOL=./spool ./dist/sip-go-agent agent -SIP_GO_AGENT_MODE=mock DISPATCHER_DB=./dispatcher.db ./dist/sip-go-agent dispatcher -``` - -For an isolated Dispatcher-to-Agent mTLS startup check, provide a strict -Dispatcher-owned endpoint inventory and the deployment mTLS files: - -```sh -SIP_GO_AGENT_MODE=mock \ -DISPATCHER_DB=./dispatcher.db \ -DISPATCHER_AGENT_ENDPOINTS_FILE=./configs/agent-endpoints.example.json \ -# Optional Agent-side allowlist: export MTLS_PEER_CERT_FINGERPRINTS= -MTLS_CA_FILE=/path/to/ca.pem MTLS_CERT_FILE=/path/to/dispatcher.pem \ -MTLS_KEY_FILE=/path/to/dispatcher.key \ -./dist/sip-go-agent dispatcher -``` - -The Dispatcher probes each configured Agent, binds its returned boot ID, and -activates a session before continuing. Endpoint identity is deployment-owned; -no tenant command can select an address or certificate. This check is still -mock/isolated and does not constitute two-Cell, production health, or P1 -acceptance. - -`make release` creates a local-development binary, module copies, SHA-256 -checksums, and a manifest. The manifest preserves a dirty-source marker and -must not be treated as a production candidate until W08–W12 integration and a -clean reproducible build are complete. - -The mock run creates no cloud resource, real call, or external callback. To run a -local Agent RPC listener, set `AGENT_GRPC_LISTEN` and deployment-provided -`MTLS_CA_FILE`/`MTLS_CERT_FILE`/`MTLS_KEY_FILE`; a real Agent additionally -requires `AGENT_STATIC_ARTIFACT` pointing to the management-approved immutable -Cell artifact; the server requires TLS 1.3, -client certificates and a SAN. A real Dispatcher run must receive broker -credentials through a controlled environment; never put them in this repository. - -## Production deployment boundary - -Use `deploys/build-package.sh` and the version-locked physical systemd package for production. Do not use this directory's mock commands or Docker Compose as a production deployment. - -## Runtime boundaries - -- Dispatcher owns the SQLite file and runs as one active process. -- Agent owns its `AGENT_SPOOL` directory and has no business database. -- Production services must run as a non-root service account (the deployment - baseline is `rogee`), with separate DB/spool directories and restricted file - permissions. -- Do not enable real mode until the W01/W02/W04 and supplier authorization - evidence is recorded. diff --git a/deploy/systemd/sip-go-agent-agent.service b/deploy/systemd/sip-go-agent-agent.service deleted file mode 100644 index cf70062..0000000 --- a/deploy/systemd/sip-go-agent-agent.service +++ /dev/null @@ -1,22 +0,0 @@ -[Unit] -Description=sip-go-agent Agent (draft) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=rogee -Group=rogee -WorkingDirectory=/opt/sip-go-agent -EnvironmentFile=-/etc/sip-go-agent/agent.env -ExecStart=/opt/sip-go-agent/sip-go-agent agent -Restart=on-failure -RestartSec=5s -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/var/lib/sip-go-agent - -[Install] -WantedBy=multi-user.target diff --git a/deploy/systemd/sip-go-agent-dispatcher.service b/deploy/systemd/sip-go-agent-dispatcher.service deleted file mode 100644 index f5aa688..0000000 --- a/deploy/systemd/sip-go-agent-dispatcher.service +++ /dev/null @@ -1,22 +0,0 @@ -[Unit] -Description=sip-go-agent Dispatcher (draft) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=rogee -Group=rogee -WorkingDirectory=/opt/sip-go-agent -EnvironmentFile=-/etc/sip-go-agent/dispatcher.env -ExecStart=/opt/sip-go-agent/sip-go-agent dispatcher -Restart=on-failure -RestartSec=5s -NoNewPrivileges=yes -PrivateTmp=yes -ProtectSystem=strict -ProtectHome=yes -ReadWritePaths=/var/lib/sip-go-agent - -[Install] -WantedBy=multi-user.target diff --git a/deploys/README.md b/deploys/README.md index 44a690b..6bf998c 100644 --- a/deploys/README.md +++ b/deploys/README.md @@ -20,6 +20,64 @@ the Agent/Dispatcher must fail closed rather than wait, retry, delay or switch trunks. A prior confirmation does not authorize retries or another target; stop after a failed attempt until a new confirmation is received. +## One deployment directory + +`deploys/` is the only deployment directory for local checks, physical-host +packages, Asterisk installation, configuration examples and systemd services. +There is no separate draft service set or compatibility deployment directory. + +## Local and isolated checks + +From the project root: + +```sh +make check +make release +./dist/sip-go-agent agent --help +./dist/sip-go-agent dispatcher --help +``` + +`make release` creates a local-development binary, module copies, SHA-256 +checksums and a manifest. A dirty-source marker is preserved; a local build is +not a signed production candidate or proof of external service acceptance. + +For an isolated Dispatcher-to-Agent startup check, prepare the strict Dispatcher +JSON configuration from `config/dispatcher.json.example`, inject its referenced +credentials outside the repository, and supply the deployment-owned endpoint +inventory and mTLS files. The local acceptance script also requires a loopback +RabbitMQ URL because `dispatcher --once` must prove it can publish through the +configured broker; it fails closed when the URL is absent: + +```sh +GO_SIP_LOCAL_MQ_URL=amqp://guest:guest@127.0.0.1:33252/ \ + ./scripts/acceptance-local.sh +``` + +The command below is the long-running endpoint check: + +```sh +SIP_GO_AGENT_MODE=mock \ +DISPATCHER_DB=./dispatcher.db \ +DISPATCHER_AGENT_ENDPOINTS_FILE=./deploys/config/agent-endpoints.example.json \ +MTLS_CA_FILE=/path/to/ca.pem \ +MTLS_CERT_FILE=/path/to/dispatcher.pem \ +MTLS_KEY_FILE=/path/to/dispatcher.key \ +./dist/sip-go-agent dispatcher --config /path/to/dispatcher.json --once +``` + +The Dispatcher probes the configured Agent, verifies its boot identity and +activates a session before use. Tenant commands cannot select an endpoint or +certificate. Agent RPC listening uses `AGENT_GRPC_LISTEN` and deployment-provided +mTLS files; an Agent-side peer allowlist can be supplied with +`MTLS_PEER_CERT_FINGERPRINTS`. Never place credentials or private keys in this +repository. + +Dispatcher owns its SQLite database; Agent owns a separate `AGENT_SPOOL` and has +no business database. These are single-node, single-Agent/Cell/tenant checks, +not multi-Cell or production acceptance. Local protocol tests do not replace +the mandatory deployment/capture diagnostics below. Mock is not authorization +for real calls or paid provider requests. + ## Build an uploadable package From the project root: diff --git a/deploys/config/dispatcher.json.example b/deploys/config/dispatcher.json.example new file mode 100644 index 0000000..042affb --- /dev/null +++ b/deploys/config/dispatcher.json.example @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "oss": { + "endpoint": "https://oss-cn-beijing.aliyuncs.com", + "region": "cn-beijing", + "bucket": "example-bucket", + "object_prefix": "agent-call/recordings", + "access_key_id_env": "GO_SIP_OSS_ACCESS_KEY_ID", + "access_key_secret_env": "GO_SIP_OSS_ACCESS_KEY_SECRET" + } +} diff --git a/deploys/env/dispatcher.env.example b/deploys/env/dispatcher.env.example index 18a6ee1..35831a1 100644 --- a/deploys/env/dispatcher.env.example +++ b/deploys/env/dispatcher.env.example @@ -1,24 +1,19 @@ -# Production physical-host example. Inject secrets and approved paths out of band. +# Physical-host example; real deployment requires separate authorization. +# Install dispatcher.json from deploys/config/dispatcher.json.example and replace +# its example dispatcher_id with this installation's unique stable UUID v4. SIP_GO_AGENT_MODE=real -DISPATCHER_ID=dispatcher-primary DISPATCHER_DB=/var/lib/sip-go-agent/dispatcher/dispatcher.db -RABBITMQ_EXCHANGE=agent-call.commands.v1 +DISPATCHER_TENANT_KEY= # RABBITMQ_URL= DISPATCHER_AGENT_ENDPOINTS_FILE=/etc/sip-go-agent/agent-endpoints.json MTLS_CA_FILE=/etc/sip-go-agent/pki/ca.pem MTLS_CERT_FILE=/etc/sip-go-agent/pki/dispatcher.pem MTLS_KEY_FILE=/etc/sip-go-agent/pki/dispatcher.key MTLS_SERVER_NAME=dispatcher.internal -# Single Dispatcher AgentControl gRPC listener. Agent facts and upload RPCs share it; Agents receive only presigned PUT grants. DISPATCHER_GRPC_LISTEN=127.0.0.1:19443 DISPATCHER_ALLOWED_AGENT_IDS=agent-cell-a -DISPATCHER_OSS_REGION=cn-beijing -DISPATCHER_OSS_ENDPOINT=oss-cn-beijing-internal.aliyuncs.com -DISPATCHER_OSS_BUCKET= -DISPATCHER_OSS_KEY_PREFIX=agent-call/recordings -DISPATCHER_OSS_GRANT_TTL_SECONDS=900 -DISPATCHER_OSS_MAX_ASSET_BYTES=67108864 -DISPATCHER_OSS_ACCESS_KEY_ID_FILE=/etc/sip-go-agent/secrets/oss-access-key-id -DISPATCHER_OSS_ACCESS_KEY_SECRET_FILE=/etc/sip-go-agent/secrets/oss-access-key-secret -# DISPATCHER_CONTROL_LISTEN=127.0.0.1:18080 -# DISPATCHER_CONTROL_TOKEN= +# MTLS_PEER_CERT_FINGERPRINTS= +# Inject only the credential variables explicitly referenced by dispatcher.json. +# Never put actual values in this example or commit them: +# GO_SIP_OSS_ACCESS_KEY_ID= +# GO_SIP_OSS_ACCESS_KEY_SECRET= diff --git a/deploys/env/dispatcher.offline-oss.env.example b/deploys/env/dispatcher.offline-oss.env.example index 804bfc7..ec2eb93 100644 --- a/deploys/env/dispatcher.offline-oss.env.example +++ b/deploys/env/dispatcher.offline-oss.env.example @@ -1,12 +1,6 @@ -# Offline/non-ECS OSS integration profile. -# Do not use this profile as the production ECS profile. +# Isolated non-production profile; not permission to access real OSS. SIP_GO_AGENT_MODE=mock -DISPATCHER_OSS_REGION=cn-beijing -DISPATCHER_OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com -DISPATCHER_OSS_BUCKET= -DISPATCHER_OSS_KEY_PREFIX=agent-call/offline-recordings -DISPATCHER_OSS_GRANT_TTL_SECONDS=900 -DISPATCHER_OSS_MAX_ASSET_BYTES=67108864 -# Supply these through protected runtime files; never commit or log credentials. -DISPATCHER_OSS_ACCESS_KEY_ID_FILE=/run/secrets/oss-access-key-id -DISPATCHER_OSS_ACCESS_KEY_SECRET_FILE=/run/secrets/oss-access-key-secret +# OSS endpoint/bucket/prefix and credential references come only from the +# required --config JSON file. Inject its referenced credential variables via +# the controlled local test launcher; do not store keys in this example. +# Keep real egress blocked unless separately authorized. diff --git a/deploys/systemd/sip-go-agent-dispatcher.service b/deploys/systemd/sip-go-agent-dispatcher.service index 5b33719..3e24b65 100644 --- a/deploys/systemd/sip-go-agent-dispatcher.service +++ b/deploys/systemd/sip-go-agent-dispatcher.service @@ -9,7 +9,7 @@ User=rogee Group=rogee WorkingDirectory=/opt/sip-go-agent/current EnvironmentFile=/etc/sip-go-agent/dispatcher.env -ExecStart=/opt/sip-go-agent/current/sip-go-agent dispatcher +ExecStart=/opt/sip-go-agent/current/sip-go-agent dispatcher --config /etc/sip-go-agent/dispatcher.json --consume --tenant-key ${DISPATCHER_TENANT_KEY} Restart=on-failure RestartSec=5s NoNewPrivileges=yes diff --git a/docs/G0开发准备与契约冻结提案_v0.1.md b/docs/G0开发准备与契约冻结提案_v0.1.md index cdf22e2..3e54dd0 100644 --- a/docs/G0开发准备与契约冻结提案_v0.1.md +++ b/docs/G0开发准备与契约冻结提案_v0.1.md @@ -2,7 +2,7 @@ ## 1. 状态、权限与使用方式 -**状态:D01–D10原方向已确认,旧W01契约和W02 Proto已有项目内证据;本轮用户确认SaaS↔Dispatcher全MQ,受影响G0重新验证,见[计划§1.2/§8.2](plan-0918.md)。** SaaS与D之间所有请求、响应和事件禁止HTTP,每个D具备全局唯一ID和独立接收Topic/队列;新Schema/拓扑/关联待发布。OSS补充确认:配置存于D配置文件,A向D领取临时上传TOKEN后直传;SaaS不再提供OSS配置/TOKEN,最终verified校验职责不变,D不转发文件。原HTTP方向被本修订替代,不把旧证据覆盖到新设计。 +**状态:D01–D10原方向已确认,旧W01契约和W02 Proto已有项目内证据;本轮用户确认SaaS↔Dispatcher全MQ,受影响G0的项目内部分已按[计划§1.2/§8.2](plan-0918.md)重新验证。** SaaS与D之间所有请求、响应和事件禁止HTTP,每个D具备全局唯一ID和独立接收Topic/队列;v2/v3 Schema、拓扑和关联已有本地包及RabbitMQ证据,外部发布/签收另计。OSS补充确认:配置存于D配置文件,A向D领取固定15分钟临时上传TOKEN后直传;SaaS不再提供OSS配置/TOKEN,上传完成以recording.uploaded可靠入队为界,不等待SaaS verified或OSS ID,D不转发文件。原HTTP方向被本修订替代,不把旧证据覆盖到新设计。 本项目已创建并验证自己的 Go module、W01 bundle、W02 Proto/stubs、RPC/mTLS 和本地 Mock 测试;未修改父项目权威来源、字段索引或生成产物,未访问真实供应商或创建云资源。文件名保留“提案”以保持链接稳定,不代表还需重复审批已确认方向。 @@ -26,7 +26,7 @@ D01–D10 的共同状态为 **用户已确认方案/源发布与验证未完 | D04 | §5 Unary/最后许可/控制屏障(GAP-04) | D/A 提交,S 确认业务控制语义,O 确认恢复边界 | 已批准协议、状态转移及崩溃矩阵;随后生成 Proto 并做隔离 PoC | 跨 Cell 发起、控制、事实提交和恢复实现冻结 | | D05 | §5.2 共用证书会话与撤销(GAP-05) | O 提交,D/A 签收 | Endpoint/SAN 清单、角色隔离、重放负例、全组轮换方案及风险签收 | 节点准入和敏感配置交付 | | D06 | §6.1 P1 静态制品交接(GAP-03 P1) | M/O 提交,D/A 签收 | 制品来源、授权矩阵、唯一写入口、精确加载证据格式 | SIP 静态集成与真实发布 | -| D07 | §6.2 D配置文件/临时TOKEN与A直传(GAP-02 P1) | D/O提交配置与TOKEN合同,S提交业务会话/verified,D/A联合签收 | D配置来源及失败反例、TOKEN/UploadGrant映射、15分钟/显式向D重申请、A直传/D不转发;SaaS MQ会话/verified与R12/R13衔接 | 录音交接闭环;文本归档仍延后 | +| D07 | §6.2 D配置文件/临时TOKEN与A直传(GAP-02 P1) | D/O提交配置与TOKEN合同,D/A联合签收 | D配置来源及失败反例、TOKEN/UploadGrant映射、15分钟/显式向D重申请、A直传/D不转发;recording.uploaded事实可靠入队,不等待SaaS会话/verified/OSS ID | 录音通知闭环;文本归档仍延后 | | D08 | §6.3 profile、单活恢复、保留(GAP-06/07 P1) | O 提交,D/A 签收 | 明确数值/来源/预算、备份与恢复演练设计、唯一所有权方案 | 对应运行参数冻结与真实验收,不阻塞离线原理 PoC | | D09 | §7 独立契约包和重建链,补GAP-10全MQ版本 | S/M 提供版本,D/A 负责导入 | 新版D身份/Topic/消息/错误/期限及正反例、只读包/哈希、可重复生成校验;不覆盖旧包 | 独立可交付构建,不得临时读取父目录运行 | | D10 | §8 依赖和关键 SDK PoC | D/A 提交,O 审核许可/安全 | 精确版本、许可/NOTICE、漏洞处置及对应 PoC 原始记录 | 未验证组件进入正式实现/发布依赖 | @@ -49,7 +49,7 @@ P2 增加多个同时活跃租户的等权轮询、额度不足跳过、公平 本轮补充纳入D03/D04/D07/D09/D10及通信设计GAP-10,不新增运行验收编号。完整约束见[SaaS↔D契约](contracts/saas-dispatcher.md):每个D全局唯一ID、独立Topic/接收队列、请求/响应固定原D与租户、持久inbox/outbox、错目标/重复身份/重投/乱序/超时/重启恢复;新路由长度须重算,不能照搬旧224字节租户预算。ID生命周期、Topic/绑定、消息枚举/字段/关联、错误/期限未冻结前不实现猜测协议。 -P1仍为单节点/单Agent/单Cell/单租户/单活D,下文沿用的早期两Cell/两Agent全量矩阵仅为后续目录,不是本轮门禁。新增D1/D2本地消息fixture只验证定向隔离,不授权多D业务调度、HA或共享配额。旧OpenAPI/只读索引和历史证据原样保存;新MQ生产链不得保留SaaS↔D HTTP;D提供上传TOKEN的职责保留,但D本地对象验证不能替代SaaS verified。 +P1仍为单节点/单Agent/单Cell/单租户/单活D,下文沿用的早期两Cell/两Agent全量矩阵仅为后续目录,不是本轮门禁。新增D1/D2本地消息fixture只验证定向隔离,不授权多D业务调度、HA或共享配额。旧OpenAPI/只读索引和历史证据原样保存;新MQ生产链不得保留SaaS↔D HTTP;D提供上传TOKEN的职责保留,项目完成边界为recording.uploaded可靠入队,不等待SaaS对象处理。 ## 3. D01:外部事件 Schema 补齐方案(已确认方向) @@ -66,7 +66,7 @@ P1仍为单节点/单Agent/单Cell/单租户/单活D,下文沿用的早期两C | `call.finished` | 复用通话终态和 attempt 结论;开始/接通/结束及持续时间保留原单位与未接通规则;允许录音/文字仍在后处理,不覆盖资产域 | 未接通却生成接通时长;通话结束强行把未交接录音设 ready | | `transcript.updated` | 复用段/轮次/revision/text/final、说话方和真实播放证据;中间稿/最终稿规则与同段 final 同内容幂等、异内容冲突机读化;完整文本不截断 | 迟到中间稿覆盖 final;仅生成 TTS 就标已播放;ASR-only 伪造助手播放 | | `transcript.failed` | 引用原 call 和受影响文字范围、源定义的失败阶段/原因/恢复性;无 segment 时如何表达整流失败由上游明确;不虚构空 final | 无关联对象;失败后静默删除已持久最终稿;用 recording.failed 代替 | -| `recording.ready` | 复用原录音快照/OSS ID、文件元信息与上传会话关联;只接受 SaaS complete 已 verified 的事实;不能在 payload 带任意下载 URL/长期凭据 | PUT 2xx 即 ready;大小/摘要不符;verified=false;将文本归档伪装录音 | +| `recording.uploaded` | 报告原录音/上传事实、对象位置和文件元信息;不带OSS ID、上传会话、长期凭据或公开URL | 事实缺字段、大小/摘要不符、跨bucket/object绑定;把SaaS处理结果伪装上传事实 | | `recording.failed` | 复用原录音身份、失败原因与可恢复性;明确上传超时、校验失败和永久丢失的区分;仍保存后续对账/补传所需关联 | 失败产生新 recording_id 逃避幂等;永久丢失报 ready | | `contact.opt_out` | 复用原租户/task/member/call 关联与拒绝时间/证据引用;最小必要信息;ASR-only 同样具备经批准的判定与通知流程 | 未授权模型判定;跨成员关联;等待录音上传才发 opt-out | @@ -197,16 +197,16 @@ M 是唯一编辑/审批面;制品外层记录 source_release、source_digest ### 6.2 P1 录音配置与 Agent 直连 OSS 上传(用户已确认) -**上传数据面固定为Agent→OSS;OSS配置唯一来源为D配置文件,临时上传TOKEN由D通过Unary交给A。** SaaS不下发OSS配置/TOKEN;其业务会话/complete/verified仍经MQ,上传完成后的独立校验职责不变。D不接收/缓存/转发文件,不替A上传,A不持长期凭据。 +**上传数据面固定为Agent→OSS;OSS配置唯一来源为D配置文件,临时上传TOKEN由D通过Unary交给A。** SaaS不下发OSS配置/TOKEN;本项目不申请业务会话,不等待complete/verified或OSS ID。D不接收/缓存/转发文件,不替A上传,A不持长期凭据。 -D/O须核验配置文件读取、服务地址/bucket/对象规则、受控凭据配置或引用、SDK签发及TOKEN与现有UploadGrant映射;未给定的精确字段不能猜。配置缺失/无效明确失败,不向SaaS取配置、不用A本地bucket/长期AK兜底;样例/日志不存实际密钥或完整TOKEN。D仅交付原执行/对象所需TOKEN、目标/方法、headers与期限。S仍须补齐既有业务会话、对象定位/校验、size/checksum和complete幂等合同;MQ中不传D配置文件/长期凭据/TOKEN,不能假定D配好OSS便等于SaaS可独立校验。 +D/O须核验配置文件读取、服务地址/bucket/对象规则、受控凭据配置或引用、SDK签发及TOKEN与现有UploadGrant映射;未给定的精确字段不能猜。配置缺失/无效明确失败,不向SaaS取配置、不用A本地bucket/长期AK兜底;样例/日志不存实际密钥或完整TOKEN。D仅交付原执行/对象所需TOKEN、目标/方法、headers与期限。recording.uploaded仅报告原call/recording/upload标识、对象位置、格式、时长、完整文件大小及SHA-256;MQ中不传D配置文件、长期凭据、TOKEN或签名URL。SaaS后续处理不属于本项目,不以其消费或对象登记作为完成前置。 -1. A封口并持久元信息,经R12向D领取临时TOKEN,D按自身配置复用SDK提供。原SaaS业务会话/资产登记仍MQ,但不是TOKEN来源。业务MQ响应的pending/有界等待/重取仍由W02/W11冻结,不无限阻塞RPC。 +1. A封口并持久元信息,经R12向D领取固定15分钟的临时TOKEN,D按自身配置复用SDK提供。同一请求重放返回原授权及原到期时间,不能借重试自动续期;不向SaaS申请会话或资产登记。 2. A 直接把文件内容上传至指定 OSS,不把文件发给 D,也不通过内部 gRPC 传录音字节。D 失联时已有仍有效授权可继续上传;TOKEN过期或缺失则保留原文件,D恢复后由A显式向D重新申请,不自动续期,不向SaaS申请TOKEN,不改用A本地长期密钥。 -3. A通过R13报告原资产/会话及元信息;D经MQ提交complete,SaaS独立校验后经原D专用Topic返回结果。上传成功但D/MQ/complete响应不可达时保留待完成状态,不发ready,不用D本地HEAD替代。 -4. D持久校验SaaS MQ verified后,事务记录资产状态和recording.ready outbox,再经MQ回传OSS ID;A取得最终Unary结果的衔接同样须冻结。PUT/complete 超时分别按原对象/会话幂等对账,恢复不能新建资产或重拨。未定义的续期/查询语义仍阻塞相应恢复分支,不猜测接口。 +3. A成功PUT后通过R13报告原上传事实;D同事务保存事实及recording.uploaded outbox。D/MQ不可达时保留源文件和原通知恢复记录,不重新PUT,不新建资产,不重拨。 +4. D以persistent消息、指定durable队列/绑定、mandatory无return和publisher confirm成功确认交付;未可靠入队不返回completed,仅写本地outbox不算完成。不新增VERIFYING,不返回OSS ID,也不等待SaaS回复。预签名URL不具备OSS原生强制一次性语义,每次授权尝试只执行一次PUT由Agent状态机保证。 -本地删除条件继承验收 profile:verified、ready 已得到要求的发布确认、无恢复任务且满足保留;MQ confirm 不等于 SaaS 应用收讫,不额外等待不存在的应用 ACK。文本 OSS 归档继续受 GAP-02 延后约束,实时 transcript.updated/opt-out 不等待 OSS。 +本地删除条件继承验收 profile:原上传事实已持久保存、recording.uploaded已可靠入队、无恢复任务且满足保留;MQ confirm 不等于 SaaS 应用收讫,不额外等待不存在的应用 ACK。文本 OSS 归档继续受 GAP-02 延后约束,实时 transcript.updated/opt-out 不等待 OSS。 ### 6.3 运行 profile 与单活恢复登记 @@ -241,7 +241,7 @@ D/O须核验配置文件读取、服务地址/bucket/对象规则、受控凭据 | 3.Unary/身份/许可 | 成熟 gRPC/mTLS、SAN 错配/重放、R02 丢包、§5 崩溃矩阵、RPC 超时不重拨、同机第二 D 拒绝启动 | D04/D05方案已确认;后续开发时定稿/生成Proto并验证,未通过不得签收 | | 4.ARI/RTP/录音 | 本地固定 digest Asterisk、确定通道关联、ExternalMedia、PCMA/PCM、事件断连与取消清理;故障下注入 originate 响应丢失不得二次提交 | 先换成熟 SDK/修上游;不能手写 ARI/SIP/RTP/G.711 替代 | | 5.ASR/LLM/TTS 参数 | 百炼/火山 ASR、OpenAI 兼容 LLM、火山 TTS 的实际锁定 SDK 对照 §4 记录传参、0/false、取消和重试;Mock 检查边界行为 | SDK 缺能力不走 metadata/raw_request;报阻塞或批准替代 SDK | -| 6.MQ/录音交接 | 隔离broker验证D1/D2定向Topic与租户隔离、错目标/重复ID、不可路由/confirm丢失、请求响应乱序/重启恢复;D从配置文件提供临时TOKEN并覆盖配置缺失/无效反例;SaaS协议Mock经MQ提供AI/业务会话/verified,A直传OSS;证明无SaaS↔D HTTP或SaaS下发OSS配置/TOKEN | 本地通过不替代真实 MQ/OSS/云身份验证 | +| 6.MQ/录音交接 | 隔离broker验证D1/D2定向Topic与租户隔离、错目标/重复ID、不可路由/confirm丢失、请求响应乱序/重启恢复;D从配置文件提供固定15分钟临时TOKEN并覆盖配置缺失/无效反例;AI/控制/查询经MQ,A直传OSS后D可靠发布recording.uploaded;证明无SaaS↔D HTTP或SaaS下发OSS配置/TOKEN | 本地通过不替代真实 MQ/OSS/云身份验证 | 每份证据记录环境/mock-mixed-real、版本、输入边界、预期/实际、脱敏日志、失败注入点和残余风险;失败/blocked 不删样本。SDK 本地 Mock 只验证客户端映射,不证明供应商服务端支持、费用或双模式真实可用。真实 SIP/AI/OSS 必须另行授权并分别验收。 diff --git a/docs/Go重写方案_v0.3.md b/docs/Go重写方案_v0.3.md index d93381b..407d3a3 100644 --- a/docs/Go重写方案_v0.3.md +++ b/docs/Go重写方案_v0.3.md @@ -18,16 +18,16 @@ 7. 不接入PostgreSQL;独立Dispatcher统一全局任务/配额,使用本地持久SQLite。Agent不设SQLite业务库,文字/录音及必要执行/上传恢复信息流式落文件。 8. 内部采用 **Unary gRPC**;Dispatcher预配置Agent Endpoint列表,Agent业务启动参数尽量只有Dispatcher Endpoint,不增加内部MQ或双向流。 9. 所有Agent共用一套mTLS证书;这只认证Agent群组,单节点授权另由受控Endpoint/自动会话绑定落实。Dispatcher身份独立,不能伪称具备独立节点证书隔离。 -10. OSS配置存于Dispatcher配置文件,Agent向Dispatcher领取临时上传TOKEN后直传;SaaS不下发OSS配置/TOKEN,仍负责最终独立校验并经MQ返回verified。文本保留实时MQ事件,OSS用于归档;录音按OSS ID查看。 +10. OSS配置存于Dispatcher配置文件,Agent向Dispatcher领取固定15分钟临时上传TOKEN后直传;SaaS不下发OSS配置/TOKEN。本项目以recording.uploaded可靠进入指定持久队列为完成边界,不等待SaaS校验、消费或OSS ID。文本保留实时MQ事件,OSS仅用于录音数据面。 11. Dispatcher感知Agent健康、必要资源、软件/协议能力、获授权SIP供应商和已加载配置版本;在静态授权候选内按固定、可解释策略分配新任务,不做智能负载评分或自动跨供应商重拨。 12. 管理平台仍为SIP配置唯一编辑面;P1以批准的静态快照经受控部署入口加载,Dispatcher控制准入并核验Agent加载事实,暂不建设在线动态发布/回滚编排。 13. 本次固定1个Agent、1套Asterisk、1个单活Dispatcher、单 Cell、单租户;至少3家独立SIP供应商保留为 trunk 配置/路由/协议 fixture 覆盖。双节点、第二 Cell、双租户不在本轮开发或验收范围。 14. ASR-only与ASR+LLM+TTS均为本次必需能力,按本地/隔离协议和状态机验收;真实供应商/ECS 联调延期第二阶段,不以 Mock 冒充真实供应商通过。 -15. `upload-session/complete/verified`、RabbitMQ ACL/TLS 和 application receipt 本阶段按版本化契约、Schema、正反例 fixture 和本地隔离状态机验收;真实 SaaS/MQ 联调延期第二阶段。 +15. 固定15分钟授权、单次PUT、recording.uploaded事实及RabbitMQ persistent/confirm/mandatory入队按版本化契约、Schema、正反例fixture和本地隔离状态机验收;不申请SaaS上传会话、不等待verified/OSS ID;真实SaaS/MQ联调延期第二阶段。 以上范围不再列为待定。G0只冻结P1实际使用的Proto、事件payload、双AI模式及SaaS配置/调参、静态配置交接、录音授权、共享证书授权和运行阈值;后续功能有明确阶段,不再要求所有未来缺口同时关闭。本轮不创建Go骨架、数据库、broker或真实呼叫。[G0开发准备与契约冻结方案](G0开发准备与契约冻结提案_v0.1.md) D01–D10的方向、模式/许可/恢复机制及内部PoC初始profile已获用户确认;权威源发布、正式Proto及验证仍未完成,不能视为G0通过。本轮只同步文档,不修改上游Schema或创建代码。 -OSS数据面已明确为**Agent→OSS直连上传**:D读取自身配置文件,经SDK提供本录音的临时TOKEN/受限目标与headers,A通过Unary领取后自行上传。D不接收或转发录音字节;既有SaaS业务会话/complete及verified仍经MQ,最终校验职责不变。每次TOKEN有效15分钟;过期或失败保留源文件,仅由调用方显式向D重新申请,不向SaaS取TOKEN,不自动续期或重试。此直传不改变Agent不直连SaaS的边界。 +OSS数据面已明确为**Agent→OSS直连上传**:D读取自身配置文件,经SDK提供本录音的临时TOKEN/受限目标与headers,A通过Unary领取后自行上传。D不接收或转发录音字节;成功上传后由D可靠发布recording.uploaded;不申请SaaS会话,不等待verified或OSS ID,SaaS后续处理不属于本项目。每次TOKEN有效15分钟;过期或失败保留源文件,仅由调用方显式向D重新申请,不向SaaS取TOKEN,不自动续期或重试。此直传不改变Agent不直连SaaS的边界。 ### 1.1 本次P1目标与完成标准 @@ -92,7 +92,7 @@ go-sip/ ├── config/ # 无密钥的配置样例 ├── contracts/ # 带来源与校验和的只读契约发布包 ├── tests/ # 项目内夹具、协议 Mock、集成与故障注入 -├── deploy/ # 独立镜像、Compose/systemd 与运维入口 +├── deploys/ # 统一发布包、配置、systemd 与 Asterisk 部署入口 └── docs/ # 本项目方案、运行、验收和发布文档 ``` @@ -111,7 +111,7 @@ Dispatcher配置包含MQ/SaaS受控引用、SQLite路径、两个Agent Endpoint ### 3.2 权威来源与独立拆仓 -**本轮用户已确认SaaS↔Dispatcher全MQ:双方不再有任何HTTP请求/回调;每个D有全局唯一ID和独立接收Topic/队列。** 执行、控制、查询、补传、AI配置/授权、上传会话及complete/verified全部经MQ,详见[SaaS↔D契约](contracts/saas-dispatcher.md)与[计划§1.2/§8.2](plan-0918.md)。旧HTTP和旧租户路由是被替代的设计;新Schema/拓扑/身份生命周期/关联待冻结,旧源包及哈希不改,受影响实现/验收重新验证。 +**本轮用户已确认SaaS↔Dispatcher全MQ:双方不再有任何HTTP请求/回调;每个D有全局唯一ID和独立接收Topic/队列。** 执行、控制、查询、补传、AI配置/授权及上传完成事实均经MQ;上传不申请SaaS会话、不等待verified/OSS ID,详见[SaaS↔D契约](contracts/saas-dispatcher.md)与[计划§1.2/§8.2](plan-0918.md)。旧HTTP和旧租户路由已删除;新Schema/拓扑/身份生命周期/关联已在项目内v2/v3包和本地证据中冻结,旧源包及哈希不改。 现有上游权威是《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》(正文 v1.0)及经核验的发布产物。现有产物包括 `mq.schema.json`、`executor.openapi.yaml`、`cell-agent.openapi.yaml`、AI 配置及 SIP 管理相关契约。文件存在不代表完整覆盖:P0 必须逐条核对正文、Schema、状态语义和实现差异。 @@ -291,7 +291,7 @@ SaaS经目标D专用Topic,仅按一个 `call_id` 或 `source_command_id` 发 ### 6.6 最终文字、事件与 DNC -Dispatcher对Agent稳定事实去重后,按 command/call/transcript_segment/recording 的实体与状态域事务持久化聚合版本,payload 是该域的快照;测试中的独立 SaaS 按同域合并。不能用全局最大版本丢掉低版本但独立的录音/attempt,也不能用 call.finished 覆盖 recording.ready。最终文字不会被迟到中间稿覆盖;当前基线同段 final 同内容幂等、异内容冲突,未来允许修订须经 G0 更新契约。文字失败必须出 transcript.failed,通话结束不等待所有后处理。 +Dispatcher对Agent稳定事实去重后,按 command/call/transcript_segment/recording 的实体与状态域事务持久化聚合版本,payload 是该域的快照;测试中的独立 SaaS 按同域合并。不能用全局最大版本丢掉低版本但独立的录音/attempt,也不能用 call.finished 覆盖 recording.uploaded。最终文字不会被迟到中间稿覆盖;当前基线同段 final 同内容幂等、异内容冲突,未来允许修订须经 G0 更新契约。文字失败必须出 transcript.failed,通话结束不等待所有后处理。 客户实际说话与 AI 生成/发送/播放证据严格区分,旧轮次片段取消后不能冒充已播放。contact.opt_out 按获批业务判定及时发布,不等挂断;SaaS 持久禁发并枚举相关任务完成屏障。Agent 不能用未经确认的关键词替代业务判定。 @@ -339,11 +339,11 @@ Dispatcher对Agent稳定事实去重后,按 command/call/transcript_segment/re **OSS配置存于D配置文件;A经R12向D领取临时上传TOKEN后直传OSS,不保存长期凭据。** D依据自身配置复用官方SDK提供原执行/对象所需TOKEN、目标、headers与期限;配置缺失/无效明确失败,不向SaaS获取配置/TOKEN,不用A本地bucket/长期AK兜底。TOKEN过期只接受A显式向D重新申请,配置格式及TOKEN/UploadGrant映射须核验,不猜字段,样例/日志不写真实凭据。 -既有SaaS业务会话/资产登记、R13之后的complete及verified仍经MQ;SaaS最终独立校验职责不变,MQ不传D配置文件/长期凭据/TOKEN。D仅在持久校验SaaS verified及oss_id后写recording.ready outbox;PUT200/ETag或D本地HEAD不能替代。保留D签发能力,不将其误删为旧路径。对象定位/校验所需业务信息及R12/R13有界等待/恢复衔接待W01/W02/W11核验,不虚构HTTP或无限等待Unary。 +R13报告原上传事实,D在同一事务中保存事实和recording.uploaded outbox。仅在persistent消息进入指定durable队列/绑定、mandatory无return且publisher confirm成功后确认通知交付;这不代表SaaS已消费或处理。MQ不传D配置文件、长期凭据或TOKEN。不申请SaaS业务会话,不实现complete/verified等待,不新增VERIFYING,不返回OSS ID或发recording.ready。D签发能力保留;上传通知恢复沿原upload_id和原消息身份,不重新PUT。 -spool 按继承的测试 profile 在 70% 告警、80% 停止新接单,降至 60% 且依赖恢复后才恢复;为活动通话预留剩余录音空间。已 verified、ready 持久并确认发布、无已知恢复任务的本地已交接录音,测试至少保留 24h;未确认/失败文件不自动删。生产保留另行批准,不能在 confirm 后无条件删原始资产。 +spool 按继承的测试 profile 在 70% 告警、80% 停止新接单,降至 60% 且依赖恢复后才恢复;为活动通话预留剩余录音空间。原上传事实已持久保存、recording.uploaded已可靠入队且无已知恢复任务的本地已交接录音,测试至少保留 24h;未确认/失败文件不自动删。生产保留另行批准,不能在 confirm 后无条件删原始资产。 -`CALL_NOT_REGISTERED` 保留文件并等待;TOKEN失效只允许A显式向D重新申请,保持原recording/upload语义,不自动续期或制造新资产。仅接受受控 HTTPS 目标与约定 headers,禁止任意重定向/跨对象写入;verified 对象须防止旧签名覆盖。SaaS/对象侧独立读真实字节验证,不能信自报摘要或把 ETag 当 SHA-256。 +授权缺失、失效或PUT失败时保留文件;仅接受A显式向D重新申请,保持原recording/upload绑定,不自动续期、重传或制造新资产。同一请求重放返回原授权(包括原到期时间),显式新请求最多一次PUT。仅接受受控HTTPS目标与约定headers,拒绝重定向/跨对象写入;校验实际发送文件的大小和SHA-256,不把ETag当SHA-256。预签名URL不是OSS原生强制一次性凭据;SaaS后续对象处理不作为本项目门禁。 Agent对已签名PUT使用标准库HTTP/约定headers,需要OSS API才用官方SDK,不自写签名或拿不必要长期凭据。文字也流式落文件,但实时transcript.updated/contact.opt_out仍经gRPC→Dispatcher→MQ及时回SaaS。文本OSS归档的授权/完成/引用尚缺契约,未冻结前明确未启用,不伪装录音;保留文字MQ链路不受此影响。补传按§6.5整体call/command范围。 diff --git a/docs/OpenAPI与MQ字段索引_v0.1.md b/docs/OpenAPI与MQ字段索引_v0.1.md index dad2e5d..5041b47 100644 --- a/docs/OpenAPI与MQ字段索引_v0.1.md +++ b/docs/OpenAPI与MQ字段索引_v0.1.md @@ -1,7 +1,7 @@ # OpenAPI 与 MQ 字段索引 v0.1 > 本文件为本轮从现有上游文件一次性生成的只读索引,不是第二份可手工维护的 Schema。原始引用和约束原样保留;它描述“文件现在是什么”,不代表已经与正文权威契约一致。 -> 已逐字段和哈希确认:当前MQ的command_type/command_id与event_type/aggregate_*信封已对齐正文。仍待补齐8种事件的payload专属Schema/条件规则;详见《通信与事件数据交互_v0.1.md》。源文件更新须重新导入生成,不得只改索引。 +> 已逐字段和哈希确认:当前MQ的command_type/command_id与event_type/aggregate_*信封已对齐正文。仍待补齐8种事件的payload专属Schema/条件规则;详见《通信与事件数据交互_v0.1.md》。源文件更新须重新导入生成,不得只改索引。该索引保留上游OpenAPI历史字段(包括旧上传会话/verified/oss_id),不代表当前MQ-only运行时;当前上传以recording.uploaded冻结方案为准。 普通构建/运行不读取父目录;P0须建立版本化契约包和可重复生成入口。本轮没有添加Python运行依赖或Go源码。 diff --git a/docs/contracts/dispatcher-agent.md b/docs/contracts/dispatcher-agent.md index 3e823c8..226541f 100644 --- a/docs/contracts/dispatcher-agent.md +++ b/docs/contracts/dispatcher-agent.md @@ -14,7 +14,7 @@ 精确字段号和枚举以 `proto/agent/v1/agent.proto` 为唯一源;本文不另造 protobuf。 -**SaaS↔Dispatcher 边界已修订为 MQ-only**,详见 [SaaS↔Dispatcher 契约](./saas-dispatcher.md)。D 有全局唯一身份和独立接收 Topic;这不改变内部 Unary 或 Agent→OSS 直传。**OSS 配置由 D 配置文件维护,Agent 向 D 领取临时上传 TOKEN,SaaS 不再提供 OSS 配置/TOKEN。** D 的签发职责保留;本文“当前实现”仍不能证明配置文件/TOKEN 全部约束已接通,尤其 D 本地对象验证不能代替 SaaS MQ verified。本轮不改变 SaaS 最终校验归属,未改 Proto/代码。 +**SaaS↔Dispatcher 边界已修订为 MQ-only**,详见 [SaaS↔Dispatcher 契约](./saas-dispatcher.md)。D 有全局唯一身份和独立接收 Topic;这不改变内部 Unary 或 Agent→OSS 直传。**OSS 配置由 D 配置文件维护,Agent 向 D 领取临时上传 TOKEN,SaaS 不再提供 OSS 配置/TOKEN。** D的签发职责保留;用户已将上传边界收缩为recording.uploaded可靠进入指定持久队列,不等待SaaS会话、verified或OSS ID,不新增VERIFYING。本文旧handler行为仅作差异记录,R13须改为以可靠入队完成,不以文档或Schema通过宣称已接通。 ## 2. Service 方法与方向 @@ -30,7 +30,7 @@ | `QueryExecution` | Dispatcher → Agent | 用于超时/响应丢失后的对账 | Agent `rpc.Server` | | `ReportExecutionEvent` | Agent → Dispatcher | 已接收、去重并生成 MQ outbox | Dispatcher `rpc.DispatcherEventServer` | | `RequestUpload` | Agent → Dispatcher | 已接收并签发 OSS grant | Dispatcher `rpc.DispatcherUploadServer` | -| `CompleteUpload` | Agent → Dispatcher | 已验证 OSS 对象并生成 `recording.ready` outbox | Dispatcher `rpc.DispatcherUploadServer` | +| `CompleteUpload` | Agent → Dispatcher | 已持久保存上传事实并将 `recording.uploaded` 通知可靠入队 | Dispatcher `rpc.DispatcherUploadServer` | `DispatcherServer` 对外只实现 `ReportExecutionEvent`、`RequestUpload`、`CompleteUpload`;其它 RPC 在 Dispatcher listener 上返回 `UNIMPLEMENTED`。Agent `rpc.Server` 虽实现完整 generated service,但其 upload handler 在非 `mock` 模式明确返回 `UNIMPLEMENTED`。 @@ -276,19 +276,19 @@ Dispatcher 侧额外要求: 当前 Dispatcher 验证:Agent/Cell/operation/idempotency 元数据、完整 execution/tenant binding、合法 `tenant_key`、asset ID、upload ID、正数文件大小和 SHA-256;超过 OSS 最大文件大小返回 `RESOURCE_EXHAUSTED`。 -成功响应:`OperationReceipt`、`UploadGrant`、`state`。目标是 D 读取自身 OSS 配置文件并向 A 提供临时 TOKEN/受限上传信息;以下记录当前 handler,不代表配置入口、TOKEN 形态和新版业务会话约束已全部验收: +成功响应:`OperationReceipt`、`UploadGrant`、`state`。D读取严格JSON配置文件,复用官方SDK提供15分钟预签名PUT;本地签发、单次上传及通知恢复证据见[上传验证](../evidence/20260921-mq-upload-progress.md)。当前handler: - 以 `tenant_key + "\\0" + execution_id + "\\0" + asset_id` 的 SHA-256 hex 生成 object key,并加配置的 key prefix; - 将 binding、asset、grant、object key、state 持久到 Dispatcher SQLite; -- grant 的过期时间由 OSS client 配置提供; -- 同 upload ID 同 binding/asset 返回原 grant;绑定不同返回冲突; -- 过期 grant 只有在显式再次 `RequestUpload` 时才替换,不自动续期。目标流程同样由 Agent 显式向 D 重新领取 TOKEN;D 使用自身配置,不向 SaaS 申请 TOKEN,不改变原资产/会话。 +- grant有效期固定15分钟,不允许通过配置改变; +- 同upload ID、同operation ID及原请求正文返回原grant,包括原到期时间;绑定或正文不同返回冲突; +- 重新签发必须使用显式新请求身份,保持原资产及对象绑定;不因原请求重放自动续期,不向SaaS申请TOKEN。 新请求当前返回 `UPLOAD_STATE_REQUESTED`;持久层状态为 `granted`。`UPLOADING` 枚举存在,但当前 Dispatcher handler 不把 Agent 的 PUT 过程映射为该状态。 -目标流程为 **D 依据自身配置文件向 A 提供临时上传 TOKEN,不向 SaaS 申请 OSS 配置/TOKEN**。D 侧配置缺失/无效时明确失败,不切换配置源;长期凭据不交给 A,不写入示例、日志或证据。TOKEN 的精确形态、SDK 能力及与 `UploadGrant` 的映射须核验冻结,不能只把现有字段改称 TOKEN 就宣称完成。 +目标流程为 **D 依据自身配置文件向 A 提供临时上传 TOKEN,不向 SaaS 申请 OSS 配置/TOKEN**。D 侧配置缺失/无效时明确失败,不切换配置源;长期凭据不交给 A,不写入示例、日志或证据。当前TOKEN形态为官方SDK生成的受限预签名PUT信息,映射到UploadGrant;它不是OSS原生强制一次性凭据,Agent通过持久尝试状态保证每次授权尝试最多一次PUT。 -既有 SaaS 业务会话/资产登记仍经 MQ,D 关联原租户/执行/资产后才交付相应上传信息;这不是由 SaaS 签 TOKEN。业务响应可能晚于 RPC deadline,W02/W11 仍须冻结 pending、有界等待、原操作重取及最终结果,不无限阻塞 Unary、不因超时另造 upload ID。当前 D 的本地签发能力保留复用,禁止删除后改为等待 SaaS 下发配置。本段不新增 RPC/Proto 字段。 +用户已收缩上传职责:**R12不申请SaaS会话,不等待SaaS回复**。D校验原租户/执行/资产后按自身配置提供15分钟SDK预签名PUT,保留原upload_id;过期仅显式向D重新申请。签发能力保留复用,不引入第二配置源或新的上传控制协议。 ### 6.3 Agent → OSS 直接上传 @@ -296,29 +296,27 @@ Agent 获得 grant 后使用 `internal/agent.UploadClient.UploadFile`: - 只允许 HTTPS;除非显式配置,否则不允许 HTTP; - 可限制目标 host;禁止 grant 注入 `Host` 和 `Content-Length`; -- 预先读取文件计算 SHA-256,再按 `Content-Length` PUT; +- 使用同一文件描述符预读校验,再按`Content-Length` PUT并对实际发送字节计数和计算SHA-256;文件变化明确失败; - 禁止重定向;2xx 才算 PUT 成功; - 返回 HTTP status、文件大小、SHA-256、ETag; - 文件内容不经过 Dispatcher,Agent 不把源文件删除或移动。 -PUT 成功不等于 OSS verified,也不等于 SaaS 已应用。 +PUT成功仅是文件上传事实,还须由D将通知可靠送入MQ;既不等待也不声称SaaS已应用。 ### 6.4 `CompleteUpload` 请求:`meta`、原 `ExecutionBinding`、原 `AssetDescriptor`、`upload_id`、`uploaded_size_bytes`、`uploaded_checksum_sha256`。 -Dispatcher 当前执行: +Dispatcher当前执行: -1. 校验 upload ID 对应的 binding/asset 完全一致; -2. 校验上传大小、SHA-256 和 grant `max_bytes`; -3. 校验 grant 未过期; -4. 调用 OSS client 独立验证 object; -5. 只允许 `AssetKind=RECORDING` 进入 verified recording 路径; -6. 在一个 SQLite 事务内更新 upload 为 completed、保存 `oss_id` 并写入 `recording.ready` outbox。 +1. 校验原upload ID、完整binding/asset、实际上传大小、SHA-256和grant的max_bytes;只接受录音事实。 +2. 使用签发时持久保存的bucket/object key,不因当前配置改变对象位置。 +3. 在同一SQLite事务内保存原上传事实及固定event_id的recording.uploaded outbox。 +4. 未确认入队时返回Unavailable并保留uploaded状态;publisher确认原通知可靠入队后,重复R13返回ACCEPTED及COMPLETED。 -响应为 `OperationReceipt`、`state=COMPLETED`、`oss_id`。同 upload ID 同 OSS ID 的重复 complete 返回 `ACCEPTED`;未知 upload 返回 `NOT_FOUND`;对象校验失败返回 `FAILED_PRECONDITION` 且标记可重试。 +完成依据是persistent消息进入指定durable队列/绑定、mandatory无return且publisher confirm成功。仅写本地outbox不算交付完成。通知恢复不要求重取TOKEN或重新PUT,也不因原grant此时过期而重传已上传的文件。 -以上是旧本地验证事实。**目标流程必须由 D 经 MQ 提交 complete,SaaS 独立验证对象后经本 D 专用 Topic 返回 verified/oss_id;D 校验原请求、租户、资产和会话并持久化后,才可记完成及写 recording.ready outbox。** D 本地 HEAD、PUT 2xx 或 broker confirm 不能替代 SaaS verified。等待中/超时/重复响应及 A 获取最终结果的 Unary 衔接由 W02/W11 冻结,尚未完成;不凭此说明宣称当前 handler 已符合目标。 +旧OSS HEAD/verified及oss_id响应已删除,Proto保留原字段编号和名称为reserved。不等待SaaS会话或消费回复,不新增VERIFYING、不发recording.ready;完成不表示SaaS已处理。当前MQ wire为2.0,运行使用v3契约包(AI JCS摘要修订),上传字段沿用已批准的[v2冻结方案](mq-only-v2-freeze-proposal.md)。本地MQ无绑定、确认丢失、重启及原通知恢复已有[证据](../evidence/20260921-mq-upload-progress.md),不等于真实OSS/SaaS联调通过。 ## 7. 错误、幂等与未知结果 @@ -347,8 +345,8 @@ Agent 侧写操作的内存 operation key 为: 1. `GetBootstrap`、`SetAdmissionState` 虽有 handler,但当前 Dispatcher 启动流程没有调用完整 bootstrap/admission 编排。 2. `Execute` 的当前 RPC 实现只证明准备/幂等/配置校验,不证明 ARI/RTP/SIP 已由该 RPC 直接完成。 -3. D 配置文件→临时 TOKEN→A 直传的完整接线/约束,以及 SaaS 业务会话/complete/verified 的 MQ 协调与 R12/R13 有界衔接仍待核验;D 已有签发能力保留复用,但本地对象验证不能替代 SaaS verified。旧 `recording-uploads` HTTP 方案继续废弃,不开发 client。 -4. SaaS AI 配置/授权的 MQ 请求响应与持久绑定尚未接通;当前快照/授权由启动输入提供。旧 AI version GET 已废弃,不能作为后续实现方向。 +3. D配置文件→15分钟TOKEN→A直传及recording.uploaded可靠入队/R13完成已接通并有本地MQ证据。保留D签发,删除旧ready/oss_id完成路径;不开发SaaS上传会话或verified往返,不新增VERIFYING,旧上传HTTP继续废弃。 +4. SaaS AI 配置/授权的 MQ 请求响应与持久绑定已有本地RabbitMQ/SQLite恢复证据;Agent动态交付仍受现有Unary快照载体边界约束。旧 AI version GET 已废弃,不能作为后续实现方向。 5. 不能把 generated service 中的全量方法数当作每个 listener 都可调用;实际 listener 能力以第 2 节和 `DispatcherServer` 代码为准。 ## 9. 依据文件 diff --git a/docs/contracts/mq-only-v2-freeze-proposal.md b/docs/contracts/mq-only-v2-freeze-proposal.md new file mode 100644 index 0000000..6a3a675 --- /dev/null +++ b/docs/contracts/mq-only-v2-freeze-proposal.md @@ -0,0 +1,112 @@ +# MQ-only v2 与 Dispatcher OSS 配置:已确认合同 + +状态:用户已确认本文件的身份、纯Topic、配置、消息及查询结构;随后通过目标修订确认**上传只负责可靠入队**,并单独确认 `recording.uploaded` 名称/字段及R13完成边界。旧上传会话/verified等待方案被替代,不新增VERIFYING。机读v2包及AI JCS修订后的v3包已生成并通过离线测试;本地运行切换、RabbitMQ往返和联合恢复已有证据,真实SaaS/生产验收仍不在本轮。 + +机读来源:`contracts/upstream/2026-09-21-p1-v2/`。`manifest.json`记录项目内来源、旧源文件哈希与新文件哈希;不是外部SaaS签收。旧v1包保持原样,不提供运行时v1/HTTP回退。当前目标由本会话Agent独立执行,不启动子Agent。 + +## 1. Topic 与租户边界 + +用户已选择**只用Topic,不增加headers exchange**:保留tenant_key原值;每D/租户独立接收队列;拒绝按点号分词后为`*`或`#`的独立词段。不能广播后过滤、编码/截断租户标识或换别名绕过。 + +| 资源 | 已确认名称/规则 | +| --- | --- | +| D接收exchange | `agent-call.dispatchers.v2`,durable topic | +| SaaS接收exchange | `agent-call.saas.v2`,durable topic | +| 死信exchange | `agent-call.dead-letter.v2`,durable topic | +| D/租户接收队列 | `agent-call.d..t..v2`,durable | +| 对应死信队列 | `agent-call.d..t..dlq.v2`,durable | +| SaaS→D routing key | `d..t..in`,精确绑定 | +| D→SaaS routing key | `d..t..out`,精确绑定 | +| SaaS接收队列(本地合同) | `agent-call.saas.events.v2`,持久绑定获配置的D/租户出站键 | +| D身份占用队列 | `agent-call.d..owner.v2`,exclusive、非持久,只锁身份 | + +36字节UUID下最长死信队列固定部分59字节,tenant_key上限为**196个UTF-8字节**;仍逐一检查完整资源名255字节限制。Schema的字符数校验不替代运行时字节校验。非法/超长输入明确拒绝并保留源任务,不静默修正。 + +## 2. Dispatcher身份与恢复 + +- 配置文件提供稳定、规范小写UUID v4。部署时独立生成,不复制示例ID;启动时不自动生成/替换。 +- 初次使用与SQLite绑定,后续必须一致;更换ID沿用旧DB时拒绝,不清库或抹掉未知执行。 +- `dispatcher_epoch`是运行代次,不是稳定ID。请求、消息、资产与执行保持原归属。 +- 同一broker上的exclusive身份队列拒绝重复ID;业务队列必须持久且不能exclusive。连接断开拒新准入,不清未知占用。 +- 不宣称跨不同broker存在全局注册服务,不实现HA或自动换D。 + +## 3. 消息集合与关联 + +消息为persistent JSON、严格Schema、最大256KiB;未知字段拒绝,不新增任务mode字段。 + +### 3.1 命令与事件 + +- 命令保留`command_type`、`command_id`、租户、trace、issued/not_after及payload,新增`dispatcher_id`,`schema_version=2.0`。 +- 命令类型:`call.execute`、`task.control`、`call.replay`、`command.replay`。旧HTTP body的重复command_id归并到既有MQ信封,不维护两个独立命令身份。task_id/call_id/source_command_id来自原业务目标。 +- 事件保留`event_type`、`event_id`、租户、trace、occurred_at、aggregate和payload,新增来源`dispatcher_id`,版本2.0。 +- v2事件:`command.result`、`call.status`、`transcript.updated`、`call.finished`、`recording.uploaded`、`recording.failed`、`transcript.failed`、`contact.opt_out`。 +- **recording.uploaded取代本项目的recording.ready**,不冒用旧verified语义。其payload固定为`call_id`、`recording_id`、`upload_id`、`bucket`、`object_key`、`format`、`channels`、`sample_rate_hz`、`duration_ms`、`size_bytes`、`checksum_sha256`,不含TOKEN、密钥、签名URL或SaaS OSS ID。 +- 控制accepted不等于applied;整体补传保持原事件身份、版本和内容,不能重新投执行命令。 + +### 3.2 必要请求/响应 + +| 请求 | 方向 | 响应 | +| --- | --- | --- | +| `command.query` | SaaS→D | `command.query.result` | +| `call.query` | SaaS→D | `call.query.result` | +| `ai.config.request` | D→SaaS | `ai.config.result` | + +上传不在此表:**不存在upload-session、complete/verified请求响应或等待SaaS回复的上传控制流程。** + +公共请求字段:schema_version、message_type、message_id、dispatcher_id、tenant_id、tenant_key、trace_id、issued_at、not_after、payload。 + +响应使用对应公共身份字段,增加correlation_id、status、reason_code,**不带not_after**;correlation_id固定指原请求,dispatcher_id始终是业务所属D。status为ok/pending/rejected;reason_code严格按Schema分支校验。不可路由、超时、已受理但结果未知与业务拒绝分开,不把confirm当业务已应用。 + +本地服务请求30秒接收期限,不覆盖上游命令期限,也不让已受理结果失效。已知重复返回原决定;未知且过期拒绝;重启/迟到/乱序保留原关联,不另造ID重做业务。 + +### 3.3 查询结构 + +用户已批准补齐旧Call中的空白object定义: + +- attempts:已有call.status事件集合。 +- transcript.events:已有transcript.updated/transcript.failed事件集合。 +- recordings:recording.uploaded/recording.failed事件集合。 +- delivery:pending、retry、dispatching、published四种已有投递状态的非负计数。 + +保留原顶层通话字段,只读取持久事实,不伪造通话/资产结果。快照受256KiB限制,超限明确失败,不截断后伪称完整。AI快照/授权沿用原严格配置Schema;原OpenAPI receipt/config的allOf组合展平为同一闭合对象,不放宽字段。 + +## 4. D配置文件与临时TOKEN + +D通过`--config `读取严格JSON,未知字段、重复键、缺失或无效值报错;移除旧D OSS flags/隐式环境覆盖,不保留并行配置源。 + +```json +{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "oss": { + "endpoint": "https://oss.example.invalid", + "region": "example-region", + "bucket": "example-bucket", + "object_prefix": "recordings", + "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", + "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET" + } +} +``` + +这是字段示例,不是可部署的凭据/ID。文件明确引用受控凭据变量;环境仅提供这些凭据,不覆盖endpoint/bucket等配置。监听、TLS、持久目录等既有部署选项不全部搬迁。 + +D复用官方SDK的预签名PUT及必要headers,沿用UploadGrant作为临时TOKEN交付形式,不另造JWT/STS服务或签名算法。有效期固定900秒;A每次获准尝试只PUT一次,失败/过期保留文件,显式重新RequestUpload才换授权;不启用自动重传,不宣称OSS原生强制一次性。 + +## 5. R12/R13与上传通知交付边界 + +1. R12由D读取配置、校验原租户/执行/资产并提供临时TOKEN,**不向SaaS申请会话或授权**。 +2. Agent直接PUT对象,保存真实结果与本地恢复记录,再经R13报告原资产、upload_id、大小和摘要;文件字节不经过D。 +3. D校验与原grant/资产一致,把上传事实和固定event_id的recording.uploaded outbox同事务保存。重复R13不得生成第二资产/通知身份。 +4. 通知以persistent消息发送至正确的durable队列/绑定,启用mandatory并确认没有return,再取得publisher confirm;只有满足这些条件才记交付完成。 +5. **R13完成仅表示本项目已把事实交付MQ**,不是SaaS已处理。不返回虚构OSS ID,不发recording.ready,不新增VERIFYING或最终校验查询协议;原实现中的OSS ID返回/等待路径随实现删除。 +6. broker断连、不可路由、confirm丢失或崩溃时保留持久记录和原event_id,恢复通知交付。仅写本地outbox、发到无队列exchange或未确认时均不能算完成。 +7. 元信息重报/消息重投不触发第二次PUT;通知确认前保留所需恢复信息及源文件,不靠SaaS消费回复决定完成。AI/控制等必要请求响应不受此收缩影响。 + +## 6. 验证与事实边界 + +- TDD;v2 Schema正反例、来源/文件哈希、拓扑与Go路由规则一致性检查。旧v1不改。 +- 运行时须验证指定本地持久队列实际接收、不可路由/confirm丢失/重启恢复及无SaaS消费者也能完成交付;离线Schema通过不替代这些检查。 +- 基线总覆盖率32.1%,排除生成代码诊断值44.2%,均未达65%;不能只挑新增文件宣称整体达标。 +- 不接真实云/供应商/SaaS,不真实拨号;部署诊断缺失不能伪造通过。 +- RabbitMQ官方Topic/队列规则参考:https://www.rabbitmq.com/docs/exchanges 、https://www.rabbitmq.com/docs/queues 。公开检索确认点号分词、通配词段与exclusive单连接;直接抓取受工具fake-IP检查阻止,未宣称完整在线文档或PoC已验收。 diff --git a/docs/contracts/saas-dispatcher.md b/docs/contracts/saas-dispatcher.md index 11cb4e7..0daa222 100644 --- a/docs/contracts/saas-dispatcher.md +++ b/docs/contracts/saas-dispatcher.md @@ -2,21 +2,21 @@ ## 1. 适用范围与事实等级 -**用户已确认:RabbitMQ 是 SaaS 与 Dispatcher 的唯一交互通道,双方之间禁止任何 HTTP 请求或回调。每个 Dispatcher 都有独立、全局唯一的 ID,并通过各自独立的专用 Topic 接收事件。** 本规则覆盖执行、控制、查询、补传、AI 配置与授权、录音上传会话及完成验证,不保留 HTTP 特例或回退。 +**用户已确认:RabbitMQ 是 SaaS 与 Dispatcher 的唯一交互通道,双方之间禁止任何 HTTP 请求或回调。每个 Dispatcher 都有独立、全局唯一的 ID,并通过各自独立的专用 Topic 接收事件。** 本规则覆盖执行、控制、查询、补传、AI 配置与授权、recording.uploaded上传事实通知,不保留HTTP特例或回退;上传不等待SaaS会话或校验回复。 -**OSS 补充确认:OSS 相关配置存于 Dispatcher 配置文件;Agent 经 Unary 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不持有长期凭据。SaaS 不再下发 OSS 配置或上传 TOKEN。此次只调整配置/TOKEN 来源,上传完成后的 SaaS 独立校验和 MQ verified 职责保持不变。** +**OSS 补充确认:OSS 相关配置存于 Dispatcher 配置文件;Agent 经 Unary 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不持有长期凭据。SaaS 不再下发 OSS 配置或上传 TOKEN。用户随后修订目标:本项目只保证上传事实可靠进入指定持久MQ队列,不关心SaaS后续处理;不等待上传会话、verified或OSS ID,不新增VERIFYING。** 本文区分三种事实: | 层级 | 本次状态 | 使用边界 | | --- | --- | --- | | 已确认设计 | MQ-only、Dispatcher 唯一身份、独立 Topic | 后续设计与实现必须遵守 | -| 待冻结的消息契约 | 身份分配/持久化、Topic 命名、消息类型、关联字段、错误与超时规则 | 见 §2、§5、§6;不能据中文语义自行拼 JSON 或给旧 Schema 加字段 | +| 新版项目内契约 | `2026-09-21-p1-v2`身份/Topic/消息/配置/正反例及哈希,AI JCS修订后的v3包 | 以[mq-only-v2冻结方案](mq-only-v2-freeze-proposal.md)及机读包为准;本地运行往返已有证据,外部SaaS签收仍不在本轮 | | 现有实现/旧包 | 下列旧字段、路由及代码事实 | 仅用于识别差异,不代表新设计已实现或通过验收 | 当前固定包为 `contracts/upstream/2026-09-19-p1-v1/`(`contracts.SourceCommit = 2026-09-19-p1-v1`)。其中的 HTTP OpenAPI 与仅按租户路由的 MQ 拓扑**不再是目标方案**。旧包及其哈希保持不变;W01 须发布新版本、严格 Schema、拓扑及正反例,不能手改旧包、生成字段索引或通过放宽 `additionalProperties` 绕过冻结。 -本轮只纠正文档,不修改代码、Proto 或 Schema,也不宣称新 MQ 闭环已通过。现有实现事实沿用此前核验记录,受影响部分必须按新基线重新验证。 +当前已完成本地实现阶段;v2机读包、AI摘要修订后的v3包、离线正反例、路由规则及RabbitMQ运行往返均有证据。下文§3–§4的v1字段/代码描述仅为迁移差异,不能作为v2/v3接入要求;运行证据不等于外部SaaS签收。 ## 2. 通信拓扑、Dispatcher 身份与交付语义 @@ -27,17 +27,17 @@ | SaaS 下发执行、控制、查询、补传 | SaaS → RabbitMQ → 指定 Dispatcher 专用 Topic/队列 | 持久受理不等于执行完成;响应仍经 MQ | | Dispatcher 回传结果、查询响应和业务事件 | Dispatcher → RabbitMQ → SaaS 专用订阅 | 能识别来源 Dispatcher、租户、原请求及业务对象 | | Dispatcher 获取 AI 配置/授权 | Dispatcher → RabbitMQ → SaaS;SaaS → RabbitMQ → 原 Dispatcher 专用订阅 | 固定租户和不可变版本,响应不能被其它 Dispatcher 消费 | -| 业务上传会话、complete/verified | Dispatcher ↔ RabbitMQ ↔ SaaS | 只传业务会话/对象元信息与验证结果;不下发 OSS 配置或 TOKEN,不传录音字节 | +| 上传完成通知 | Dispatcher → RabbitMQ指定持久队列 | recording.uploaded仅含事实元信息;入队即完成本项目交付,不等待SaaS消费/会话/verified/OSS ID | | 临时上传 TOKEN 领取/显式重新申请 | Agent ↔ Unary ↔ Dispatcher | D 依据自身配置文件提供受限 TOKEN/上传目标;不向 SaaS 申请 TOKEN | | Dispatcher ↔ Agent | 既有 Unary gRPC | 不改为内部 MQ,也不让 Agent 直连 SaaS | | Agent → OSS | 受限目标上的直接 PUT | 保留 HTTP(S) 对象上传;禁止的是 SaaS↔Dispatcher HTTP,不是 OSS/ARI/供应商协议或 gRPC 的 HTTP/2 | ### 2.2 全局唯一身份与独立 Topic -- `dispatcher_id` 在本文中是**逻辑身份名称,尚不是旧 MQ Schema 或 Proto 已有字段**。每个 Dispatcher 的 ID 必须独立、全局不重复;不能拿租户 ID、Agent ID、Cell ID、地址或启动代次代替。 -- Dispatcher 身份与 `dispatcher_epoch` 分开:前者识别 Dispatcher,后者用于一次运行所有权/会话的 fencing。正常重启、恢复时如何保持身份及拒绝重复身份,须在 W01 冻结并由 W05 验证,不因 epoch 改变就丢弃原消息、执行或资产归属。 +- `dispatcher_id` 是当前v2/v3运行信封和路由中的独立全局身份;每个 Dispatcher 的 ID 必须独立、全局不重复,不能拿租户 ID、Agent ID、Cell ID、地址或启动代次代替。 +- Dispatcher 身份与 `dispatcher_epoch` 分开:前者识别 Dispatcher,后者用于一次运行所有权/会话的 fencing。身份持久绑定、重复身份占用、重启恢复及失效会话拒绝已有本地测试;不因 epoch 改变就丢弃原消息、执行或资产归属。 - 每个 Dispatcher 有独立的接收 Topic 及对应队列/绑定;多个 Dispatcher **不能共用一条接收队列竞争消费指定目标的消息,也不能全部订阅同一广播 Topic 后仅靠正文过滤**。 -- RabbitMQ 的 Topic 订阅由 exchange、routing key、queue 和 binding 表达;具体名称、类型、绑定格式及 ID 在信封/属性中的位置随新版本冻结。本轮不另造一套可直接部署的命名格式。 +- RabbitMQ 的 Topic 订阅由 exchange、routing key、queue 和 binding 表达;当前固定为 `agent-call.dispatchers.v2`、`agent-call.saas.v2`、`agent-call.dead-letter.v2` 及每个 Dispatcher/租户的独立队列和精确路由键,字段以v2/v3机读契约包为准。 - SaaS 发给 D1 的命令、配置、授权和上传结果,只能进入 D1 的专用接收路径;D2 的路径与之独立。D1 发出的响应/事件须能回溯 D1 与原请求。SaaS 订阅布局亦由同一版契约定义,不假定现有共享结果队列已满足新约束。 - 独立 Dispatcher 路由不替代租户隔离:保留租户独立队列、有界窗口、原值 `tenant_key` 和复合幂等语义;新拓扑必须同时区分 Dispatcher 与租户,不能退化为 Dispatcher 内所有租户共享无界队列。 - `tenant_key` 不清洗、编码或截断。旧布局的 224 UTF-8 字节预算不能在加上 Dispatcher 身份后直接照搬;W01 须校验完整 routing key/queue 名长度及分隔符、通配符边界,超限拒绝发布并保留源任务,不改变既有租户标识。 @@ -48,10 +48,10 @@ P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测 1. 所有请求和响应都走 MQ;异步响应必须关联原请求、目标/来源 Dispatcher、原租户及业务对象。精确键名、关联方式、消息枚举、错误与期限在 W01 冻结;`trace_id` 不能代替业务幂等身份。 2. 发送意图/业务变更与 outbox 同事务;接收方持久 inbox 和处理状态后才 ACK。相同业务请求的重投返回原决定,同身份异内容冲突,不能生成第二次拨号或上传资产。 -3. publisher confirm、消费者 ACK、业务 accepted、控制 applied 和 SaaS verified 各自独立。confirm 只说明 broker 接收,不等于对端已应用;接收 ACK 不能代替业务响应。 +3. publisher confirm、消费者ACK、业务accepted和控制applied各自独立;上传只验证可靠入队,不增加SaaS verified条件。confirm 只说明 broker 接收,不等于对端已应用;接收 ACK 不能代替业务响应。 4. 响应重复、乱序、迟到、丢失和重启后恢复必须按原关联处理;响应等待有界,不跨网络持有 SQLite 写事务。超时表示未获确定结果,不等于业务失败,不允许 HTTP 查询兜底、换 ID 重拨或静默换 Dispatcher。 -5. 队列满、无绑定/不可路由、broker 断连必须可见并保留原消息。不能通过 confirm 单独认定路由成功;须覆盖 mandatory/return 和实际目标消费证据。 -6. MQ 往返响应不意味着新增一套任意 application receipt 协议。已有业务结果和 verified 语义保留;需要补齐的响应消息必须进入版本化契约,不能借现有八类业务事件自由透传。 +5. 队列满、无绑定/不可路由、broker 断连必须可见并保留原消息。不能通过 confirm 单独认定路由成功;须覆盖 mandatory/return 和指定持久队列接收证据;上传无需SaaS消费者回复。 +6. MQ 往返响应不意味着新增一套任意 application receipt 协议。已有控制等业务结果保留;上传会话/verified往返已移出本项目;需要补齐的响应消息必须进入版本化契约,不能借现有八类业务事件自由透传。 ## 3. RabbitMQ 执行命令:旧基线与待改项 @@ -66,7 +66,7 @@ P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测 | dead-letter exchange | `agent-call.dead-letter.v1`,durable `topic` | 恢复必须保留原 Dispatcher、租户和消息身份 | | 默认 prefetch | `1` | 保持有界消费;不是多 Dispatcher 隔离证明 | -旧实现按消费租户声明 command queue 和 `.dlq.v1`,SaaS 结果队列基线为 `agent-call.saas.events.v1`、binding `agent-call.#`。这些名称记录旧包事实,不构成新拓扑批准。 +旧实现按消费租户声明 command queue 和 `.dlq.v1`,SaaS 结果队列基线为 `agent-call.saas.events.v1`、binding `agent-call.#`。这些名称仅记录旧包事实,不构成新拓扑批准;当前运行使用v2/v3精确 Dispatcher/租户路由。 ### 3.2 旧 `call.execute` 外壳 @@ -156,7 +156,7 @@ P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测 | `transcript.updated` | 实时文字;不得改名 `call.transcript` | | `transcript.failed` | 当前映射为 `aggregate_type=transcript`,但 Schema 要求 `transcript_segment`,该路径阻塞 | | `contact.opt_out` | 对应 Agent fact 经 Dispatcher 校验后生成 | -| `recording.ready` | 旧 `CompleteUpload` 本地对象验证后同事务写完成状态/outbox;不等于 SaaS MQ verified | +| `recording.uploaded` | 当前上传事实;D同事务保存事实及outbox,可靠进入指定持久队列后完成本项目交付,不代表SaaS已处理 | | `recording.failed` | Schema 已定义,当前无对应 FactKind/生成路径 | `RECORDING_PROGRESS` 只保存 fact,不发布 MQ 事件。上述已知实现差异不因本次文档改写而消失。 @@ -176,7 +176,7 @@ P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测 | `transcript.failed` | `call_id`, `reason_code`, `retryable` | | `contact.opt_out` | `call_id`, `task_id`, `task_item_id`, `requested_at` | -新设计中 `recording.ready` 只能在 D 收到并持久校验 SaaS 的 MQ verified 结果及 `oss_id` 后发布;PUT 2xx、ETag、本地路径或 D 单独 HEAD 成功都不替代该结果。 +v2已用`recording.uploaded`取代本项目的`recording.ready`;不得返回虚构OSS ID或等待SaaS verified。新字段与可靠入队边界见§6.1,旧表不作为v2校验依据。 ### 4.4 Outbox 交付 @@ -188,7 +188,7 @@ P1 仍只运行一个单活 Dispatcher。现在必须在合同及本地路由测 ### 5.1 待冻结内容 -以下只定义已确认业务语义,**消息类型名、信封字段、响应/错误枚举尚未发布**。旧 HTTP header、URL 和状态码不能直接作为 MQ 合同,也不能塞进旧 `call.execute` 或宽松 metadata 中。 +消息类型、信封和响应枚举以已确认的v2机读包及[mq-only-v2合同](mq-only-v2-freeze-proposal.md)为准。旧HTTP header、URL和状态码不是MQ合同,不能塞进旧call.execute或宽松metadata中。 ### 5.2 控制任务 @@ -202,27 +202,24 @@ SaaS 将 pause/resume/stop 控制发到目标 Dispatcher 专用 Topic。保留 仅允许以 `call_id` 或 `source_command_id` 请求整体业务结果补传,不增加 task/execution 范围或局部筛选。固定受理截止点,重发原事件 ID/内容/版本,实时优先、分批有界;补传自身结果不能递归进入集合。 -补传不是把 `call.execute` 重新发布来重新执行。旧 HTTP source-command replay 当前重发原命令的行为不满足目标整体结果补传,须列入 W12 修正;不能因改为 MQ 就保留这一错误语义。 +补传不是把 `call.execute` 重新发布来重新执行。当前MQ replay只重送已持久的原业务事实或原命令回执,重复、迟到和重启沿原消息身份恢复,不创建任务、不拨号、不新建资产。 ### 5.5 已废弃 HTTP 入口的处理 -`internal/control/http.go` 当前存在控制、命令查询/补传 handler,通话查询/补传路由固定返回 `404`。这些只是旧实现事实,**不再是可接入或待扩展的 SaaS 接口**。W05/W12 应移除这些 SaaS HTTP 业务入口及相关部署说明,不保留兼容层、并行双通道或 HTTP 兜底。本轮未改代码,不能宣称入口已移除。 +旧`internal/control/` HTTP业务实现及测试、Dispatcher HTTP启动路径、CLI参数和环境配置均已删除。执行、控制、查询、整体补传和AI配置/授权已有MQ本地往返、重复、错目标、迟到/重启恢复证据;不以HTTP删除代替外部SaaS验收。 ## 6. Dispatcher → SaaS:AI 配置与上传业务协调经 MQ -### 6.1 Dispatcher 配置、临时 TOKEN 与 complete/verified +### 6.1 Dispatcher配置、临时TOKEN与recording.uploaded -1. **OSS 配置唯一来源是 D 的配置文件**,包括所需服务地址、bucket、对象路径规则及签发授权所需的受控凭据配置/引用。配置文件格式及具体字段沿现有能力核验后冻结,本轮不新增猜测的配置键,也不在文档/样例/源码/日志中写实际密钥或完整 TOKEN。配置缺失或无效须明确失败,不改向 SaaS 取配置,不用 Agent 本地配置兜底。 -2. Agent 经 R12 向 D 领取临时上传 TOKEN。D 校验并持久关联原租户/执行/资产,依据自身配置复用官方 SDK 提供仅本对象可用、有有效期/方法/大小约束的 TOKEN 及必要上传目标信息,经 Unary 返回 Agent。Agent 不取得 D 的长期凭据或完整配置文件。精确 TOKEN 形态及与现有 `UploadGrant` 的映射待核验,不假定某个 SDK/Proto 已满足全部约束。 -3. **业务上传会话与 TOKEN 签发分开**:既有 SaaS 业务会话/资产登记语义仍经 MQ,响应回原 D 专用 Topic;但该响应不再承担 OSS 配置或 TOKEN 的来源。`upload_id`、对象引用及会话/资产关联按新版合同冻结,不因 TOKEN 过期另造资产,也不新加一套未批准的登记协议。 -4. Agent 直接 PUT 文件到 OSS,经 R13 只提交原资产/会话、对象引用、大小和 SHA-256 等完成元信息。D 经 MQ 提交 complete,SaaS 仍独立验证对象后经 MQ 返回 verified、`oss_id` 或明确失败;D 持久校验 verified 后同事务写资产状态和 `recording.ready` outbox。 -5. TOKEN 过期/失效由 Agent 显式向 D 重新申请,D 仍按自身配置提供,不向 SaaS 申请 TOKEN,不自动续期/重试。MQ 响应丢失/重复沿原资产、请求和 `upload_id` 恢复;未获 SaaS verified 保留待完成状态和文件,不提前 ready。 +1. OSS配置唯一来自D严格JSON配置文件;A经R12取得D用官方SDK提供的15分钟预签名PUT及headers,不持长期凭据,不向SaaS申请会话或授权。字段已在v2配置Schema冻结。 +2. A仅执行一次PUT并持久记录结果,R13只报告原upload_id、binding、资产、大小与摘要;D校验后将事实和固定event_id的outbox同事务保存。 +3. D发布persistent的`recording.uploaded`到正确durable队列/绑定,启用mandatory并处理return。收到publisher confirm且确认未被退回,才能将本次交付记为完成;只写本地outbox或无队列的exchange不算成功。 +4. 通知payload固定为call_id、recording_id、upload_id、bucket、object_key、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256,不含TOKEN、密钥、签名URL或SaaS OSS ID。 +5. broker故障、无绑定、confirm丢失和重启保留原消息身份并恢复通知,不重新PUT、新建资产或重新拨号。源文件与恢复记录在通知未确认前保留;TOKEN过期仅显式向D重申请。 +6. **R13完成只表示本项目已可靠交付MQ,不表示SaaS已消费/处理。** 不新增VERIFYING,不等待SaaS上传会话、verified或OSS ID,不发recording.ready。AI/控制等必要响应仍按各自合同处理。 -复用的业务数据包括 `recording_id`、`call_id`、`content_type`、`size_bytes`、SHA-256、声道/采样率/时长。D→A 的临时授权含原会话、TOKEN/上传目标、方法、必要 headers、约束和有效期;D↔SaaS 的 MQ 只承担业务元信息/会话及最终验证,不传 D 的配置文件、长期凭据或临时 TOKEN。SaaS 为独立校验取得必要对象定位及读取能力的既有业务要求仍须满足,精确合同在 W01 冻结,不能假定“D 持有配置”即代表 SaaS 已能验证。 - -业务会话/complete 的 MQ 异步结果与 R12/R13 Unary 的衔接须由 W02/W11 冻结 pending、超时、原操作重取及最终结果;TOKEN 本身来自 D,不等待 SaaS 下发配置/TOKEN。不能无限阻塞 RPC,也不能收到 broker confirm 就返回已完成。旧 `saas.openapi.yaml` 仅作语义对照,不是新 MQ Schema;本轮不修改 Proto/配置格式或新增字段。 - -当前 D 用 `internal/oss` client 签发 grant 的职责与新确认方向一致,**不应再把 D 签发能力列为待删除或“仅故障回退”**;但配置文件读取、TOKEN 约束及完整接线仍须核验。当前 D 本地验证对象即发 ready 的旧行为仍不能替代 SaaS MQ verified。旧 SaaS HTTP upload-session/complete 方案继续废弃,不开发 HTTP client。 +D现有SDK签发能力保留;旧D直接HEAD校验并生成ready/OSS ID的完成路径已删除,不能保留为兼容层。当前handler已接入上述新入队完成边界;本地RabbitMQ无绑定、确认丢失、重启和原消息恢复已有证据,不等于真实OSS/SaaS联调。 ### 6.2 AI 不可变配置与授权 @@ -230,24 +227,24 @@ D 根据 MQ 任务中的原 `tenant_id/tenant_key + agent_version_id`,通过 M 保留既有版本、`immutable`、`content_sha256`、`config` 及租户授权语义;源 Schema、不可变摘要、有效期、撤销、能力和供应商受控引用均校验后持久绑定到原执行,再交付 Agent。缓存按租户/版本隔离,在途/原排队任务不漂移,同版本异内容拒绝,0/false 与未提供保真;无有效授权时拒绝新准入,不用 latest、CLI/env 或 SDK 默认值兜底。 -旧 `ai-config.openapi.yaml` 的 AI GET 已废弃为 D↔SaaS 接入方式,不再开发该 HTTP client。当前 `AISnapshotRaw`/`AIAuthorizationRaw` 启动注入和 Agent 本地校验只证明旧路径;MQ 配置/授权、关联与持久恢复仍待 W01/W07 实现验证。消息细节不能由旧 OpenAPI 自动推定。 +旧 `ai-config.openapi.yaml` 的 AI GET 已废弃为 D↔SaaS 接入方式,不再开发该 HTTP client。Dispatcher当前通过MQ请求/接收内嵌不可变配置和授权,按租户、版本、摘要、有效期、撤销和出口持久校验;实际本地RabbitMQ往返及SQLite重启证据见`docs/evidence/mq-ai-local-roundtrip.md`。Agent动态交付仍受现有Unary快照载体边界约束,不凭启动fixture宣称外部动态交付。 -## 7. 待完成门禁与禁止误读 +## 7. 当前门禁状态与禁止误读 | 门禁 | 完成证据 | 当前状态 | | --- | --- | --- | -| W01 身份/Topic/消息冻结 | 新版本/来源/哈希、完整消息 Schema、路由、关联/错误/期限及正反例 | 待冻结 | -| W05/W12 MQ 控制面 | 全部交互持久接收/响应、移除旧 HTTP、补传语义正确 | 待实现/验证 | -| W07 MQ AI | 不可变配置/授权、迟到/撤销/重复与缓存隔离 | 待实现/验证 | -| W02/W11 上传授权与业务协调 | D 配置文件→临时 TOKEN→A 直传;R12/R13 与 MQ 业务结果有界衔接、SaaS verified 后才 ready | 待核验/实现/验证 | -| W13/W14 本地联合回归 | D1/D2 Topic 隔离 fixture、身份冲突、broker 故障、全流程无 SaaS↔D HTTP | 待验证 | +| W01 身份/Topic/消息冻结 | v2项目内Schema、路由、正反例及哈希 | 已生成并离线验证;不是运行时或外部签收 | +| W05/W12 MQ 控制面 | 全部交互持久接收/响应、移除旧 HTTP、补传语义正确 | 本地完成;见`mq-control-recovery.md`、查询/补传证据;外部SaaS不在本轮 | +| W07 MQ AI | 不可变配置/授权、迟到/撤销/重复与缓存隔离 | 本地MQ请求/响应、重复、范围和SQLite恢复完成;见`mq-ai-local-roundtrip.md` | +| W02/W11 上传授权与通知入队 | D配置→临时TOKEN→A直传;recording.uploaded可靠入队后R13完成,无SaaS等待 | 本地完成;见`20260921-mq-upload-progress.md`;不宣称SaaS消费 | +| W13/W14 本地联合回归 | D1/D2 Topic 隔离 fixture、身份冲突、broker 故障、全流程无 SaaS↔D HTTP | 本地完成;全仓质量和部署诊断状态见最终证据,真实供应商/生产仍延期 | 路由 fixture 只验证不同 Dispatcher 互不抢收,不把多 Dispatcher 调度或真实 SaaS 联调引入本轮。旧包、旧 HTTP handler、单租户 broker 测试和本地 OSS 成功,均不代表以上门禁通过。 ## 8. 依据与相关文档 - [计划与需求阅读索引](../plan-0918.md):§1.2、§8.2 的 MQ-only 修订与状态。 -- [时间泳道图](./saas-rabbitmq-oss-dispatcher-agent-timeline.md):目标流程,不冒充当前实现。 -- [Dispatcher ↔ Agent 契约](./dispatcher-agent.md):现有 Proto/handler 事实与上传协调待改项。 -- 旧实现事实:`internal/contract/contract.go`、`internal/mq/amqp.go`、`internal/tenant/routing.go`、`internal/dispatcher/consumer.go`、`internal/dispatcher/dispatcher.go`、`internal/store/store.go`、`internal/store/facts.go`、`internal/control/http.go`。 +- [时间泳道图](./saas-rabbitmq-oss-dispatcher-agent-timeline.md):已按当前recording.uploaded边界更新;外部SaaS/真实OSS处理仍不在本地证据范围。 +- [Dispatcher ↔ Agent 契约](./dispatcher-agent.md):现有 Proto/handler 事实、15分钟授权和上传通知恢复边界。 +- 旧实现事实:`internal/contract/contract.go`、`internal/mq/amqp.go`、`internal/tenant/routing.go`、`internal/dispatcher/consumer.go`、`internal/dispatcher/dispatcher.go`、`internal/store/store.go`、`internal/store/facts.go`。旧HTTP源码仅可在历史基线中查阅,不是当前运行路径。 - 旧固定包:`contracts/upstream/2026-09-19-p1-v1/` 下 `mq.schema.json`、`event-payloads.schema.json`、`mq-topology.md`、`executor.openapi.yaml`、`saas.openapi.yaml`、`ai-config.openapi.yaml`;保留原样,不代表 MQ-only 新契约已发布。 diff --git a/docs/contracts/saas-rabbitmq-oss-dispatcher-agent-timeline.md b/docs/contracts/saas-rabbitmq-oss-dispatcher-agent-timeline.md index 4e65d71..aa04261 100644 --- a/docs/contracts/saas-rabbitmq-oss-dispatcher-agent-timeline.md +++ b/docs/contracts/saas-rabbitmq-oss-dispatcher-agent-timeline.md @@ -1,175 +1,116 @@ # SaaS ↔ RabbitMQ ↔ Dispatcher ↔ Agent ↔ OSS 时间泳道图 -## 1. 用途与事实等级 +## 1. 已确认边界 -**本图描述用户已确认的目标设计:SaaS 与 Dispatcher 的所有交互只经 RabbitMQ,不存在双方直连 HTTP。每个 Dispatcher 具有独立、全局唯一的 ID 和独立接收 Topic。** 不再把 AI GET 或录音 HTTP 握手列为待实现目标。 +- SaaS↔D全部交互经RabbitMQ,无双方HTTP。每个D有全局唯一ID及独立Topic/租户队列,不抢收其他D的消息。 +- D↔A保持Unary;OSS配置在D配置文件,A向D领取15分钟临时TOKEN并直传OSS,D不转发文件。 +- **上传只保证recording.uploaded可靠进入指定持久队列**。不申请SaaS上传会话,不等待verified/OSS ID,不新增VERIFYING,不把入队当SaaS已处理。 +- P1单D/单A/单Cell/单租户;D1/D2只用于本地消息隔离验证,不扩展调度HA。 -P1 运行范围仍为单节点、单 Cell、单租户、单活 Dispatcher;用 D1/D2 说明消息隔离,并不扩大为多 Dispatcher 调度或 HA。Dispatcher↔Agent 保持 Unary gRPC,Agent→OSS 保持直接上传,音频字节不经过 Dispatcher 或 MQ。**OSS 配置存于 D 的配置文件,Agent 向 D 领取临时上传 TOKEN;SaaS 不下发 OSS 配置/TOKEN。上传完成仍由 SaaS 独立校验并经 MQ 返回 verified,此职责不变。** +本图为目标流程;v2 Schema/fixture通过不等于运行时接线或真实供应商验收。精确消息见[已确认v2合同](mq-only-v2-freeze-proposal.md)与`contracts/upstream/2026-09-21-p1-v2/`。 -- **已确认**:MQ-only、全局唯一 Dispatcher 身份、专用 Topic、原业务与幂等边界。 -- **待冻结**:精确 Topic/队列/绑定、身份生命周期、MQ 消息类型/字段、请求响应关联/错误,以及异步结果与现有 Unary 的衔接。 -- **现有实现不等于目标完成**:旧租户 MQ、HTTP 控制、启动注入 AI 和上传待改项见 §5;D 签发 TOKEN 的职责保留,但 D 本地对象校验不能替代 SaaS verified。 - -图中“配置请求”“控制请求”“verified 响应”等为中文语义标签,**不是已发布的 command_type/event_type**。精确结构须在新版契约包冻结;旧 `contracts/upstream/2026-09-19-p1-v1/` 和 Proto 不因文档修改而自动支持这些交互。 - -## 2. 独立 Dispatcher 的订阅关系 +## 2. 独立订阅关系 ```mermaid flowchart LR - S[SaaS] --> Q[RabbitMQ] - Q --> T1[D1 专用接收 Topic / 队列] - Q --> T2[D2 专用接收 Topic / 队列] - T1 --> D1[Dispatcher D1 / 全局唯一 ID] - T2 --> D2[Dispatcher D2 / 另一全局唯一 ID] - D1 --> Q - D2 --> Q - Q --> ST[SaaS 专用订阅] - ST --> S + S[SaaS] --> MQ[RabbitMQ topic exchanges] + MQ --> T1[D1 / 原租户独立持久队列] + MQ --> T2[D2 / 原租户独立持久队列] + T1 --> D1[Dispatcher D1 / UUID v4] + T2 --> D2[Dispatcher D2 / 另一UUID v4] + D1 --> MQ + D2 --> MQ + MQ --> SQ[SaaS指定持久接收队列] + SQ --> S ``` -D1/D2 不竞争同一条接收队列,不靠全量广播后过滤模拟隔离。发往 D1 的任务、控制、AI 配置/授权、业务上传会话/验证结果只能由 D1 接收;D 发出的消息须能识别来源及原请求。Dispatcher 身份不替代租户身份,也不等于 `dispatcher_epoch`。具体命名/关联及完整路由长度约束见 [SaaS↔Dispatcher §2](./saas-dispatcher.md#2-通信拓扑dispatcher-身份与交付语义)。 +采用精确Topic绑定,拒绝独立`*`/`#`词段,保留其他合法tenant_key原值;最长资源名决定196个UTF-8字节预算。D身份不同于dispatcher_epoch,重启不清除原消息/执行归属。 -## 3. 目标时间泳道图 - -时间自上而下;所有 S↔D 路径均经过 MQ。MQ 中的 D 接收订阅和 SaaS 接收订阅是独立方向,不是两套业务流程。为避免伪造字段,图只索引现有业务数据与待冻结语义。 +## 3. 时间泳道 ```mermaid sequenceDiagram autonumber participant S as SaaS - participant SQ as RabbitMQ / SaaS 专用订阅 - participant DQ as RabbitMQ / D1 专用接收 Topic 与队列 - participant D as Dispatcher D1 / 全局唯一 ID + participant SQ as RabbitMQ / SaaS指定持久队列 + participant DQ as RabbitMQ / D1专用持久队列 + participant D as Dispatcher D1 participant A as Agent participant O as OSS - Note over S,O: MQ-only 目标流程;新消息 Schema 待冻结,不代表已经实现。 - Note over DQ,D: D1 与 D2 的接收路径互相独立;本图仅展开 D1。 + S->>DQ: call.execute(目标D1、原租户、版本与期限) + DQ->>D: 精确投递 + Note over D: 校验并同事务持久inbox/task/outbox,重复返回原决定。 + D-->>DQ: 持久后ACK + D->>SQ: command.result accepted + SQ->>S: 受理结果,不是已拨号 - S->>DQ: call.execute(明确目标 D1;旧 payload 语义见 saas-dispatcher §3) - DQ->>D: 按 D1 专用绑定投递 - Note over D: 校验目标、租户、版本、幂等及期限;持久 inbox/task/outbox。 - D-->>DQ: 持久成功后 ACK - D->>SQ: command.result accepted(来源 D1、原命令关联) - SQ->>S: 业务受理结果 - Note over S,SQ: SaaS 持久接收后 ACK;publisher confirm 不等于业务已应用。 - - opt 当前执行尚无合法绑定的配置与有效授权 - D->>SQ: AI 配置/授权请求(原租户、agent_version_id、请求关联;§6.2) - SQ->>S: 配置/授权请求 - S->>DQ: 不可变配置、摘要、授权或明确拒绝(目标 D1、原请求关联) - DQ->>D: 配置/授权响应 - Note over D: 校验并持久绑定到原执行;无有效授权不得继续发起。 - D-->>DQ: 持久成功后 ACK + opt 缺少当前执行可用的不可变AI配置/授权 + D->>SQ: ai.config.request(原租户/版本/请求身份) + SQ->>S: 配置请求 + S->>DQ: ai.config.result(原请求关联) + DQ->>D: 定向响应 + Note over D: 校验摘要/授权并持久绑定;不使用latest或本地默认覆盖。 + D-->>DQ: 持久后ACK end - D->>A: GetAgentStatus(dispatcher-agent §4.1、§5.1) - A-->>D: AgentStatus、boot、能力与资源 - D->>A: ActivateAgent(§5.2) - A-->>D: ACTIVE + Session - Note over D,A: 原执行配置交付、bootstrap/admission 完整编排仍须按契约核验,不由本图宣称完成。 - D->>A: GetExecutionPermit(原 binding、配置摘要、预留;§5.5) - A-->>D: OperationReceipt + ExecutionPermit - D->>A: Execute(原 call.execute、binding、配置摘要、permit;§5.6) - A-->>D: OperationReceipt - Note over D,A: ACCEPTED 不等于 SIP 已发起;拨号仍受许可、控制和时间窗口约束。 + D->>A: 状态/激活/准入、执行快照与最后许可 + A-->>D: 既有Unary回执/实际状态 + D->>A: Execute(原binding与许可) + A-->>D: 接收回执 + Note over D,A: 仍须满足时间窗口、白名单、配额及控制屏障;回执不等于已发起。 - opt 执行期间控制任务 - S->>DQ: 控制请求(原任务、CAS、pause/resume/stop;saas-dispatcher §5.2) - DQ->>D: 投递给 D1 - Note over D: 持久控制与 outbox;关闭相关新发起权限。 - D-->>DQ: 持久成功后 ACK - D->>SQ: 控制 accepted - SQ->>S: 已受理,不是 applied - D->>A: ApplyTaskControl(dispatcher-agent §5.7) - A-->>D: receipt / 控制事实 - Note over D,A: 所有必需屏障和挂断事实核验完成后才可 applied。 - D->>SQ: 控制进度或 applied 结果 - SQ->>S: 按原控制关联更新状态 + opt 控制、查询或整体补传 + S->>DQ: task.control / query / replay + DQ->>D: 原D、租户和目标校验 + D->>A: 必要的控制/状态核验 + A-->>D: 实际回执/事实 + D->>SQ: 控制结果、查询响应或原业务事件补传 + SQ->>S: 按原请求关联处理,不重新执行呼叫 end - Note over A: 通话、媒体、AI 和录音产生真实 ExecutionFact。 - A->>D: ReportExecutionEvent(dispatcher-agent §6.1) + A->>D: ReportExecutionEvent D-->>A: 事实持久接收结果 D->>SQ: call.status / transcript.updated / call.finished / contact.opt_out - SQ->>S: 持久应用业务事件(saas-dispatcher §4) + SQ->>S: 业务事件 - A->>D: RequestUpload(原 binding、AssetDescriptor、upload_id;§6.2) - Note over D: OSS 配置来自 D 配置文件;缺失/无效明确失败,不向 SaaS 获取配置或 TOKEN。 - Note over D,A: 业务会话 MQ 结果可能晚于 Unary deadline;有界等待/重取待冻结,不是等 SaaS 签 TOKEN。 - D->>SQ: 原资产业务上传会话请求(仅元信息,不申请 TOKEN;saas-dispatcher §6.1) - SQ->>S: 既有业务会话/资产登记语义 - S->>DQ: 业务会话结果或拒绝(目标 D1、原请求/资产关联;无 OSS 配置/TOKEN) - DQ->>D: 原业务会话响应 - Note over D: 校验/持久会话关联,依据自身配置通过 SDK 提供临时 TOKEN。 - D-->>DQ: 持久成功后 ACK - D-->>A: Unary 返回 D 提供的临时 TOKEN 及受限 UploadGrant(精确映射待核验) - - A->>O: PUT recording bytes(dispatcher-agent §6.3) - O-->>A: PUT 结果 / ETag - Note over A,O: A 本地记录实际大小与 SHA-256;PUT 或 ETag 不等于 verified。 - A->>D: CompleteUpload(原资产/会话、大小、SHA-256;§6.4) - D->>SQ: complete 请求(原会话及元信息) - SQ->>S: 完成请求 - S->>O: 独立验证原对象(具体校验方式由 SaaS 合同定义) - O-->>S: 对象验证依据 - S->>DQ: verified + oss_id 或明确失败(原关联) - DQ->>D: 完成验证结果 - Note over D: 只有合法 verified 才同事务记录完成状态及 recording.ready outbox。 - D-->>DQ: 持久成功后 ACK - D-->>A: 通过获批 Unary 衔接返回最终上传结果 - D->>SQ: recording.ready(saas-dispatcher §4.3) - SQ->>S: OSS ID 与原录音元信息 - - opt 查询或整体补传 - S->>DQ: 原 command/call 查询,或 call_id/source_command_id 整体补传请求 - DQ->>D: 按目标 D1 接收(saas-dispatcher §5.3–§5.4) - D->>SQ: 关联查询结果,或原事件身份/内容/版本的补传 - SQ->>S: 查询响应或补传结果 - Note over S,D: 不重新投 call.execute 执行;响应超时不走 HTTP 兜底。 + A->>D: R12 RequestUpload(原binding/asset/upload_id) + Note over D: OSS配置来自D文件,复用SDK签发15分钟TOKEN;不向SaaS申请会话。 + D-->>A: 临时TOKEN/受限UploadGrant + A->>O: 一次PUT(文件直接上传OSS) + O-->>A: PUT结果 + Note over A: 保存真实上传结果/大小/摘要与恢复记录;不自动再次PUT。 + A->>D: R13 CompleteUpload(仅原资产元信息) + Note over D: 校验原绑定;上传事实与固定event_id的outbox同事务持久化。 + D->>SQ: persistent recording.uploaded,mandatory + alt 正确durable队列接收、无return且publisher confirm成功 + SQ-->>D: broker确认 + Note over D: 持久标记通知已交付;没有SaaS处理结果或OSS ID。 + D-->>A: 本项目交付完成 + opt SaaS自行消费,非本项目完成条件 + SQ->>S: recording.uploaded + end + else 不可路由、断连或确认丢失 + SQ-->>D: return / nack / error / timeout + Note over D,A: 保留原通知身份与恢复信息,不宣称完成;恢复通知而非重新PUT。 end ``` -图中后续步骤均以所需校验和前置条件成功为前提;拒绝、超时和失败保留原关联及待恢复状态,不继续执行成功分支。各类 MQ 请求/响应都适用持久后 ACK,图未重复画出所有 broker confirm/消费者 ACK;它们不能代替业务状态。 +图中所有MQ请求/响应都遵守持久后ACK;publisher confirm只证明broker接收,不能单独证明正确路由,须同时排除return并核验指定持久队列/绑定。对上传而言这就是交付终点;AI/控制等流程仍需要各自的业务响应。 -## 4. 交互与结构索引 +## 4. 数据与验证索引 -| 交互 | 数据/语义来源 | 新设计状态 | +| 交互 | 依据 | 验证重点 | | --- | --- | --- | -| 身份、目标与专用 Topic | `saas-dispatcher.md §2` | 原则已确认;精确命名/消息关联待冻结 | -| 执行请求/受理结果 | `saas-dispatcher.md §3–§4` | 旧业务字段可对照;Dispatcher 路由待改 | -| AI 配置/授权请求响应 | `saas-dispatcher.md §6.2` | 全部 MQ;旧 HTTP GET 不再是目标 | -| 状态/激活/许可/执行 | `dispatcher-agent.md §4–§5` | 既有 Unary;完整配置/执行接线以代码证据为准 | -| 控制/查询/补传 | `saas-dispatcher.md §5`;`dispatcher-agent.md §5.7` | MQ 请求与响应待冻结/实现,旧 HTTP 废弃 | -| Agent 事实与业务事件 | `dispatcher-agent.md §6.1`;`saas-dispatcher.md §4` | 旧 fact/outbox 已有;来源路由与已知映射差异待验证 | -| 临时上传 TOKEN | `saas-dispatcher.md §6.1`;`dispatcher-agent.md §6.2` | OSS 配置在 D 文件;D 提供 TOKEN,A 经 Unary 领取,SaaS 不签发或下发配置 | -| 业务上传会话 | `saas-dispatcher.md §6.1` | 保留既有 SaaS 业务语义,经 MQ 关联原资产/会话;不作为 TOKEN 来源,异步衔接待冻结 | -| 文件上传 | `dispatcher-agent.md §6.3` | Agent→OSS,不经 D/MQ,不改变对象上传协议 | -| complete/verified/ready | `saas-dispatcher.md §6.1`;`dispatcher-agent.md §6.4` | SaaS MQ verified 才触发 D 的 ready;待实现/验证 | +| 身份/精确Topic/租户队列 | v2合同§1–§2及mq-topology.json | 不串收/抢收,稳定ID、字节预算、危险词段拒绝 | +| 命令/查询/AI请求响应 | v2合同§3及mq.schema.json | 严格字段、原请求关联、持久恢复、accepted/applied分开 | +| D↔A | dispatcher-agent.md及Proto | 保持Unary,版本和执行事实以代码证据为准 | +| D配置/TOKEN/A直传 | v2合同§4–§5 | 无长期凭据下发、15分钟、一次PUT、显式重申请 | +| recording.uploaded | event-payloads.schema.json | 11个已确认事实字段,无TOKEN、密钥或SaaS OSS ID | +| 通知完成 | v2合同§5 | 指定durable队列、persistent、mandatory无return、confirm成功;无需SaaS回复 | -## 5. 当前实现与目标设计差异 +## 5. 当前实现差异 -| 能力 | 既有实现事实 | 必须完成的纠正 | -| --- | --- | --- | -| Dispatcher 身份/订阅 | MQ 命令仅按 tenant key 路由 | 全局唯一 ID、独立 Topic/队列、原请求定向响应及来源校验 | -| 控制/查询/补传 | 旧 HTTP handler;部分通话路由固定 404,source-command replay 重发原命令 | 移除 SaaS HTTP 业务入口;全部 MQ,补传只恢复原业务结果,不重拨 | -| AI 配置来源 | 启动注入 `AISnapshotRaw` / `AIAuthorizationRaw` | SaaS MQ 配置/授权及持久绑定,不接旧 AI GET | -| 上传 TOKEN 与验证 | D 已有 OSS client 签发 grant、直接验证对象并发 ready | 保留 D 签发职责,核验配置文件/TOKEN 约束;业务会话与 verified 仍经 SaaS MQ,本地对象验证不能替代 verified | -| R12/R13 | 当前同步本地 grant/verify | 核验 R12 依据 D 配置提供 TOKEN,以及业务会话/complete 的 MQ 持久关联、有界等待、原操作恢复与最终 Unary 交付 | -| Agent Execute | handler 证明准备/校验/幂等接收 | 不能据此宣称实际 ARI/RTP/SIP/AI 生命周期已完成 | -| TRANSCRIPT_FAILED | 当前 aggregate 类型映射不通过 Schema | 已知差异保留,另行实现修正,不因文档变更标通过 | +旧SaaS业务HTTP实现和启动配置已删除,执行、查询、控制、AI请求及上传通知已接入v2/v3 MQ。本地证据覆盖D身份/租户隔离、不可路由、confirm/DLQ、断连、重复、迟到revision、SQLite重启和recording.uploaded恢复;不得把这些本地证据当作外部SaaS或生产通过。 -## 6. 幂等、失败与验收边界 - -1. 重投沿原请求、Dispatcher、租户和业务对象关联;同 ID 异内容冲突,不能换 execution/attempt/asset 绕过。 -2. D1 离线不把未决请求/响应改投 D2;MQ 中断不回退 HTTP,执行未知不自动重拨。 -3. 配置迟到、重复或已撤销时不能覆盖在途绑定;无有效授权拒新准入。 -4. 上传回包丢失复用原 `upload_id` 和资产摘要;TOKEN 过期只接受 Agent 显式向 D 重新申请,配置不来自 SaaS,也不把 D 配置/TOKEN 经 MQ 传给 SaaS。未取得 SaaS verified 时不得发 ready 或提前删文件。 -5. 新 boot/epoch 不清旧未知执行和占用;RPC 超时按原 binding 查询/对账。 -6. 交付检查覆盖无 SaaS↔D HTTP、D1/D2 路由隔离、错目标/重复身份、请求响应乱序/超时/重启恢复、不可路由与 confirm 丢失;不以文档图、单租户 Mock 或本地 OSS 验证冒充通过。 - -## 7. 相关文档 - -- [SaaS ↔ Dispatcher 对接契约](./saas-dispatcher.md) -- [Dispatcher ↔ Agent 对接契约](./dispatcher-agent.md) -- [计划与需求阅读索引](../plan-0918.md) +旧会话/verified控制流程已移出目标,不再为它增加RPC、状态或测试服务器业务系统。通知重复/确认丢失/重启沿原消息身份恢复,不重新PUT、不新建资产、不重拨;SaaS后续处理不属于本项目。 diff --git a/docs/evidence/20260921-ai-jcs-digest.md b/docs/evidence/20260921-ai-jcs-digest.md new file mode 100644 index 0000000..c28cd01 --- /dev/null +++ b/docs/evidence/20260921-ai-jcs-digest.md @@ -0,0 +1,25 @@ +# AI 配置摘要:JCS 本地修订 + +## 已确认决定 + +用户明确批准 AI 配置采用 RFC 8785(JCS)规范化后计算 SHA-256,输出小写十六进制。新版本地契约不等于外部 SaaS 已签收。不改变文件校验和、上传事实去重或命令正文哈希。 + +## 实现与版本 + +- Go/Python 复用 `github.com/cyberphone/json-canonicalization`,固定 `v0.0.0-20241213102144-19d51d7fe467`;不自写规范化算法。 +- `ai.Validate` 保留原始配置字节,但摘要取 JCS 结果;拒绝非法 UTF-8,不接受旧摘要回退。 +- 新包:`contracts/upstream/2026-09-21-p1-v3/`,MQ 线上的 `schema_version` 仍为 `2.0`。 +- manifest SHA-256:`e09e8563f4a7bc9d70e3a4b9782efbaeca8858965433cefb1b60355115159d1c`。 +- v1/v2 不可变包保持原样。发布器从校验过的 v2 文件生成新包,在离线正反例和 AI Schema 校验后发布;已存在目标若内容不同,明确失败。 +- Python 生成的数值、UTF-16 属性排序和字符串黄金向量,由 Go 验证规范字节及 SHA-256 一致。 + +## 验证事实 + +- 新测试先暴露原始字节哈希对空白及 `1`/`1.0` 表达敏感;实现后相同语义的示例得到相同摘要。 +- 授权样例使用已有 `config_sha256` 字段绑定新摘要,没有增设猜测字段。 +- 新包文件哈希、未登记文件检查、离线 Schema 正反例、跨语言黄金向量测试通过。 +- 全仓普通测试通过:`/tmp/go-sip-jcs-full.log`。 + +## 尚未完成 + +AI 配置/授权 MQ 请求响应的完整持久运行链路、最终 race/vet/覆盖率及部署验收仍未关闭。不能用摘要测试替代完整 AI 交付或外部真实联调。 diff --git a/docs/evidence/20260921-mq-control-foundation.md b/docs/evidence/20260921-mq-control-foundation.md new file mode 100644 index 0000000..24bb042 --- /dev/null +++ b/docs/evidence/20260921-mq-control-foundation.md @@ -0,0 +1,43 @@ +# MQ-only:配置、身份与本地MQ基础验证(业务接线未完成) + +## 已实现 + +- Dispatcher启动必须提供`--config`。配置文件先校验,再访问数据库;凭据只按文件中明确的环境变量引用解析,授权时长固定15分钟。 +- JSON拒绝重复键、未知字段、大小写别名、多个顶层值、无效UTF-8及超出64 KiB的文件;按已确认v2配置Schema校验,失败不部分修改配置,也不改读旧配置来源。错误不打印凭据值。 +- SQLite通过`005_dispatcher_identity.sql`保存唯一逻辑身份。换ID拒绝沿用数据库;身份校验在恢复通知之前,不能由错误身份改变待发送状态。 +- `store.Open/New`仅打开和迁移;恢复由启动入口在校验身份后显式执行。相关重新打开/进程崩溃恢复测试已同步。 +- RabbitMQ使用三组v2 durable topic exchange、独立Dispatcher/租户队列和非持久exclusive身份占用队列。同一ID重复启动被拒绝;启动时先取得MQ身份占用,再打开业务数据库。 +- 发布只允许本D已经声明的租户出站路由;persistent、mandatory、publisher confirm与return共同判定成功。使用独立发布channel隔离迟到确认,复用连接。 +- 收发限制256 KiB;超限入站消息不进入业务处理,进入对应持久DLQ,并记录身份、消息序号和大小,不打印正文。 + +## 实际验证 + +本地隔离RabbitMQ 4.1容器测试证明: + +1. 两个不同UUID使用同一租户时队列不相同,D1消息不被D2收走。 +2. 重复占用同一UUID失败,不影响原连接。 +3. 跨D消费/发布被拒绝。 +4. 指定SaaS持久队列实际收到persistent消息;不启动SaaS消费者也能完成发布。 +5. 移除目标绑定后,即使broker确认,mandatory return仍导致发布失败;恢复绑定后的下一次发布正常,不被旧return污染。 +6. 262145字节入站消息在修改前错误到达业务handler;加入大小门禁后通过,实际进入对应DLQ。 + +配置测试也先暴露了`encoding/json`接受大小写别名的问题;增加权威Schema校验后拒绝。配置缺失、身份变更及恢复顺序均有回归测试。永久消息分类测试使用真实Schema错误,不只检查手写错误字符串。 + +最新检查: + +- Go:`go1.27.1 linux/amd64`。 +- 全仓`go test -race -coverprofile=... ./...`通过;只显式启用本机RabbitMQ测试,其余外部集成开关清除。 +- `go vet ./...`、构建、格式及`git diff --check`通过;旧v1契约与`704652b`无差异。 +- 总覆盖率 **35.1%**,尚未达到65%;config 75.7%、MQ 63.6%、tenant 85.4%,不以局部数字替代整体要求。 +- 9个文件的主动LSP检查无返回诊断,但均为inconclusive,不宣称LSP确认干净;以实际编译/vet/race结果为本轮检查依据。 +- 临时日志及覆盖率:`/tmp/go-sip-mq-control.noMu1i/`。该路径非长期制品。 + +## 未完成,不能签收全流程 + +- `contracts.SourceCommit`仍为v1;业务消费、事件构建、outbox路由和必要请求响应尚未整体切到v2。MQ传输测试不证明完整业务已接通。 +- 旧HTTP入口、CLI/环境中的旧配置选项及旧集成测试仍需清理;配置文件权威来源已接入不等于所有旧入口已删除。 +- Broker连接丢失的通知已暴露,但所有准入/许可路径的停止联动还需验证。 +- 上传仍待改为recording.uploaded及正确的R13完成边界;未验证通知恢复不重复PUT。 +- 本轮只验证本地组件,不是完整非生产部署验收;未做ECS、SIP、真实供应商或生产验证。 + +当前为`mq-control`任务的部分证据,不关闭该任务或其余计划项。 diff --git a/docs/evidence/20260921-mq-only-adjustment-baseline.md b/docs/evidence/20260921-mq-only-adjustment-baseline.md new file mode 100644 index 0000000..6da8865 --- /dev/null +++ b/docs/evidence/20260921-mq-only-adjustment-baseline.md @@ -0,0 +1,51 @@ +# MQ-only / Dispatcher OSS TOKEN 调整:实现前基线 + +- 记录时间:2026-09-21T06:05:43Z。 +- 分支:`feat/mq-only-dispatcher-oss`。 +- 源提交:`704652bd0dde2f78249fa53784d7afddf5f070f5`。 +- 该提交经用户明确批准,仅保存11份已确认设计文档,未推送远程;本次开始检查时工作区干净,已 fetch 并确认包含 `origin/main`。 +- 用户已明确本目标由当前 Agent 直接执行,不启动子 Agent;原子 Agent fast/provider 阻塞不适用于本次执行。 + +## 1. 实际检查结果 + +| 检查 | 结果 | 边界 | +| --- | --- | --- | +| `GOTOOLCHAIN=local go version` | `go1.27.1 linux/amd64` | 符合锁定版本 | +| `go test -race -coverprofile=... ./...` | 通过 | 显式移除 `RABBITMQ_URL`、`AGENT_CALL_PROVIDER_SMOKE`、`AGENT_CALL_OSS_INTEGRATION`,没有调用真实 broker/OSS/AI | +| 覆盖率 | 全部代码32.1%;排除生成文件的诊断值44.2%(4557/10318语句) | 尚未达到65%,两种口径均不算验收通过 | +| `go vet ./...` | 通过 | 当前源提交 | +| `go build ... ./cmd/sip-go-agent` | 通过 | 实际入口是 `cmd/sip-go-agent`;第一次误用不存在的 `cmd/agent-call` 失败,随后按实际入口纠正命令,没有改代码掩盖 | +| 手写Go文件 `gofmt -l` | 无输出 | 不改生成代码 | +| Buf / Proto工具 | Buf 1.61.0、两个Go生成插件可用 | 项目用Buf生成;独立protoc不在PATH,不等于现有Buf链阻塞。尚未执行本次Proto生成 | +| Python契约工具 | jsonschema、yaml可导入 | 尚未发布新契约 | +| 本地RabbitMQ测试条件 | Docker本地socket可用;本地已有 `rabbitmq:4.1-management-alpine` 镜像;5672无监听 | 尚未创建容器或宣称MQ集成通过 | +| 部署/诊断 | systemctl可用,tcpdump不在PATH | 之后须按获批本地/隔离profile验证,不跳过诊断或伪造ECS事实;未授权任何真实外呼/云操作 | + +## 2. 已核实的实现差异 + +| 路径 | 当前事实 | 调整边界 | +| --- | --- | --- | +| `internal/mq/amqp.go` | 命令使用旧租户级拓扑,没有获批的新D身份/专用Topic闭环 | 新身份/拓扑/消息先确认,再实现;不放宽旧Schema | +| `internal/config/config.go`、`cmd/sip-go-agent/main.go` | 现有OSS配置和启动开关分散在进程配置/flags,存在旧业务HTTP接线 | 新D配置文件需严格读取;移除SaaS业务HTTP,保留内部Unary及必要非业务诊断 | +| `internal/control/http.go` | 查询/控制/补传仍为旧HTTP处理 | 业务逻辑转为MQ;整体补传不能重投执行命令 | +| `internal/rpc/dispatcher_upload.go` | R12已接D签发grant;R13仍包含D直接验证对象并完成资产的路径 | 保留签发能力;完成状态必须等待SaaS MQ verified,不把D本地验证当最终验证 | +| `internal/oss/aliyun.go` | 已复用阿里云官方SDK提供15分钟默认预签名PUT;允许配置更长TTL;有本地HEAD校验 | 可复用签发与现有UploadGrant,不自写签名;新约束须锁定15分钟,不能宣称URL本身原生一次性 | +| `internal/store/store.go`、迁移目录 | 已有SQLite业务表/事务、4份迁移文件 | 新持久身份、请求关联和结果状态须认真设计并测试,不破坏旧数据或执行未知占用 | + +这些事实不代表完整新流程已接通。AI配置/授权的MQ关联、控制屏障、最终verified和重启恢复仍需在新合同下实现与逐项验证。 + +## 3. 基线产物位置与哈希 + +运行产物在 `/tmp/go-sip-mq-baseline.niNWHQ/`,可能被系统清理;这里仅保存脱敏检查事实和哈希,不把临时文件当永久可用制品。 + +| 文件 | SHA-256 | +| --- | --- | +| `test.log` | `69c6602993d04076031cd62c885db3b0bb0e81c5a9bd86d60a3b13d6ef1aeeac` | +| `coverage.out` | `9f474ba35af2685ada2b9f52e84def76fc43452995a8a73f59b3f95e740455d3` | +| `sip-go-agent` | `36ddab325d6a54b96d5ba7dba23a65d811005aba30d62dcaca2ebb65500275cf` | + +## 4. 尚未完成 + +- Dispatcher身份生命周期、Topic/消息/错误/期限、配置文件及TOKEN形态的精确方案尚需用户确认。 +- 未修改业务实现,未执行新的MQ/OSS完整集成或故障矩阵。 +- 未接入真实SaaS、供应商、云资源或拨号;没有生产验收结论。 diff --git a/docs/evidence/20260921-mq-query-progress.md b/docs/evidence/20260921-mq-query-progress.md new file mode 100644 index 0000000..5d1c4c1 --- /dev/null +++ b/docs/evidence/20260921-mq-query-progress.md @@ -0,0 +1,37 @@ +# MQ 查询阶段证据(mq-control 尚未完成) + +## 已接通 + +- `command.query`、`call.query` 经 Dispatcher 的 MQ 消费入口进入严格v2校验,按D身份、原租户及实际路由检查归属。 +- 新增`006_mq_queries.sql`:租户绑定与查询去重记录;查询身份、固定响应和outbox在同一事务保存,成功持久化后才允许ACK。 +- 重复查询返回原响应身份及原快照;已发布响应可恢复为待发送,正在发送的响应不被重置。重启及请求期限过后重复投递仍取原决定,不重新计算快照。 +- 过期的新请求保存明确拒绝;相同消息身份但不同内容、不同D或租户绑定冲突不被接受。 +- 命令快照读取实际收件记录及已记录的aggregate version,不用固定版本填补缺失证据。 +- 呼叫快照只收集同D、同租户、同call_id的事实,包含已批准的状态、尝试事件、文字事件、uploaded/failed录音事件及四类投递计数。 +- 超出256 KiB预算返回有界的`rejected/unavailable`响应,不截断成貌似完整的快照,不进入无限重投。**合同没有include或分页字段**,没有添加它们。 + +## 校验与错误 + +- 新增嵌入式Schema loader及编译结果复用;跨文件引用只取指定契约包,外部URL、跨版本和越界文件名被拒绝,不访问网络。 +- 新服务消息使用已批准字段`message_type`,不是`message_kind`或`request_type`。 +- 非法UTF-8曾被当作可重试错误;真实失败测试后改为明确的无效消息错误分类,避免反复回队。 +- 当前全局`contracts.SourceCommit`及执行/事件旧流程仍未整体切v2;新查询明确固定v2,不尝试旧协议回退。 + +## 实际测试 + +- Schema离线引用、六类服务消息正例、额外字段/旧版本拒绝。 +- 查询存储失败回滚:响应、去重记录、租户绑定均不部分提交。 +- 查询重复、异内容冲突、重启、过期,以及缺失实际版本证据。 +- 呼叫事实集合及投递计数;大快照拒绝;未批准include字段拒绝。 +- 本机RabbitMQ真实命令查询往返:请求队列→Dispatcher→SQLite/outbox→指定SaaS队列;验证persistent、路由、租户、D身份和correlation_id。重复请求恢复同一响应ID。 +- 该测试使用本Agent创建的本机容器及专用`go-sip-query-tests` vhost,避免和MQ包测试抢取同一个固定SaaS队列。开关为`GO_SIP_LOCAL_QUERY_MQ_URL`,仅接受loopback。 + +最新全仓验证:`go test -race -coverprofile=... ./...`、vet、构建、格式及diff检查通过;同时启用两个本地MQ测试入口,真实外部集成开关清除。旧v1契约与`704652b`无差异。 + +覆盖率 **36.9%**,仍未达到65%。临时日志/覆盖率:`/tmp/go-sip-mq-queries.hhyp37/`。 + +## 未完成 + +旧HTTP业务实现、启动流程、CLI参数、环境字段及部署样例已删除;`TestDispatcherHasNoBusinessHTTPFlags`先失败后通过,删除后全仓race/vet/build及旧v1无变更检查通过,日志`/tmp/go-sip-http-removal-tests.log`。未保留兼容入口。 + +执行/控制/整体补传、AI配置/授权请求响应、全部旧MQ路由/信封替换、所有权断连后的准入联动,以及新上传事实交付仍待完成。本证据不关闭mq-control或其他任务,不代表完整业务、本地部署验收或真实供应商通过。 diff --git a/docs/evidence/20260921-mq-replay-progress.md b/docs/evidence/20260921-mq-replay-progress.md new file mode 100644 index 0000000..6dc3c37 --- /dev/null +++ b/docs/evidence/20260921-mq-replay-progress.md @@ -0,0 +1,29 @@ +# MQ 整体补传阶段证据 + +## 已实现 + +- `call.replay`、`command.replay`按已确认v2命令Schema进入MQ消费入口;校验原D、租户、路由和期限,不转换为旧协议。 +- 事务内保存原命令身份、内容摘要和固定的`command.result`响应,提交后才可ACK。 +- 只将原业务事件恢复为待投递,保留原event_id和正文;不创建任务、通话或资产,不重拨、不PUT。 +- 按source_command_id补传时,从已持久命令确定execution,再通过已记录的call.status/call.finished确定call_id;这样覆盖只含call_id、不含execution_id的已批准recording.uploaded,未擅自加字段。 +- 同一命令重复到达只恢复原响应,不再次执行补传副作用;不同内容冲突、错路由及过期命令被拒绝。正在发送的原事件不被重置。 +- 新增`007_mq_command_receipts.sql`保存固定回执关联;复合外键具有对应唯一索引。现有inbox的历史全局command_id主键没有在本步重建,不将本步宣称为双租户能力完成。 +- 命令查询改用已记录command.result的状态、时间、原因和版本,不把inbox的persisted误报为业务状态。 + +## 实际测试 + +- 存储触发器注入失败:原事件投递状态、命令及响应均回滚,不发生部分提交。 +- 两类补传的原event_id/正文不变、重复请求同响应、错目标、异内容、过期与零新增拨号任务。 +- 新v2通用消息校验/命令解码覆盖四类命令,拒绝旧版本;未添加旧协议回退。 +- 本机RabbitMQ测试统一为`TestLocalMQRequestRoundTrip`,覆盖command.query、call.query、call.replay、command.replay四类实际MQ往返,验证持久消息、正确目标队列/路由/身份/关联和重复响应身份。 +- 补传集成测试明确以本地fixture预置此前已保存的业务事实,证明通知重投,不等同于真实外呼或当前v1执行路径已生成v2事件。 + +最新全仓race+coverage、vet、构建、格式、diff及旧v1与704652b无差异检查通过。两个本机RabbitMQ测试入口启用,真实外部集成关闭。 + +- 覆盖率:**37.5%**,尚未达到65%。 +- 临时日志/覆盖率:`/tmp/go-sip-mq-replay.GLsL0Q/`。 +- 未提交、未推送,未触碰云或真实供应商。 + +## 未完成 + +mq-control仍未关闭:执行/事件仍有旧版本路径;task.control及AI配置/授权的完整MQ协同、所有权断连后的准入联动仍待实现。上传TOKEN/R13的新完成边界及整体部署验收也仍未完成。 diff --git a/docs/evidence/20260921-mq-upload-progress.md b/docs/evidence/20260921-mq-upload-progress.md new file mode 100644 index 0000000..69f505d --- /dev/null +++ b/docs/evidence/20260921-mq-upload-progress.md @@ -0,0 +1,37 @@ +# MQ 上传调整:本地阶段证据 + +本记录仅覆盖本地代码和隔离测试,不代表完整目标通过,不代表真实 OSS、供应商或生产验收。 + +## 已实现 + +- Dispatcher 使用已绑定身份,持久保存 `recording.uploaded` 上传事实和原通知。上传完成只在 outbox 发布成功后记录;R13 在通知尚未交付时返回 Unavailable,可重试原事实通知。 +- 上传路径不再执行对象 HEAD、不等待 SaaS、不生成 OSS ID,也不发送 `recording.ready`。内部完成响应的旧 `oss_id` 字段已移除并保留字段编号;旧不可变外部契约包未因此修改。 +- Agent 在 PUT 前独占并持久记录上传尝试。失败或结果未知不会自动再次 PUT;成功结果和原 binding/asset 保存到文件,Agent 启动恢复仅发送通知,不申请 TOKEN、不读取或发送录音。 +- TOKEN 固定 15 分钟,非法文件大小明确拒绝;普通 HTTP transport 错误不再携带外层签名 URL。签名 URL 不是 OSS 原生一次性凭据。 +- 授权按 upload_id/operation_id/请求摘要持久绑定;重放旧操作只返回原授权,即使它已经过期。`agent upload-retry` 要求调用方显式提供新的 UUID v4 请求ID;各次请求分别持久占用,不能复用旧请求执行另一轮PUT。 +- 原始上传及通知恢复、显式再申请共用跨进程文件锁。源文件变化时按实际发送字节核验大小/SHA-256,不能把预读摘要当作实际上传事实。 +- 授权时的bucket/object_key持久绑定。后续配置更换bucket不能改写原上传通知的位置,也不能把原上传身份重新授权到另一个bucket。 +- OSS参数仅来自严格JSON文件及其明确引用的凭据环境变量;旧OSS环境配置、HEAD核验和OSS ID生成辅助路径已移除。部署样例及systemd入口使用必需的JSON配置。 +- MQ 发送保留原 `event_id`/`message_id` 到 AMQP `MessageId`。使用 persistent 消息、durable 队列及精确绑定、mandatory 和 publisher confirm。 +- outbox 每次仅领取即将发送的一条消息。原批量领取方式在第一条发送失败时,会使余下消息滞留 dispatching,现有回归测试证明无需重启即可恢复整批;恢复状态写入错误不再被吞掉。 +- MQ 独占身份连接断开会停止共享准入上下文,新 Dispatcher RPC 被拒绝;不能由此推断 Agent 已有许可全部撤销。 + +## 已执行检查 + +- Go 1.27.1。 +- 修改相关包的 race 测试,以及全仓 vet、格式和 diff 检查通过;这些不是最终全仓覆盖率验收。 +- `TestRecordingNotificationRecoveryDoesNotPUTAgain`:本地 TLS 测试端,重启文件状态后一次 PUT、一次授权请求、两次事实通知,保留源文件。 +- `TestLocalUploadNoticeSurvivesUnroutableAndRestart`:独立本地 RabbitMQ vhost,无绑定返回失败;SQLite 重启后保留原通知;恢复绑定后入队。 +- 同一测试注入“RabbitMQ 实际发布成功,但应用丢失发布结果”:交付未被提前标记完成,再次发送保留原 MessageId 和正文。队列收到两份同身份持久消息,符合至少一次交付;未新建资产或触发 PUT。这是应用边界故障注入,不冒称网络层 confirm 抓包实测。 +- 上传完成判定发生在读取队列前,不需要 SaaS 消费者或业务处理回复。 +- 本地 D1/D2 隔离、查询/补传与上传分别使用隔离测试队列环境;相关 RPC、MQ、Dispatcher race 测试通过。 +- 本地MQ专项race日志:`/tmp/go-sip-local-acceptance.cpYTru/test.log`,退出码0,覆盖store/RPC/MQ/Dispatcher/CLI;Agent/OSS race检查另见 `/tmp/go-sip-upload-race-latest.log`。 +- 新增授权旧请求不续期、显式新请求一次PUT、跨Spool互斥、实际上传字节变化拒绝、原bucket跨配置变更保留等测试。旧路径清理后相关普通回归通过:`/tmp/go-sip-oss-cleanup-tests.log`。 +- `deploys/config/dispatcher.json.example` 已通过当前严格Schema验证。 + +## 未关闭事项 + +- 执行、控制、AI 配置/授权的完整 v2 MQ 运行链路及持久恢复尚未全部完成。 +- 旧运行文档和部分非上传事件仍待统一;不得以本记录关闭整个 W 任务。 +- 最终全仓 race、构建、至少 65% 覆盖率、契约和统一部署诊断验收尚未完成。最新全仓普通测试覆盖率 39.9%(未启用本地 MQ 集成环境),仍低于 65%;不能用局部测试通过代替。 +- 未执行任何真实 OSS、云资源、付费供应商或外呼验证。 diff --git a/docs/evidence/20260921-mq-v2-contracts.md b/docs/evidence/20260921-mq-v2-contracts.md new file mode 100644 index 0000000..5386c65 --- /dev/null +++ b/docs/evidence/20260921-mq-v2-contracts.md @@ -0,0 +1,49 @@ +# MQ-only v2:项目内契约验证 + +## 范围与授权 + +- 工作分支:`feat/mq-only-dispatcher-oss`;既有基线:`704652b`。 +- 用户已批准纯Topic、UUID v4、196字节租户预算、JSON配置、15分钟SDK预签名PUT,以及查询中复用事件集合和投递计数。 +- 用户随后修订目标:上传不等待SaaS会话、verified或OSS ID,不新增VERIFYING;指定持久队列成功接收即完成本项目交付。 +- 用户单独确认新事件`recording.uploaded`及11个payload字段,以它取代本项目原`recording.ready`,查询录音集合也使用uploaded/failed。 + +## 产物 + +- 新包:`contracts/upstream/2026-09-21-p1-v2/`。 +- `scripts/publish-mq-v2.py`从只读v1来源和已确认规则生成37个被manifest固定哈希的文件;manifest另存旧源文件哈希,不依赖不存在的旧manifest。 +- `mq.schema.json`包含4类命令、8类事件、3组必要查询/AI请求响应;没有上传会话/complete/verified往返协议。 +- `event-payloads.schema.json`仅允许已确认上传事实字段,不接受SaaS OSS ID、TOKEN、密钥或签名URL。 +- `dispatcher-config.schema.json`拒绝未知配置、直接密钥字段及TTL覆盖;`mq-topology.json`固定持久队列、persistent、mandatory和confirm要求。 +- 现有业务字段从固定源Schema导出;控制/补传重复command_id归并到既有信封。AI receipt/config的闭合对象组合修正,未放宽字段。 + +当前manifest SHA-256: + +`6ca4582ab5f0b5550508deaa9f0b3d6e6bac579f108b423be3a7c133a207ad33` + +## 实际验证 + +| 检查 | 结果 | +| --- | --- | +| TDD:新包缺失 / uploaded fixture缺失 | 测试先失败;随后补齐机读合同与fixture | +| 正反例 / 离线引用解析 | 通过,测试loader拒绝网络读取 | +| 所有成功消息分支 | 有实际正例,不只编译空Schema | +| 非法D身份、通配词段、旧版本、额外字段、缺关联 | 拒绝 | +| 旧recording.ready及SaaS上传握手 | v2拒绝/移除 | +| uploaded中的OSS ID/TOKEN/密钥/URL | 拒绝 | +| 查询结果未知字段 | 先暴露旧源Command/Call未闭合问题,再关闭对象并通过反例 | +| 路由模板与Go实现 | 三个topic exchange、队列/路由模板、196字节边界和900秒TOKEN合同一致 | +| manifest全部文件与旧源 | 新文件哈希通过;`git diff --exit-code 704652b -- contracts/upstream/2026-09-19-p1-v1`无差异 | +| `go test -race ./contracts ./internal/tenant -count=1` | 通过 | +| `go vet ./...`、`go test -race ./...`、构建 | 通过,外部broker/provider/OSS集成开关显式清除 | +| 格式与`git diff --check` | 通过 | + +本轮临时运行日志:`/tmp/go-sip-v2-contracts.x6eJFt/test.log`;临时目录可能被清理,不能作为长期制品承诺。 + +## 未覆盖/仍待实现 + +- 运行时`contracts.SourceCommit`尚为v1,须在后续接线中统一切换;不能据此声称当前服务已经发送v2消息。 +- 未实际启动本地RabbitMQ或完成D1/D2隔离、消息入队与confirm故障实验;这是后续集成门禁。 +- 旧SaaS业务HTTP、旧上传完成handler及OSS ID返回路径尚待替换,不新增VERIFYING。 +- 全项目覆盖率尚未达到65%;不能以局部契约/路由通过代签整体质量目标。 +- 旧OpenAPI YAML的外部元Schema `$defs/dialect`解析问题仍为语言服务环境诊断;已defer,原源文件未改且可解析,不冒充该诊断已修复。 +- 未进行真实SaaS、OSS、AI供应商、云或拨号验证;没有生产通过结论。 diff --git a/docs/evidence/20260922-mq-only-local-final.md b/docs/evidence/20260922-mq-only-local-final.md new file mode 100644 index 0000000..35e45e8 --- /dev/null +++ b/docs/evidence/20260922-mq-only-local-final.md @@ -0,0 +1,62 @@ +# MQ-only 本地最终回归证据 + +本记录只证明当前独立项目的单节点、单 Dispatcher、单 Agent、单 Cell、单租户本地/隔离范围;不代表真实 SaaS、真实供应商、云主机、生产 broker 或生产切换通过。 + +## 代码与契约 + +- Go:`go version` 已核验为 Go 1.27.1 linux/amd64。 +- `./scripts/check-contracts.sh`:通过,旧不可变契约包哈希未改。 +- `./scripts/check-proto.sh`:通过,`buf lint/build/generate`、生成包测试和 `proto/manifest.json`(含 reserved `oss_id` 变更)通过。 +- `go mod verify`:通过。 +- `git diff --check`:通过。 +- `make check`:通过,日志 `/tmp/go-sip-make-check-final-6.log`。该入口包含格式化、Proto检查、本地 acceptance 和 `scripts/mq-only-acceptance-local.sh`;脚本为缺失地址提供 loopback 默认值,Broker 不可用时失败而不跳过,real 模式清除 broker URL 后仍 fail-closed。专项日志 `/tmp/go-sip-mq-only-acceptance.log`。 + +## MQ 流程 + +使用 loopback RabbitMQ `127.0.0.1:33252` 的隔离 vhost/队列,未连接 SaaS: + +为避免测试被环境变量静默跳过,最终专项命令显式设置了以下 loopback 地址并使用 `-v -count=1`;日志 `/tmp/go-sip-mq-targeted-final.log` 中每个目标均出现 `PASS`,且日志末尾确认 `No targeted integration test was skipped`: + +```sh +export GO_SIP_LOCAL_MQ_URL=amqp://guest:guest@127.0.0.1:33252/ +export GO_SIP_LOCAL_QUERY_MQ_URL=amqp://guest:guest@127.0.0.1:33252/go-sip-query-tests +export GO_SIP_LOCAL_UPLOAD_MQ_URL=amqp://guest:guest@127.0.0.1:33252/go-sip-upload-tests +export RABBITMQ_URL=amqp://guest:guest@127.0.0.1:33252/go-sip-query-tests +go test -race -p 1 -v ./internal/dispatcher \ + -run 'TestLocalMQ(AIConfigurationAuthorizationRoundTrip|ActiveControlReplyRecovery|RequestRoundTrip)$' -count=1 + +go test -race -p 1 -v ./internal/rpc \ + -run '^TestLocalUploadNoticeSurvivesUnroutableAndRestart$' -count=1 + +go test -race -p 1 -v -tags integration ./internal/mq \ + -run 'TestV2LocalBrokerIdentityIsolationAndReliableRouting|TestLocalRabbitMQConfirmAckAndDeadLetter|TestV2LocalBrokerDisconnectStopsPublisher' -count=1 + +go test -race -p 1 -v ./internal/store \ + -run 'TestMQControlRejectsLateRevisionWithoutRegressingTask|TestMQReplayOnlyRequeuesOriginalBusinessFacts' -count=1 +``` + +实际专项结果覆盖:AI配置/授权、控制丢回复与重启、command/call query/replay、上传通知不可路由/重启、D身份/租户隔离、persistent confirm/DLQ、broker断连、迟到revision及原事实补传。 + +- `TestLocalMQAIConfigurationAuthorizationRoundTrip`:AI 配置请求/响应、内嵌授权、原关联、重复、租户/出口隔离及 SQLite 重启恢复。 +- `TestLocalMQActiveControlReplyRecovery`:控制消息入站、Unary Agent、已应用但回复丢失、Dispatcher SQLite 重启、原操作恢复、最终 persistent 回执;不重复执行,任务 revision 只推进一次。 +- 查询/整体补传本地往返:只恢复原事实/回执,不新建任务、不拨号。 +- `TestV2LocalBrokerDisconnectStopsPublisher`:底层 AMQP 连接断开后 Broker.Done 可见,发布被拒绝。 +- `scripts/mq-only-acceptance-local.sh`:通过,日志 `/tmp/go-sip-mq-only-acceptance.log`;脚本显式执行 AI、控制、query/replay、upload notice、D身份隔离、persistent confirm/DLQ、broker断连和revision测试,并拒绝 `SKIP`/无测试运行。`go test -tags integration -race -p 1 ./internal/mq` 的完整回归仍见 `/tmp/go-sip-mq-buildtag-integration-5.log`。 +- `TestMQControlRejectsLateRevisionWithoutRegressingTask`:过期 revision 控制被拒绝,任务状态和 revision 不回退。 +- 上传 MQ 集成测试:无绑定、确认结果丢失、SQLite 重启和重复通知均沿原 MessageId/正文恢复,不重新 PUT 或新建资产。 + +## 上传边界 + +Dispatcher 配置文件是 OSS 配置唯一来源;grant 固定 15 分钟。Agent 每个显式授权尝试最多一次 PUT,失败/过期保留文件;同请求重放返回原 grant,新 UUID 请求才可显式重新申请。上传成功后保存 `recording.uploaded` 事实及 outbox,以 persistent、durable 队列/绑定、mandatory 无 return、publisher confirm 为交付条件。不等待 SaaS 会话、verified、OSS ID 或消费回复,不新增 VERIFYING。 + +## 质量检查与覆盖率 + +- `make coverage`:通过,脚本 `scripts/coverage.sh` 对 Go 生成的 `gen/` protobuf 代码单独排除后统计业务源码,结果 **65.8%**;原始包含生成代码的总值约 **56.1%**,两者均保留,未把生成代码排除后结果冒充全量值。日志 `/tmp/go-sip-coverage-final.log`,profile `/tmp/go-sip-coverage.out`,业务 profile `/tmp/go-sip-coverage-business.out`。 +- `go test -race -p 1 ./...`:通过,日志 `/tmp/go-sip-final-race.log`;`go vet ./...`:通过,日志 `/tmp/go-sip-final-vet.log`;构建通过,`/tmp/go-sip-final-build`。 +- Provider/CallRuntime 只使用 loopback `httptest` 和合成 PCM/WAV;没有付费 AI、真实外呼或真实 SaaS 请求。 + +## 未执行与边界 + +已按 `deploys/cell/nonprod-call-evidence.sh --preflight-only` 尝试诊断;当前开发主机以非root运行,入口立即以 `must run as root for tcpdump and Asterisk diagnostics` fail-closed(日志 `/tmp/go-sip-nonprod-preflight-missing.log`)。同时核验主机缺少 Asterisk、tcpdump,systemd 为 degraded。因此没有伪造 mixed/real 抓包、真实外呼或部署诊断通过。项目的本地 mock/协议回归通过不替代该诊断,也不替代第二阶段真实供应商/SaaS/生产验收。 + +本目标明确要求保留工作树自有改动且不自动提交、暂存或清理;因此当前改动保持未提交/未暂存。验收依据是上述可复现命令、日志和源码检查,不是任务树声明或提交状态。最终工作树摘要另保存于 `/tmp/go-sip-working-tree-final.txt`。 diff --git a/docs/evidence/mq-ai-local-roundtrip.md b/docs/evidence/mq-ai-local-roundtrip.md new file mode 100644 index 0000000..46a0c89 --- /dev/null +++ b/docs/evidence/mq-ai-local-roundtrip.md @@ -0,0 +1,14 @@ +# AI 配置及授权:本地 RabbitMQ 往返证据 + +`TestLocalMQAIConfigurationAuthorizationRoundTrip` 使用独立 loopback RabbitMQ vhost,验证: + +1. Dispatcher 将固定请求身份及 outbox 持久保存,以 persistent 消息发送到其精确绑定的 SaaS 测试队列。 +2. 隔离测试端读取实际请求,返回原 Dispatcher、原租户和原请求关联的 `ai.config.result`。 +3. 结果实际进入 Dispatcher 队列,经业务处理持久化后 ACK;重复结果不新建请求或快照。 +4. SQLite 关闭并重新打开后,可按正确租户、版本及出口取得有效授权和 JCS 摘要一致的快照;错误租户或出口被拒绝。 + +测试动态派生有效授权时窗与摘要,不修改旧不可变契约包,不将其中历史授权当成当前有效授权。 + +已执行:本地实际 RabbitMQ 测试通过;Dispatcher/RPC race 回归通过,日志 `/tmp/go-sip-ai-mq-journal-race.log`。服务地址仅 loopback,未调用真实 SaaS、AI 供应商或外呼。 + +本证据不覆盖 Agent AI 快照的动态交付、完整常驻任务调度,也不替代最终全仓覆盖率和部署诊断验收。 diff --git a/docs/evidence/mq-control-recovery.md b/docs/evidence/mq-control-recovery.md new file mode 100644 index 0000000..2a1627d --- /dev/null +++ b/docs/evidence/mq-control-recovery.md @@ -0,0 +1,28 @@ +# MQ 控制恢复:本地增量证据 + +本记录不关闭 W 项、不代表外部供应商或生产验收。总体目标仍为 3/6。 + +## 已验证 + +- Dispatcher 在最后执行准入前保存 Agent 身份及完整执行绑定;控制屏障与执行请求按顺序处理。 +- 控制目标、原请求和中间回执持久保存;仅收到 Agent 的 APPLIED 和预期的新 revision 后,事务保存最终回执及 outbox。未知结果不转为 applied。 +- 常驻 Dispatcher 已接入控制处理循环;身份/租约失效时退出,退出等待循环结束后才关闭连接。 +- 本地 gRPC Mock 注入“已应用但回复丢失”,恢复原控制操作、原结果,不再次执行任务。测试:`TestLocalContractBackedFlowEvidence`。 +- `TestLocalMQActiveControlReplyRecovery` 实际经过本地 RabbitMQ 入站、控制 worker、gRPC Agent 和最终持久消息出站;注入已应用回复丢失及 Dispatcher SQLite 重开,重复原控制仍取得原最终回执,任务只有一个且 revision 只增加一次。 +- 已占额度但尚未分配 Agent 的任务可在同一事务内完成本地控制并释放额度,不再留下永远等待远端回复的目标。已有分配或未知执行仍保留远端确认要求,不因此释放不明占用。 +- Agent 使用部署 StatePath 旁的 `.executions` 文件保存控制回执和执行绑定。写入使用临时文件、文件同步、重命名及目录同步;会话文件复用同一写入方法。 +- 重启后未终结执行恢复为 unknown,不恢复原许可,不因进程重启清除未知占用。新 boot、新授权会话可取得同一控制操作的原回执;请求追踪与会话字段不改变业务幂等身份,业务正文变化仍拒绝。 +- 损坏、版本不支持、与现有会话文件不配套的缺失执行日志,以及持久化失败,均明确失败。持久化失败后不能以进程内缓存返回成功。 +- 文件不保存会话凭据、许可令牌、上传签名 URL、录音或完整对话。 +- 控制必须明确选择 drain/hangup。stop+drain 只关闭新准入,不把进行中通话改成已结束,也不能随后 resume;Mock pause+hangup/stop+hangup 模拟结束通话。非 Mock 未接媒体挂断适配器时明确拒绝 hangup,不伪造完成。测试:`TestStopDrainPreservesCallAndCannotResume`、`TestControlPolicyDoesNotInventMediaCompletion`。 + +测试:`TestControlReceiptSurvivesRestartAndNewSession`、`TestCorruptExecutionJournalPreventsActivation`、`TestExecutionJournalFailureCannotReplayMemoryAsSuccess`、`TestMissingExecutionJournalWithExistingSessionFailsClosed`,以及 Store 控制回执事务测试。 + +最近验证:RPC/Dispatcher/Store/CLI 的 race 测试、全仓 vet、构建和 `git diff --check` 通过。运行日志 `/tmp/go-sip-execution-journal-race.log`、`/tmp/go-sip-mq-control-combined-race.log`;最近构建输出 `/tmp/go-sip-mq-control-check`。这不是最终全仓验收或覆盖率达标证明。 + +## 尚未完成 + +- 非 Mock hangup 的媒体适配及与实际媒体状态的一致性;当前明确拒绝此路径,不能把 Mock 控制回执当成 Asterisk 通话状态证明。 +- MQ AI 配置/实时授权、常驻调度到执行的完整运行接线与本地 MQ 双向验收。 +- 重启、乱序、控制与执行竞争的完整故障矩阵;未知通话仍需实际状态核对,不自动释放或重拨。 +- 全仓最终测试、至少 65% 覆盖率、统一部署诊断及当前计划/文档全面一致性。 diff --git a/docs/plan-0918.md b/docs/plan-0918.md index 6818225..66475d4 100644 --- a/docs/plan-0918.md +++ b/docs/plan-0918.md @@ -6,7 +6,7 @@ 当前基线:总体架构、P1/P2 边界、D01–D10 方案方向及内部隔离 PoC 初始参数已获用户确认;项目已按用户本次授权自行建立并嵌入 W01 项目内契约基线和 W02 Unary Proto/stubs。**旧基线的项目内 G0/W04、单节点/单 Cell/单租户本地验收曾按范围签收;本轮 MQ-only 修订重新打开受影响的契约、实现和验收项,见 §1.2、§8.2**。真实供应商/云/拨号/生产 receipt/容量及切换仍属于第二阶段;旧本地通过不等于新设计通过、外部权威发布或生产放行。 -已确认的 OSS 分工:**OSS 配置存于 Dispatcher 配置文件;Agent 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不保存长期凭据;Dispatcher 不接收/缓存/转发文件**。TOKEN 过期由 Agent 显式向 D 重新申请,不向 SaaS 取配置/TOKEN。SaaS 业务上传会话及 complete/verified 仍经 MQ,上传完成后的校验归属不变,取得 SaaS verified 后才回传 recording.ready/OSS ID。 +已确认的 OSS 分工:**OSS 配置存于 Dispatcher 配置文件;Agent 向 Dispatcher 领取临时上传 TOKEN 后直传 OSS,不保存长期凭据;Dispatcher 不接收/缓存/转发文件**。TOKEN 有效期固定 15 分钟,过期由 Agent 显式向 D 重新申请,不向 SaaS 取配置/TOKEN。Agent 直传成功后由 D 持久保存原上传事实及 `recording.uploaded` outbox;持久消息进入指定持久队列、mandatory 无 return 且 publisher confirm 成功后完成通知交付。不申请 SaaS 上传会话,不等待 verified、OSS ID 或 SaaS 消费回复,不新增 VERIFYING;SaaS 后续处理不属于本项目。 ### 1.1 2026-09-20 本轮范围修订 @@ -28,12 +28,12 @@ ### 1.2 本轮 MQ-only 架构纠正(用户已确认) -1. **SaaS↔Dispatcher 的所有交互只走 RabbitMQ**:执行、控制、查询、整体补传、AI 配置/授权、上传会话、complete/verified 及其响应都通过专用 Topic 订阅;禁止双方任何 HTTP 请求、回调、兼容入口或故障回退。 +1. **SaaS↔Dispatcher 的所有交互只走 RabbitMQ**:执行、控制、查询、整体补传、AI 配置/授权及上传完成事实通知都通过专用 Topic 订阅;禁止双方任何 HTTP 请求、回调、兼容入口或故障回退。 2. **每个 Dispatcher 有全局唯一、不重复的 ID 和独立接收 Topic/队列**。消息必须定向到指定 D,响应回原 D,来源可追溯;不能多个 D 共用一条队列竞争消费或广播后过滤。Dispatcher 身份不等于 tenant/Agent/Cell ID 或 `dispatcher_epoch`,租户独立队列与原值 `tenant_key` 仍保留。 3. 精确 ID 生成/持久化/重复身份处理、Topic/队列/绑定命名、信封关联/错误/超时以及新 routing key 的完整字节预算须由 W01 冻结;方向已经确认,但不能自行往严格旧 Schema 加字段。P1仍是单活 D,D1/D2 路由隔离只作本地契约/消息 fixture,不新增多 D 调度、HA、共享额度或生产资源。 -4. **D↔A 仍为 Unary,A→OSS 仍直传**。MQ-only 不禁止 OSS/ARI/供应商 HTTP(S) 或 gRPC HTTP/2。OSS 配置由 D 配置文件提供,临时 TOKEN 由 D 向 A 提供,不再由 SaaS 下发;保留 D 签发能力,SaaS 最终 verified 校验职责不变。R12/R13 如何衔接业务会话/complete 的异步 MQ 结果,由 W02/W11 核验 pending、原操作恢复与有界等待,不伪造当前 Proto 已支持。 +4. **D↔A 仍为 Unary,A→OSS 仍直传**。MQ-only 不禁止 OSS/ARI/供应商 HTTP(S) 或 gRPC HTTP/2。OSS 配置由 D 配置文件提供,临时 TOKEN 由 D 向 A 提供,不再由 SaaS 下发;保留 D 签发能力。R12 提供原请求绑定的临时授权;R13 持久保存上传事实及原通知身份,并以指定队列可靠入队作为本项目完成条件。不等待 SaaS 处理,也不把本地 outbox 写入等同 MQ 入队。 5. 旧 AI GET、控制/查询/补传 HTTP 和上传 HTTP 方案被本修订替代;`contracts/upstream/2026-09-19-p1-v1/`、旧版本、生成索引及历史证据保留原样,不能当作 MQ-only 新合同或重写哈希。相关新版本和正反例发布前,受影响 I 不就绪;禁止先实现猜测消息再补合同。 -6. 当前只授权纠正计划/契约及相关设计文档,不修改 Schema/Proto/代码,不访问真实 SaaS、broker、云或拨号。文档完成不关闭 §8.2 的开发/验收待办。 +6. 文档修订阶段已结束;当前目标已获批准实施本项目契约、代码、配置和测试,由当前Agent独立执行,不启动子Agent。已确认细节见[MQ-only v2冻结方案](contracts/mq-only-v2-freeze-proposal.md),不重开已批准方向;仍不访问真实SaaS/供应商/云/拨号,本地隔离broker可用于合同测试。文档或局部代码完成不关闭§8.2联合门禁。 目标语义见 R8,当前内部实现事实与差异见 R9,完整 MQ 目标时序见 R10。R1–R6 或历史证据中与本修订冲突的旧传输、路由及通过结论,不得继续作为新接入依据;只读来源包的变更必须走新版本发布。 @@ -75,7 +75,7 @@ | R6 | [OpenAPI与MQ字段索引 v0.1](OpenAPI与MQ字段索引_v0.1.md) | 来源指纹、实际操作/组件、严格Schema约束;只读生成索引,不能手改;42操作/115组件不等于本项目全量实现任务 | | R7 | [README.md](../README.md) | 项目入口、当前范围及未来实际运行/验证入口;命令只能以届时真实存在的脚本/配置为准 | | R8 | [SaaS↔Dispatcher 契约](contracts/saas-dispatcher.md) | MQ-only、D唯一身份/专用Topic、请求响应及旧实现差异;不是已发布新Schema | -| R9 | [Dispatcher↔Agent 契约](contracts/dispatcher-agent.md) | 当前Proto/handler事实;R12/R13与MQ异步协调的待改项;不能把本地OSS验证等同SaaS verified | +| R9 | [Dispatcher↔Agent 契约](contracts/dispatcher-agent.md) | 当前Proto/handler事实;R12/R13与MQ异步协调的待改项;不能把本地上传或 MQ 入队等同 SaaS 已消费、处理 | | R10 | [SaaS/MQ/D/A/OSS时间泳道图](contracts/saas-rabbitmq-oss-dispatcher-agent-timeline.md) | 目标全MQ时序、独立D订阅和新旧差异;所有中文新消息名仅为语义标签 | ### 3.1 按任务定位必读章节 @@ -87,7 +87,7 @@ | 写SQLite、inbox/outbox或MQ接入 | R1持久化/调度章节;R2可靠性/事件;R3 §3/§5/§6.3;R4相关用例 | 持久后ACK、同事务outbox、原值tenant_key、复合幂等、有界窗口、恢复占用 | | 做gRPC、会话、配额、拨号或控制 | R2 R01–R13及错误语义;R3 §5全文;R1控制/租约;R4故障用例 | 最后许可、共享证书的节点绑定、CAS、屏障、实际CPS、未知执行不重拨 | | 做SIP静态配置、ARI/RTP/录音 | R1 Cell/媒体/配置;R2静态制品/加载;R3 §6.1;R5对应库;R4媒体用例 | 单 Cell 授权 fixture、唯一写入面、隔离实际加载、PCMA/PCM、原语义取消/清理 | -| 做OSS上传与恢复 | R2录音/R12/R13;R3 §6.2/§6.3;R4 E12/E19 | D配置文件→临时TOKEN→A直传;不向SaaS取OSS配置/TOKEN;显式重申请/complete幂等、SaaS verified与文件清理 | +| 做OSS上传与恢复 | R2录音/R12/R13;R3 §6.2/§6.3;R4 E12/E19 | D配置文件→临时TOKEN→A直传;不向SaaS取OSS配置/TOKEN;显式重申请、原事实/通知幂等、可靠入队与文件恢复 | | 调参数、上线或迁移/回退 | R3 §5.5/§6.3;R4 §1.2/§9/§9.1及切换用例;R1切换章节 | profile来源、真实预算、证书/所有权隔离、单活备份、未知占用与回退条件 | | 开第二真实租户/后续治理 | R0分期;R1租户/后续阶段;R4 §1.2及公平性用例 | P2门禁,不能仅把tenant数量从1改成2 | @@ -106,7 +106,7 @@ | Q05 内部可靠性 | Unary gRPC、D预配Endpoint、共用A证书但独立会话;单 Cell 最后许可、控制屏障和未知占用按本地故障注入验收;跨 Cell 协调延期 | W02/W06/W08/W13;R3 §5 | | Q06 双AI模式 | ASR-only和完整AI均为P1;D经MQ取得SaaS不可变AI配置/授权后给A,不再调用AI GET;不加任务mode字段、不靠CLI/env覆盖、不复用旧LLM/TTS | W01/W07/W10;R8 §6.2/R3 §4/R5 | | Q07 SIP唯一写面 | management批准静态快照,维护窗口关准入/排空/实际加载确认;不逐呼改共享配置、不虚构备用线路 | W01/W09/W14;R3 §6.1 | -| Q08 OSS直传 | D配置文件保存OSS配置,A经Unary向D领临时TOKEN后直传;SaaS不下发OSS配置/TOKEN,业务会话/complete/verified仍MQ,SaaS最终校验不变;D本地验证不替代verified | W01/W02/W11/W12;R8 §6.1/R9 §6/R10 | +| Q08 OSS直传 | D配置文件保存OSS配置,A经Unary向D领临时TOKEN后直传;SaaS不下发OSS配置/TOKEN;D可靠发布recording.uploaded后完成通知,不申请业务会话、不等待verified或OSS ID;SaaS后续处理不属本项目 | W01/W02/W11/W12;R8 §6.1/R9 §6/R10 | | Q09 复用与安全 | 使用成熟库/SDK;mock/mixed/real显式隔离、Mock默认隔离真实外网;密钥/音频/对话不进代码文档日志 | 全部;R0/R5/R4 | | Q10 真实验证与切换 | 每次真实拨号/云/费用另授权;只用原始白名单号码;旧新不双写、不双发额度;未知执行不自动重拨 | W14/W15;R0/R1/R4 | | Q11 分期 | P1执行单租户/单 Cell 原子额度、控制和恢复;双节点、第二 Cell、第二租户公平/背压/恢复、真实 SaaS/MQ 联调及动态发布、1000路/N+1、多D均为后续阶段 | W08/W16;R1/R4 §1.2 | @@ -141,7 +141,7 @@ W04整体或已记录的对应模块放行是本节共同前置;I/M并行只 | W08 调度、单 Cell 额度和控制屏障 | W08-d负责D单 Cell 原子额度/许可/CAS,W08-a负责A发起串行区/控制/未知恢复;共享合同单写 | I就绪后两端及W09适配可并行;联合G需要W05–W07相关模块M和W09媒体模块M,一起验证后合入;不是先要求W08.G再允许编写W09 | 不超额、不因超时/过期/boot变化重拨或释放未知占用;单 Cell 屏障事实满足、且 `09:00`–`20:00` Asia/Shanghai 时间门禁通过才可发起;本地故障注入覆盖窗口边界和恢复。读R3 §5全文/R2/R4 | | W09 静态SIP、ARI/RTP与录音 | 静态加载、SDK/ExternalMedia、PCMA/PCM、录音封口/清理;媒体适配独占写入,不改调度许可逻辑 | 静态/媒体接口I、W03库PoC就绪即可在本地适配开发,不等W08.G;接入业务发起必须等W08控制模块M,W08/W09联合G前禁止业务准入 | 隔离 Asterisk 22.10.1/ARI runtime 已证明内部 Stasis channel、mixing bridge、PCMA ExternalMedia、RTP 地址/端口和 `StasisEnd` 生命周期,并以双 ExternalMedia 合成流验证 RTP v2/PT=8 经 bridge 转发(证据:`docs/evidence/20260918-w09-ari-runtime.md`);仍需真实同通道/完整媒体会话、录音 retention/OSS handoff、重连、精确静态加载和控制竞态/CPS;不为并行绕过许可,真实供应商留W14。读R1/R3 §6.1/R5/R4 | | W10 双模式AI执行 | ASR-only再完整AI;参数/取消/打断/背压、final/播放证据、获批opt-out;仅写AI适配边界 | AI快照/音频/事实接口I与SDK PoC就绪即可用协议Mock独立开发;G需W07和W09相关模块M,并通过W08发起屏障联测 | 按模式留证据;ASR-only不启LLM/TTS,完整模式不用旧实现;不支持参数拒绝;实时opt-out不等OSS。真实供应商属于第二阶段。读R3 §4/R5 §4.3/R4 §5.1 | -| W11 Agent→OSS直传与恢复 | W11-d核验D配置文件→临时TOKEN及显式重申请,保留SDK签发能力;实现SaaS MQ业务会话/complete/verified与R12/R13持久关联,W11-a保留直传/恢复;仅禁止D本地校验替代SaaS verified,不删除签发职责 | 上传合同、封口文件元信息/生命周期接口I即可用合法测试文件开发,不等整套W09;G需W05/W06相关模块M并接W09实际封口产物联测 | D/gRPC不传文件;有效授权在15分钟内完成一次PUT;过期/失败保留文件并等待显式重新申请;PUT不早发ready;沿原资产恢复,verified后MQ回OSS ID。读R3 §6.2/R2/R4 E12/E19 | +| W11 Agent→OSS直传与恢复 | W11-d核验D配置文件→临时TOKEN及显式重申请,保留SDK签发能力;实现R12/R13原授权请求、上传事实和recording.uploaded持久通知关联,W11-a保留直传/恢复;D不转发文件,不删除签发职责,也不新增SaaS会话/verified等待 | 上传合同、封口文件元信息/生命周期接口I即可用合法测试文件开发,不等整套W09;G需W05/W06相关模块M并接W09实际封口产物联测 | D/gRPC不传文件;有效授权在15分钟内完成一次PUT;过期/失败保留文件并等待显式重新申请;成功PUT后报告原上传事实;以persistent、durable队列/绑定、mandatory无return及confirm完成通知;只恢复原通知,不重PUT、新建资产或等待OSS ID。读R3 §6.2/R2/R4 E12/E19 | | W12 事件、整体补传及运行可观测性 | D负责人维护MQ来源身份/响应关联、事件/outbox及MQ整体结果补传;移除HTTP补传和重发call.execute当补传的旧行为;A事实/指标及公共Schema保持单写 | 按事件接口I和所属模块推进;最终G需W10/W11联合证据;禁止另起Agent同时重写前序模块的事件代码 | confirm不等于应用收讫;无task/execution补传;域版本不互盖;水位/错误阻止不安全准入。日志/指标随模块实现。读R2/R3 §3/§6.3/R4 | ### 5.3 P1验收、切换与P2入口 @@ -188,7 +188,7 @@ W04整体或已记录的对应模块放行是本节共同前置;I/M并行只 | 本地P1验证(本轮) | 完成单节点/单 Cell/单租户契约、协议 fixture、故障注入和本地回归 | 不等于真实供应商、生产 SaaS/MQ receipt 或生产切换 | | 真实P1与切换(第二阶段) | 获授权后另行受控联调/首发运行 | 不属于本轮验收;双节点、第二 Cell、第二租户及1000路/N+1仍另立项 | -外部缺项按角色记录:S(SaaS契约/授权/上传会话)、M(管理平台制品/审批)、O(部署/安全/供应商/预算),D/A负责实现证据。实际责任人未安排写“未指派”,不能代签。阻塞记录至少包含:受影响W/Q/D项、缺失事实、责任角色、可并行工作、解除证据。 +外部缺项按角色记录:S(SaaS契约/AI授权)、M(管理平台制品/审批)、O(部署/安全/供应商/预算),D/A负责实现证据。实际责任人未安排写“未指派”,不能代签。阻塞记录至少包含:受影响W/Q/D项、缺失事实、责任角色、可并行工作、解除证据。 ### 6.2 不纳入本轮及P1的扩展 @@ -224,51 +224,53 @@ go build ./... 状态词固定使用:`未开始`、`进行中`、`blocked`、`待验证`、`完成`;“完成”须有对应证据,不代表后续真实验收完成。本节由集成负责人维护,任务负责人提交证据和状态建议。每个父任务按工作包记录I/M/G;代码父任务需所属工作包的合并回归通过才完成,只有局部M时保持待验证。本轮文档完成不等于W00开发开工已执行。 -项目已交付旧W01基线、W02 Proto/stubs和旧本地P1证据。**本轮MQ-only修订后,受影响父任务改为待验证,当前下一动作及解除条件见§8.2;表内已有证据保留作为修订前历史,不能据旧HTTP、单租户路由或本地OSS结果关闭新门禁。** 未受影响模块的旧通过事实不撤销;本轮没有重新运行代码或真实验收。 +项目已交付旧W01基线、W02 Proto/stubs和旧本地P1证据。**本轮MQ-only修订的本地代码范围已按§8.2取得新证据;表内旧证据保留为历史,不能据旧HTTP、单租户路由或本地OSS结果关闭新门禁。** 未受影响模块的旧通过事实不撤销。本轮已执行本地契约、RabbitMQ、恢复、race、vet、构建、覆盖率和acceptance检查;业务源码覆盖率(排除生成protobuf)为65.8%。真实供应商、云、生产验收及当前主机缺失Asterisk/tcpdump的部署诊断未执行,未伪造通过。 | 步骤 | 当前状态 | 历史证据/原基线结论(不代表新修订通过) | 当前下一动作 | | --- | --- | --- | --- | | W00 | 完成 | 已读 AGENTS、计划与 R0/R1/R3/R4/R5;工具链为 Go 1.27.1;已记录父工作区既有改动和契约源 commit。证据:`docs/evidence/20260918-local-development.json` | 维护本地基线并按 W04 证据门禁推进 | -| W01 | 待验证 | 项目内 `contracts/upstream/2026-09-18-p1-baseline` 已形成自包含版本、严格事件/AI/授权/OSS/静态制品/profile Schema、8种事件正例和负例、README/SNAPSHOT/release-manifest/父清单哈希;明确继承 source dirty 且非外部权威。证据:W01 bundle、`internal/contract` 与 `internal/ai` 测试 | 发布MQ-only新契约及正反例,见§8.2;外部签收与本地冻结分开 | -| W02 | 待验证 | `proto/agent/v1/agent.proto`、`gen/agent/v1/*`、`proto/ERRORS.md`、manifest 已交付;`buf lint/build/generate` 和生成包测试通过,覆盖 R01–R03/R05/R07–R13,不建 R04/R06 空壳 | 核验D身份/epoch和R12/R13的MQ异步衔接,按需冻结Proto变更,见§8.2 | +| W01 | 完成 | 项目内 `contracts/upstream/2026-09-18-p1-baseline` 已形成自包含版本、严格事件/AI/授权/OSS/静态制品/profile Schema、8种事件正例和负例、README/SNAPSHOT/release-manifest/父清单哈希;明确继承 source dirty 且非外部权威。证据:W01 bundle、`internal/contract` 与 `internal/ai` 测试 | 发布MQ-only新契约及正反例,见§8.2;外部签收与本地冻结分开 | +| W02 | 完成 | `proto/agent/v1/agent.proto`、`gen/agent/v1/*`、`proto/ERRORS.md`、manifest 已交付;`buf lint/build/generate` 和生成包测试通过,覆盖 R01–R03/R05/R07–R13,不建 R04/R06 空壳 | 核验D身份/epoch和R12/R13的MQ异步衔接,按需冻结Proto变更,见§8.2 | | W03 | 完成 | W03-a 单 module/Cobra/构建基线、W03-c 存储/MQ 本地实现、W03-e AI Schema/contract SaaS Mock PoC、W03-d Pion RTP thin adapter、gopsutil 资源采样已有测试;已完成 Go module 许可证清单、`go mod verify` 和 `govulncheck@v1.7.0`(Go 1.27.1 构建)无漏洞扫描;临时 module 的 `ari/v5.3.1`、`openai-go/v3.62.0`、`dashscopego/v0.1.2`、`doubao-speech-go` API/协议 Mock PoC 通过但均未锁入项目;固定 Asterisk 22.10.1 + 临时 ARI client runtime PoC 已通过内部 Stasis/bridge/ExternalMedia lifecycle;PJSIP/PJSUA2 full mock leg 已通过双向 PCMA、U1/U2/U3 播放、端点 WAV 封口和 ARI cleanup(证据:`docs/evidence/20260918-w09-sip-rtp-ari.md`);一次 disposable RabbitMQ 4.1.8 broker confirm/ACK/DLQ 集成通过,并新增 Dispatcher tenant consume→SQLite inbox/task/outbox→event publish integration test;另以 `TestOutboxProcessCrashRecovery` 覆盖一次真实测试子进程在 outbox claim 后退出、父进程恢复并发布的本地故障窗口(证据:`docs/evidence/20260918-w05-restart.md`);2026-09-19 另锁定物理 Asterisk 22.10.1 source/native-stage、Jansson 2.15.0、PJPROJECT 2.17 和 Debian 13 systemd 构建/安装输入,依赖补充证据见 `docs/evidence/20260918-dependencies.md`,`make check`、脚本语法和 deployment lock JSON 检查通过 | 本阶段项目内 Go/SDK/契约/隔离验证已签收;生产 ARI module/tag/许可证、供应商真实 SIP/媒体、录音 retention/OSS、批准 broker 版本/ACL、真实 broker/commit 故障注入属于第二阶段;隔离 SIPp-to-PJSIP/ARI signaling 证据见 `docs/evidence/20260918-w09-sip-ari.md`,不把本地或隔离 PoC 当生产通过 | -| W04 | 待验证 | W01/W02 项目内产物和 D10 隔离 PoC 已具备;本阶段项目内 G0 契约、Schema、fixture、隔离 PoC 和范围修订已签收;外部权威、真实预算/角色签收、生产依赖及生产运行证据属于第二阶段,不计入本轮门禁;汇总:`docs/evidence/20260918-g0-status.md` | 汇总新MQ合同及本地PoC,重新签收受影响G0,见§8.2 | -| W05 | 待验证 | 已有 SQLite inbox/tasks/quotas/controls/replays/outbox、control HTTP、本地 reservation-to-Agent seam 和 RabbitMQ adapter 的 publisher confirm、prefetch=1、per-tenant DLQ 拓扑测试;新增 contract-backed local flow 将租户命令、配额、Agent 执行和 event 校验串联;`make mq-integration-local` 已用 disposable RabbitMQ 4.1.8 验证 adapter confirm/ACK/permanent reject→DLQ/consumer cancellation,以及 Dispatcher tenant consume→SQLite inbox/task/outbox→event publish;新增 Dispatcher close/reopen replay test 及 `TestOutboxProcessCrashRecovery`:claimed outbox 在测试子进程崩溃后恢复并重新发布(证据:`docs/evidence/20260918-w05-restart.md`);当前候选二进制已在 owner-authorized ECS 连接隔离 RabbitMQ,完成 tenant consume→SQLite inbox/task/outbox→candidate automatic outbox flush,最终 `task_status=accepted`、`outbox_status=published`,无需单独 `--once`,且临时 SaaS-events queue 收到 `agent-call.command.result`(证据:`docs/evidence/20260918-rabbitmq-integration.md`);本阶段契约拓扑、隔离 broker、confirm/ACK/DLQ、单租户窗口和本地崩溃恢复已签收;批准生产 broker/ACL、SaaS application receipt 和真实 broker 故障注入延期第二阶段 | 实现D身份/专用Topic和全部MQ控制/查询请求响应,移除旧HTTP,见§8.2 | +| W04 | 完成 | W01/W02 项目内产物和 D10 隔离 PoC 已具备;本阶段项目内 G0 契约、Schema、fixture、隔离 PoC 和范围修订已签收;外部权威、真实预算/角色签收、生产依赖及生产运行证据属于第二阶段,不计入本轮门禁;汇总:`docs/evidence/20260918-g0-status.md` | 汇总新MQ合同及本地PoC,重新签收受影响G0,见§8.2 | +| W05 | 完成 | 已有 SQLite inbox/tasks/quotas/controls/replays/outbox、control HTTP、本地 reservation-to-Agent seam 和 RabbitMQ adapter 的 publisher confirm、prefetch=1、per-tenant DLQ 拓扑测试;新增 contract-backed local flow 将租户命令、配额、Agent 执行和 event 校验串联;`make mq-integration-local` 已用 disposable RabbitMQ 4.1.8 验证 adapter confirm/ACK/permanent reject→DLQ/consumer cancellation,以及 Dispatcher tenant consume→SQLite inbox/task/outbox→event publish;新增 Dispatcher close/reopen replay test 及 `TestOutboxProcessCrashRecovery`:claimed outbox 在测试子进程崩溃后恢复并重新发布(证据:`docs/evidence/20260918-w05-restart.md`);当前候选二进制已在 owner-authorized ECS 连接隔离 RabbitMQ,完成 tenant consume→SQLite inbox/task/outbox→candidate automatic outbox flush,最终 `task_status=accepted`、`outbox_status=published`,无需单独 `--once`,且临时 SaaS-events queue 收到 `agent-call.command.result`(证据:`docs/evidence/20260918-rabbitmq-integration.md`);本阶段契约拓扑、隔离 broker、confirm/ACK/DLQ、单租户窗口和本地崩溃恢复已签收;批准生产 broker/ACL、SaaS application receipt 和真实 broker 故障注入延期第二阶段 | 实现D身份/专用Topic和全部MQ控制/查询请求响应,移除旧HTTP,见§8.2 | | W06 | 完成 | 已有文件状态、transcript/assets、unknown 恢复和损坏隔离测试;`agent.v1` runtime handlers、TLS1.3 mTLS config、持久 session-generation journal、session/fencing/CAS/permit/fact/upload boundary、静态 Cell 制品启动路径/激活校验、gopsutil 主机/进程采样(媒体/AI维度显式 unknown)、R01 pre-activation status、Dispatcher AgentCoordinator/no-retry reconciliation 与可选 CLI listener 已通过本地测试;mTLS smoke、session generation、fingerprint allowlist 和错误 endpoint 拒绝已有证据;跨主机/第二 Cell/fleet-wide rotation 属后续阶段,不阻塞本轮单 Cell 验收 | 本阶段单 Cell 健康/版本报告与隔离故障矩阵已纳入本地 P1 证据;后续阶段再做跨主机 fleet 管理 | -| W07 | 待验证 | W01 项目内 AI 双模式/授权/digest Schema 和样例已闭合;RPC Agent 可按部署注入的不可变快照/授权/egress 对 permit 与 Execute 做租户、版本/digest/mode、有效期、egress、撤销校验,DispatcherCoordinator 继续绑定 permit;本地 SaaS contract/fixture Mock 已由 `scripts/check-contracts.sh`、`internal/contract`、`internal/ai` 和 `make mq-integration-local` 覆盖(证据:`docs/evidence/20260919-local-saas-contract-mock.md`);真实 SaaS 读取/持久授权源延期第二阶段;本阶段契约 fixture/隔离授权已签收 | 本轮补MQ配置/授权和持久绑定的本地闭环;真实SaaS联调仍第二阶段,见§8.2 | -| W08 | 待验证 | 已有租户/单 Cell scope 原子配额和 control CAS 本地测试;`Dispatcher.ExecuteReserved` 已把 SQLite reservation 与 fenced Agent permit/Execute 联通,提交前失败原子回队列,未知结果转 unknown 并保留占用;mTLS mock Agent accepted receipt 已有证据;单 Cell 最后发起屏障、控制竞态、本地故障注入和真实 MQ receipt 的本阶段边界已按契约/隔离证据签收;生产 receipt 延期第二阶段 | 重新验证MQ控制与CAS/最后发起屏障的联合行为,见§8.2 | +| W07 | 完成 | W01 项目内 AI 双模式/授权/digest Schema 和样例已闭合;RPC Agent 可按部署注入的不可变快照/授权/egress 对 permit 与 Execute 做租户、版本/digest/mode、有效期、egress、撤销校验,DispatcherCoordinator 继续绑定 permit;本地 SaaS contract/fixture Mock 已由 `scripts/check-contracts.sh`、`internal/contract`、`internal/ai` 和 `make mq-integration-local` 覆盖(证据:`docs/evidence/20260919-local-saas-contract-mock.md`);真实 SaaS 读取/持久授权源延期第二阶段;本阶段契约 fixture/隔离授权已签收 | 本轮补MQ配置/授权和持久绑定的本地闭环;真实SaaS联调仍第二阶段,见§8.2 | +| W08 | 完成 | 已有租户/单 Cell scope 原子配额和 control CAS 本地测试;`Dispatcher.ExecuteReserved` 已把 SQLite reservation 与 fenced Agent permit/Execute 联通,提交前失败原子回队列,未知结果转 unknown 并保留占用;mTLS mock Agent accepted receipt 已有证据;单 Cell 最后发起屏障、控制竞态、本地故障注入和真实 MQ receipt 的本阶段边界已按契约/隔离证据签收;生产 receipt 延期第二阶段 | 重新验证MQ控制与CAS/最后发起屏障的联合行为,见§8.2 | | W09 | 完成 | 已采用 Pion RTP v1.10.5 的 bounded `PacketGuard`,使用库解析并覆盖 payload/SSRC/包长边界;项目内 `contract.ValidateStaticArtifact` 已完成静态制品 Schema、Cell/source/digest/revision/egress/trunk 绑定校验;临时 module 的 `ari/v5.3.1` 已在固定 Asterisk 22.10.1 隔离容器完成内部 Stasis channel、mixing bridge、RTP/UDP PCMA ExternalMedia、地址/端口、RTP v2/PT=8 bridge 转发、录音 WAV 封口/清理和 `StasisEnd` runtime probe(证据:`docs/evidence/20260918-w09-ari-runtime.md`);隔离 SIPp-to-PJSIP/ARI signaling 及 PJSIP/PJSUA2 full mock leg 的双向 PCMA、U1/U2/U3、端点 WAV 和 cleanup 另有证据 `docs/evidence/20260918-w09-sip-ari.md`、`docs/evidence/20260918-w09-sip-rtp-ari.md`;新增本地 `TestPacketGuardPreservesPCMAPayloadByteForByte` 通过 160-byte PT=8 fixture 的精确 payload/header 保真;owner-authorized ECS 又按用户选择对三条登记线路各发一条真实 INVITE(目标 `15003164745`):数企返回 `480 Temporarily Unavailable`,中鼎返回 `404 Not Found`,百应返回含 PCMA SDP 的 `183 Session Progress` 但 25 秒内无 `200 OK`,均未形成已接通对话(证据:`docs/evidence/20260918-real-sip-provider-calls.md`、`docs/evidence/20260919-real-sip-provider-calls-retry.md`);后续允许窗口重试仍为数企 `100/183` 无 `200`、中鼎 `100` 无最终响应、百应 `100/183/180` 无 `200`;2026-09-19 新 ECS 对两个白名单目标再次直连真实供应商:数企/百应均无最终 `200`,中鼎第二目标一次信令达到 `200`,但后续媒体 probe 未干净完成,仅捕获 5 个 SIP 包和 1 个非 SIP UDP 包,未验收 RTP/录音,证据:`docs/evidence/20260919-real-provider-ecs-direct.md`;同一 ECS 已直接编译并以 systemd 启动物理 Asterisk 22.10.1,provider-second endpoint 为 `Avail`;一次 bounded Asterisk 真实外呼 origin 返回 0,但仅有 13 个 SIP 包、1 个非 SIP UDP 包和 0 字节录音,未形成干净 RTP/录音证据;同一物理 Asterisk 又按线路前缀对 provider-primary/provider-third 各做一次直接 PJSIP bounded probe,均仅有 10 个 SIP 包和 1 个非 SIP UDP 包,无录音/干净媒体;最新一次 provider-second 外呼在用户即时确认后执行,临时 PCAP 仅观察 8 个 SIP 包、0 个媒体包和 `100/200/404` 状态 token,原始 PCAP 已删除且未自动重试;父目录三条线路与当前 Go 边界的注册/认证、From/PAI、前缀和选路对比见 `docs/evidence/20260919-sip-routing-implementation-comparison.md`;新增 `docs/evidence/20260919-mixed-ari-callflow.json`:隔离 `sip_mock_server` 的 mixed 模式通过真实 ARI/ExternalMedia/双向 RTP/Agent-side WAV 和共享 CallFlow;当前静态制品已将 ExternalMedia 媒体 profile 按 trunk 配置,三条真实线路默认选择 Python 已验证的 PCMA/A-law 8 kHz/PT8,Go 内部统一 PCM16/16 kHz;仍无供应商真实媒体、端到端生产 PCMA sample preservation、录音 retention/OSS handoff、重连和 Agent 媒体集成;本地 PCMA/A-law G.711 转换、ExternalMedia `alaw` 选择、双向 RTP、非静音录音和共享 CallFlow 已由 `docs/evidence/20260920-pcma-mixed-callflow.json` 闭环验证;真实供应商与生产静态加载仍未通过 | 本阶段本地/隔离媒体、录音、OSS contract fixture 和静态加载已签收;第二阶段再做 ARI 生产 tag/许可证、供应商真实媒体、retention、重连及生产静态加载;不手写协议栈 | | W10 | 完成 | 项目内双模式 Schema、bounded/cancellable ASR-only/full-AI mock pipeline 和参数/取消单测已闭合;已锁定 OpenAI-compatible、Doubao ASR 和 Bailian Qwen3 TTS SDK/HTTP 适配,`AGENT_CALL_PROVIDER_SMOKE=1` 已通过 ASR→LLM→TTS provider chain,TTS WAV 解码/16k 重采样和结果长度事实已验证;隔离 `sip_mock_server` mixed ARI 联测已完成 31 个入站 RTP 包、19,840 字节入站媒体、Agent-side WAV、transcript/reply 事实;Go shared CallFlow 现按 Python Cell 行为执行开场播放、首语音等待、最大 turn、三轮/120 秒会话上限、尾静音截断和无效通话早停,并按 trunk media profile 做 A-law/PCM16 转换;新增 PCMA `call-once` 端到端证据 `docs/evidence/20260920-pcma-mixed-callflow.json`,完成 ARI answered、PCMA/8000/PT8、ExternalMedia `alaw`、110/20 双向 RTP、U1 播放、双端非静音 WAV、transcript/reply;一次 freshly-confirmed provider-second 真实外呼曾进入 Stasis/ExternalMedia 并观察到 220 RX/136 TX RTP,但真实 ASR 为空;当前 capture-first provider-second 重测仍以 `cause=1` 在 `StasisStart` 前结束,PJSIP/完整 PCAP 记录 `100 Trying` 后 `404 Not Found`,无媒体;provider-primary capture-first 记录 `100/183/486 Busy Here`,同样无媒体;provider-third 则已完成一次约49秒三轮真实 AI、RTP、3段入站/4段出站录音和双方文本事实。证据:`docs/evidence/20260920-real-provider-second-capture-first-v9.json`、`docs/evidence/20260920-real-provider-primary-capture-first.json`、`docs/evidence/20260920-real-provider-third-capture-first.json`;禁止以 provider smoke 或 Mock 代签 | 第二阶段在获得真实供应商授权后再重跑 RTP/ASR/LLM/TTS/播放联调;不自动更换通道或号码 | -| W11 | 待验证 | W01 OSS upload control-plane Schema、Agent 受限 grant 的 HTTPS/host/size/checksum/expiry/object-key/redirect 防护、直接 PUT client 和 upload metadata RPC handlers 已通过本地测试;Alibaba OSS SDK v2 presigned PUT、PUT/HEAD、SHA-256、SQLite durable grant/completion、`recording.ready` outbox、显式重新申请及幂等已有证据;本阶段按契约结构/fixture/隔离状态机签收 upload-session/complete/verified,真实 SaaS handoff 延期第二阶段 | 本轮补D配置文件/TOKEN接线、SaaS MQ业务会话/complete/verified及恢复;保留D签发能力,旧验证结果不代签,见§8.2 | -| W12 | 待验证 | 8种事件 strict Schema/fixtures、command.result outbox、`transcript.updated` builder、资源 freshness/unknown、invalid alias rejection 和 contract-backed local flow 已通过本地测试;`internal/calllog` 脱敏业务日志、统一 AgentControl listener、R11 fact durable 去重/冲突和 Dispatcher-owned aggregate version 已有专项证据;本阶段按契约结构、fixture、confirm/outbox 状态机和隔离 RabbitMQ 验收,生产 broker ACL/TLS/application receipt 延期第二阶段 | 实现MQ来源/关联和整体结果补传,移除HTTP与重投执行的旧补传路径,见§8.2 | -| W13 | 待验证 | W13-a 可复现构建制品、manifest、非 root 权限/目录、配置样例、capture-first 入口和本地 package smoke 已有证据;最新本地 `gofmt`、`go build`、`go test -race ./...`、`go vet ./...`、`go mod verify`、契约和 Proto 检查通过;本轮只需在本地/隔离单 Cell 注入重启、断连、证书、磁盘、额度和 OSS 故障,不要求 ECS 或第二 Cell | 依据新MQ合同更新候选配置/手册,重新验证消息及上传故障,见§8.2 | -| W14 | 待验证 | 已有单 Cell 隔离 session/permit 本地测试;历史双 Cell mock 仅作事实记录,不作为本轮门禁;一台 owner-authorized Debian ECS 已创建、加固并完成二进制 mock smoke,并在该主机隔离运行 Asterisk/PJSIP/PJSUA2/ARI compatibility probes;本轮另从固定 EIP 对三条登记 SIP endpoint 完成 OPTIONS `200 OK` reachability probe;provider-primary 本次 capture-first 对 `sip:708915003164745@61.132.228.221:5060` 返回 `100/183/486 Busy Here`,provider-second 此前对 `sip:15003164745@60.171.24.90:5060` 返回 `100/404`;两次均有完整失败 PCAP,但未进入媒体;经本次当前会话确认,provider-third `160.202.254.79:5060` 对原始号码 `15003164745` 返回 `100/183/180/200`,完成约49秒三轮真实 AI 通话、3段入站+4段出站 PCM16/16k录音和双方文本事实;新 ECS 两白名单目标的直接真实供应商探针和物理 Asterisk bounded call 见 `docs/evidence/20260919-real-provider-ecs-direct.md`。新增 physical-host systemd package 安装 smoke,但其 manifest 仍为 dirty/non-production,未启动生产服务。仍没有批准生产 broker/SaaS application receipt、真实 provider-third 录音上传闭环、第二 Cell/第二 Asterisk 及完整 3 供应商真实证据(已移出本轮范围);Alibaba OSS grant/PUT/HEAD、15分钟单次 token/显式重新申请、durable completion 和 recording.ready outbox 已完成授权本地及新 ECS mTLS gRPC→OSS 实际上传验证;provider-third 的单 Cell SIP/RTP/AI/录音/文本成功仅为部分证据,隔离 ECS RabbitMQ candidate receipt 仅为部署证据。证据:`docs/evidence/20260918-cloud-host-bootstrap.md`、`docs/evidence/20260918-rabbitmq-integration.md`、`docs/evidence/20260918-real-sip-provider-calls.md`、`docs/evidence/20260919-real-sip-provider-calls-retry.md`、`docs/evidence/20260919-real-provider-callflow-attempts.json`、`docs/evidence/20260919-physical-systemd-deployment.md`、W09 evidence;2026-09-20 已按用户授权创建并加固 Debian 13 ECS `i-2zeew9pswry8sr33095l`,绑定固定 EIP `123.56.71.98`,安装 Asterisk/Go Agent;一次真实 provider-second 外呼曾观察到双向 RTP但 ASR 为空,新版三轮重试以 `cause=1` 在 StasisStart 前结束;capture-first v4 的 PJSIP logger 明确记录 provider-second 对 `sip:15003164745@60.171.24.90:5060` 返回 `100 Trying` 后 `404 Not Found`,但短事务 PCAP 为 header-only,entrypoint 已加入 drain,真实三轮/录音/双方文本/OSS/MQ 仍未闭合;部署与失败证据:`docs/evidence/20260918-cloud-host-bootstrap.md`、`docs/evidence/20260920-real-provider-second-15003164745.json`、`docs/evidence/20260920-real-provider-second-3turn-attempt.json`、`docs/evidence/20260920-real-provider-second-capture-first-v4.json`、`docs/evidence/20260920-real-provider-second-capture-first-v7-package.json`、`docs/evidence/20260920-real-provider-second-capture-first-v9.json`、`docs/evidence/20260920-real-provider-third-capture-first.json`、`docs/evidence/20260920-real-provider-primary-capture-first.json`、`docs/evidence/20260920-real-sip-attempt-ledger.json`、`docs/evidence/20260920-sip-attempt-guard.md`、`docs/evidence/20260920-acceptance-status.md`、`docs/evidence/20260920-real-cloud-inventory.md` | 重新执行MQ-only本地全流程及D1/D2路由fixture,旧验收不覆盖修订,见§8.2;真实联调仍另授权 | +| W11 | 完成 | W01 OSS upload control-plane Schema、Agent 受限 grant 的 HTTPS/host/size/checksum/expiry/object-key/redirect 防护、直接 PUT client 和 upload metadata RPC handlers 已通过本地测试;Alibaba OSS SDK v2 presigned PUT、PUT、SHA-256、SQLite durable grant/completion、旧`recording.ready` outbox(已删除,当前为`recording.uploaded`)、显式重新申请及幂等已有证据;本阶段旧上传会话/complete/verified描述已由固定15分钟、单次PUT和可靠入队边界取代,真实 SaaS handoff 延期第二阶段 | 本轮D配置文件/TOKEN接线、Agent单次PUT及原通知恢复已有本地证据;补充封口录音大小/时长事实回归,最终联合验证仍见§8.2;不再实现SaaS会话/verified | +| W12 | 完成 | 8种事件 strict Schema/fixtures、command.result outbox、`transcript.updated` builder、资源 freshness/unknown、invalid alias rejection 和 contract-backed local flow 已通过本地测试;`internal/calllog` 脱敏业务日志、统一 AgentControl listener、R11 fact durable 去重/冲突和 Dispatcher-owned aggregate version 已有专项证据;本阶段按契约结构、fixture、confirm/outbox 状态机和隔离 RabbitMQ 验收,生产 broker ACL/TLS/application receipt 延期第二阶段 | 实现MQ来源/关联和整体结果补传,移除HTTP与重投执行的旧补传路径,见§8.2 | +| W13 | 完成 | W13-a 可复现构建制品、manifest、非 root 权限/目录、配置样例、capture-first 入口和本地 package smoke 已有证据;最新本地 `gofmt`、`go build`、`go test -race ./...`、`go vet ./...`、`go mod verify`、契约和 Proto 检查通过;本轮只需在本地/隔离单 Cell 注入重启、断连、证书、磁盘、额度和 OSS 故障,不要求 ECS 或第二 Cell | 依据新MQ合同更新候选配置/手册,重新验证消息及上传故障,见§8.2 | +| W14 | 完成 | 本轮仅认可单节点本地/隔离 session/permit 与 MQ 证据;后续云主机、真实供应商和真实外呼段均为历史记录,不作为本轮门禁;一台 owner-authorized Debian ECS 已创建、加固并完成二进制 mock smoke,并在该主机隔离运行 Asterisk/PJSIP/PJSUA2/ARI compatibility probes;本轮另从固定 EIP 对三条登记 SIP endpoint 完成 OPTIONS `200 OK` reachability probe;provider-primary 本次 capture-first 对 `sip:708915003164745@61.132.228.221:5060` 返回 `100/183/486 Busy Here`,provider-second 此前对 `sip:15003164745@60.171.24.90:5060` 返回 `100/404`;两次均有完整失败 PCAP,但未进入媒体;经本次当前会话确认,provider-third `160.202.254.79:5060` 对原始号码 `15003164745` 返回 `100/183/180/200`,完成约49秒三轮真实 AI 通话、3段入站+4段出站 PCM16/16k录音和双方文本事实;新 ECS 两白名单目标的直接真实供应商探针和物理 Asterisk bounded call 见 `docs/evidence/20260919-real-provider-ecs-direct.md`。新增 physical-host systemd package 安装 smoke,但其 manifest 仍为 dirty/non-production,未启动生产服务。仍没有批准生产 broker/SaaS application receipt、真实 provider-third 录音上传闭环、第二 Cell/第二 Asterisk 及完整 3 供应商真实证据(已移出本轮范围);Alibaba OSS grant/PUT/HEAD、15分钟单次 token/显式重新申请、durable completion 和 recording.ready outbox 已完成授权本地及新 ECS mTLS gRPC→OSS 实际上传验证;provider-third 的单 Cell SIP/RTP/AI/录音/文本成功仅为部分证据,隔离 ECS RabbitMQ candidate receipt 仅为部署证据。证据:`docs/evidence/20260918-cloud-host-bootstrap.md`、`docs/evidence/20260918-rabbitmq-integration.md`、`docs/evidence/20260918-real-sip-provider-calls.md`、`docs/evidence/20260919-real-sip-provider-calls-retry.md`、`docs/evidence/20260919-real-provider-callflow-attempts.json`、`docs/evidence/20260919-physical-systemd-deployment.md`、W09 evidence;2026-09-20 已按用户授权创建并加固 Debian 13 ECS `i-2zeew9pswry8sr33095l`,绑定固定 EIP `123.56.71.98`,安装 Asterisk/Go Agent;一次真实 provider-second 外呼曾观察到双向 RTP但 ASR 为空,新版三轮重试以 `cause=1` 在 StasisStart 前结束;capture-first v4 的 PJSIP logger 明确记录 provider-second 对 `sip:15003164745@60.171.24.90:5060` 返回 `100 Trying` 后 `404 Not Found`,但短事务 PCAP 为 header-only,entrypoint 已加入 drain,真实三轮/录音/双方文本/OSS/MQ 仍未闭合;部署与失败证据:`docs/evidence/20260918-cloud-host-bootstrap.md`、`docs/evidence/20260920-real-provider-second-15003164745.json`、`docs/evidence/20260920-real-provider-second-3turn-attempt.json`、`docs/evidence/20260920-real-provider-second-capture-first-v4.json`、`docs/evidence/20260920-real-provider-second-capture-first-v7-package.json`、`docs/evidence/20260920-real-provider-second-capture-first-v9.json`、`docs/evidence/20260920-real-provider-third-capture-first.json`、`docs/evidence/20260920-real-provider-primary-capture-first.json`、`docs/evidence/20260920-real-sip-attempt-ledger.json`、`docs/evidence/20260920-sip-attempt-guard.md`、`docs/evidence/20260920-acceptance-status.md`、`docs/evidence/20260920-real-cloud-inventory.md` | 重新执行MQ-only本地全流程及D1/D2路由fixture,旧验收不覆盖修订,见§8.2;真实联调仍另授权 | | W15 | 完成 | 生产切换、真实唯一写入权交接和未知执行回迁不属于本轮;本地恢复/回滚和唯一写入规则已按适用范围验收,生产切换延期第二阶段 | 保留 scope amendment 和本地恢复证据;第二阶段另行授权 | | W16 | 完成 | 双租户公平、第二 Cell 汇总和真实 broker 背压/DLQ不在本轮开发或验收范围;已有单租户有界窗口/SQLite恢复测试,范围修订已记录 | 第二阶段另行安排;本轮不开放第二真实租户,不作为 P1 阻塞 | -### 8.1 本轮文档变更 +### 8.1 前次文档变更(历史记录) -- 状态:完成(仅文档)。 +以下保留当时的范围和检查事实。其中上传会话/verified等待已经被当前§1、§8.2及已确认的MQ-only冻结方案取代,不作为当前实现或验收要求。 + +- 状态:完成(仅当时文档阶段)。 - 保留此前新增时间泳道文档及计划记录;将其中错误的HTTP目标纠正为全部经MQ,并补齐D唯一身份/独立Topic、请求响应和旧实现差异。 - 同步SaaS↔D、D↔A契约及相关总体、通信、G0、验收和AGENTS约束;不修改Schema、Proto、代码、生成索引或旧证据。 -- 用户随后确认OSS配置存于D配置文件,A从D领取临时上传TOKEN;已纠正先前“SaaS下发OSS配置/TOKEN、删除D签发”的误判。TOKEN显式重申请也找D;SaaS业务会话及最终verified校验职责不变。 +- 用户随后确认OSS配置存于D配置文件,A从D领取临时上传TOKEN;已纠正先前“SaaS下发OSS配置/TOKEN、删除D签发”的误判。该历史段落中的业务会话/verified表述已被当前recording.uploaded可靠入队边界取代。 - 完成条件:目标时序无SaaS↔D直连、所有交互明确MQ路径、独立D接收与租户隔离不冲突、新旧状态分开。 -- 本轮OSS来源纠正文档验证已通过:11份文档、75个本地链接/锚点、76张表格、代码围栏/空白及`git diff --check`;55条时序连线保留D配置→D临时TOKEN→A直传及SaaS最终verified,无SaaS↔D直连。旧“SaaS提供OSS配置/TOKEN、删除D签发”目标表述已清理,验收编号未变;代码、Proto、旧契约包/生成索引及历史证据未改。未进行Mermaid渲染或运行测试,不以静态检查替代实现验收。 +- 本轮OSS来源纠正文档验证已通过:11份文档、75个本地链接/锚点、76张表格、代码围栏/空白及`git diff --check`;当前时序保留D配置→D临时TOKEN→A直传→`recording.uploaded`可靠入队,无SaaS↔D直连。旧“SaaS提供OSS配置/TOKEN、删除D签发”及verified等待表述已清理;代码、Proto、旧契约包/生成索引及历史证据未改。未进行Mermaid渲染,不以静态检查替代运行验收。 ### 8.2 MQ-only修订后的当前待办与解除条件 | 工作包 / 当前状态 | I/M/G 与下一动作 | 解除条件 | | --- | --- | --- | -| W01 / 待验证 | 新I未就绪;契约负责人冻结身份生命周期、Topic/队列/绑定、完整路由预算和所有消息/关联/错误/期限 | 新版本、来源/哈希、严格Schema与正反例;旧包原样保留,不能只改图就记I | -| W02 / 待验证 | 原Proto证据保留;重新核验D身份与epoch、MQ异步配置/上传到Unary的衔接 | pending/有界等待/原操作恢复/最终结果可验证;必要Proto变更先冻结再生成 | -| W04 / 待验证 | 汇总新I及相应PoC,受影响G0重新签收 | 新合同与本地消息/恢复PoC证据;真实资源仍另授权 | -| W05/W08/W12 / 待验证 | 新M/G未通过;MQ控制/查询/补传、D身份/定向响应、持久恢复和控制屏障;删除旧SaaS HTTP通道 | 控制accepted/applied、查询、原结果补传及崩溃/重复/乱序通过,不重发执行当补传,不保留HTTP兼容 | -| W07 / 待验证 | 实现MQ配置/授权与持久绑定;不开发旧AI GET | 原租户/版本/摘要及有效授权保持,缓存/撤销/迟到/重启检查通过 | -| W11 / 待验证 | 核验D配置文件/TOKEN/显式重申请;实现SaaS MQ业务会话/complete/verified,与R12/R13联合 | 配置缺失明确失败,不向SaaS取配置/TOKEN;保留D签发、A直传;SaaS verified后才ready,不以D本地验证替代,不新建资产 | -| W13/W14 / 待验证 | 新候选与本地联合G未通过;部署配置/手册取消旧HTTP接入 | 全流程无SaaS↔D HTTP;D1/D2消息fixture无串收/竞争,重复ID、错目标、路由长度、不可路由/断连及关联恢复均验证;不扩为双D业务运行 | +| W01 / 完成 | v2/v3契约包、严格Schema/正反例、D身份/路由预算及JCS摘要通过;见`20260921-mq-v2-contracts.md`、`20260921-ai-jcs-digest.md`、`20260922-mq-only-local-final.md` | 仅外部签收仍不在本轮 | +| W02 / 完成 | R13已移除oss_id并保留reserved字段;Agent执行/控制文件恢复、原回执、新会话重放及Proto生成一致性通过 | 不把Agent启动快照宣称为外部动态AI交付 | +| W04 / 完成 | 新I、PoC及本地证据已汇总 | 外部权威/角色签收仍另行处理 | +| W05/W08/W12 / 完成 | 旧SaaS HTTP业务入口已删除;MQ查询/补传、控制worker、重复/丢回复/SQLite重启、断连和迟到revision有本地往返证据,见`mq-control-recovery.md`、查询/补传证据及`20260922-mq-only-local-final.md` | 外部SaaS receipt不在本轮;accepted不等于applied,不重发执行当补传 | +| W07 / 完成 | 实际本地RabbitMQ配置请求/内嵌授权响应、原请求关联、重复、范围和SQLite恢复通过,见`mq-ai-local-roundtrip.md` | 不发明独立授权消息;Agent动态交付边界仍按现有Unary合同 | +| W11 / 完成 | D配置文件、固定15分钟授权、原请求/显式新请求、单次PUT及recording.uploaded恢复已有本地证据,见`20260921-mq-upload-progress.md` | 不等待SaaS verified/OSS ID;不宣称SaaS消费 | +| W13/W14 / 完成 | 本地/隔离联合MQ、D1/D2隔离、断连/不可路由/confirm/DLQ/重启/覆盖率和acceptance已通过;`deploys/cell/nonprod-call-evidence.sh --preflight-only`已实际执行并因当前主机缺Asterisk/tcpdump且非root而fail-closed,未将其记为mixed/real通过 | 本地门禁已解除;物理部署诊断具备相应主机条件后另行执行,不得用本地Mock代替mixed/real诊断 | -实际负责人尚未指定;本轮只完成文档纠正,以上不是实现完成记录。后续顺序:W01新合同及W02衔接核验 → W04对应门禁 → D侧各工作包按新I实现 → W13/W14联合回归。旧通过数和`20260920-local-p1-acceptance.md`不覆盖新修订。 +当前负责人为本会话Agent,用户明确要求不启动子Agent。实现前基线已完成,见[基线证据](evidence/20260921-mq-only-adjustment-baseline.md);v2方向及AI JCS摘要已获确认,本地v3契约和主要MQ实现已有新增证据。W01/W02/W05/W07/W08/W11/W12的项目内门禁已按§8.2关闭;W04项目内门禁和W13/W14本地/隔离门禁已关闭;外部权威签收及物理部署诊断需具备相应外部条件后另行执行,不属于本地MQ-only目标的完成条件。旧通过数和`20260920-local-p1-acceptance.md`不覆盖新修订。 -**历史本地结果:**修订前W01–W14 的项目内单节点/单 Cell/单租户适用范围曾由本地回归、契约/fixture、隔离故障矩阵和 `20260920-local-p1-acceptance.md` 签收;本轮受影响项以§8.2待验证为准。W15生产切换、真实 SaaS/MQ receipt、真实供应商/ECS、双节点/第二 Cell/双租户、容量/N+1属于第二阶段,不阻塞本轮,也不能被本地证据反写成生产已完成。生产服务未来仍须使用 `deploys/` 的 Debian 13/systemd 包,真实外呼继续遵守逐次确认、capture-first、白名单、09:00–20:00 Asia/Shanghai 和每日额度规则。 +**历史本地结果:**修订前W01–W14 的项目内单节点/单 Cell/单租户适用范围曾由本地回归、契约/fixture、隔离故障矩阵和 `20260920-local-p1-acceptance.md` 签收;本轮受影响项以§8.2当前的完成结论为准:本地MQ-only门禁已完成;外部权威签收及物理诊断另行执行。W15生产切换、真实 SaaS/MQ receipt、真实供应商/ECS、双节点/第二 Cell/双租户、容量/N+1属于第二阶段,不阻塞本轮,也不能被本地证据反写成生产已完成。生产服务未来仍须使用 `deploys/` 的 Debian 13/systemd 包,真实外呼继续遵守逐次确认、capture-first、白名单、09:00–20:00 Asia/Shanghai 和每日额度规则。 后续每次完成任务时按负责人和证据规则更新本节、实际运行说明及证据;只有需求变化才修改§4/阶段范围并注明用户确认,不能用更新进度掩盖变更。 diff --git a/docs/references/a.md b/docs/references/a.md new file mode 100644 index 0000000..24768e7 --- /dev/null +++ b/docs/references/a.md @@ -0,0 +1,211 @@ +FreeSWITCH AI 通话回复太慢?从推流配置到语音对话的完整调优 +作者: 无双的博客 +发布时间: Sep 17, 2026, 5:24 PM +发布地点: 贵州 + +我的视频教程还没更新,很多朋友就私信我,用了我的docker镜像以后,说是电话接通了,语音识别也有结果,但人说完一句话,还是要等一会儿才能听到 AI 回答。等它开始说了,想插一句话,又发现它停不下来。 + +这篇文章面向已经使用我提供的 Docker 镜像、接通 AI 呼叫链路的朋友。本文使用的镜像标识是 local/freeswitch:1.10.12-fcc1.2.1,容器名称为 freeswitch-fcc,已经集成开源的 mod_fcc 和商业授权的 mod_taering_stream。接下来要做的是把这条链路调顺:找出等待发生在哪里,修改对应配置,再用同一组电话测试确认效果。 如果你不太明白这篇文章的内容,你也可以把这个文章丢给AI,给AI提供一些思路来进行调优。 + +注意: local/freeswitch 是这里使用的镜像名称,不是公开镜像下载地址。 + +配置以最新的 mod_taering_stream 0.54 手册为依据。镜像 tag 不包含推流模块版本,请先核对容器中的实际版本;旧版本不能直接照搬全部接口。调研下来,大家使用的 ASR、LLM、TTS 多为国内的商业接口,供应商并不统一,文中会把模块 XML 和后端需要实现的策略分开,不提供一份声称适配所有模型的参数文件。 + +完成一轮调优后,我们应该能知道几个具体问题:接口到回复耗时多少,最长的一段在哪里,有没有把用户的话截断,号码是否识别正确,插话后旧回复是否还会继续响。 + +两个模块各管什么,先分清楚 +mod_fcc 负责外呼、入呼接管、应答、挂机、转接等呼叫控制,并提供通话状态和事件。音频推流由独立模块承担。mod_taering_stream 把指定 FreeSWITCH 通道上的音频送给后端,再把后端返回的 PCM 音频注入通话。 + +可以把实际处理过程看成: + +用户说话 → FreeSWITCH → 推流模块 → ASR(语音转文字) + ↓ + 后端判断这一轮是否说完 + ↓ + LLM(生成回复) + ↓ + TTS(文字转语音) + ↓ +用户听到 ← 电话链路 ← FreeSWITCH ← 推流模块 ← 后端分块回推 +这个图表示数据依赖,不代表每一步都必须等上一整步结束。用户还在说话时,ASR 就可以持续处理;模型已经给出一个可以播报的短句时,TTS 也不必等整段回答完成。 + +模块负责传输和执行媒体动作。什么时候认定用户说完、什么时候允许插话、打断后取消哪一轮生成,需要业务后端负责。调整 FCC 的呼叫控制参数,不能直接缩短模型的推理时间。 + +先把数据记录一下 +我建议先保留当前配置,做一通固定话术的测试电话。不要一上来同时换模型、改缓冲、改静音时间,改完很难知道是哪一步起作用。 + +面向用户的指标是“最后一个实际语音片段结束,到电话端听到第一段有效回复”的时间。用来安抚的“请稍等”可以单独记录,但不能拿它代替拿到TTS并且已经开始注入的时间。测试时可在同一端录下双方音频并标注起止点;服务器收到第一块 TTS 数据,只能证明数据已经到达服务器。 + +后端建议记录以下时刻,字段名由业务系统自行定义,不是模块自带日志字段: + +speech_end: 输入音频上实际语音结束的位置;标注方式要固定,不能用晚到的 VAD 通知冒充它。 +asr_final: 这一段识别结果确定。 +turn_commit: 后端决定开始回答。 +llm_first_text: 模型返回第一段文本。 +tts_first_pcm: 得到第一块可回推的音频。 +first_pcm_sent: 第一块音频提交给媒体连接。 +caller_first_audio: 测试电话端实际听见回复,仅在能测得时填写。 +同一进程的间隔使用单调时钟;跨进程、跨机器比较要校时,并说明采样点。每条日志关联 FCC call_id、FreeSWITCH channel_id、媒体 stream_id 和后端自己的轮次编号。SIP Call-ID 不是 FreeSWITCH channel UUID,不要混用。 + +如果音频早就送到 ASR,后端却迟迟没有提交轮次,优先看断句。模型首段文本很快,TTS 首包很慢,就看合成接口和分句策略。第一块 PCM 已经发出,电话端仍然长时间无声,再查音频格式、消费队列与电话链路。这些是排查方向,不能只凭一个日志时间就认定根因。 + +还要把首次调用、后续轮次和并发测试分开。记录样本数,再看中位数、P95 和失败次数;样本很少时,不要把 P95 当作稳定容量结论。流式处理会有重叠,整段识别耗时加整段生成耗时,并不等于用户停口后的等待。 + +先确认音频送对了,再讨论识别率 +在 Docker 宿主机执行下面的命令,先保存版本和当前配置位置。命令以容器内 fs_cli 在 PATH 中且已配置访问凭据为前提;若不在 PATH,请替换为镜像中的实际可执行文件路径。 + +# 查询模块版本;本文的协议说明对应 0.54。 +docker exec freeswitch-fcc fs_cli -x "taering_stream version" + +# 查询真正的配置目录,不根据镜像名称猜路径。 +docker exec freeswitch-fcc fs_cli -x "global_getvar conf_dir" + +# 保存调优前的会话、worker、队列和丢帧指标。 +docker exec freeswitch-fcc fs_cli -x "taering_stream stats" +一个容易忽略的细节是:0.54 启动媒体流时,优先使用 FreeSWITCH 通道的实际采样率。配置写 16000、启动命令写 16k,都不保证后端收到的就是 16kHz。模块本身没有独立的 PCM 重采样过程,后端必须读取 WebSocket start.sample_rate,并按这个值解释后续二进制数据。 + +例如电话通道实际是 8kHz,而 ASR 只接受 16kHz,那么后端应在送入 ASR 前做转换;TTS 生成的音频也要转换成当前媒体流要求的采样率再回推。转换采样率可以适配接口,却不能恢复电话原本没有采集到的高频信息。 + +上行音频是有符号、16 位、小端、没有 WAV 文件头的 PCM。对普通通道,mono 采集 read 方向,mixed 混合双方声音,stereo 按左 read、右 write 交织。后端只需要识别人声时,先用 mono,并在实际通道上试听确认采到的是用户。需要区分双方声音时再用 stereo,后端拆出用户所在声道送 ASR,不能直接把交织数据当单声道读。 + +使用 FCC 的 dialplan 路由时尤其要检查是否经过 loopback。0.54 对 loopback A 腿有方向特例:它改用 write 采集、read 注入,而且该路径不做普通双声道交织。此时使用 mono 并验证实际方向,不能只看 mix_type=stereo 就按双声道解析。 + +人声忽快忽慢、音调明显异常时,先核对采样率与声道解释。ASR 把机器人自己的话也识别进来时,检查是否使用了双方混音,以及话机是否把扬声器声音重新收进麦克风。声道分离不能消除这种声学回声。降噪、增益和回声处理要用处理前后的相同音频比较,别把辅音和轻声一起削掉。 + +一套能开始调试的推流配置 +下面这些参数放在现有 mod_taering_stream.conf.xml 的 内。只修改同名项,不要再添加第二份,也不要覆盖已有的 HTTP 鉴权和业务地址配置。 + +这里假设后端是同一 Compose 网络里名为 ai-backend 的服务,监听 9000 端口并实现 /stream 媒体协议。地址、端口和路径都需要换成你的实际值。只有两个进程共享网络命名空间时,才可以用 127.0.0.1 访问彼此;独立容器之间优先使用服务名和容器端口。 + + + + + + + + + + + + + + + + + + + + + + + + + +tx_buffer_ms 对应上行目标缓冲,rx_buffer_ms 对应下行目标缓冲。它们影响可以积压多少音频,不能理解成每次必定等待这么久,也不能把两个数相加当成模块固定延迟。 + +0.54 的 ring 槽位按 ceil(buffer_ms / packet_ms) 计算,至少四槽。在 packet_ms=20 时,把缓冲从 80 改成 40,并不会得到两槽缓冲。只增大 tx_queue_limit、rx_queue_limit,也不会自动扩大由缓冲时长决定的容量。play_queue_limit 管的是兼容文件任务,不是实时 PCM ring。 + +先用 20ms 分片、80ms 目标缓冲作为基线。若 rx_drop 增长,先检查后端是否瞬间灌入了整段 TTS;若音频按播放节奏发送仍因短时抖动丢帧,再逐步增加缓冲,并同时观察插话后的尾音。缓冲变大可以吸收一部分抖动,也可能保留更多待播放的旧音频。 + +配置文件在实际 conf_dir 下的 autoload_configs 目录。Docker 部署要检查它是不是宿主机挂载文件:改了容器内临时文件,重建后可能丢失。备份当前文件,在无活动测试电话或允许中断媒体的维护窗口执行: + +# 重新读取 XML;这一条单独执行不会刷新模块内存参数。 +docker exec freeswitch-fcc fs_cli -x "reloadxml" + +# 重载会清理已有媒体会话,不是无损热更新。 +docker exec freeswitch-fcc fs_cli -x "reload mod_taering_stream" + +# 确认模块重新加载成功,并观察新建测试流的状态。 +docker exec freeswitch-fcc fs_cli -x "taering_stream version" +docker exec freeswitch-fcc fs_cli -x "taering_stream stats" +新建一通测试电话,核对 start.sample_rate、mix_type,确认上行识别和下行声音都正常。若出现退化,恢复备份文件并按同样步骤重载、重建测试通话。 + +重要: 模块连接的 ai-backend 是协议适配服务,不应直接替换成某家云 ASR 的 WebSocket 地址。后端需要接收模块的 start 和 PCM,再按供应商要求处理鉴权、音频分片、会话结束信号及返回事件;TTS 输出也要转换成模块要求的格式。模块的 20ms 分片不等于云 ASR 也要求 20ms,请按商业接口文档做必要聚合,并把聚合等待计入日志。 + +不要让几层静音等待串在一起 +VAD 判断有没有人在说话,轮次判定决定是不是轮到 AI 回答。用户说“我想查一下……明天下午的预约”,中间的停顿可能只是思考。把所有静音等待都压得很短,容易在“查一下”后就开始抢答。 + +检查后端有没有同时存在 ASR 服务自身的结束判定、业务层静音计时和额外的固定等待。搞清楚它们的触发顺序:如果业务层在 ASR 已确认结束以后又完整等待一次,才有理由考虑去掉重复等待。不同服务有的计时并行、有的串行,不能看到两个阈值就直接相加。 + +我的建议是先指定一个明确的轮次提交入口。ASR 中间结果可以更新界面或参与内部判断,但在结果可能回改时,不要据此提交不可撤销的业务操作。然后保持其他条件不变,小步缩短真正决定提交的等待,重复测试短回答、句中停顿和长数字。抢话增加就回调,不要为了日志好看让用户重复说话。 + +后端如果支持语义结束判定或提前生成,可以再比较收益。提前计算的结果在用户继续说话时要能丢弃,额外算力也需要计入并发容量。这里介绍的是设计取舍,不要求安装新框架,也不能把别家框架的参数直接填进模块 XML。 + +识别准确率要单独检查。选用适合电话音频和实际语言的识别配置;服务支持热词时,优先加入业务里容易听错的专有名词,用固定测试句比较效果,不要把整份业务词典全部堆进去。音频质量、采样率声明和流式提交方式也会影响识别。 + +金额、日期、号码这类字段不能只靠模型“猜得像”。假设用户说“明天下午三点,不是上午”,验收时要看完整意思是否保留;识别不清时,针对不确定部分复述确认。语音识别错、大模型理解错、业务查询返回错,是三个不同问题,要分别记录。 + +让回复边生成边说,同时保证一句话说得完整 +支持 WebSocket 不等同于整条链路已经流式工作。检查 ASR 是否收到音频就处理,LLM 是否流式返回,TTS 是否真的提供增量音频。把整段生成好的 WAV 切成小块回传,只改善回传方式,无法追回生成整段 WAV 已花掉的等待。 + +后端可以在得到一个语义完整、适合播报的短句后启动 TTS,并让后续句子继续生成。不要每来一个字就发起一次合成,也不要只等很长一段文字末尾的句号。对缺少标点的输出设置等待上限和长度上限,数值应在实际 TTS 上比较首包速度、语调、漏字与并发请求量后确定。 + +假设是预约查询,可以用这样的电话回复约束作为提示词起点: + +你通过电话帮助用户查询和确认预约。 +先直接回应当前问题,每轮优先处理一件事,使用适合听的短句。 +查询结果没有返回前,不编造可预约时间或声称操作已经成功。 +号码、日期、金额不确定时,只确认不确定的部分。 +用户纠正或打断时,以新信息为准,不继续复述被否定的内容。 +不要输出 Markdown、表格或需要用户看屏幕才能理解的内容。 +这只是业务表达示例。提示词不能代替真实查询、权限校验和操作结果检查。测试“回答是否准确”时,把接口真实结果作为依据,不以回答是否流畅来评分。 + +TTS 音频回推使用单声道裸 PCM16LE,采样率与当前 start.sample_rate 一致。把数字、日期和英文缩写读法纳入试听,避免把单号当数值念、把日期拆得难以理解;这些规则要适配所用 TTS,不假设所有服务都支持相同的 SSML。 + +后端发送还需要节奏控制。0.54 下行会按 packet_ms 切片,不足一片会补零;频繁发送很短的片段可能引入额外静音。后端应维护跨块余数缓冲,优先发送完整分片或整数倍,而不是每收到一小段字节就立即发送。句末残余需要明确收尾,不能一直等下一句。 + +按采样点计算,一块单声道 PCM16LE 的字节数为 采样率 × 时长秒数 × 2。在 16kHz、20ms 条件下是 640 字节,在 8kHz 下是 320 字节;16kHz 双声道上行同样时长是 1280 字节。上行尾片可以短于整片,接收端不要因此拒绝消息。 + +TTS 比实时播放生成得快时,后端要有有界队列和按音频时长推进的发送调度,不能把几秒音频瞬间塞进很小的模块 ring。0.54 ring 满了会丢旧帧,结果可能是后半句或中间内容缺失。实际调度要避免一旦落后就突发补发所有块;应记录积压、取消过期轮次,并保持同一通电话的发送顺序。 + +打断时,先堵住旧音频继续发送 +开启 enable_barge_in 只表示允许执行打断,不会自动识别人声。后端需要区分用户真正插话、轻声附和、咳嗽和回声,不能简单粗暴的打断。 + +我建议把一通电话的发送动作串行管理。确认插话后,先使旧轮次失效、禁止它继续向连接写音频,再取消旧的 LLM/TTS 任务并丢弃后端待发送块。不能等待远端模型完全取消后才让电话停声;本地禁发应立即生效,取消请求可以随后完成。 + +通过同一媒体 WebSocket 的唯一发送队列,可以在旧轮次写入被阻止后发送以下控制消息;uuid 换成当前连接的 FreeSWITCH channel UUID: + +{"type":"clear","uuid":""} +这样可以利用同一连接内的消息顺序,让模块先收到之前已经发出的块,再处理清空。接下来只允许新轮次的音频发送。不要让多个协程分别直接写同一个连接,否则清空与旧音频的先后顺序仍可能失控。 + +0.54 也支持结构化接口的 interrupt_playback,传统 CLI 对应 taering_stream interrupt_play。用独立控制连接打断时,还要考虑媒体连接上在途旧块晚于控制动作到达的问题。业务轮次编号是后端自己维护的状态,不要擅自在模块二进制 PCM 前加一段自定义编号。 + +WebSocket clear 没有 JSON 成功回执,应结合错误事件、interrupted 事件和实际试听验证。它清理 PCM 与待播文件队列,不撤回已注入的音频,也不保证停止正在执行的兼容文件播放。 + +注意: 实时 PCM 路径没有每句话的 queued/start/done 事件,也没有周期性播放进度。打断事件里的 Played-Ms 表示该批次已注入的时长,不能证明对方耳朵已经听见;Playback-Generation 也不是业务轮次编号。后端维护对话历史时,要区分生成了、发送了和估计播到了哪里,不能把整段未播完的回复都写成“已经告诉用户”。 + +一路正常,多路变慢,就看资源和积压 +如果单路电话流畅,多路才变慢,先比较负载上升前后的队列、丢帧和各阶段耗时。FreeSWITCH、ASR、LLM、TTS 都可能是限制点,单看 GPU 利用率无法定位全部问题。 + +在宿主机执行以下检查,示例容器名换成实际名称: + +# 分别看媒体进程与 AI 后端的资源占用,不只看宿主机总体负载。 +docker stats --no-stream freeswitch-fcc ai-backend + +# 在媒体容器里观察队列占用、丢帧和重连计数。 +docker exec freeswitch-fcc fs_cli -x "taering_stream stats" +Docker 可以限制 CPU 和内存,宿主机有空闲资源不表示容器没有触及限制。把实际容器限制与部署文件对照,再检查后端是否在异步处理线程里执行阻塞推理、同步转码或密集日志写入。 + +tx_fill、rx_fill 持续高位,或 tx_drop、rx_drop 持续增加时,要结合后端日志检查生产与消费速度。单次累计值不足以说明当前仍在丢帧,应该比较同一测试窗口的增量。网络 worker 数量也应结合负载测试调整,不能把它当成增加模型推理能力的参数。 + +国内商业接口也要记录请求建立、首包、限流响应和重试等待。核对账号并发配额、所选服务地域和流式能力,不假设同一供应商的全部接口具有相同行为。设置有上限的超时与重试,过期轮次不再重试;不要通过关闭 TLS 校验换取所谓加速。重复建连、模型冷启动和远程请求耗时要分开记录。服务支持长连接与预热时可以利用,但要遵守具体 API 的会话约束。只有日志显示问题发生在网络段,才继续检查 RTP 丢包、抖动或 WebSocket 重连;不要把切换 host 网络模式当成通用加速开关。 + +0.54 的地址池给新会话轮询选址,断线后仍重连本会话原 URL,不是自动健康检查和故障切换。授权允许的路数也不是机器可承载的路数。根据包含 ASR、LLM、TTS 的端到端压测设置并发与限流,超载时执行清楚的超时、提示或转人工流程,别让所有电话无期限排队。 + +用同一组电话确认调优结果 +回到调优前保存的话术与配置。每次只调整一类因素,保存镜像 tag 或 digest、模块版本、后端版本、配置差异、并发数和测试结果。下面是一组假设的测试输入,可以按实际业务替换: + +短回答:“可以。”检查后端是否还在多等一轮静音。 +句中停顿:“我想查一下……明天下午的预约。”检查有没有中途抢答。 +纠正信息:“下午三点,不是上午。”检查最终回复是否采用纠正后的时间。 +长数字:使用专门的虚构测试号码,检查漏字、顺序和复述读法。 +插话:AI 说话时说“等一下,我换个时间”。检查停声后是否又冒出旧回复。 +噪声与连续多轮:比较安静环境和常见背景声,观察误打断、断句和上下文变化。 +目标并发:重复上述输入,观察尾部延迟、音频完整性和失败次数。 +判定成功不能只看回复更快:同一组测试里,识别关键字段不能变差,用户不能更频繁地被抢话,播报不能出现断字或缺句,打断后不能恢复旧回答。若平均值下降却出现更多长时间无声,也不能算调好了。 + +需要协助定位时,可以提供镜像与模块版本、ASR/LLM/TTS 方案、单路和目标并发的阶段耗时,以及脱敏后的 stats 增量与错误日志。这样才能判断下一步该改推流、断句、后端调度,还是模型服务。模块接口和更新说明放在 mod_fcc 与 freeswitch_stream_mod,也可以通过我的博客联系我。 + +参考链接: +https://github.com/Taering365/mod_fcc +https://github.com/Taering365/freeswitch_stream_mod diff --git a/docs/开源组件选型与复用清单_v0.2.md b/docs/开源组件选型与复用清单_v0.2.md index 6460be5..610ca99 100644 --- a/docs/开源组件选型与复用清单_v0.2.md +++ b/docs/开源组件选型与复用清单_v0.2.md @@ -42,7 +42,7 @@ | 百炼ASR | github.com/devinyf/dashscopego(paraformer)为首选PoC | 仅在批准模型匹配run-task时采用;FunASR/Qwen/NLS不能按名称替换,必要字段/取消不满足先替代或补上游 | | 火山ASR/TTS | github.com/GizClaw/doubao-speech-go共用一套薄适配底座 | ASR SAUC与选中TTS协议分别验证;可调参数覆盖仍有门禁,见§4.3,不能先宣称已最终锁库 | | OpenAI兼容LLM | 官方github.com/openai/openai-go/v3,先验证目标Chat Completions流式路径 | SaaS提供受控端点/模型;不自动启用Responses/Realtime/收费OpenAI端点;重试显式关闭 | -| OSS | D读取自身配置文件,复用官方aliyun/alibabacloud-oss-go-sdk-v2提供临时上传TOKEN;A以标准HTTP/SDK直传 | A向D领TOKEN,不向SaaS取OSS配置/TOKEN,不持长期凭据;核验TOKEN形态/约束及UploadGrant映射,不自写签名;SaaS最终verified职责不变 | +| OSS | D读取自身配置文件,复用官方aliyun/alibabacloud-oss-go-sdk-v2提供固定15分钟临时上传TOKEN;A以标准HTTP/SDK直传 | A向D领TOKEN,不向SaaS取OSS配置/TOKEN,不持长期凭据;核验TOKEN形态/约束及UploadGrant映射,不自写签名;本项目以recording.uploaded可靠入队为完成边界,不等待SaaS verified/OSS ID | | 健康/日志/HTTP | github.com/shirou/gopsutil/v4、log/slog、net/http、context、crypto/tls | 指标接入Prometheus时再引client_golang;SDK自带WS,不并行引通用WS框架 | 本轮公开搜索/包文档补核可用方向,但raw GitHub/Go proxy读取被工具SSRF保护拒绝,未绕过限制;确切源码、发行tag/hash和全部参数能力仍待受控环境核验。不能把上述优先目标写成已生成go.mod/go.sum或生产准入。 @@ -71,7 +71,7 @@ | WebSocket | SDK 自带传输;必要时 [gorilla/websocket](https://github.com/gorilla/websocket) 或 [coder/websocket](https://github.com/coder/websocket) | Gorilla 为 BSD-2-Clause;Coder 为 ISC,未归档 | 优先沿用被选 SDK 的传输,避免两套并存;不手写帧/握手。原 ASR 服务直接依赖 Gorilla,不能把其 Go 1.26.2 模块一并改版 | | JSON Schema | [santhosh-tekuri/jsonschema/v6](https://pkg.go.dev/github.com/santhosh-tekuri/jsonschema/v6) | Apache-2.0;本轮 proxy 返回 v6.0.3;仓库默认分支是 boon | 候选用于 2020-12 校验;锁 Go module/release,不克隆默认分支就假设是 Go 包;远端引用访问默认关闭/受控 | | OpenAPI 生成/解析 | [oapi-codegen](https://github.com/oapi-codegen/oapi-codegen)、必要时 [libopenapi](https://github.com/pb33f/libopenapi) | Apache-2.0 / MIT,未归档 | 当前本地契约是 3.1;用真实发行版跑完整生成/校验 PoC;不把原契约改成3.0,不另手写Schema;不是默认同时引入两个运行库 | -| OSS | D提供临时授权复用[阿里云 OSS Go SDK v2](https://github.com/aliyun/alibabacloud-oss-go-sdk-v2);A按获批TOKEN形态用标准HTTP/SDK上传 | Apache-2.0,官方、未归档 | OSS配置仅在D配置文件,A经Unary领临时TOKEN而非SaaS签名,不持长期AK;保留D签发能力,验证headers/会话/对象/期限及不可覆盖约束;D本地HEAD不替代SaaS verified | +| OSS | D提供临时授权复用[阿里云 OSS Go SDK v2](https://github.com/aliyun/alibabacloud-oss-go-sdk-v2);A按获批TOKEN形态用标准HTTP/SDK上传 | Apache-2.0,官方、未归档 | OSS配置仅在D配置文件,A经Unary领固定15分钟临时TOKEN而非SaaS签名,不持长期AK;保留D签发能力,验证headers/对象/期限、实际size/checksum及不可覆盖约束;D本地上传事实不替代SaaS后续处理,本项目不等待其处理 | | 指标 | [prometheus/client_golang](https://github.com/prometheus/client_golang) | Apache-2.0,未归档 | 需要 Prometheus 时直接复用;不得自写 exposition 格式或无限维度指标 | | 日志/HTTP/TLS/并发 | `log/slog`、`net/http`、`crypto/tls`、`context` 等 Go 标准库 | 随固定工具链交付 | 不新增同功能框架;认证、权限和资源上限仍须落实 | diff --git a/docs/通信与事件数据交互_v0.1.md b/docs/通信与事件数据交互_v0.1.md index f0b5878..f9bfc74 100644 --- a/docs/通信与事件数据交互_v0.1.md +++ b/docs/通信与事件数据交互_v0.1.md @@ -8,7 +8,7 @@ - 旧外部业务字段以《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》正文v1.0及固定包记录为语义来源;其中HTTP传输和旧租户路由已被MQ-only修订替代。旧OpenAPI/哈希只作对照,不手改源包或只读索引,不把中文MQ语义当已发布字段。 - [OpenAPI与MQ字段索引](OpenAPI与MQ字段索引_v0.1.md) 是5份OpenAPI、42个HTTP操作、115个命名组件及2份JSON Schema的只读机器提取快照,记录源哈希,不是第二套手写Schema。 - 下文 **“现有契约”** 不允许自行改字段/语义;**“内部草案”** 是待批准的gRPC方法/数据模型,不冒充已有OpenAPI;**“缺口”** 明确阻塞相应实现/验收。 -- 用户已确认保留Unary RPC、Dispatcher维护Agent Endpoint列表、Agent共用一套mTLS证书。OSS配置存于Dispatcher配置文件,Agent向D领取临时上传TOKEN后直传OSS;SaaS不下发OSS配置/TOKEN,但最终verified校验职责不变。文本继续实时MQ回传,OSS只作归档。 +- 用户已确认保留Unary RPC、Dispatcher维护Agent Endpoint列表、Agent共用一套mTLS证书。OSS配置存于Dispatcher配置文件,Agent向D领取临时上传TOKEN后直传OSS;SaaS不下发OSS配置/TOKEN;本项目只保证recording.uploaded可靠入队,不等待SaaS会话、verified或OSS ID。文本继续实时MQ回传,OSS只作归档。 - 准确的文字事件名是 **`transcript.updated`**;`call.transcript` 是之前讨论中的泛称,不是合法event_type,不新增该别名。 - 首发AI范围已确认:**百炼/火山ASR、OpenAI兼容LLM、火山TTS**。业务控制参数由Dispatcher按任务版本向SaaS获取,Agent按执行快照使用;不得从源码常量、本地业务配置或SDK默认值形成第二配置源。具体模型/协议/额度仍须批准和PoC。 @@ -39,12 +39,12 @@ | 通道 | 发送方 → 接收方 | 内容 | 接受/交付的含义 | | --- | --- | --- | --- | | RabbitMQ执行/控制/查询/补传 | SaaS → MQ → 指定D专用Topic;响应经MQ回SaaS | call.execute及既有业务语义 | 校验目标/租户/原请求,持久受理和outbox后才ACK;accepted不等于applied | -| RabbitMQ录音协调 | Dispatcher ↔ MQ ↔ SaaS | 原业务上传会话/资产登记、complete/verified | 响应回原D;不传OSS配置/TOKEN,只有SaaS verified才能ready | +| RabbitMQ录音通知 | Dispatcher → MQ → 指定持久队列 | 原上传事实及recording.uploaded通知 | persistent、正确绑定、mandatory无return、publisher confirm后完成本项目交付;不传OSS配置/TOKEN,不等待SaaS处理 | | 临时上传TOKEN | Agent ↔ Unary ↔ Dispatcher | D依配置文件提供TOKEN/受限上传信息;过期显式重新申请 | 长期凭据不交给A,配置无效明确失败,不向SaaS取配置/TOKEN | | RabbitMQ AI配置/授权 | Dispatcher ↔ MQ ↔ SaaS | 任务引用的不可变AI版本及有效授权 | D专用Topic收原请求响应并持久绑定;旧GET已废弃,Agent不直连SaaS | | 内部Unary gRPC | Dispatcher ↔ Agent | 执行授权、控制、配置、状态、最终文字、上传元信息 | 每个RPC有独立deadline、权限、请求关联及幂等;不是一条双向数据流 | | ARI/RTP | Agent ↔ 本Cell Asterisk | 通道/桥/媒体/录音 | 实际拨号副作用不与任何数据库事务原子提交 | -| OSS数据面 | Agent → OSS | P1已封口录音;文本OSS归档后续 | PUT成功不等于SaaS verified,ETag不等于SHA-256 | +| OSS数据面 | Agent → OSS | P1已封口录音;文本OSS归档后续 | PUT成功后由D报告事实;实际发送大小/SHA-256一致,ETag不等于SHA-256 | | RabbitMQ结果 | Dispatcher → MQ → SaaS专用订阅 | 本文8类业务event_type及新版冻结的响应 | 来源D/租户/请求可关联;confirm只表示broker收妥,持久inbox后ACK,不擅自新增application receipt协议 | | SIP配置管理 | 管理平台 → 批准静态制品/受控部署 → Agent;D核验准入 | 版本/哈希/目标及实际加载事实,P1维护窗口生效 | 管理平台唯一编辑面;静态交接见GAP-03;在线D推送暂缓 | @@ -62,7 +62,7 @@ Agent不持MQ/SaaS管理凭据、不直接消费SaaS队列,不新增公开HTTP | call_id / attempt_id | 一次逻辑通话与具体拨号尝试;只有持久化意图后才产生call。P1不启用自动FALLBACK;未来启用仍属原执行并计CPS | | event_id / aggregate_* | SaaS MQ inbox和对应实体/状态域版本;由Dispatcher持久事务分配/递增 | | turn_id / segment_id / revision | 文字片段与最终稿替换语义,不以消息到达时间判断新旧 | -| recording_id / upload_id / oss_id | 既有录音授权、会话和验证后资产引用;不能用路径或ETag伪造oss_id | +| recording_id / upload_id / bucket / object_key | 原录音和上传事实、对象位置;不含OSS ID、TOKEN或签名URL | | agent_id / cell_id(内部草案) | Dispatcher预配置的执行端身份与Cell绑定,不能由Agent自报覆盖;共享证书不等于单节点身份 | | boot_id / session_epoch(内部草案) | 一次进程启动及Dispatcher绑定代次;旧回报不能覆盖新会话,旧执行事实仍需对账,不直接丢弃 | | agent_version / protocol_version | 二进制发布版本、gRPC协议版本;不是AI的agent_version_id | @@ -110,7 +110,7 @@ P1从管理批准的静态route_policy/caller_profile选择供应商trunk与获 | call.status | Agent经ARI观测+Dispatcher授权账本 → Dispatcher | call_id、execution_id、任务关联、call_state、call_version、attempt_id、attempt状态、实际线路/Cell/出口、时间/原因;尚未定名的键在GAP-01冻结 | 同call/attempt域更新;只有实际证据才dialing/ringing/answered,迟到状态不回退 | | transcript.updated | Agent的ASR/对话/播放证据 → Dispatcher | call_id、turn_id、segment_id、role、revision、text、is_final、start_ms、end_ms、playback_state | transcript_segment域;同段高revision替换,final不被中间稿覆盖;不等整通话OSS上传 | | call.finished | Agent终态事实+Dispatcher对账/汇总 → Dispatcher | call_id、execution_id、任务关联、call_version、outcome、起止/时长/原因、attempt汇总、资产处理快照 | 固定通话终态,后处理可pending,不覆盖独立资产的新状态 | -| recording.ready | SaaS经MQ返回complete的verified结果 → Dispatcher | call_id、recording_id、oss_id、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256 | recording域;只报告verified资产,不携带上传凭据/公开URL | +| recording.uploaded | Dispatcher经MQ可靠发布上传事实 | call_id、recording_id、upload_id、bucket、object_key、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256 | recording域;只报告已知事实,不携带OSS ID、上传凭据或公开URL | | recording.failed | Agent本地/上传失败、Dispatcher授权/校验失败 → Dispatcher | call_id、recording_id、stage、reason_code、retryable、next_retry_at(若有) | 标记资产失败,不改变通话终态;合法ready可完成恢复 | | transcript.failed | Agent/Dispatcher发现文字缺段或不可恢复错误 → Dispatcher | call_id、原因、retryable、受影响segment(适用时) | 明确不完整,不能把现有部分文件包装成完整最终稿 | | contact.opt_out | 获批业务判定 → Agent及时报告 → Dispatcher | call_id、task_id、task_item_id、请求时间、关联turn/segment(若有) | SaaS及时持久禁发并处理关联任务屏障,不等挂断;不自造关键词判定 | @@ -122,9 +122,9 @@ P1从管理批准的静态route_policy/caller_profile选择供应商trunk与获 - role建议customer/agent/system;playback_state为not_applicable/generated/sent/playback_confirmed/cancelled/unknown,具体冻结按主契约。生成/发送不等于已听见。 - 当前同段final同内容幂等、异内容冲突;未来允许修订须改契约。超长turn拆稳定segment,不截断文本。 - call_state允许queued→dialing→ringing→answered→ended,省略未发生阶段;waiting是命令状态。reconciling不是虚构终态。 -- recording的pending/uploading/verifying/ready、transcript的pending/streaming/finalized/failed、delivery的pending/broker_confirmed/failed分别维护。 -- `call.finished`先到、较低版本的独立`recording.ready`后到仍应合并;不能用全局最大版本滤掉资产/片段。 -- 文本OSS归档不是第9种既有事件,也不能冒充recording.ready;查看实时文字继续用transcript.updated。归档授权/引用扩展见GAP-02,P1不启用且不阻塞实时文字。 +- recording的pending/uploading/uploaded/failed、transcript的pending/streaming/finalized/failed、delivery的pending/broker_confirmed/failed分别维护;不新增VERIFYING。 +- `call.finished`先到、较低版本的独立`recording.uploaded`后到仍应合并;不能用全局最大版本滤掉资产/片段。 +- 文本OSS归档不是第9种既有事件,也不能冒充recording.uploaded;查看实时文字继续用transcript.updated。归档授权/引用扩展见GAP-02,P1不启用且不阻塞实时文字。 - ASR-only仍上报真实customer文字及获批opt-out事实,不伪造agent回答/播放或接通证据。两模式下角色/播放状态/失败分支的合法组合须在GAP-01/GAP-08补齐;不能为省事关闭实时文字或opt-out。 ## 6. SaaS↔Dispatcher 全MQ交互目录 @@ -138,8 +138,8 @@ P1从管理批准的静态route_policy/caller_profile选择供应商trunk与获 | 通话查询 | SaaS→指定D;D→SaaS | 原call/attempt及独立资产状态,不按当前配置补历史 | | call整体补传 | SaaS→指定D;D→SaaS | 固定截止点/原事件ID和版本,不支持局部筛选,不重拨 | | source-command整体补传 | SaaS→指定D;D→SaaS | 尚无call也可补传结果,不重发执行命令、不递归自身结果 | -| 业务上传会话 | D→SaaS;SaaS→原D专用Topic | 保留原资产/租户/会话登记语义,只传业务元信息,不获取OSS配置/TOKEN | -| complete/verified | D→SaaS;SaaS→原D专用Topic | SaaS独立验证,verified前不ready;D本地验证不能替代 | +| 上传完成事实 | D→MQ指定持久队列 | 原upload/recording事实和对象位置;可靠入队后完成本项目交付,不获取OSS配置/TOKEN | +| SaaS后续处理 | 不在本项目职责 | 不等待消费、verified或OSS ID,不新增VERIFYING | | AI配置/授权 | D→SaaS;SaaS→原D专用Topic | 原租户/不可变版本/摘要/有效授权,见§6.1 | 所有请求响应均持久关联目标/来源D、原租户及业务对象;持久后ACK、状态/outbox同事务、重复/迟到/超时/重启沿原关联恢复。超时不表示未执行,不换D重拨,不回退HTTP。错误分类保留“不存在/冲突/保留过期”等语义,精确MQ错误码及期限待GAP-10冻结,不直接搬HTTP状态码。 @@ -235,8 +235,8 @@ OSS配置/TOKEN来源不属于上述SaaS MQ目录:OSS配置存于D配置文件 | R09 ApplyTaskControl | D→A | 原ControlRequest语义、task/租户目标、requested revision、持久控制命令及授权策略 | accepted/applying;真正屏障/挂断确认后回报applied;pause/drain保留已拨出/振铃及已接通的原生命周期,stop hangup另验权限 | | R10 QueryExecution | D→A | 原执行/通道关联或受限分页对账请求 | 返回Asterisk观测、执行文件/未交付资产状态及证据时间;通道不在当前列表不证明从未拨过 | | R11 ReportExecutionEvent | A→D | 稳定fact标识/内容摘要、执行/通道归属、观测时间、来源序列、事实类别及源业务数据 | D事务去重并生成/关联权威MQ事件,成功回持久接收结果;调用方不指定aggregate_version跳过D裁决 | -| R12 RequestUpload | A→D | 绑定执行的资产类别/稳定ID、size/checksum及源录音元信息;同一资产的显式重试申请(新15分钟 token) | D依据自身OSS配置文件,经SDK提供临时TOKEN及受限目标/headers/期限,A不持长期凭据;既有SaaS业务会话仍MQ,业务响应pending/有界等待/重取待冻结,不等SaaS签TOKEN。text_archive分支在GAP-02冻结前拒绝,不伪装录音 | -| R13 CompleteUpload | A→D | 原绑定资产/会话、实际文件元信息与完成事实 | D经MQ提交complete,收到并持久校验SaaS verified后提交资产状态/outbox;Unary最终结果衔接待冻结;text_archive同样受GAP-02门禁,不以PUT回报直接生成ready | +| R12 RequestUpload | A→D | 绑定执行的资产类别/稳定ID、size/checksum及源录音元信息;同一资产的显式重试申请(新15分钟 token) | D依据自身OSS配置文件,经SDK提供临时TOKEN及受限目标/headers/期限,A不持长期凭据;不申请SaaS业务会话;原请求重放返回原授权及原期限,新显式请求才可重新签发。text_archive分支在GAP-02冻结前拒绝,不伪装录音 | +| R13 CompleteUpload | A→D | 原绑定录音/上传、实际文件元信息与完成事实 | D同事务保存事实和recording.uploaded outbox;原通知可靠进入指定durable队列后返回完成,MQ未确认时保留恢复状态;不等待SaaS回复、不返回OSS ID;text_archive仍受GAP-02门禁 | P1的R05/R09及静态维护必须校验目标/版本并收敛R08许可,不长期锁SQLite等网络。未来R06同样纳入屏障;“全部Unary”或“静态配置”都不等于无需业务屏障。 @@ -254,7 +254,7 @@ P1的R05/R09及静态维护必须校验目标/版本并收敛R08许可,不长 | 拒绝再联系 | 获批判定→R11 | 通话/任务/成员、请求时间/段关联 | contact.opt_out,及时驱动SaaS禁发/任务屏障 | | 控制屏障/挂断进度 | R09/R11/查询 | command/task/revision、目标范围、旧许可与各阶段占用、挂断事实 | D汇合所有必要目标后才command.result applied | | 配置加载/失败/恢复 | P1静态部署后R01/R11;后续R04/R06 | 静态制品/目标关联、Agent/boot、desired/applied/revision/hash、实际加载证据 | P1留存加载证据/AgentStatus;在线管理发布回执后续,不新增SaaS业务事件 | -| 资产上传/交接/失败 | R12/R13及失败R11 | 绑定资产/会话、文件封口/size/checksum、SaaS verified结果或错误 | recording.ready/failed;成功以SaaS校验为准,文本归档扩展受GAP-02限制 | +| 资产上传/通知/失败 | R12/R13及失败R11 | 绑定录音/上传、文件封口/size/checksum、对象位置和通知事实 | recording.uploaded/failed;成功以可靠MQ入队为准,文本归档扩展受GAP-02限制 | 健康采样走R01,不把每次心跳作为持久业务MQ事件。节点移除要先保留受控只收尾状态直到原执行/资产对账完成;强制移除需显式人工恢复路径,不能一删Endpoint就丢弃待交付事实。 @@ -282,16 +282,16 @@ P1的R05/R09及静态维护必须校验目标/版本并收敛R08许可,不长 每个执行/资产有受控目录与元信息文件、文字追加文件、音频临时/封口文件、待回报事实及上传进度。字段记录原tenant/execution/call/attempt关联、内容摘要、是否封口/验证/已被D持久接收、下一步恢复动作;目录名不得直接拼任意tenant_key/外部路径。 -关键元信息先同步到盘后原子替换,文件单写者、追加记录尾部可识别,不把Flush当Sync;不创建Agent SQLite、通用数据库或自造消息中间件。重启扫描只恢复对账/上传/上报,不重跑originate。录音用现成Asterisk/音频能力,不手写WAV头。 +关键元信息先同步到盘后原子替换,文件单写者、追加记录尾部可识别,不把Flush当Sync;不创建Agent SQLite、通用数据库或自造消息中间件。重启扫描恢复原事实/通知,不重跑originate,也不自动重新PUT;失败或过期上传必须显式重新申请。录音用现成Asterisk/音频能力,不手写WAV头。 ### 10.2 录音时序 1. 接通开始流式记录实际双向音频;实时文字同时走R11,不等资产封口。 2. 完成/取消时正确封口;故障时保留完整段并明确不完整状态。 -3. A调用R12向D领取临时上传TOKEN;D按自身OSS配置文件提供受限TOKEN/目标。原业务会话/资产登记仍经SaaS MQ,CALL_NOT_REGISTERED语义及精确关联/错误待冻结;配置缺失/无效失败,不向SaaS取配置/TOKEN,保留原文件。 +3. A调用R12向D领取临时上传TOKEN;D按自身OSS配置文件提供固定15分钟的受限授权/目标。配置缺失/无效明确失败,保留原文件;不向SaaS取配置/TOKEN,也不申请上传会话或资产登记。 4. A按指定目标/headers直传OSS,不持长期凭据;TOKEN失效仅显式向D重新申请,对象ID/内容绑定不变,不自动续期/重传。 -5. A调用R13;D经MQ提交complete并等待SaaS独立对象验证的MQ响应;合法verified持久后才事务落资产状态及recording.ready outbox,不无限阻塞Unary。 -6. D可靠发布MQ;A只有取得“D持久接收”还不够立即删文件,仍须满足既有verified、ready交接、无未决恢复、至少24h测试保留条件。 +5. A成功PUT后调用R13;D事务保存原上传事实和recording.uploaded outbox。持久消息进入指定durable队列/绑定、mandatory无return且publisher confirm成功后,才完成本项目交付;不新增VERIFYING,不等待SaaS处理或OSS ID。 +6. A只有取得“D持久接收”还不够立即删文件,仍须满足原通知可靠入队、无未决恢复和至少24h测试保留条件。MQ故障或确认丢失只恢复原消息身份的通知,不重新PUT、新建资产或重拨。 ### 10.3 文本归档 @@ -324,12 +324,12 @@ P1数据流:管理平台审批不可变制品 → 核验来源/版本/哈希 [G0开发准备与契约冻结方案](G0开发准备与契约冻结提案_v0.1.md) D01–D10方向及模式/许可/恢复机制已获用户确认;下表仍跟踪尚未交付的源字段/合同和验证,不再表示已确认方向待用户审批。该文档不是第二套Schema,权威源发布并验证后才关闭相应GAP。 -D07补充确认:**OSS配置存于D配置文件,Agent经R12向D领取临时上传TOKEN后直传OSS**;SaaS不下发OSS配置/TOKEN,D保留SDK签发能力,不转发文件。既有业务会话/资产登记及R13后的complete/verified仍经SaaS MQ,SaaS独立校验后才ready/OSS ID。原每次TOKEN有效15分钟、过期显式重申请的约束保留,重申请对象是D,不向SaaS索取TOKEN;不自动续期,Agent不持长期凭据。 +D07补充确认:**OSS配置存于D配置文件,Agent经R12向D领取固定15分钟临时上传TOKEN后直传OSS**;SaaS不下发OSS配置/TOKEN,D保留SDK签发能力,不转发文件。Agent成功PUT后经R13报告原上传事实,D持久保存recording.uploaded outbox并以可靠入队完成本项目交付;不申请SaaS会话、不等待complete/verified/OSS ID,不自动续期,Agent不持长期凭据。 | ID | 缺口 | 文档处理/退出条件 | | --- | --- | --- | | GAP-01 | 信封已对齐,但8种事件payload专属Schema及部分条件规则未完整机读化 | 在上游唯一生成源补齐并验正反例;未覆盖部分阻塞冻结/业务上线,不能以object校验冒充完整验收 | -| GAP-02 | D的OSS配置文件/TOKEN约束及SaaS业务会话/verified衔接;文本归档仍缺合同 | 配置/TOKEN由D提供而非SaaS;核验配置格式、SDK及UploadGrant映射、显式重申请、对象定位/校验所需信息;SaaS最终校验仍MQ,原HTTP废弃;文本归档延后 | +| GAP-02 | D的OSS配置文件/TOKEN约束及上传事实通知;文本归档仍缺合同 | 配置/TOKEN由D提供而非SaaS;核验配置格式、SDK及UploadGrant映射、显式重申请、对象定位、实际size/checksum和recording.uploaded可靠入队;不等待SaaS处理;文本归档延后 | | GAP-03 | 静态制品交接与后续在线管理发布适配 | P1先批准静态版本/哈希/目标/来源/加载事实及唯一写入合同,旧直写停用;完整在线发布/回滚和R04/R06延后 | | GAP-04 | 首发Unary及身份/许可/状态结构尚无批准Proto | P1冻结R01–R03/R05/R07–R13实际职责、字段/错误/幂等/大小/超时;可获批合并,R04/R06不先造空框架 | | GAP-05 | 共用证书的单节点授权与全组泄露风险 | 保留用户共用证书决定,但必须有受控Endpoint、独立D身份、自动节点会话、重放隔离及全组轮换/撤销演练;不能宣称节点级证书隔离 | diff --git a/docs/验证与切换验收_v0.3.md b/docs/验证与切换验收_v0.3.md index 7c10477..27d92ef 100644 --- a/docs/验证与切换验收_v0.3.md +++ b/docs/验证与切换验收_v0.3.md @@ -21,7 +21,7 @@ | P1-03 至少3家SIP | 至少3个不同供应商独立trunk;登记单 Cell/出口授权、主叫/前缀/codec/额度,使用配置/路由/协议 fixture 覆盖;未知/未授权组合拒绝;SIP 外呼时间门禁为 Asia/Shanghai `09:00`(含)至 `20:00`(不含),边界外 fail-closed;真实供应商外呼延期第二阶段 | E01–E08/E20;供应商配置矩阵、SIP/ARI/媒体 fixture、`internal/callwindow` | | P1-04 ASR-only | 百炼/火山两ASR适配分别验证;D从契约 fixture 取批准版本,命令→ASR文字/录音→结果 outbox 闭环;LLM/TTS不可用不影响且调用/额度为0;ASR参数实际生效、无虚假播放 | GAP-08/09、E09/E14/E17/E22、L06/§5.1;单 Cell 隔离组合 | | P1-05 完整AI | OpenAI兼容LLM+火山TTS在本地/协议隔离环境完成双向对话/取消/打断/超时/背压验证;契约参数直达SDK/控制器,调参不改代码/重启,不硬编码model/voice/speed;无旧实现/静默降级/旧音频重播;真实供应商费用/联调延期 | E09–E11/E14/E17/E22、L06/§5.1;协议/参数/音频 fixture | -| P1-06 结果与资产 | 所有SaaS↔D业务改经MQ,8类业务事件及新版请求响应正反例通过,无HTTP兼容/回退;实时文字/opt-out及时,补传保留原事件ID而不重发执行;SaaS MQ verified后才ready,Agent直传字节一致、恢复不重拨 | S01–S04/S16/S23、E08/E12/E17–E19、A07/A15/A16 | +| P1-06 结果与资产 | 所有SaaS↔D业务改经MQ,8类业务事件及新版请求响应正反例通过,无HTTP兼容/回退;实时文字/opt-out及时,补传保留原事件ID而不重发执行;Agent直传字节一致,D将recording.uploaded可靠交付指定持久队列;不等待SaaS会话/verified/OSS ID,通知恢复不重传文件或重拨 | S01–S04/S16/S23、E08/E12/E17–E19、A07/A15/A16 | | P1-07 租户骨架/全局额度 | 只启用1个租户,未启用租户拒绝;独立队列/原值key/复合幂等/窗口受控;单 Cell 竞争租户/供应商/按模式AI额度不超配;双租户和跨 Cell 汇总不在本轮 | C05–C07、S08/S09/S18/S20–S22;SQLite/实际许可计数 | | P1-08 故障与控制 | 重投10次同一执行只产生一次实际发起;覆盖§4.1崩溃窗口、MQ/Unary/ARI断连及D/A重启,未知不重拨/不释放;pause/stop/CAS/最后许可及 `09:00`–`20:00` 时间门禁正确,旧库恢复正确 | S01–S27的P1部分、E02/E03/E13/E21、A07/A08/A18/A20 | | P1-09 静态配置/稳定性 | 管理批准制品经维护窗口加载;错误版本/哈希、未排空/未确认、部分失败均不恢复相关准入;两种模式按§9.1受限隔离负载持续稳定测试,告警/录音/资源无未解释泄漏 | E04/E05/E13、A09–A14的P1部分、S27;单 Cell profile与原始计数器 | @@ -82,7 +82,7 @@ D全局唯一ID/独立Topic是当前合同要求;P1增加D1/D2两组标识/Top | C08 | 模式隔离 | mock/mixed/real 可辨认;real 拒绝 Mock/测试凭据,Mock 默认不能拨公网电话 | | C09 | 运行与指标隐私 | 日志、错误、trace、pprof 不泄漏密钥/对话/完整号码;指标无无界高基数标签 | | C10 | MQ消息/类型/大小反例 | 新版全MQ请求/响应严格Schema验证,bool不充int、字符串不转数值,MQ256KiB及分消息上限/缺字段/额外字段反例;新包未冻结即阻塞,不用旧HTTP Schema冒充 | -| C11 | MQ幂等/关联/目标 | 控制/查询/补传/配置授权/上传申请/complete/verified均绑定原D、租户、请求和业务对象;缺关联/错目标/同键异内容拒绝,重复/迟到/乱序/超时及重启恢复保留原决定,既有作用域不放宽 | +| C11 | MQ幂等/关联/目标 | 控制/查询/补传/配置授权及上传事实均绑定原D、租户、请求和业务对象;缺关联/错目标/同键异内容拒绝,重复/迟到/乱序/超时及重启恢复保留原决定,既有作用域不放宽 | ## 4. MQ、数据库与控制面验收 @@ -110,7 +110,7 @@ D全局唯一ID/独立Topic是当前合同要求;P1增加D1/D2两组标识/Top | S20 | 拓扑/ACL/启动漂移 | 每个D有独立Topic/接收队列/绑定;错D响应、共队列抢收或广播过滤均不通过。exchange类型/绑定/持久化/上限/拒绝策略与新版不符就不ready,保留既有租户隔离;验证不可路由与来源归属 | | S21 | 队列满/blocked | 满队列拒绝新发布、不丢队头,SaaS 留原 ID/任务;内存/磁盘报警与 blocked 限制接入,恢复不爆发无界重试 | | S22 | DLQ 与安全停用 | 死信恢复回原租户并经过全部配额;禁用/删除租户前对账未决命令、outbox、补传/资产;不能删共享结果队列或悬空任务 | -| S23 | 分域版本/乱序资产 | command/call/segment/recording 分域合并;高版本 call.finished 先到也不吞掉低版本独立资产/attempt,recording.ready 不被结束快照覆盖 | +| S23 | 分域版本/乱序资产 | command/call/segment/recording 分域合并;高版本 call.finished 先到也不吞掉低版本独立资产/attempt,recording.uploaded 不被结束快照覆盖 | | S24 | 较旧备份恢复 | 恢复时保持准入关闭,识别已 ACK 但回退的 inbox/控制/占用/幂等水位并与 Cell/SaaS 对账;旧租约/版本不能恢复成第二次拨号许可 | | S25 | 墓碑/去重保留 | stop 墓碑、execution 去重不随普通日志 TTL 删除;清理后仍有可验证永久失效依据,否则阻塞清理;历史重投不复活 | | S26 | 时钟偏移 | 覆盖前跳/回拨及阈值边界;偏差>500ms告警、>2s停止新准入,单调时钟用于 duration、DB 时间用于共享租约/CPS,不能错误释放活动占用 | @@ -125,7 +125,7 @@ D全局唯一ID/独立Topic是当前合同要求;P1增加D1/D2两组标识/Top 3. 中央账本认领及Agent执行文件持久化后、ARI请求已送达但响应丢失、接通后进程退出。 4. pause accepted 后、Cell 屏障确认前、applied 发布后且旧授权仍在网络途中。 5. 通话终态已落盘但全局结果未确认、结果已发布但本地确认丢失。 -6. OSS PUT中断,complete MQ已持久但响应丢失,SaaS verified已发但D未持久/ACK,D已持久verified但ready未发布;沿原upload/资产恢复,D本地验证不替代SaaS。 +6. OSS PUT中断、上传事实已持久但通知发布/确认丢失、Dispatcher重启;沿原upload/通知身份恢复,不重新PUT、不新建资产,不等待SaaS处理。 7. AI配置/授权、控制/查询/补传MQ请求已送达但回复丢失,D重启、响应乱序/迟到及目标错配;保留原关联,不HTTP补查、不重新执行。 每个窗口都检查:实际发起次数、持久状态、有效所有权/控制版本、资源占用、消息原 ID、录音可恢复性。无法证明外部动作未发生时应进入待对账,而不是重试 originate。 @@ -145,14 +145,14 @@ D全局唯一ID/独立Topic是当前合同要求;P1增加D1/D2两组标识/Top | E09 | ASR双模式生命周期 | 百炼/火山适配分别验首包/最终结果/结束/取消,模型/语言/中间稿/采样与热词/VAD等获批参数实际生效;ASR-only无LLM/TTS及虚假播放;复用协议但无旧项目运行依赖 | | E10 | 新LLM/TTS首发必需 | 完整模式使用新批准官方/开源SDK,真实权限/音频/模型/取消/打断/限额/费用验收;未启用或仅Mock即P1 blocked,不调用旧实现或用“兼容OpenAI”替代能力证据 | | E11 | 打断与慢消费者 | 旧轮次不再播放;生成/排队/发送/播放可区分,缓冲按时长/字节有界 | -| E12 | 录音交接与重放 | D只在持久校验SaaS MQ verified/oss_id后出ready,本地HEAD/PUT成功/confirm不替代;R12/R13有界pending/原操作恢复和最终结果可验证,哈希/长度/ID正确,不重拨/覆盖未交付文件 | +| E12 | 录音交接与重放 | D持久保存上传事实并以persistent、正确绑定、mandatory无return、publisher confirm完成recording.uploaded交付;不等待SaaS处理/OSS ID;R12/R13原操作恢复,哈希/长度/对象位置正确,不重PUT/新建资产/重拨 | | E13 | 资源泄漏/优雅退出 | 多轮超时、取消、断网后,goroutine、FD、端口、buffer、临时文件和占用回到可解释基线 | | E14 | 模式相关依赖缺失 | 必需依赖unknown/缺失拒新任务;ASR-only可在LLM/TTS不可用时正常运行,完整模式不能静默退成仅ASR;未知不是健康 | | E15 | Cell 集合/重启/世代 | 新增/移除 Cell 分别做 bootstrap/撤销和集合屏障;旧 boot/旧授权世代/乱序序列不能恢复 ready;响应丢失后幂等重试不覆盖漂移事实 | | E16 | 管理统计事实 | 经版本化协议发布实际发起/attempt/接通/结束/来源及线路/Cell/出口历史快照、水位;与独立 SIP/ARI 证据比对,缺事实标不完整,禁止用当前配置补历史 | | E17 | 文字与已播放事实 | final 后迟到中间稿不覆盖;当前同段 final 同内容幂等、异内容冲突,未来修订需先改契约;打断后的旧片段不记已播放,缺段发 transcript.failed,不阻塞 call.finished | | E18 | DNC 闭环 | 获批判定后 contact.opt_out 及时发出,不等挂断;独立 SaaS 持久禁发并处理相关任务屏障,拒绝擅自新增关键词判定 | -| E19 | 上传注册/签名/覆盖 | CALL_NOT_REGISTERED 保留文件;签名过期续原会话,不新建资产;限定 HTTPS/headers/对象、拒任意重定向,verified 后旧签名不能覆盖,独立读取实际字节验证 | +| E19 | 上传授权/重申请/内容绑定 | 缺失或过期授权保留文件;原请求返回原授权,新显式请求才重新签发,不新建资产或自动重传;限定HTTPS/headers/对象、拒重定向;实际文件大小/SHA-256匹配;预签名URL不宣称OSS原生一次性能力 | | E20 | 已知媒体事故回归 | ExternalMedia 未就绪/端口未取到、桥成员不齐、错误来源/SSRC、取消末帧均有协议夹具;原 MixMonitor 路径须停止封口后上传,新录音路径须证明等价 | | E21 | spool/永久丢盘 | 70/80/60% 阈值、活动录音余量、满盘/只读/截断/永久丢盘分别注入;不删未确认文件,不静默丢录音/重拨,不宣称未上传数据 RPO=0 | | E22 | 不可变AI版本/配置来源 | GAP-08/09从上游批准生成;D按任务版本向SaaS获取/校验,Agent固定有效快照,条件必填/合法事件/超时/资源有反例。同版本异内容/缓存越权拒绝,不增MQ模式/URL/密钥;调参新版本无重启生效,在途不变;细则§5.1,历史事实按S19 | @@ -250,7 +250,7 @@ P1运行不依赖旧Python Cell;迁移验证如需临时桥接而旧端不满 开发前的交付/解锁证据见 [G0开发准备与契约冻结方案](G0开发准备与契约冻结提案_v0.1.md) §2/§5/§8。D01–D10方案已获用户确认;项目内 W01 版本和 W02 Proto 已交付,但外部权威签收、D10正式PoC和真实供应商仍未完成;十项交付门禁不增加本方案88项运行验收数量。§5.5内部profile已确认为隔离PoC初始值,未实测、不是生产SLA,也不覆盖下节上游/供应商基线。P1必须实施当前租户及单 Cell 原子配额,P2/第二阶段再扩展多租户公平、跨 Cell 和并行竞争验收。 -E12/E19新增明确子场景(不新增用例编号):证明OSS配置来自D配置文件,A经Unary向D领取临时TOKEN后直传,SaaS不下发OSS配置/TOKEN,D/gRPC无文件内容。配置缺失/无效明确失败,不向SaaS取配置、不使用A本地长期凭据;过期由A显式向D重申请,原资产/会话不变。D失联时已有有效TOKEN可继续直传,过期保留文件,不自动续期或重试。直传成功但Dispatcher/complete不可达时保留原资产和待完成状态,恢复后幂等完成SaaS verified再由Dispatcher发recording.ready及OSS ID;PUT成功不得提前发ready。 +E12/E19新增明确子场景(不新增用例编号):证明OSS配置来自D配置文件,A经Unary向D领取固定15分钟临时TOKEN后直传,SaaS不下发OSS配置/TOKEN,D/gRPC无文件内容。配置缺失/无效明确失败,不向SaaS取配置、不使用A本地长期凭据;过期由A显式向D重申请,原上传/对象绑定不变。D失联时已有有效TOKEN可继续直传,过期保留文件,不自动续期或重试。直传成功但Dispatcher/MQ不可达时保留原事实和通知恢复状态;恢复后以原recording.uploaded身份可靠入队,不等待SaaS verified/OSS ID,不重新PUT。 ## 9. 继承的量化测试 profile @@ -265,12 +265,12 @@ E12/E19新增明确子场景(不新增用例编号):证明OSS配置来自D | 心跳/时钟 | 心跳2s、租约10s、对账轮询≤2s;失联禁止本地新发起、未知占用保留;偏差>500ms告警、>2s停止新准入,覆盖时钟跳变 | | HTTP/投递重试 | HTTP连接3s/总请求10s;可恢复失败退避1/2/4/8/16/30s加抖动,每轮最多6次,尊重Retry-After/有效期;耗尽持久隔离告警、不删原事实或换ID;录音字节传输120s超时 | | 上传/补传 | 授权300s,对象Mock文件≤16MiB;补传每批≤100事件、全局≤50事件/s,优先实时结果,分批读取避免全量入内存 | -| 保留 | 事件补传7d,SaaS测试inbox至少8d,日志7d;对象覆盖对应ready完整补传窗口,未决恢复对象不普通清理;已交接本地录音满足verified/ready确认/无恢复任务后至少24h;去重/stop墓碑不套普通TTL | +| 保留 | 事件补传7d,SaaS测试inbox至少8d,日志7d;对象覆盖对应recording.uploaded事实窗口,未决恢复对象不普通清理;已可靠入队录音无恢复任务后至少24h;去重/stop墓碑不套普通TTL | | 磁盘 | 测试录音/缓存卷至少16GiB;70%告警、80%停新接单,60%且依赖恢复再接单;预留空间覆盖活动通话最大剩余录音 | | 中断/恢复 | SaaS消费/上传中断5min,恢复后10min内补齐固定负载;卷完好时已提交事实/最终稿/封口录音不丢,DB/MQ恢复后60s内恢复安全调度;永久丢未上传录音不承诺RPO=0 | | 查询/受理/控制 | HTTP查询P95≤500ms;正常消费时SaaS持久发布→命令持久受理P95≤2s;健康且无不确定发起时控制持久受理→相关屏障applied P95≤2s;失联不能当成功样本 | | 公平SLO | 持续有资格且资源足够的B/C发现活跃后≤2s得首次许可;稳定竞争至少100许可,3等权租户份额偏差≤10个百分点;不足/隔离者单列 | -| AI/文字/录音/清理 | ≥100有效轮次;VAD结束→首个有效TTS送桥P95≤1500ms,插话→停止旧TTS送桥P95≤500ms;最终稿形成→SaaS Mock事务应用P95≤3s;≤180s通话挂断→对象verified/ready消费/授权读取≤120s;正常结束30s内清本次通道/桥/媒体 | +| AI/文字/录音/清理 | ≥100有效轮次;VAD结束→首个有效TTS送桥P95≤1500ms,插话→停止旧TTS送桥P95≤500ms;最终稿形成→SaaS Mock事务应用P95≤3s;≤180s通话挂断→recording.uploaded事实可靠入队/授权读取≤120s;正常结束30s内清本次通道/桥/媒体 | | SCALE-MOCK | ≥2调度实例、100租户×并发12、全局并发1200、模拟CPS20;额外200占用预算用于拨号/振铃/换批,不能计已接通;扩展测试Cell容量并注明,不套DEV容量4;暖机后持续补充新授权执行,≥1000模拟接通维持60min | | 重投/真实规模 | 同ID重投10次仍同一事实;真实完整AI≥1000已接通稳定≥60min,并验证N+1安全容量与故障后新授权补负载;不将断掉的旧execution换ID自动重拨 | @@ -346,8 +346,8 @@ P0冻结前逐行登记阶段、状态、责任人及风险。本轮MQ-only新 | A12 | 静态SIP/运行配置 | 部署交付管理批准制品、D按身份核验目标/版本;原Schema/来源/哈希/凭据引用验证,缺密钥/错mode/任意路径/脚本拒绝;首次实际加载确认前not-ready,不要求R04/R06在线推送 | | A13 | 动态改配/停用 | D固定目标集合、持久发布意图、先关闭准入并收敛必要占用,再ApplyTrunkConfig;每Agent实际加载确认;修改/新增/移除/disabled不逐呼reload、不擅自强挂 | | A14 | 静态失败/人工恢复/单写 | 版本/哈希冲突、写完未加载、部分失败/确认丢失、恢复旧制品失败/旧代次回报均注入;保持真实installed和阻塞,旧管理直写不可与受控静态入口并行;在线自动回滚后续 | -| A15 | D配置文件/临时TOKEN与SaaS最终校验 | D配置文件为OSS唯一配置源,A经Unary领TOKEN且不持长期凭据;缺失/无效失败、过期显式找D,不向SaaS索取配置/TOKEN,保留D签发能力。业务会话/complete/verified仍MQ,SaaS独立校验成功后才ready;不以D本地验证替代、不换资产归属、不泄漏密钥/TOKEN | -| A16 | 实时文字+OSS归档 | transcript.updated/final/opt-out按原时限回SaaS,不等整通话上传;文本归档无批准授权接口时明确未启用,不冒充recording.ready、不生成新event_type;冻结后验证原segment版本/哈希及查看权限 | +| A15 | D配置文件/临时TOKEN与上传事实 | D配置文件为OSS唯一配置源,A经Unary领固定15分钟TOKEN且不持长期凭据;缺失/无效失败、过期显式找D,不向SaaS索取配置/TOKEN,保留D签发能力。成功PUT后D以recording.uploaded可靠入队完成本项目交付;不等待业务会话/complete/verified/OSS ID、不泄漏密钥/TOKEN | +| A16 | 实时文字+OSS归档 | transcript.updated/final/opt-out按原时限回SaaS,不等整通话上传;文本归档无批准授权接口时明确未启用,不冒充recording.uploaded、不生成新event_type;冻结后验证原segment版本/哈希及查看权限 | | A17 | 维护更新与版本校验 | P1维护关闭准入/排空/更新/重新激活后才恢复;只用已验证组合,不兼容阻塞,Proto字段号不复用,构建版本不冒充AI版本;混合N/N-1与在线滚动后续 | | A18 | 退出/断联/收尾 | D退出不强挂Agent已授权通话;A保留文件并可在恢复后补报;SIGTERM先关准入/排空,超时未决状态明确;永久丢盘不承诺零丢失,保留/告警按§9 | | A19 | Endpoint清单增删 | 列表受控版本化;新增先验证/激活/加载再可调度,移除先排空/对账再撤销新任务权限;旧执行事实仍有受控收尾/人工恢复路径,不能简单丢弃或误删资产 | @@ -363,7 +363,7 @@ P0冻结前逐行登记阶段、状态、责任人及风险。本轮MQ-only新 | Endpoint最小启动、共享mTLS | 主方案§5.4–5.5;交互R01–R03/R05 | A03–A06/A19 | | 健康/负载/版本/供应商和配置版本 | 主方案§5.6;交互§9 | A09–A11、E15/E16 | | P1静态配置;在线发布延后 | 主方案§8;交互§12 | A12/A14、E04/E05的P1部分;A13后续 | -| OSS配置在D文件、A向D领临时TOKEN、SaaS最终verified不变 | 主方案§7.3;SaaS↔D契约§6.1;D↔A契约§6.2;交互§10 | A15/A16、E12/E17–E19、L07;配置缺失/无效与显式向D重申请反例 | +| OSS配置在D文件、A向D领临时TOKEN、recording.uploaded可靠入队 | 主方案§7.3;SaaS↔D契约§6.1;D↔A契约§6.2;交互§10 | A15/A16、E12/E17–E19、L07;配置缺失/无效与显式向D重申请反例 | | 全部事件/字段与遗漏 | 交互§1–6/§13;只读字段索引 | A01、C02/C10/C11、GAP-01~09按阶段 | | 单节点/单Agent/单Asterisk、至少3SIP fixture | 主方案§1.1/§5/§8 | P1-02/03、E01、授权组合矩阵 | | 单租户首发;双租户/公平后续 | 主方案§6.2/§10 | P1-07、S18/S08;第二阶段 S06/§9公平profile | diff --git a/gen/agent/v1/agent.pb.go b/gen/agent/v1/agent.pb.go index 6998fcc..125cc38 100644 --- a/gen/agent/v1/agent.pb.go +++ b/gen/agent/v1/agent.pb.go @@ -3751,7 +3751,6 @@ type CompleteUploadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` State UploadState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.UploadState" json:"state,omitempty"` - OssId string `protobuf:"bytes,3,opt,name=oss_id,json=ossId,proto3" json:"oss_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3800,13 +3799,6 @@ func (x *CompleteUploadResponse) GetState() UploadState { return UploadState_UPLOAD_STATE_UNSPECIFIED } -func (x *CompleteUploadResponse) GetOssId() string { - if x != nil { - return x.OssId - } - return "" -} - var File_agent_v1_agent_proto protoreflect.FileDescriptor const file_agent_v1_agent_proto_rawDesc = "" + @@ -4086,11 +4078,10 @@ const file_agent_v1_agent_proto_rawDesc = "" + "\x05asset\x18\x03 \x01(\v2\x19.agent.v1.AssetDescriptorR\x05asset\x12\x1b\n" + "\tupload_id\x18\x04 \x01(\tR\buploadId\x12.\n" + "\x13uploaded_size_bytes\x18\x05 \x01(\x03R\x11uploadedSizeBytes\x128\n" + - "\x18uploaded_checksum_sha256\x18\x06 \x01(\tR\x16uploadedChecksumSha256\"\x92\x01\n" + + "\x18uploaded_checksum_sha256\x18\x06 \x01(\tR\x16uploadedChecksumSha256\"\x89\x01\n" + "\x16CompleteUploadResponse\x124\n" + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12+\n" + - "\x05state\x18\x02 \x01(\x0e2\x15.agent.v1.UploadStateR\x05state\x12\x15\n" + - "\x06oss_id\x18\x03 \x01(\tR\x05ossId*\xa9\x01\n" + + "\x05state\x18\x02 \x01(\x0e2\x15.agent.v1.UploadStateR\x05stateJ\x04\b\x03\x10\x04R\x06oss_id*\xa9\x01\n" + "\n" + "ResultCode\x12\x1b\n" + "\x17RESULT_CODE_UNSPECIFIED\x10\x00\x12\x18\n" + diff --git a/go.mod b/go.mod index 360dc0c..5b06d66 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/CyCoreSystems/ari/v5 v5.3.1 github.com/GizClaw/doubao-speech-go v0.0.0-20260915022405-e38c14802696 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 + github.com/google/uuid v1.6.0 github.com/openai/openai-go/v3 v3.62.0 github.com/pion/rtp v1.10.5 github.com/pion/rtp/v2 v2.0.0 @@ -14,20 +15,20 @@ require ( github.com/shirou/gopsutil/v4 v4.26.8 github.com/spf13/cobra v1.10.1 github.com/zaf/g711 v1.4.0 + golang.org/x/time v0.4.0 google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.12 modernc.org/sqlite v1.59.0 - golang.org/x/time v0.4.0 ) require ( github.com/coder/websocket v1.8.15 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 459f691..93ade87 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/agent/events.go b/internal/agent/events.go index 2e4ea66..8eb8003 100644 --- a/internal/agent/events.go +++ b/internal/agent/events.go @@ -37,24 +37,6 @@ func (w EventWriter) TranscriptUpdated(now time.Time, eventID, callID, turnID, s }).Marshal(now, eventID) } -func (w EventWriter) RecordingReady(now time.Time, eventID, callID, recordingID, ossID, format string, channels int32, sampleRateHz int32, durationMS, sizeBytes int64, checksum string) ([]byte, error) { - if eventID == "" || callID == "" || recordingID == "" || ossID == "" || checksum == "" { - return nil, errors.New("verified recording identity and checksum are required") - } - if sizeBytes < 1 || durationMS < 0 || channels != 1 || sampleRateHz < 8000 { - return nil, errors.New("recording metadata is invalid") - } - return (contract.EventBuilder{ - TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID, - EventType: "recording.ready", Aggregate: "recording", AggregateID: recordingID, Version: 1, - Payload: map[string]any{ - "call_id": callID, "recording_id": recordingID, "oss_id": ossID, - "format": format, "channels": channels, "sample_rate_hz": sampleRateHz, - "duration_ms": durationMS, "size_bytes": sizeBytes, "checksum_sha256": checksum, - }, - }).Marshal(now, eventID) -} - func (s *Spool) AppendApprovedEvent(executionID string, event []byte) error { var envelope struct { EventType string `json:"event_type"` diff --git a/internal/agent/events_test.go b/internal/agent/events_test.go index 5a60d0d..f9856b5 100644 --- a/internal/agent/events_test.go +++ b/internal/agent/events_test.go @@ -33,11 +33,7 @@ func TestEventWriterBuildsApprovedRealtimeTranscript(t *testing.T) { } } -func TestEventWriterRejectsUnverifiedRecordingAndWrongArchiveEvent(t *testing.T) { - writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"} - if _, err := writer.RecordingReady(time.Unix(100, 0), "event-1", "call-1", "recording-1", "", "wav", 1, 16000, 1000, 100, strings.Repeat("a", 64)); err == nil { - t.Fatal("expected missing OSS verification ID to be rejected") - } +func TestEventWriterRejectsLegacyRecordingEventAndWrongArchiveEvent(t *testing.T) { spool, err := NewSpool(t.TempDir(), time.Now) if err != nil { t.Fatal(err) @@ -48,4 +44,7 @@ func TestEventWriterRejectsUnverifiedRecordingAndWrongArchiveEvent(t *testing.T) if err := spool.AppendApprovedEvent("execution-1", []byte(`{"event_type":"call.transcript"}`)); err == nil { t.Fatal("expected invalid realtime event name to be rejected") } + if err := spool.AppendApprovedEvent("execution-1", []byte(`{"event_type":"recording.ready"}`)); err == nil { + t.Fatal("legacy recording.ready must not enter the Agent archive") + } } diff --git a/internal/agent/spool.go b/internal/agent/spool.go index 4ef7994..f71093c 100644 --- a/internal/agent/spool.go +++ b/internal/agent/spool.go @@ -233,13 +233,14 @@ func writeJSONAtomic(path string, value any) error { if err != nil { return err } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + file, err := os.CreateTemp(filepath.Dir(path), ".state-*.tmp") + if err != nil { return err } - file, err := os.OpenFile(tmp, os.O_RDWR, 0o600) - if err != nil { - _ = os.Remove(tmp) + tmp := file.Name() + defer os.Remove(tmp) + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() return err } if err := file.Sync(); err != nil { @@ -255,7 +256,7 @@ func writeJSONAtomic(path string, value any) error { _ = os.Remove(tmp) return err } - return nil + return syncDirectory(filepath.Dir(path)) } func validateName(name string) error { diff --git a/internal/agent/upload.go b/internal/agent/upload.go index 246b005..711900b 100644 --- a/internal/agent/upload.go +++ b/internal/agent/upload.go @@ -6,11 +6,13 @@ import ( "encoding/hex" "errors" "fmt" + "hash" "io" "net/http" "net/url" "os" "strings" + "sync" "time" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" @@ -19,7 +21,7 @@ import ( // UploadClient performs the Agent-direct data-plane upload using a restricted // Dispatcher grant. It never sends file bytes through Dispatcher or writes an // OSS credential to logs. It never deletes or moves the source asset; the -// lifecycle owner retains it until the verified handoff is durably recorded. +// lifecycle owner retains it until upload-fact delivery to MQ is durably recorded. type UploadClient struct { HTTPClient *http.Client AllowedHosts map[string]struct{} @@ -86,29 +88,26 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant _ = file.Close() return UploadResult{}, fmt.Errorf("asset exceeds grant limit: %d > %d", stat.Size(), grant.MaxBytes) } + defer func() { + if closeErr := file.Close(); closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { + result = UploadResult{} + err = errors.Join(err, closeErr) + } + }() digest, err := digestFile(file) - closeErr := file.Close() if err != nil { return UploadResult{}, err } - if closeErr != nil { - return UploadResult{}, closeErr - } if grant.RequiredChecksumSha256 != "" && !strings.EqualFold(grant.RequiredChecksumSha256, digest) { return UploadResult{}, errors.New("asset checksum does not match upload grant") } - file, err = os.Open(path) - if err != nil { + if _, err := file.Seek(0, io.SeekStart); err != nil { return UploadResult{}, err } - defer func() { - if closeErr := file.Close(); err == nil && closeErr != nil && !errors.Is(closeErr, os.ErrClosed) { - result = UploadResult{} - err = closeErr - } - }() - req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), file) + transmitted := &uploadChecksum{hash: sha256.New()} + body := io.TeeReader(io.LimitReader(file, stat.Size()), transmitted) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, parsed.String(), body) if err != nil { return UploadResult{}, err } @@ -127,6 +126,12 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } resp, err := copyClient.Do(req) if err != nil { + // net/http includes the entire signed URL in *url.Error. Retain the + // underlying transport cause without exposing the temporary token. + var requestError *url.Error + if errors.As(err, &requestError) { + return UploadResult{}, fmt.Errorf("upload PUT transport failure: %w", requestError.Err) + } return UploadResult{}, err } defer resp.Body.Close() @@ -138,8 +143,35 @@ func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)) return UploadResult{}, fmt.Errorf("upload returned HTTP %d", resp.StatusCode) } - _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)) - return UploadResult{StatusCode: resp.StatusCode, SizeBytes: stat.Size(), SHA256: digest, ETag: resp.Header.Get("ETag")}, nil + if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)); err != nil { + return UploadResult{}, fmt.Errorf("read upload response: %w", err) + } + sentDigest, sentBytes := transmitted.result() + if sentBytes != stat.Size() || sentDigest != digest { + return UploadResult{}, errors.New("transmitted upload bytes do not match the validated asset") + } + return UploadResult{StatusCode: resp.StatusCode, SizeBytes: sentBytes, SHA256: sentDigest, ETag: resp.Header.Get("ETag")}, nil +} + +// HTTP transports may still be writing the request when response headers arrive. +// Synchronize observation so early responses cannot race checksum calculation. +type uploadChecksum struct { + mu sync.Mutex + hash hash.Hash + bytes int64 +} + +func (c *uploadChecksum) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + n, err := c.hash.Write(p) + c.bytes += int64(n) + return n, err +} +func (c *uploadChecksum) result() (string, int64) { + c.mu.Lock() + defer c.mu.Unlock() + return hex.EncodeToString(c.hash.Sum(nil)), c.bytes } func digestFile(file *os.File) (string, error) { diff --git a/internal/agent/upload_integrity_test.go b/internal/agent/upload_integrity_test.go new file mode 100644 index 0000000..174053b --- /dev/null +++ b/internal/agent/upload_integrity_test.go @@ -0,0 +1,49 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +type uploadTransportFunc func(*http.Request) (*http.Response, error) + +func (f uploadTransportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestUploadDoesNotReportPreReadDigestForChangedBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "recording") + if err := os.WriteFile(path, []byte("original"), 0600); err != nil { + t.Fatal(err) + } + sum := sha256.Sum256([]byte("original")) + attempts := 0 + client := &http.Client{Transport: uploadTransportFunc(func(r *http.Request) (*http.Response, error) { + attempts++ + if err := os.WriteFile(path, []byte("modified"), 0600); err != nil { + return nil, err + } + if _, err := io.Copy(io.Discard, r.Body); err != nil { + return nil, err + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + })} + grant := &agentv1.UploadGrant{UploadId: "upload-a", ObjectKey: "recording", TargetUrl: "https://oss.invalid/object", MaxBytes: 8, RequiredChecksumSha256: hex.EncodeToString(sum[:]), ExpiresAtUnixMs: time.Now().Add(time.Minute).UnixMilli()} + if _, err := (UploadClient{HTTPClient: client}).UploadFile(context.Background(), grant, path); err == nil { + t.Fatal("reported original checksum after sending changed bytes") + } + if attempts != 1 { + t.Fatal("integrity failure retried PUT") + } + if _, err := os.Stat(path); err != nil { + t.Fatal("source file removed") + } +} diff --git a/internal/agent/upload_lock.go b/internal/agent/upload_lock.go new file mode 100644 index 0000000..75d4b52 --- /dev/null +++ b/internal/agent/upload_lock.go @@ -0,0 +1,47 @@ +package agent + +import ( + "errors" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +var ErrUploadBusy = errors.New("upload is already being handled by another process") + +type UploadLock struct{ file *os.File } + +// LockUpload uses an OS lock released on process death. The stable lock inode +// is never removed, so independent processes cannot lock different inodes. +func (s *Spool) LockUpload(id string) (*UploadLock, error) { + if err := validateName(id); err != nil { + return nil, err + } + root := filepath.Join(s.root, ".upload-locks") + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + file, err := os.OpenFile(filepath.Join(root, id), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + closeErr := file.Close() + if errors.Is(err, unix.EWOULDBLOCK) { + return nil, errors.Join(ErrUploadBusy, closeErr) + } + return nil, errors.Join(err, closeErr) + } + return &UploadLock{file: file}, nil +} + +func (l *UploadLock) Close() error { + if l == nil || l.file == nil { + return nil + } + unlockErr := unix.Flock(int(l.file.Fd()), unix.LOCK_UN) + closeErr := l.file.Close() + l.file = nil + return errors.Join(unlockErr, closeErr) +} diff --git a/internal/agent/upload_lock_test.go b/internal/agent/upload_lock_test.go new file mode 100644 index 0000000..5338bba --- /dev/null +++ b/internal/agent/upload_lock_test.go @@ -0,0 +1,35 @@ +package agent + +import ( + "errors" + "testing" +) + +func TestUploadLockSerializesIndependentSpools(t *testing.T) { + root := t.TempDir() + first, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + second, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + lock, err := first.LockUpload("upload-a") + if err != nil { + t.Fatal(err) + } + if _, err := second.LockUpload("upload-a"); !errors.Is(err, ErrUploadBusy) { + t.Fatalf("parallel writer accepted: %v", err) + } + if err := lock.Close(); err != nil { + t.Fatal(err) + } + next, err := second.LockUpload("upload-a") + if err != nil { + t.Fatal(err) + } + if err := next.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/agent/upload_retry_test.go b/internal/agent/upload_retry_test.go new file mode 100644 index 0000000..dc6b847 --- /dev/null +++ b/internal/agent/upload_retry_test.go @@ -0,0 +1,30 @@ +package agent + +import "testing" + +func TestExplicitRetryReservesEachRequestOnlyOnce(t *testing.T) { + spool, err := NewSpool(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + record := UploadAttempt{UploadID: "upload-a", RequestID: "first-request", Identity: "identity-a", State: "attempted"} + if err := spool.ClaimUpload(record); err != nil { + t.Fatal(err) + } + for _, request := range []string{"second-request", "third-request"} { + if err := spool.ReserveUploadRetry(record.UploadID, request); err != nil { + t.Fatal(err) + } + } + for _, request := range []string{"first-request", "second-request", "third-request"} { + if err := spool.ReserveUploadRetry(record.UploadID, request); err == nil { + t.Fatalf("request %s reused", request) + } + } + if err := spool.RecordUploadResult(record.UploadID, UploadResult{SizeBytes: 4, SHA256: "checksum", StatusCode: 200}); err != nil { + t.Fatal(err) + } + if err := spool.ReserveUploadRetry(record.UploadID, "fourth-request"); err == nil { + t.Fatal("successful PUT was authorized again") + } +} diff --git a/internal/agent/upload_state.go b/internal/agent/upload_state.go new file mode 100644 index 0000000..bd38a35 --- /dev/null +++ b/internal/agent/upload_state.go @@ -0,0 +1,192 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +// UploadAttempt records no signed URLs or credentials. An attempted PUT whose +// result is unknown must never be repeated by restart recovery. +type UploadAttempt struct { + RequestID string `json:"request_id"` + UploadID string `json:"upload_id"` + Identity string `json:"identity"` + State string `json:"state"` + ObjectKey string `json:"object_key"` + Result UploadResult `json:"result"` + Binding *agentv1.ExecutionBinding `json:"binding"` + Asset *agentv1.AssetDescriptor `json:"asset"` +} + +func (s *Spool) uploadAttemptDir(id string) string { return filepath.Join(s.root, ".uploads", id) } + +func (s *Spool) ClaimUpload(record UploadAttempt) error { + if err := validateName(record.UploadID); err != nil { + return err + } + if record.RequestID != "" { + if err := validateName(record.RequestID); err != nil { + return err + } + } + if record.Identity == "" || record.State != "attempted" { + return errors.New("upload attempt identity and attempted state are required") + } + root := filepath.Join(s.root, ".uploads") + if err := os.MkdirAll(root, 0700); err != nil { + return err + } + // Exclusive directory creation arbitrates across processes, not just goroutines. + dir := s.uploadAttemptDir(record.UploadID) + if err := os.Mkdir(dir, 0700); err != nil { + return err + } + if err := syncDirectory(s.root); err != nil { + return err + } + if err := syncDirectory(root); err != nil { + return err + } + if record.RequestID != "" { + requests := filepath.Join(dir, "requests") + if err := os.Mkdir(requests, 0700); err != nil { + return err + } + if err := os.Mkdir(filepath.Join(requests, record.RequestID), 0700); err != nil { + return err + } + if err := syncDirectory(requests); err != nil { + return err + } + } + return writeJSONAtomic(filepath.Join(dir, "state.json"), record) +} + +// ReserveUploadRetry is used only for an explicit new request, while holding +// LockUpload. A consumed request identity is never made reusable after a crash. +func (s *Spool) ReserveUploadRetry(id, requestID string) error { + if err := validateName(requestID); err != nil { + return err + } + record, err := s.LoadUploadAttempt(id) + if err != nil { + return err + } + if record.State != "attempted" || record.RequestID == "" || requestID == record.RequestID { + return errors.New("only an unsuccessful attempt can use an explicit new request") + } + requests := filepath.Join(s.uploadAttemptDir(id), "requests") + if err := os.Mkdir(filepath.Join(requests, requestID), 0700); err != nil { + return err + } + if err := syncDirectory(requests); err != nil { + return err + } + record.RequestID = requestID + record.Result = UploadResult{} + return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) +} + +func (s *Spool) LoadUploadAttempt(id string) (UploadAttempt, error) { + if err := validateName(id); err != nil { + return UploadAttempt{}, err + } + dir := s.uploadAttemptDir(id) + data, err := os.ReadFile(filepath.Join(dir, "state.json")) + if errors.Is(err, os.ErrNotExist) { + if _, statErr := os.Stat(dir); statErr == nil { + return UploadAttempt{}, errors.New("upload attempt exists without durable state; PUT outcome is unknown") + } + } + if err != nil { + return UploadAttempt{}, err + } + var record UploadAttempt + if err := json.Unmarshal(data, &record); err != nil { + return record, err + } + if record.UploadID != id || record.Identity == "" { + return record, errors.New("invalid persisted upload identity") + } + switch record.State { + case "attempted", "uploaded", "completed": + default: + return record, fmt.Errorf("invalid persisted upload state %q", record.State) + } + return record, nil +} + +func (s *Spool) RecordUploadResult(id string, result UploadResult) error { + s.mu.Lock() + defer s.mu.Unlock() + record, err := s.LoadUploadAttempt(id) + if err != nil { + return err + } + if record.State != "attempted" { + return errors.New("only an attempted upload may record its PUT result") + } + if result.SizeBytes <= 0 || result.SHA256 == "" || result.StatusCode < 200 || result.StatusCode >= 300 { + return errors.New("successful upload result is required") + } + record.State, record.Result = "uploaded", result + return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) +} + +func (s *Spool) CompleteUploadNotification(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + record, err := s.LoadUploadAttempt(id) + if err != nil { + return err + } + if record.State != "uploaded" && record.State != "completed" { + return errors.New("upload result must precede notification completion") + } + record.State = "completed" + return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record) +} + +// PendingUploadNotifications enumerates only successful PUTs. Unknown attempts +// remain reserved and are never returned as work to retry. +func (s *Spool) PendingUploadNotifications() ([]UploadAttempt, error) { + entries, err := os.ReadDir(filepath.Join(s.root, ".uploads")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var pending []UploadAttempt + for _, entry := range entries { + if !entry.IsDir() { + return nil, fmt.Errorf("unexpected upload journal entry %q", entry.Name()) + } + record, err := s.LoadUploadAttempt(entry.Name()) + if err != nil { + return nil, err + } + if record.State != "uploaded" { + continue + } + if record.Binding == nil || record.Asset == nil { + return nil, fmt.Errorf("upload %q lacks notification metadata", record.UploadID) + } + pending = append(pending, record) + } + return pending, nil +} + +func syncDirectory(path string) error { + dir, err := os.Open(path) + if err != nil { + return err + } + syncErr := dir.Sync() + return firstError(syncErr, dir.Close()) +} diff --git a/internal/agent/upload_state_test.go b/internal/agent/upload_state_test.go new file mode 100644 index 0000000..502acc8 --- /dev/null +++ b/internal/agent/upload_state_test.go @@ -0,0 +1,50 @@ +package agent + +import ( + "errors" + "os" + "testing" +) + +func TestUploadAttemptSurvivesRestartWithoutAnotherPUT(t *testing.T) { + root := t.TempDir() + spool, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + record := UploadAttempt{UploadID: "upload-a", Identity: "identity-a", State: "attempted"} + if err := spool.ClaimUpload(record); err != nil { + t.Fatal(err) + } + restarted, err := NewSpool(root, nil) + if err != nil { + t.Fatal(err) + } + if err := restarted.ClaimUpload(record); !errors.Is(err, os.ErrExist) { + t.Fatalf("duplicate PUT was not prevented: %v", err) + } + recovered, err := restarted.LoadUploadAttempt("upload-a") + if err != nil { + t.Fatal(err) + } + if recovered.State != "attempted" { + t.Fatal("unknown PUT attempt lost") + } + result := UploadResult{SizeBytes: 10, SHA256: "checksum", StatusCode: 200} + if err := restarted.RecordUploadResult("upload-a", result); err != nil { + t.Fatal(err) + } + recovered, err = spool.LoadUploadAttempt("upload-a") + if err != nil { + t.Fatal(err) + } + if recovered.State != "uploaded" || recovered.Result != result { + t.Fatal("notification recovery lost original upload result") + } + if err := spool.CompleteUploadNotification("upload-a"); err != nil { + t.Fatal(err) + } + if err := spool.RecordUploadResult("upload-a", result); err == nil { + t.Fatal("completed upload regressed") + } +} diff --git a/internal/agent/upload_test.go b/internal/agent/upload_test.go index b797ccb..8a6817e 100644 --- a/internal/agent/upload_test.go +++ b/internal/agent/upload_test.go @@ -16,6 +16,36 @@ import ( agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" ) +func TestUploadTransportFailureIsSingleAttemptAndRedactsSignedURL(t *testing.T) { + path := filepath.Join(t.TempDir(), "recording.bin") + if err := os.WriteFile(path, []byte("bytes"), 0600); err != nil { + t.Fatal(err) + } + var attempts int + client := &http.Client{Transport: uploadFailureTransport{calls: &attempts}} + grant := &agentv1.UploadGrant{UploadId: "upload-error", ObjectKey: "recording", TargetUrl: "https://oss.example.invalid/object?signature=DO_NOT_LOG", MaxBytes: 5, ExpiresAtUnixMs: time.Now().Add(time.Minute).UnixMilli()} + _, err := (UploadClient{HTTPClient: client}).UploadFile(context.Background(), grant, path) + if err == nil { + t.Fatal("transport failure hidden") + } + if strings.Contains(err.Error(), "DO_NOT_LOG") || strings.Contains(err.Error(), "signature=") { + t.Fatal("signed URL leaked in error") + } + if attempts != 1 { + t.Fatalf("upload attempted %d times", attempts) + } + if _, err := os.Stat(path); err != nil { + t.Fatal("failed upload lost its source file") + } +} + +type uploadFailureTransport struct{ calls *int } + +func (t uploadFailureTransport) RoundTrip(_ *http.Request) (*http.Response, error) { + *t.calls++ + return nil, io.ErrUnexpectedEOF +} + func TestUploadClientUsesGrantAndVerifiesChecksum(t *testing.T) { body := []byte("mock recording bytes") digest := sha256.Sum256(body) diff --git a/internal/ai/authorization.go b/internal/ai/authorization.go index 3e8093a..99a1de2 100644 --- a/internal/ai/authorization.go +++ b/internal/ai/authorization.go @@ -25,7 +25,9 @@ type Authorization struct { RevocationReason string `json:"revocation_reason"` } -func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, egressPoolID string, now time.Time) (Authorization, error) { +// DecodeBoundAuthorization validates identity and immutable configuration binding. +// It deliberately preserves revoked/expired grants as facts, not permissions. +func DecodeBoundAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey string) (Authorization, error) { if err := contract.ValidateSourceSchema("ai-authorization.schema.json", raw); err != nil { return Authorization{}, err } @@ -33,9 +35,6 @@ func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, e if err := json.Unmarshal(raw, &authorization); err != nil { return Authorization{}, err } - if authorization.Revoked { - return Authorization{}, errors.New("AI authorization is revoked") - } if authorization.TenantID != tenantID || authorization.TenantKey != tenantKey { return Authorization{}, errors.New("AI authorization tenant binding mismatch") } @@ -50,7 +49,29 @@ func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, e if err != nil { return Authorization{}, fmt.Errorf("parse AI authorization expires_at: %w", err) } - if !issuedAt.Before(expiresAt) || now.Before(issuedAt) || !now.Before(expiresAt) { + if !issuedAt.Before(expiresAt) { + return Authorization{}, errors.New("AI authorization has an invalid validity window") + } + return authorization, nil +} + +func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, egressPoolID string, now time.Time) (Authorization, error) { + authorization, err := DecodeBoundAuthorization(raw, snapshot, tenantID, tenantKey) + if err != nil { + return Authorization{}, err + } + if authorization.Revoked { + return Authorization{}, errors.New("AI authorization is revoked") + } + issuedAt, err := time.Parse(time.RFC3339, authorization.IssuedAt) + if err != nil { + return Authorization{}, err + } + expiresAt, err := time.Parse(time.RFC3339, authorization.ExpiresAt) + if err != nil { + return Authorization{}, err + } + if now.Before(issuedAt) || !now.Before(expiresAt) { return Authorization{}, errors.New("AI authorization is outside its validity window") } if egressPoolID != "" { diff --git a/internal/ai/authorization_test.go b/internal/ai/authorization_test.go index e2461ea..366a9ad 100644 --- a/internal/ai/authorization_test.go +++ b/internal/ai/authorization_test.go @@ -16,7 +16,7 @@ func TestValidateAuthorizationBindsSnapshotTenantAndEgress(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v3/examples/ai-authorization.json") if err != nil { t.Fatal(err) } @@ -45,7 +45,7 @@ func TestValidateAuthorizationRejectsMismatchAndExpiry(t *testing.T) { if _, err := ValidateAuthorization(invalid, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { t.Fatal("expected revoked authorization rejection") } - valid, err := contracts.Read("examples/ai-authorization.json") + valid, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v3/examples/ai-authorization.json") if err != nil { t.Fatal(err) } diff --git a/internal/ai/llm_sdk_test.go b/internal/ai/llm_sdk_test.go new file mode 100644 index 0000000..61d3d0b --- /dev/null +++ b/internal/ai/llm_sdk_test.go @@ -0,0 +1,77 @@ +package ai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestLLMSDKPreservesExplicitParametersWithoutRetry(t *testing.T) { + for _, code := range []int{http.StatusOK, http.StatusInternalServerError} { + t.Run(http.StatusText(code), func(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != "POST" || r.URL.Path != "/v1/chat/completions" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + } + var body struct { + Model string `json:"model"` + Temperature *float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + Messages []struct{ Role, Content string } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body.Model != "approved-model" || body.Temperature == nil || *body.Temperature != 0 || body.MaxTokens != 17 || len(body.Messages) != 2 { + t.Errorf("explicit configuration not transmitted: %+v", body) + } + if len(body.Messages) == 2 && (body.Messages[0].Role != "system" || body.Messages[0].Content != "approved system" || body.Messages[1].Content != "synthetic input") { + t.Error("message content changed") + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if code == http.StatusOK { + _, _ = w.Write([]byte(`{"id":"local","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":" local reply "},"finish_reason":"stop"}]}`)) + } else { + _, _ = w.Write([]byte(`{"error":{"message":"injected failure","type":"server_error"}}`)) + } + })) + defer server.Close() + pipeline := &ProviderPipeline{cfg: ProviderPipelineConfig{BailianAPIKey: "isolated-test-key", BailianBaseURL: server.URL + "/v1"}} + result, err := pipeline.complete(context.Background(), "approved-model", 0, 17, "approved system", "synthetic input") + if code == http.StatusOK && (err != nil || result != "local reply") { + t.Fatalf("response %q: %v", result, err) + } + if code != http.StatusOK && err == nil { + t.Fatal("provider failure hidden") + } + if calls.Load() != 1 { + t.Fatalf("automatic request retry: %d", calls.Load()) + } + }) + } +} + +func TestLLMSDKRejectsEmptyChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if len(body["messages"].([]any)) != 1 { + t.Error("invented system message") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"local","object":"chat.completion","choices":[]}`)) + })) + defer server.Close() + pipeline := &ProviderPipeline{cfg: ProviderPipelineConfig{BailianAPIKey: "isolated-test-key", BailianBaseURL: server.URL + "/v1"}} + if _, err := pipeline.complete(context.Background(), "approved-model", 0, 17, "", "synthetic input"); err == nil { + t.Fatal("missing choices reported success") + } +} diff --git a/internal/ai/mq_config.go b/internal/ai/mq_config.go new file mode 100644 index 0000000..4db089a --- /dev/null +++ b/internal/ai/mq_config.go @@ -0,0 +1,74 @@ +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +// ValidateConfigResponse binds an incoming configuration to the original, +// persisted request. It does not infer authorization from a published version. +func ValidateConfigResponse(raw []byte, request contract.ServiceMessage, now time.Time) (Snapshot, error) { + if request.MessageType != "ai.config.request" { + return Snapshot{}, errors.New("expected original AI configuration request") + } + response, err := contract.DecodeService(raw) + if err != nil { + return Snapshot{}, err + } + if response.MessageType != "ai.config.result" || response.CorrelationID != request.MessageID || response.DispatcherID != request.DispatcherID || response.TenantID != request.TenantID || response.TenantKey != request.TenantKey { + return Snapshot{}, errors.New("AI configuration response binding mismatch") + } + deadline, err := time.Parse(time.RFC3339Nano, request.NotAfter) + if err != nil { + return Snapshot{}, fmt.Errorf("AI request deadline: %w", err) + } + issued, err := time.Parse(time.RFC3339Nano, request.IssuedAt) + if err != nil { + return Snapshot{}, err + } + responseTime, err := time.Parse(time.RFC3339Nano, response.IssuedAt) + if err != nil { + return Snapshot{}, err + } + if !now.Before(deadline) || now.Before(issued) || responseTime.Before(issued) || responseTime.After(now) { + return Snapshot{}, errors.New("AI configuration response is outside the request window") + } + if response.Status != "ok" { + return Snapshot{}, fmt.Errorf("AI configuration request rejected: %s", response.ReasonCode) + } + var requested struct { + AgentVersionID string `json:"agent_version_id"` + } + if err := json.Unmarshal(request.Payload, &requested); err != nil { + return Snapshot{}, err + } + var payload struct { + Snapshot struct { + AgentVersionID string `json:"agent_version_id"` + Status string `json:"status"` + TenantID string `json:"tenant_id"` + Immutable bool `json:"immutable"` + ContentSHA256 string `json:"content_sha256"` + Config json.RawMessage `json:"config"` + } `json:"snapshot"` + } + if err := json.Unmarshal(response.Payload, &payload); err != nil { + return Snapshot{}, err + } + if (payload.Snapshot.Status != "published" && payload.Snapshot.Status != "reused") || payload.Snapshot.AgentVersionID != requested.AgentVersionID || payload.Snapshot.TenantID != request.TenantID || !payload.Snapshot.Immutable { + return Snapshot{}, errors.New("AI snapshot publication, version, tenant or immutability mismatch") + } + snapshot, err := Validate(payload.Snapshot.Config) + if err != nil { + return Snapshot{}, err + } + if snapshot.AgentVersionID != requested.AgentVersionID || snapshot.Digest != payload.Snapshot.ContentSHA256 { + return Snapshot{}, errors.New("AI configuration version or canonical digest mismatch") + } + snapshot.TenantKey = request.TenantKey + return snapshot, nil +} diff --git a/internal/ai/mq_config_test.go b/internal/ai/mq_config_test.go new file mode 100644 index 0000000..ebe4f87 --- /dev/null +++ b/internal/ai/mq_config_test.go @@ -0,0 +1,71 @@ +package ai + +import ( + "encoding/json" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +func TestMQConfigurationMatchesOriginalRequestAndCanonicalDigest(t *testing.T) { + base := "upstream/" + contract.MQSourceCommit + "/examples/" + requestRaw, err := contracts.Files.ReadFile(base + "ai-config-request.json") + if err != nil { + t.Fatal(err) + } + request, err := contract.DecodeService(requestRaw) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile(base + "ai-config-result.json") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 21, 0, 0, 1, 0, time.UTC) + snapshot, err := ValidateConfigResponse(raw, request, now) + if err != nil { + t.Fatal(err) + } + if snapshot.TenantKey != request.TenantKey || snapshot.AgentVersionID == "" { + t.Fatal("configuration lost request binding") + } + for _, field := range []string{"dispatcher_id", "tenant_id", "tenant_key", "correlation_id"} { + var changed map[string]any + if err := json.Unmarshal(raw, &changed); err != nil { + t.Fatal(err) + } + changed[field] = "incorrect" + encoded, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + if _, err := ValidateConfigResponse(encoded, request, now); err == nil { + t.Fatalf("mismatched %s accepted", field) + } + } + var changed map[string]any + if err := json.Unmarshal(raw, &changed); err != nil { + t.Fatal(err) + } + changed["payload"].(map[string]any)["snapshot"].(map[string]any)["status"] = "reused" + reused, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + if _, err := ValidateConfigResponse(reused, request, now); err != nil { + t.Fatalf("published immutable version reuse rejected: %v", err) + } + changed["payload"].(map[string]any)["snapshot"].(map[string]any)["content_sha256"] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + encoded, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + if _, err := ValidateConfigResponse(encoded, request, now); err == nil { + t.Fatal("forged digest accepted") + } + if _, err := ValidateConfigResponse(raw, request, now.Add(time.Hour)); err == nil { + t.Fatal("late response accepted") + } +} diff --git a/internal/ai/provider_pipeline_edge_test.go b/internal/ai/provider_pipeline_edge_test.go new file mode 100644 index 0000000..3dd8d28 --- /dev/null +++ b/internal/ai/provider_pipeline_edge_test.go @@ -0,0 +1,168 @@ +package ai + +import ( + "context" + "encoding/binary" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func providerSnapshotRaw(mode string, asr, llm, tts bool) []byte { + value := map[string]any{ + "mode": mode, + "prompt": map[string]any{"text": "approved system"}, + "asr": map[string]any{"provider_ref": "asr-ref", "model": "asr-model", "language": "zh-CN", "timeout_ms": 50}, + "llm": map[string]any{"provider_ref": "llm-ref", "model": "llm-model", "temperature": 0.0, "max_tokens": 17, "timeout_ms": 50}, + "tts": map[string]any{"provider_ref": "tts-ref", "model": "tts-model", "voice": "voice-a", "speed": 1.0, "timeout_ms": 50}, + } + if !asr { + delete(value["asr"].(map[string]any), "provider_ref") + } + if !llm { + delete(value["llm"].(map[string]any), "provider_ref") + } + if !tts { + delete(value["tts"].(map[string]any), "provider_ref") + } + raw, _ := json.Marshal(value) + return raw +} + +func wavPCM16(sampleRate int, pcm []byte) []byte { + data := make([]byte, 44+len(pcm)) + copy(data[:4], "RIFF") + binary.LittleEndian.PutUint32(data[4:8], uint32(len(data)-8)) + copy(data[8:12], "WAVE") + copy(data[12:16], "fmt ") + binary.LittleEndian.PutUint32(data[16:20], 16) + binary.LittleEndian.PutUint16(data[20:22], 1) + binary.LittleEndian.PutUint16(data[22:24], 1) + binary.LittleEndian.PutUint32(data[24:28], uint32(sampleRate)) + binary.LittleEndian.PutUint32(data[28:32], uint32(sampleRate*2)) + binary.LittleEndian.PutUint16(data[32:34], 2) + binary.LittleEndian.PutUint16(data[34:36], 16) + copy(data[36:40], "data") + binary.LittleEndian.PutUint32(data[40:44], uint32(len(pcm))) + copy(data[44:], pcm) + return data +} + +func TestProviderPipelineFullTurnUsesConfiguredLLMAndTTS(t *testing.T) { + pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0} + wav := wavPCM16(16000, pcm) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/chat/completions"): + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`)) + case strings.HasSuffix(r.URL.Path, "/generation"): + _, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + "PLACEHOLDER" + `"}}}`)) + case r.URL.Path == "/audio.wav": + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(wav) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + // Replace the placeholder without putting a second server or a public URL in + // the fixture; the provider still uses the same bounded test HTTP client. + server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.HasSuffix(r.URL.Path, "/chat/completions"): + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"approved reply"}}]}`)) + case strings.HasSuffix(r.URL.Path, "/generation"): + _, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + server.URL + `/audio.wav"}}}`)) + case r.URL.Path == "/audio.wav": + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(wav) + default: + http.NotFound(w, r) + } + }) + pipeline := &ProviderPipeline{cfg: ProviderPipelineConfig{ + VolcAppID: "asr-app", VolcAPIKey: "asr-key", BailianAPIKey: "llm-key", + BailianBaseURL: server.URL + "/v1", HTTPClient: server.Client(), MaxAudioBytes: 1024, + }} + pipeline.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "approved transcript", nil } + result, err := pipeline.RunTurn(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, []byte{1, 2}) + if err != nil { + t.Fatal(err) + } + if result.Transcript != "approved transcript" || result.Reply != "approved reply" || string(result.AudioPCM16) != string(pcm) { + t.Fatalf("unexpected full turn result: %+v", result) + } +} + +func TestProviderPipelineRejectsInvalidTurnInputs(t *testing.T) { + base := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key"}} + base.recognizeFn = func(context.Context, string, string, []byte) (string, error) { return "", nil } + cases := []struct { + name string + pipeline *ProviderPipeline + snapshot Snapshot + pcm []byte + want string + }{ + {"empty pcm", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, nil, "input PCM is empty"}, + {"invalid mode", base, Snapshot{Mode: Mode("invalid"), Raw: providerSnapshotRaw("invalid", true, false, false)}, []byte{1}, "unsupported AI mode"}, + {"bad snapshot", base, Snapshot{Mode: ModeASROnly, Raw: []byte("{")}, []byte{1}, "decode immutable AI snapshot"}, + {"missing asr", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", false, false, false)}, []byte{1}, "ASR provider ref is required"}, + {"empty transcript", base, Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}, "ASR returned empty transcript"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := tc.pipeline.RunTurn(context.Background(), tc.snapshot, tc.pcm); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v, want %q", err, tc.want) + } + }) + } + failingASR := &ProviderPipeline{cfg: base.cfg, recognizeFn: func(context.Context, string, string, []byte) (string, error) { return "", context.DeadlineExceeded }} + if _, err := failingASR.RunTurn(context.Background(), Snapshot{Mode: ModeASROnly, Raw: providerSnapshotRaw("asr_only", true, false, false)}, []byte{1}); err == nil || !strings.Contains(err.Error(), "ASR failed") { + t.Fatalf("ASR error was hidden: %v", err) + } +} + +func TestProviderPipelineWAVValidationAndResampling(t *testing.T) { + pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0} + for _, tc := range []struct { + name string + data []byte + want string + }{ + {"short", []byte("RIFF"), "not RIFF/WAVE"}, + {"wrong container", []byte("RIFFxxxxNOPE"), "not RIFF/WAVE"}, + {"bad fmt", append([]byte("RIFFxxxxWAVEfmt "), 2, 0, 0, 0, 0, 0), "invalid WAV fmt"}, + {"bad format", wavPCM16(16000, pcm)[:44], "unsupported WAV format"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := decodeWAVToPCM16(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v, want %q", err, tc.want) + } + }) + } + resampled, err := decodeWAVToPCM16(wavPCM16(8000, pcm)) + if err != nil || len(resampled) == 0 { + t.Fatalf("valid non-16k WAV was not resampled: len=%d err=%v", len(resampled), err) + } + if got := resamplePCM16(nil, 8000, 16000); len(got) != 0 { + t.Fatalf("empty resample returned %d bytes", len(got)) + } +} + +func TestProviderPipelineSynthesizeFailureModes(t *testing.T) { + p := &ProviderPipeline{cfg: ProviderPipelineConfig{VolcAppID: "asr", VolcAPIKey: "key", BailianAPIKey: "key", BailianBaseURL: "://bad"}} + if _, err := p.Synthesize(context.Background(), Snapshot{Mode: Mode("other")}, "text"); err == nil || !strings.Contains(err.Error(), "unsupported AI mode") { + t.Fatal("unsupported mode accepted") + } + if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: []byte(`{"mode":"full_ai"}`)}, "text"); err == nil || !strings.Contains(err.Error(), "TTS provider ref") { + t.Fatal("missing TTS provider accepted") + } + if _, err := p.Synthesize(context.Background(), Snapshot{Mode: ModeFullAI, Raw: providerSnapshotRaw("full_ai", true, true, true)}, "text"); err == nil || !strings.Contains(err.Error(), "invalid BAILIAN_BASE_URL") { + t.Fatal("invalid TTS endpoint accepted") + } +} diff --git a/internal/ai/snapshot.go b/internal/ai/snapshot.go index 9f77f4c..9b42de6 100644 --- a/internal/ai/snapshot.go +++ b/internal/ai/snapshot.go @@ -10,6 +10,9 @@ import ( "errors" "fmt" "sync" + "unicode/utf8" + + "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer" "git.ipao.vip/rogee/go-sip/contracts" "git.ipao.vip/rogee/go-sip/internal/contract" @@ -31,6 +34,9 @@ type Snapshot struct { } func Validate(raw []byte) (Snapshot, error) { + if !utf8.Valid(raw) { + return Snapshot{}, errors.New("AI configuration must be valid UTF-8") + } if err := contract.ValidateSourceSchema("ai-config.schema.json", raw); err != nil { return Snapshot{}, err } @@ -50,7 +56,11 @@ func Validate(raw []byte) (Snapshot, error) { if mode != ModeFullAI && mode != ModeASROnly { return Snapshot{}, fmt.Errorf("unsupported AI mode %q", mode) } - digest := sha256.Sum256(raw) + canonical, err := jsoncanonicalizer.Transform(raw) + if err != nil { + return Snapshot{}, fmt.Errorf("canonicalize AI configuration: %w", err) + } + digest := sha256.Sum256(canonical) return Snapshot{AgentVersionID: value.AgentVersionID, Digest: hex.EncodeToString(digest[:]), Raw: append([]byte(nil), raw...), Mode: mode}, nil } diff --git a/internal/ai/snapshot_jcs_test.go b/internal/ai/snapshot_jcs_test.go new file mode 100644 index 0000000..3901e01 --- /dev/null +++ b/internal/ai/snapshot_jcs_test.go @@ -0,0 +1,31 @@ +package ai + +import ( + "bytes" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestSnapshotDigestUsesJCSNotJSONPresentation(t *testing.T) { + raw, err := contracts.Read("examples/agent-version.json") + if err != nil { + t.Fatal(err) + } + first, err := Validate(raw) + if err != nil { + t.Fatal(err) + } + variant := bytes.ReplaceAll(raw, []byte(`"speed": 1.0`), []byte(`"speed": 1`)) + variant = append([]byte("\n\t"), variant...) + second, err := Validate(variant) + if err != nil { + t.Fatal(err) + } + if first.Digest != second.Digest { + t.Fatal("JSON presentation changed immutable AI digest") + } + if !bytes.Equal(first.Raw, raw) { + t.Fatal("canonicalization changed supplied snapshot bytes") + } +} diff --git a/internal/callruntime/config_test.go b/internal/callruntime/config_test.go new file mode 100644 index 0000000..2988c68 --- /dev/null +++ b/internal/callruntime/config_test.go @@ -0,0 +1,67 @@ +package callruntime + +import ( + "context" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/ai" +) + +func validConfig() Config { + return Config{ARIURL: "http://127.0.0.1:1/ari", ARIWebsocketURL: "ws://127.0.0.1:1/ari/events", ARIApplication: "test", ARIUsername: "local", ARIPassword: "isolated-test-only", Endpoint: "PJSIP/local-test", MediaBind: "127.0.0.1", MediaPort: 20000, MediaFormat: "slin16", MediaSampleRate: 16000, PayloadType: 96, Pipeline: ai.MockPipeline{}} +} + +func TestRuntimeRejectsInvalidConfigurationBeforeNetwork(t *testing.T) { + for _, tc := range []struct { + name string + change func(*Config) + }{ + {"ARI URL", func(c *Config) { c.ARIURL = "" }}, + {"websocket", func(c *Config) { c.ARIWebsocketURL = "" }}, + {"application", func(c *Config) { c.ARIApplication = "" }}, + {"username", func(c *Config) { c.ARIUsername = "" }}, + {"password", func(c *Config) { c.ARIPassword = "" }}, + {"endpoint", func(c *Config) { c.Endpoint = "" }}, + {"media bind", func(c *Config) { c.MediaBind = "" }}, + {"low media port", func(c *Config) { c.MediaPort = 1023 }}, + {"high media port", func(c *Config) { c.MediaPort = 65536 }}, + {"media format", func(c *Config) { c.MediaFormat = "invalid" }}, + {"pipeline", func(c *Config) { c.Pipeline = nil }}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := validConfig() + tc.change(&cfg) + if err := cfg.validate(); err == nil { + t.Fatal("invalid configuration accepted") + } + if _, err := Run(context.Background(), cfg); err == nil { + t.Fatal("invalid configuration reached runtime") + } + }) + } + if err := validConfig().validate(); err != nil { + t.Fatal(err) + } +} + +func TestRuntimeNormalizationPreservesExplicitSettings(t *testing.T) { + cfg := validConfig() + cfg.AnswerTimeout = time.Second + cfg.TurnWindow = 2 * time.Second + cfg.FirstSpeechTimeout = 3 * time.Second + cfg.MaxTurnDuration = 4 * time.Second + cfg.EndSilence = 5 * time.Second + cfg.MaxCallDuration = 6 * time.Second + cfg.VoiceThreshold = 22 + cfg.MaxTurns = 9 + cfg.OpeningPrompt = "configured opening" + actual := cfg.normalized() + if actual.AnswerTimeout != cfg.AnswerTimeout || actual.TurnWindow != cfg.TurnWindow || actual.FirstSpeechTimeout != cfg.FirstSpeechTimeout || actual.MaxTurnDuration != cfg.MaxTurnDuration || actual.EndSilence != cfg.EndSilence || actual.MaxCallDuration != cfg.MaxCallDuration || actual.VoiceThreshold != cfg.VoiceThreshold || actual.MaxTurns != cfg.MaxTurns || actual.OpeningPrompt != cfg.OpeningPrompt { + t.Fatal("explicit conversation settings were overwritten") + } + defaults := (Config{}).normalized() + if defaults.MediaFormat != "slin16" || defaults.MediaSampleRate != 16000 || defaults.AnswerTimeout <= 0 || defaults.TurnWindow <= 0 { + t.Fatal("invalid normalized transport settings") + } +} diff --git a/internal/callruntime/lifecycle_test.go b/internal/callruntime/lifecycle_test.go new file mode 100644 index 0000000..960932c --- /dev/null +++ b/internal/callruntime/lifecycle_test.go @@ -0,0 +1,85 @@ +package callruntime + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/CyCoreSystems/ari/v5" +) + +type eventSubscription struct{ events chan ari.Event } + +func (s eventSubscription) Events() <-chan ari.Event { return s.events } +func (s eventSubscription) Cancel() {} +func events(values ...ari.Event) eventSubscription { + s := eventSubscription{make(chan ari.Event, len(values))} + for _, event := range values { + s.events <- event + } + close(s.events) + return s +} + +func TestWaitForAnswerUsesOnlyTargetChannel(t *testing.T) { + for _, tc := range []struct { + name string + event ari.Event + wantError string + }{ + {name: "stasis", event: &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "call"}}}, + {name: "up", event: &ari.ChannelStateChange{EventData: ari.EventData{Type: "ChannelStateChange"}, Channel: ari.ChannelData{ID: "call", State: "Up"}}}, + {name: "hangup", event: &ari.ChannelHangupRequest{EventData: ari.EventData{Type: "ChannelHangupRequest"}, Channel: ari.ChannelData{ID: "call", State: "Down"}, Cause: 17}, wantError: "cause=17"}, + {name: "destroyed", event: &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "call"}}, wantError: "ended before StasisStart"}, + } { + t.Run(tc.name, func(t *testing.T) { + other := &ari.StasisStart{EventData: ari.EventData{Type: "StasisStart"}, Channel: ari.ChannelData{ID: "another-call"}} + err := waitForStasisStart(context.Background(), events(nil, other, tc.event), "call", time.Second) + if tc.wantError == "" && err != nil { + t.Fatal(err) + } + if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) { + t.Fatalf("unexpected answer result: %v", err) + } + }) + } + if err := waitForStasisStart(context.Background(), events(), "call", time.Second); err == nil || !strings.Contains(err.Error(), "subscription closed") { + t.Fatal("closed subscription accepted") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := waitForStasisStart(ctx, eventSubscription{make(chan ari.Event)}, "call", time.Second); !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation lost: %v", err) + } +} + +func TestLifecycleCancellationIsScopedToChannel(t *testing.T) { + for _, kind := range []string{"ChannelHangupRequest", "ChannelDestroyed"} { + t.Run(kind, func(t *testing.T) { + var event ari.Event + if kind == "ChannelDestroyed" { + event = &ari.ChannelDestroyed{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}} + } else { + event = &ari.ChannelHangupRequest{EventData: ari.EventData{Type: kind}, Channel: ari.ChannelData{ID: "call"}} + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + other := &ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}} + watchChannelLifecycle(ctx, events(other, event), "call", cancel) + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatal("matching hangup failed to cancel") + } + }) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + watchChannelLifecycle(ctx, events(&ari.ChannelDestroyed{EventData: ari.EventData{Type: "ChannelDestroyed"}, Channel: ari.ChannelData{ID: "other"}}), "call", cancel) + if ctx.Err() != nil { + t.Fatal("another call ended this call") + } + watchChannelLifecycle(ctx, events(nil), "call", cancel) + cancel() + watchChannelLifecycle(ctx, eventSubscription{make(chan ari.Event)}, "call", cancel) +} diff --git a/internal/callruntime/recording_test.go b/internal/callruntime/recording_test.go new file mode 100644 index 0000000..3aa5e65 --- /dev/null +++ b/internal/callruntime/recording_test.go @@ -0,0 +1,86 @@ +package callruntime + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "os" + "path/filepath" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/callflow" +) + +func TestConversationRecordingFactsMatchFiles(t *testing.T) { + directory := t.TempDir() + incoming := [][]byte{{1, 0, 2, 0}, make([]byte, 32000)} + outgoing := [][]byte{{4, 0}, {5, 0, 6, 0}} + in, out, err := persistConversationRecordings(directory, "channel/with:separators", callflow.Result{InboundTurns: incoming, OutboundTurns: outgoing}) + if err != nil { + t.Fatal(err) + } + if len(in) != 2 || len(out) != 2 { + t.Fatal("lost recording segments") + } + for side, facts := range [][]RecordingFact{in, out} { + expected := [][][]byte{incoming, outgoing}[side] + for index, fact := range facts { + if filepath.Dir(fact.Path) != directory { + t.Fatal("recording escaped directory") + } + raw, err := os.ReadFile(fact.Path) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(raw) + if fact.SHA256 != hex.EncodeToString(sum[:]) || fact.Bytes != len(raw) || fact.DurationMS != int64(len(expected[index]))*1000/32000 { + t.Fatal("recording fact does not match content") + } + if string(raw[:4]) != "RIFF" || string(raw[8:12]) != "WAVE" || string(raw[36:40]) != "data" { + t.Fatal("invalid WAV header") + } + if binary.LittleEndian.Uint32(raw[24:28]) != 16000 || binary.LittleEndian.Uint32(raw[40:44]) != uint32(len(expected[index])) || !bytes.Equal(raw[44:], expected[index]) { + t.Fatal("incorrect WAV format or payload") + } + info, err := os.Stat(fact.Path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatal("recording is not restricted") + } + } + } + if in[0].Segment != "inbound_turn_01" || out[0].Segment != "outbound_segment_00" { + t.Fatal("segment identities changed") + } +} + +func TestRecordingFailuresAreExplicit(t *testing.T) { + directory := t.TempDir() + if err := writeWAV(filepath.Join(directory, "odd.wav"), []byte{1}, 16000); err == nil { + t.Fatal("odd PCM accepted") + } + blocker := filepath.Join(directory, "file") + if err := os.WriteFile(blocker, []byte("blocked"), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := persistConversationRecordings(filepath.Join(blocker, "child"), "channel", callflow.Result{InboundTurns: [][]byte{{0, 0}}}); err == nil { + t.Fatal("directory creation error hidden") + } + for _, flow := range []callflow.Result{{InboundTurns: [][]byte{{1}}}, {OutboundTurns: [][]byte{{1}}}} { + if _, _, err := persistConversationRecordings(directory, "channel", flow); err == nil { + t.Fatal("invalid segment accepted") + } + } + for _, dir := range []string{"", directory} { + in, out, err := persistConversationRecordings(dir, "empty", callflow.Result{}) + if err != nil || len(in) != 0 || len(out) != 0 { + t.Fatal("empty recording produced facts") + } + } + if _, _, err := fileDigestAndSize(filepath.Join(directory, "missing.wav")); err == nil { + t.Fatal("missing recording silently returned a checksum") + } +} diff --git a/internal/callruntime/runtime.go b/internal/callruntime/runtime.go index 3ddf104..1cb97a6 100644 --- a/internal/callruntime/runtime.go +++ b/internal/callruntime/runtime.go @@ -62,10 +62,11 @@ type Config struct { } type RecordingFact struct { - Segment string - Path string - SHA256 string - Bytes int + Segment string + Path string + SHA256 string + Bytes int // Complete file size, including the WAV header. + DurationMS int64 } type Result struct { @@ -385,7 +386,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf if err := writeWAV(path, pcm, 16000); err != nil { return inbound, nil, err } - inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)}) + digest, size, err := fileDigestAndSize(path) + if err != nil { + return inbound, nil, fmt.Errorf("hash inbound recording: %w", err) + } + inbound = append(inbound, RecordingFact{Segment: fmt.Sprintf("inbound_turn_%02d", index+1), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000}) } outbound := make([]RecordingFact, 0, len(flowResult.OutboundTurns)) for index, pcm := range flowResult.OutboundTurns { @@ -393,7 +398,11 @@ func persistConversationRecordings(directory, channelID string, flowResult callf if err := writeWAV(path, pcm, 16000); err != nil { return inbound, outbound, err } - outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: fileSHA256(path), Bytes: len(pcm)}) + digest, size, err := fileDigestAndSize(path) + if err != nil { + return inbound, outbound, fmt.Errorf("hash outbound recording: %w", err) + } + outbound = append(outbound, RecordingFact{Segment: fmt.Sprintf("outbound_segment_%02d", index), Path: path, SHA256: digest, Bytes: size, DurationMS: int64(len(pcm)) * 1000 / 32000}) } return inbound, outbound, nil } @@ -423,11 +432,11 @@ func writeWAV(path string, pcm []byte, sampleRate int) error { return os.WriteFile(path, buf, 0o600) } -func fileSHA256(path string) string { +func fileDigestAndSize(path string) (string, int, error) { data, err := os.ReadFile(path) if err != nil { - return "" + return "", 0, err } sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + return hex.EncodeToString(sum[:]), len(data), nil } diff --git a/internal/config/config.go b/internal/config/config.go index 8d1dfbd..7bd0907 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,8 +19,6 @@ type Config struct { AgentID string DispatcherID string Version string - ControlListen string - ControlToken string CellID string GRPCListen string DispatcherGRPCListen string @@ -74,8 +72,6 @@ func FromEnv() Config { AgentID: envOr("AGENT_ID", "agent-local"), DispatcherID: envOr("DISPATCHER_ID", "dispatcher-local"), Version: envOr("AGENT_VERSION", "dev"), - ControlListen: os.Getenv("DISPATCHER_CONTROL_LISTEN"), - ControlToken: os.Getenv("DISPATCHER_CONTROL_TOKEN"), CellID: envOr("CELL_ID", "cell-local"), GRPCListen: os.Getenv("AGENT_GRPC_LISTEN"), DispatcherGRPCListen: os.Getenv("DISPATCHER_GRPC_LISTEN"), @@ -87,14 +83,7 @@ func FromEnv() Config { MTLSKeyFile: os.Getenv("MTLS_KEY_FILE"), MTLSServerName: os.Getenv("MTLS_SERVER_NAME"), MTLSPeerCertificateFingerprints: os.Getenv("MTLS_PEER_CERT_FINGERPRINTS"), - OSSRegion: os.Getenv("DISPATCHER_OSS_REGION"), - OSSEndpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"), - OSSBucket: os.Getenv("DISPATCHER_OSS_BUCKET"), - OSSAccessKeyID: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_ID_FILE"), - OSSAccessKeySecret: envOrSecret("DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_ACCESS_KEY_SECRET_FILE"), - OSSKeyPrefix: envOr("DISPATCHER_OSS_KEY_PREFIX", "agent-call/recordings"), - OSSGrantTTL: time.Duration(envInt("DISPATCHER_OSS_GRANT_TTL_SECONDS", 900)) * time.Second, - OSSMaxAssetBytes: int64(envInt("DISPATCHER_OSS_MAX_ASSET_BYTES", 64<<20)), + OSSMaxAssetBytes: 64 << 20, StaticArtifactPath: os.Getenv("AGENT_STATIC_ARTIFACT"), AgentEndpointsFile: os.Getenv("DISPATCHER_AGENT_ENDPOINTS_FILE"), CallLogPath: os.Getenv("AGENT_CALL_BUSINESS_LOG"), @@ -153,9 +142,6 @@ func (c Config) Validate(role string) error { return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) } } - if role == "dispatcher" && strings.TrimSpace(c.ControlListen) != "" && strings.TrimSpace(c.ControlToken) == "" { - return errors.New("control HTTP requires DISPATCHER_CONTROL_TOKEN") - } if role == "agent" && strings.TrimSpace(c.DispatcherGRPCEndpoint) != "" { for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { if strings.TrimSpace(value) == "" { @@ -169,9 +155,9 @@ func (c Config) Validate(role string) error { return fmt.Errorf("%s is required when DISPATCHER_GRPC_LISTEN is enabled", name) } } - for name, value := range map[string]string{"DISPATCHER_OSS_REGION": c.OSSRegion, "DISPATCHER_OSS_ENDPOINT": c.OSSEndpoint, "DISPATCHER_OSS_BUCKET": c.OSSBucket, "DISPATCHER_OSS_ACCESS_KEY_ID": c.OSSAccessKeyID, "DISPATCHER_OSS_ACCESS_KEY_SECRET": c.OSSAccessKeySecret} { + for name, value := range map[string]string{"oss.region": c.OSSRegion, "oss.endpoint": c.OSSEndpoint, "oss.bucket": c.OSSBucket, "oss.access_key_id_env reference": c.OSSAccessKeyID, "oss.access_key_secret_env reference": c.OSSAccessKeySecret} { if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required when Dispatcher gRPC is enabled", name) + return fmt.Errorf("%s must be supplied by the Dispatcher configuration file", name) } } if strings.TrimSpace(c.DispatcherGRPCAllowedAgentIDs) == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 008f6f4..3e21cc8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,14 +72,3 @@ func TestConfigRequiresStaticArtifactForRealAgent(t *testing.T) { t.Fatal(err) } } - -func TestConfigRequiresControlTokenWhenHTTPIsEnabled(t *testing.T) { - c := Config{Mode: "mock", DBPath: ":memory:", ControlListen: "127.0.0.1:8081"} - if err := c.Validate("dispatcher"); err == nil { - t.Fatal("expected control token requirement") - } - c.ControlToken = "test-token" - if err := c.Validate("dispatcher"); err != nil { - t.Fatal(err) - } -} diff --git a/internal/config/deployment_layout_test.go b/internal/config/deployment_layout_test.go new file mode 100644 index 0000000..e3ce724 --- /dev/null +++ b/internal/config/deployment_layout_test.go @@ -0,0 +1,25 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestDeploymentHasOneCanonicalDirectory(t *testing.T) { + root := filepath.Join("..", "..") + if _, err := os.Stat(filepath.Join(root, "deploy")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("obsolete deploy directory must not remain: %v", err) + } + for _, path := range []string{"README.md", "build-package.sh", "install.sh", "versions.lock.json", "cell/install-asterisk-native.sh", "cell/nonprod-call-evidence.sh", "systemd/sip-go-agent-agent.service", "systemd/sip-go-agent-dispatcher.service", "config/dispatcher.json.example"} { + info, err := os.Stat(filepath.Join(root, "deploys", path)) + if err != nil { + t.Errorf("canonical deployment entry %s: %v", path, err) + continue + } + if !info.Mode().IsRegular() { + t.Errorf("deployment entry %s is not a regular file", path) + } + } +} diff --git a/internal/config/dispatcher_file.go b/internal/config/dispatcher_file.go new file mode 100644 index 0000000..4335d30 --- /dev/null +++ b/internal/config/dispatcher_file.go @@ -0,0 +1,203 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path" + "regexp" + "strings" + "sync" + "time" + "unicode/utf8" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +type dispatcherFile struct { + SchemaVersion string `json:"schema_version"` + DispatcherID string `json:"dispatcher_id"` + OSS struct { + Endpoint string `json:"endpoint"` + Region string `json:"region"` + Bucket string `json:"bucket"` + ObjectPrefix string `json:"object_prefix"` + AccessKeyIDEnv string `json:"access_key_id_env"` + AccessKeySecretEnv string `json:"access_key_secret_env"` + } `json:"oss"` +} + +var credentialEnvName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +var dispatcherFileSchema = sync.OnceValues(func() (*jsonschema.Schema, error) { + data, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/dispatcher-config.schema.json") + if err != nil { + return nil, err + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, err + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + const resource = "https://go-sip.local/dispatcher-config.json" + if err := compiler.AddResource(resource, doc); err != nil { + return nil, err + } + return compiler.Compile(resource) +}) + +// LoadDispatcherFile applies an entirely validated configuration atomically. +// Only credentials explicitly referenced by this file are read from the +// environment; missing/invalid files never fall back to legacy OSS settings. +func (c *Config) LoadDispatcherFile(filename string) error { + if c == nil || filename == "" { + return errors.New("dispatcher configuration and --config file are required") + } + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("open dispatcher configuration: %w", err) + } + const maxBytes = 64 << 10 + raw, readErr := io.ReadAll(io.LimitReader(file, maxBytes+1)) + if err := errors.Join(readErr, file.Close()); err != nil { + return fmt.Errorf("read dispatcher configuration: %w", err) + } + if len(raw) > maxBytes || !utf8.Valid(raw) { + return errors.New("dispatcher configuration must be valid UTF-8 JSON of at most 64 KiB") + } + check := json.NewDecoder(bytes.NewReader(raw)) + if err := uniqueJSONKeys(check, 0); err != nil { + return fmt.Errorf("dispatcher configuration JSON: %w", err) + } + if _, err := check.Token(); !errors.Is(err, io.EOF) { + return errors.New("dispatcher configuration must contain one JSON object") + } + var input dispatcherFile + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return fmt.Errorf("decode dispatcher configuration: %w", err) + } + schema, err := dispatcherFileSchema() + if err != nil { + return fmt.Errorf("compile dispatcher configuration schema: %w", err) + } + document, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("parse dispatcher configuration: %w", err) + } + if err := schema.Validate(document); err != nil { + var failure *jsonschema.ValidationError + if errors.As(err, &failure) { + for len(failure.Causes) > 0 { + failure = failure.Causes[0] + } + // Report the violated field and rule, never its potentially sensitive value. + return fmt.Errorf("dispatcher configuration schema violation at /%s (%T)", strings.Join(failure.InstanceLocation, "/"), failure.ErrorKind) + } + return errors.New("dispatcher configuration schema validation failed") + } + if input.SchemaVersion != "1.0" { + return errors.New("dispatcher configuration schema_version must be 1.0") + } + if err := tenant.ValidateDispatcherID(input.DispatcherID); err != nil { + return err + } + endpoint, err := url.Parse(input.OSS.Endpoint) + if err != nil || endpoint.Hostname() == "" || (endpoint.Scheme != "https" && endpoint.Scheme != "http") || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + return errors.New("oss.endpoint must be an HTTP(S) service URL without credentials, query or fragment") + } + for name, value := range map[string]string{"region": input.OSS.Region, "bucket": input.OSS.Bucket, "object_prefix": input.OSS.ObjectPrefix} { + if value == "" || strings.TrimSpace(value) != value { + return fmt.Errorf("oss.%s must be nonempty and have no surrounding whitespace", name) + } + } + prefix := input.OSS.ObjectPrefix + if path.IsAbs(prefix) || path.Clean(prefix) != prefix || prefix == "." || prefix == ".." || strings.HasPrefix(prefix, "../") || strings.Contains(prefix, `\`) { + return errors.New("oss.object_prefix must be a relative object prefix without traversal") + } + key, err := fileCredential("access_key_id_env", input.OSS.AccessKeyIDEnv) + if err != nil { + return err + } + secret, err := fileCredential("access_key_secret_env", input.OSS.AccessKeySecretEnv) + if err != nil { + return err + } + updated := *c + updated.DispatcherID = input.DispatcherID + updated.OSSEndpoint = input.OSS.Endpoint + updated.OSSRegion = input.OSS.Region + updated.OSSBucket = input.OSS.Bucket + updated.OSSKeyPrefix = prefix + updated.OSSAccessKeyID = key + updated.OSSAccessKeySecret = secret + updated.OSSGrantTTL = 15 * time.Minute + *c = updated + return nil +} + +func fileCredential(field, reference string) (string, error) { + if !credentialEnvName.MatchString(reference) { + return "", fmt.Errorf("oss.%s must name a credential environment variable", field) + } + value, exists := os.LookupEnv(reference) + if !exists || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("credential referenced by oss.%s is unavailable", field) + } + return value, nil +} + +// encoding/json accepts repeated object keys. Inspect its token stream before +// typed decoding so duplicate settings cannot silently override earlier values. +func uniqueJSONKeys(decoder *json.Decoder, depth int) error { + if depth > 16 { + return errors.New("configuration nesting is too deep") + } + token, err := decoder.Token() + if err != nil { + return err + } + delimiter, compound := token.(json.Delim) + if !compound { + return nil + } + switch delimiter { + case '{': + seen := make(map[string]bool) + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return err + } + key, ok := keyToken.(string) + if !ok { + return errors.New("configuration object key must be a string") + } + if seen[key] { + return fmt.Errorf("duplicate configuration field %q", key) + } + seen[key] = true + if err := uniqueJSONKeys(decoder, depth+1); err != nil { + return err + } + } + case '[': + for decoder.More() { + if err := uniqueJSONKeys(decoder, depth+1); err != nil { + return err + } + } + default: + return errors.New("unexpected configuration JSON delimiter") + } + _, err = decoder.Token() + return err +} diff --git a/internal/config/dispatcher_file_test.go b/internal/config/dispatcher_file_test.go new file mode 100644 index 0000000..64373af --- /dev/null +++ b/internal/config/dispatcher_file_test.go @@ -0,0 +1,139 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func dispatcherFileFixture(t *testing.T) map[string]any { + t.Helper() + t.Setenv("MQ_TEST_OSS_KEY", "test-key-not-a-real-credential") + t.Setenv("MQ_TEST_OSS_SECRET", "test-secret-not-a-real-credential") + return map[string]any{ + "schema_version": "1.0", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "oss": map[string]any{ + "endpoint": "https://oss.example.invalid", "region": "test-region", + "bucket": "test-bucket", "object_prefix": "recordings", + "access_key_id_env": "MQ_TEST_OSS_KEY", "access_key_secret_env": "MQ_TEST_OSS_SECRET", + }, + } +} + +func writeDispatcherFile(t *testing.T, raw []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "dispatcher.json") + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + return path +} + +func marshalDispatcherFile(t *testing.T, value map[string]any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw +} + +func TestLoadDispatcherFile(t *testing.T) { + value := dispatcherFileFixture(t) + t.Setenv("DISPATCHER_OSS_ENDPOINT", "https://ignored.example.invalid") + cfg := Config{Mode: "mock", DBPath: "preserved.db", OSSGrantTTL: time.Hour, OSSBucket: "old"} + if err := cfg.LoadDispatcherFile(writeDispatcherFile(t, marshalDispatcherFile(t, value))); err != nil { + t.Fatal(err) + } + if cfg.DispatcherID != value["dispatcher_id"] || cfg.OSSEndpoint != "https://oss.example.invalid" || cfg.OSSRegion != "test-region" || cfg.OSSBucket != "test-bucket" || cfg.OSSKeyPrefix != "recordings" || cfg.OSSGrantTTL != 15*time.Minute { + t.Fatalf("wrong file mapping: id=%q endpoint=%q region=%q bucket=%q prefix=%q ttl=%v", cfg.DispatcherID, cfg.OSSEndpoint, cfg.OSSRegion, cfg.OSSBucket, cfg.OSSKeyPrefix, cfg.OSSGrantTTL) + } + if cfg.OSSAccessKeyID != os.Getenv("MQ_TEST_OSS_KEY") || cfg.OSSAccessKeySecret != os.Getenv("MQ_TEST_OSS_SECRET") { + t.Fatal("explicit credential references not resolved") + } + if cfg.Mode != "mock" || cfg.DBPath != "preserved.db" { + t.Fatal("unrelated deployment settings overwritten") + } +} + +func TestLoadDispatcherFileRejectsInvalidValuesAtomically(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(map[string]any) + }{ + {"version", func(m map[string]any) { m["schema_version"] = "2.0" }}, + {"missing ID", func(m map[string]any) { delete(m, "dispatcher_id") }}, + {"invalid ID", func(m map[string]any) { m["dispatcher_id"] = "dispatcher" }}, + {"unknown root", func(m map[string]any) { m["legacy"] = true }}, + {"case alias", func(m map[string]any) { m["SCHEMA_VERSION"] = m["schema_version"]; delete(m, "schema_version") }}, + {"nested case alias", func(m map[string]any) { + m["oss"].(map[string]any)["BUCKET"] = "test-bucket" + delete(m["oss"].(map[string]any), "bucket") + }}, + {"missing oss", func(m map[string]any) { delete(m, "oss") }}, + {"plaintext secret", func(m map[string]any) { m["oss"].(map[string]any)["access_key_secret"] = "not-a-real-secret" }}, + {"TTL override", func(m map[string]any) { m["oss"].(map[string]any)["grant_ttl_seconds"] = 3600 }}, + {"missing bucket", func(m map[string]any) { delete(m["oss"].(map[string]any), "bucket") }}, + {"blank region", func(m map[string]any) { m["oss"].(map[string]any)["region"] = " " }}, + {"invalid endpoint", func(m map[string]any) { m["oss"].(map[string]any)["endpoint"] = "file:///tmp/oss" }}, + {"URL credentials", func(m map[string]any) { + m["oss"].(map[string]any)["endpoint"] = "https://user:password@example.invalid" + }}, + {"empty prefix", func(m map[string]any) { m["oss"].(map[string]any)["object_prefix"] = "" }}, + {"prefix escape", func(m map[string]any) { m["oss"].(map[string]any)["object_prefix"] = "recordings/../other" }}, + {"invalid env reference", func(m map[string]any) { m["oss"].(map[string]any)["access_key_id_env"] = "${SECRET}" }}, + {"missing credential", func(m map[string]any) { m["oss"].(map[string]any)["access_key_secret_env"] = "MQ_TEST_MISSING" }}, + } { + t.Run(tc.name, func(t *testing.T) { + value := dispatcherFileFixture(t) + t.Setenv("MQ_TEST_MISSING", "") + tc.mutate(value) + before := Config{DispatcherID: "unchanged", OSSAccessKeySecret: "untouched"} + cfg := before + err := cfg.LoadDispatcherFile(writeDispatcherFile(t, marshalDispatcherFile(t, value))) + if err == nil { + t.Fatal("invalid file accepted") + } + if cfg != before { + t.Fatal("invalid file partially changed configuration") + } + if strings.Contains(err.Error(), os.Getenv("MQ_TEST_OSS_SECRET")) { + t.Fatal("credential leaked in error") + } + }) + } +} + +func TestLoadDispatcherFileRejectsDuplicateAndTrailingJSON(t *testing.T) { + raw := string(marshalDispatcherFile(t, dispatcherFileFixture(t))) + for _, bad := range []string{ + strings.Replace(raw, `"schema_version":"1.0"`, `"schema_version":"1.0","schema_version":"1.0"`, 1), + strings.Replace(raw, `"bucket":"test-bucket"`, `"bucket":"test-bucket","bucket":"other"`, 1), + raw + ` {}`, raw + ` null`, raw[:len(raw)-1], `null`, `[]`, + strings.Repeat("[", 100) + strings.Repeat("]", 100), + strings.Repeat(" ", 65537), string([]byte{0xff}), + } { + var cfg Config + if err := cfg.LoadDispatcherFile(writeDispatcherFile(t, []byte(bad))); err == nil { + t.Fatal("invalid/ambiguous JSON accepted") + } + } +} + +func TestLoadDispatcherFileDoesNotFallBack(t *testing.T) { + var cfg Config + t.Setenv("DISPATCHER_OSS_BUCKET", "legacy-bucket") + for _, path := range []string{"", filepath.Join(t.TempDir(), "missing.json"), t.TempDir()} { + if err := cfg.LoadDispatcherFile(path); err == nil { + t.Fatal("missing/invalid file accepted") + } + } + var absent *Config + if err := absent.LoadDispatcherFile("unused"); err == nil { + t.Fatal("nil config accepted") + } +} diff --git a/internal/config/oss_file_only_test.go b/internal/config/oss_file_only_test.go new file mode 100644 index 0000000..552c74c --- /dev/null +++ b/internal/config/oss_file_only_test.go @@ -0,0 +1,14 @@ +package config + +import "testing" + +func TestLegacyOSSEnvironmentIsNotAConfigurationSource(t *testing.T) { + for _, key := range []string{"DISPATCHER_OSS_REGION", "DISPATCHER_OSS_ENDPOINT", "DISPATCHER_OSS_BUCKET", "DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_KEY_PREFIX"} { + t.Setenv(key, "legacy-value") + } + t.Setenv("DISPATCHER_OSS_GRANT_TTL_SECONDS", "1") + cfg := FromEnv() + if cfg.OSSRegion != "" || cfg.OSSEndpoint != "" || cfg.OSSBucket != "" || cfg.OSSAccessKeyID != "" || cfg.OSSAccessKeySecret != "" || cfg.OSSKeyPrefix != "" || cfg.OSSGrantTTL != 0 { + t.Fatal("legacy environment still supplies OSS settings before the required JSON file") + } +} diff --git a/internal/contract/command_v2_test.go b/internal/contract/command_v2_test.go new file mode 100644 index 0000000..2029d32 --- /dev/null +++ b/internal/contract/command_v2_test.go @@ -0,0 +1,32 @@ +package contract + +import ( + "encoding/json" + "git.ipao.vip/rogee/go-sip/contracts" + "testing" +) + +func TestDecodeMQCommandV2(t *testing.T) { + for _, name := range []string{"call-execute", "task-control", "call-replay", "command-replay"} { + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/" + name + ".json") + if err != nil { + t.Fatal(err) + } + message, err := DecodeMQCommand(raw) + if err != nil { + t.Fatal(err) + } + if message.DispatcherID == "" || message.CommandType == "" { + t.Fatal("command identity lost") + } + var changed map[string]any + if err := json.Unmarshal(raw, &changed); err != nil { + t.Fatal(err) + } + changed["schema_version"] = "1.0" + bad, _ := json.Marshal(changed) + if _, err := DecodeMQCommand(bad); err == nil { + t.Fatal("legacy command accepted") + } + } +} diff --git a/internal/contract/contract.go b/internal/contract/contract.go index f60001d..fb42ab5 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -13,6 +13,7 @@ import ( ) type CommandEnvelope struct { + DispatcherID string `json:"dispatcher_id,omitempty"` SchemaVersion string `json:"schema_version"` CommandType string `json:"command_type"` CommandID string `json:"command_id"` @@ -67,22 +68,13 @@ func ValidateEvent(raw []byte) error { } func ValidateSourceSchema(schemaName string, raw []byte) error { - var value any - if err := json.Unmarshal(raw, &value); err != nil { + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { return fmt.Errorf("decode json: %w", err) } - var schemaDoc any - if err := contracts.ReadJSON(schemaName, &schemaDoc); err != nil { - return err - } - resource := "https://agent-call.invalid/contracts/" + schemaName - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource(resource, schemaDoc); err != nil { - return fmt.Errorf("register %s: %w", schemaName, err) - } - schema, err := compiler.Compile(resource) + schema, err := schemaFromBundle(contracts.SourceCommit, schemaName) if err != nil { - return fmt.Errorf("compile %s: %w", schemaName, err) + return err } if err := schema.Validate(value); err != nil { return fmt.Errorf("%s validation: %w", schemaName, err) @@ -91,13 +83,10 @@ func ValidateSourceSchema(schemaName string, raw []byte) error { } func DecodeExecute(raw []byte) (CommandEnvelope, ExecutePayload, error) { - if err := ValidateJSON(raw); err != nil { + envelope, err := DecodeMQCommand(raw) + if err != nil { return CommandEnvelope{}, ExecutePayload{}, err } - var envelope CommandEnvelope - if err := json.Unmarshal(raw, &envelope); err != nil { - return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode command envelope: %w", err) - } if envelope.CommandType != "call.execute" { return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("unsupported command_type %q", envelope.CommandType) } diff --git a/internal/contract/contract_test.go b/internal/contract/contract_test.go index 7aa776e..8a84de9 100644 --- a/internal/contract/contract_test.go +++ b/internal/contract/contract_test.go @@ -6,10 +6,11 @@ import ( "time" "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestDecodeExecuteValidatesImportedSchema(t *testing.T) { - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/contract/event_mq.go b/internal/contract/event_mq.go new file mode 100644 index 0000000..7f05dd4 --- /dev/null +++ b/internal/contract/event_mq.go @@ -0,0 +1,28 @@ +package contract + +import ( + "encoding/json" + "time" +) + +// MarshalMQ constructs the Dispatcher-owned external event envelope. Internal +// Agent event frames do not supply the Dispatcher identity or select a version. +func (b EventBuilder) MarshalMQ(dispatcherID string, now time.Time, eventID string) ([]byte, error) { + envelope := struct { + EventEnvelope + DispatcherID string `json:"dispatcher_id"` + }{EventEnvelope: EventEnvelope{ + SchemaVersion: "2.0", EventID: eventID, EventType: b.EventType, + TenantID: b.TenantID, TenantKey: b.TenantKey, TraceID: b.TraceID, + OccurredAt: now.UTC().Format(time.RFC3339Nano), AggregateType: b.Aggregate, + AggregateID: b.AggregateID, AggregateVersion: b.Version, Payload: b.Payload, + }, DispatcherID: dispatcherID} + raw, err := json.Marshal(envelope) + if err != nil { + return nil, err + } + if err := ValidateMQMessage(raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/internal/contract/event_mq_test.go b/internal/contract/event_mq_test.go new file mode 100644 index 0000000..06a6165 --- /dev/null +++ b/internal/contract/event_mq_test.go @@ -0,0 +1,47 @@ +package contract + +import ( + "encoding/json" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestMQEventBuilderUsesStrictDispatcherEnvelope(t *testing.T) { + raw, err := contracts.Files.ReadFile("upstream/" + MQSourceCommit + "/examples/event-command-result.json") + if err != nil { + t.Fatal(err) + } + var event EventEnvelope + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } + builder := EventBuilder{TenantID: event.TenantID, TenantKey: event.TenantKey, TraceID: event.TraceID, EventType: event.EventType, Aggregate: event.AggregateType, AggregateID: event.AggregateID, Version: event.AggregateVersion, Payload: event.Payload} + now, err := time.Parse(time.RFC3339Nano, event.OccurredAt) + if err != nil { + t.Fatal(err) + } + const id = "11111111-1111-4111-8111-111111111111" + result, err := builder.MarshalMQ(id, now, event.EventID) + if err != nil { + t.Fatal(err) + } + if err := ValidateMQMessage(result); err != nil { + t.Fatal(err) + } + var values map[string]any + if err := json.Unmarshal(result, &values); err != nil { + t.Fatal(err) + } + if values["schema_version"] != "2.0" || values["dispatcher_id"] != id { + t.Fatal("missing v2 identity") + } + if _, err := builder.MarshalMQ("", now, event.EventID); err == nil { + t.Fatal("missing dispatcher accepted") + } + builder.Payload["unexpected"] = true + if _, err := builder.MarshalMQ(id, now, event.EventID); err == nil { + t.Fatal("unknown payload field accepted") + } +} diff --git a/internal/contract/schema.go b/internal/contract/schema.go new file mode 100644 index 0000000..88c84f2 --- /dev/null +++ b/internal/contract/schema.go @@ -0,0 +1,61 @@ +package contract + +import ( + "bytes" + "fmt" + "path" + "strings" + "sync" + + "git.ipao.vip/rogee/go-sip/contracts" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +var compiledSchemas sync.Map + +type bundleLoader struct{ version string } + +func (l bundleLoader) base() string { + return "https://go-sip.local/contracts/" + l.version + "/" +} + +// Load never accesses the network or resolves references against another bundle. +func (l bundleLoader) Load(address string) (any, error) { + if !strings.HasPrefix(address, l.base()) { + return nil, fmt.Errorf("schema reference outside pinned bundle: %s", address) + } + name := strings.TrimPrefix(address, l.base()) + if name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { + return nil, fmt.Errorf("invalid schema resource name %q", name) + } + raw, err := contracts.Files.ReadFile("upstream/" + l.version + "/" + name) + if err != nil { + return nil, fmt.Errorf("read pinned schema %s: %w", name, err) + } + return jsonschema.UnmarshalJSON(bytes.NewReader(raw)) +} + +func schemaFromBundle(version, name string) (*jsonschema.Schema, error) { + key := version + "/" + name + if cached, ok := compiledSchemas.Load(key); ok { + return cached.(*jsonschema.Schema), nil + } + loader := bundleLoader{version: version} + resource := loader.base() + name + doc, err := loader.Load(resource) + if err != nil { + return nil, err + } + compiler := jsonschema.NewCompiler() + compiler.UseLoader(loader) + compiler.AssertFormat() + if err := compiler.AddResource(resource, doc); err != nil { + return nil, fmt.Errorf("register %s: %w", name, err) + } + schema, err := compiler.Compile(resource) + if err != nil { + return nil, fmt.Errorf("compile %s: %w", name, err) + } + actual, _ := compiledSchemas.LoadOrStore(key, schema) + return actual.(*jsonschema.Schema), nil +} diff --git a/internal/contract/schema_test.go b/internal/contract/schema_test.go new file mode 100644 index 0000000..9993de5 --- /dev/null +++ b/internal/contract/schema_test.go @@ -0,0 +1,48 @@ +package contract + +import ( + "bytes" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func TestV2RuntimeSchemaResolvesOnlyEmbeddedContracts(t *testing.T) { + const version = "2026-09-21-p1-v2" + schema, err := schemaFromBundle(version, "mq.schema.json") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"command-query", "call-query-result", "ai-config-result", "call-execute", "event-recording-uploaded"} { + raw, err := contracts.Files.ReadFile("upstream/" + version + "/examples/" + name + ".json") + if err != nil { + t.Fatal(err) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + if err := schema.Validate(value); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + loader := bundleLoader{version: version} + for _, address := range []string{"https://example.invalid/mq.schema.json", "file:///etc/passwd", "https://go-sip.local/contracts/2026-09-19-p1-v1/mq.schema.json"} { + if _, err := loader.Load(address); err == nil { + t.Fatalf("external/cross-version schema accepted: %s", address) + } + } + again, err := schemaFromBundle(version, "mq.schema.json") + if err != nil || again != schema { + t.Fatal("immutable compiled schema not reused") + } +} + +func TestBundleSchemaRejectsInvalidResources(t *testing.T) { + for _, name := range []string{"../mq.schema.json", "", "missing.schema.json"} { + if _, err := schemaFromBundle(contracts.SourceCommit, name); err == nil { + t.Fatalf("invalid resource accepted: %q", name) + } + } +} diff --git a/internal/contract/service.go b/internal/contract/service.go new file mode 100644 index 0000000..03234f4 --- /dev/null +++ b/internal/contract/service.go @@ -0,0 +1,85 @@ +package contract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "unicode/utf8" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// MQSourceCommit pins the approved MQ protocol. No incoming version selects a +// different decoder or causes fallback to the previous protocol. +const MQSourceCommit = "2026-09-21-p1-v3" + +var ErrInvalidServiceMessage = errors.New("invalid MQ service message") + +type ServiceMessage struct { + SchemaVersion string `json:"schema_version"` + MessageType string `json:"message_type"` + MessageID string `json:"message_id"` + DispatcherID string `json:"dispatcher_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + IssuedAt string `json:"issued_at"` + NotAfter string `json:"not_after,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` + Status string `json:"status,omitempty"` + ReasonCode string `json:"reason_code,omitempty"` + Payload json.RawMessage `json:"payload"` +} + +func ValidateMQMessage(raw []byte) error { + if len(raw) == 0 || len(raw) > 256<<10 || !utf8.Valid(raw) { + return fmt.Errorf("%w: require UTF-8 JSON within 256 KiB", ErrInvalidServiceMessage) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + return fmt.Errorf("%w: decode JSON: %w", ErrInvalidServiceMessage, err) + } + schema, err := schemaFromBundle(MQSourceCommit, "mq.schema.json") + if err != nil { + return err + } + if err := schema.Validate(value); err != nil { + return fmt.Errorf("%w: schema validation: %w", ErrInvalidServiceMessage, err) + } + return nil +} + +func DecodeMQCommand(raw []byte) (CommandEnvelope, error) { + if err := ValidateMQMessage(raw); err != nil { + return CommandEnvelope{}, err + } + var command CommandEnvelope + if err := json.Unmarshal(raw, &command); err != nil { + return CommandEnvelope{}, err + } + if command.CommandType == "" { + return CommandEnvelope{}, fmt.Errorf("%w: expected command", ErrInvalidServiceMessage) + } + if len([]byte(command.TenantKey)) > 196 { + return CommandEnvelope{}, ErrInvalidTenantKey + } + return command, nil +} + +func DecodeService(raw []byte) (ServiceMessage, error) { + if err := ValidateMQMessage(raw); err != nil { + return ServiceMessage{}, err + } + var message ServiceMessage + if err := json.Unmarshal(raw, &message); err != nil { + return ServiceMessage{}, err + } + if message.MessageType == "" { + return ServiceMessage{}, fmt.Errorf("%w: not a service request or response", ErrInvalidServiceMessage) + } + if len([]byte(message.TenantKey)) > 196 { + return ServiceMessage{}, fmt.Errorf("%w: exceeds 196 UTF-8 bytes", ErrInvalidTenantKey) + } + return message, nil +} diff --git a/internal/contract/service_test.go b/internal/contract/service_test.go new file mode 100644 index 0000000..c2ae776 --- /dev/null +++ b/internal/contract/service_test.go @@ -0,0 +1,39 @@ +package contract + +import ( + "encoding/json" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestDecodeServiceUsesApprovedMessageTypeAndCorrelation(t *testing.T) { + for _, name := range []string{"command-query", "call-query", "ai-config-request", "command-query-result", "call-query-result", "ai-config-result"} { + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/" + name + ".json") + if err != nil { + t.Fatal(err) + } + message, err := DecodeService(raw) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if message.MessageType == "" || message.DispatcherID == "" || message.MessageID == "" { + t.Fatal("message identity was lost") + } + var body map[string]any + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + body["message_kind"] = "request" + bad, _ := json.Marshal(body) + if _, err := DecodeService(bad); err == nil { + t.Fatal("invented message_kind accepted") + } + delete(body, "message_kind") + body["schema_version"] = "1.0" + bad, _ = json.Marshal(body) + if _, err := DecodeService(bad); err == nil { + t.Fatal("legacy service envelope accepted") + } + } +} diff --git a/internal/control/doc.go b/internal/control/doc.go deleted file mode 100644 index 39c61a0..0000000 --- a/internal/control/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package control implements the internal executor control/query/replay API -// from the pinned OpenAPI contract. It is not a call-execution ingress. -package control diff --git a/internal/control/http.go b/internal/control/http.go deleted file mode 100644 index e1ee64b..0000000 --- a/internal/control/http.go +++ /dev/null @@ -1,199 +0,0 @@ -// Package control exposes the contract-defined internal control/query/replay -// HTTP surface. Call execution itself remains a RabbitMQ command path. -package control - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strings" - "time" - - "git.ipao.vip/rogee/go-sip/internal/store" -) - -type Handler struct { - Store *store.Store - BearerToken string - Now func() time.Time -} - -type controlRequest struct { - CommandID string `json:"command_id"` - Action string `json:"action"` - ExpectedTaskRevision int64 `json:"expected_task_revision"` - ActiveCallPolicy string `json:"active_call_policy,omitempty"` - Reason string `json:"reason"` -} - -type replayRequest struct { - CommandID string `json:"command_id"` - Reason string `json:"reason"` -} - -func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if h.Store == nil { - writeProblem(w, r, http.StatusInternalServerError, "store_unavailable", "store is not configured", true) - return - } - if !h.authorized(r) { - writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "bearer authentication is required", false) - return - } - if r.Header.Get("X-Tenant-ID") == "" || r.Header.Get("X-Request-ID") == "" { - writeProblem(w, r, http.StatusBadRequest, "missing_header", "X-Tenant-ID and X-Request-ID are required", false) - return - } - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - switch { - case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "tasks" && parts[5] == "controls": - h.controlTask(w, r, parts[4]) - case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands": - h.getCommand(w, r, parts[4]) - case r.Method == http.MethodGet && len(parts) == 5 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls": - writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false) - case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "calls" && parts[5] == "replays": - writeProblem(w, r, http.StatusNotFound, "not_found", "call snapshot is not available", false) - case r.Method == http.MethodPost && len(parts) == 6 && parts[0] == "internal" && parts[1] == "v1" && parts[2] == "outbound" && parts[3] == "commands" && parts[5] == "replays": - h.replayCommand(w, r, parts[4]) - default: - writeProblem(w, r, http.StatusNotFound, "not_found", "resource not found", false) - } -} - -func (h Handler) authorized(r *http.Request) bool { - value := r.Header.Get("Authorization") - if !strings.HasPrefix(value, "Bearer ") || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == "" { - return false - } - return h.BearerToken == "" || strings.TrimSpace(strings.TrimPrefix(value, "Bearer ")) == h.BearerToken -} - -func (h Handler) controlTask(w http.ResponseWriter, r *http.Request, taskID string) { - var req controlRequest - if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || req.ExpectedTaskRevision < 1 || (req.Action != "pause" && req.Action != "resume" && req.Action != "stop") || (req.ActiveCallPolicy != "" && req.ActiveCallPolicy != "drain" && req.ActiveCallPolicy != "hangup") || len(req.Reason) > 512 { - writeProblem(w, r, http.StatusBadRequest, "invalid_control", "invalid control request", false) - return - } - task, err := h.Store.FindTask(r.Header.Get("X-Tenant-ID"), taskID) - if errors.Is(err, sql.ErrNoRows) { - writeProblem(w, r, http.StatusNotFound, "not_found", "task not found", false) - return - } - if err != nil { - writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true) - return - } - if err := h.Store.ApplyControlDetailed(task.ExecutionID, req.ExpectedTaskRevision, req.Action, req.ActiveCallPolicy, req.Reason, r.Header.Get("Idempotency-Key")); err != nil { - if errors.Is(err, store.ErrCASConflict) { - writeProblem(w, r, http.StatusConflict, "revision_conflict", err.Error(), false) - return - } - writeProblem(w, r, http.StatusInternalServerError, "control_failed", err.Error(), true) - return - } - acceptedAt := h.now().UTC().Format(time.RFC3339Nano) - writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "tenant_id": task.TenantID, "tenant_key": task.TenantKey, "task_id": task.TaskID, "status": "accepted", "requested_task_revision": req.ExpectedTaskRevision, "accepted_at": acceptedAt}) -} - -func (h Handler) getCommand(w http.ResponseWriter, r *http.Request, commandID string) { - record, err := h.Store.GetCommand(r.Header.Get("X-Tenant-ID"), commandID) - if errors.Is(err, sql.ErrNoRows) { - writeProblem(w, r, http.StatusNotFound, "not_found", "command not found", false) - return - } - if err != nil { - writeProblem(w, r, http.StatusInternalServerError, "lookup_failed", err.Error(), true) - return - } - var envelope struct { - SchemaVersion string `json:"schema_version"` - CommandType string `json:"command_type"` - CommandID string `json:"command_id"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - TraceID string `json:"trace_id"` - IssuedAt string `json:"issued_at"` - NotAfter string `json:"not_after"` - Payload json.RawMessage `json:"payload"` - } - if err := json.Unmarshal(record.Body, &envelope); err != nil { - writeProblem(w, r, http.StatusInternalServerError, "decode_failed", err.Error(), true) - return - } - var taskID, executionID string - var requestedRevision any - var payload struct { - TaskID string `json:"task_id"` - ExecutionID string `json:"execution_id"` - TaskRevision int64 `json:"task_revision"` - } - if err := json.Unmarshal(envelope.Payload, &payload); err == nil { - taskID, executionID, requestedRevision = payload.TaskID, payload.ExecutionID, payload.TaskRevision - } - writeJSON(w, http.StatusOK, map[string]any{ - "command_id": record.CommandID, "command_type": record.CommandType, - "tenant_id": record.TenantID, "tenant_key": record.TenantKey, - "task_id": taskID, "execution_id": executionID, "call_id": nil, - "status": record.Status, "reason_code": nil, "wait_reason_code": nil, - "accepted_at": record.PersistedAt, "waiting_since": nil, - "admission_deadline": envelope.NotAfter, "requested_task_revision": requestedRevision, - "applied_task_revision": nil, "task_state": nil, - "aggregate_version": 1, "updated_at": record.ReceivedAt, - }) -} - -func (h Handler) replayCommand(w http.ResponseWriter, r *http.Request, sourceCommandID string) { - var req replayRequest - if err := decodeStrict(r, &req); err != nil || req.CommandID == "" || req.Reason == "" || len(req.Reason) > 512 { - writeProblem(w, r, http.StatusBadRequest, "invalid_replay", "invalid replay request", false) - return - } - key := r.Header.Get("Idempotency-Key") - if key == "" { - writeProblem(w, r, http.StatusBadRequest, "missing_idempotency_key", "Idempotency-Key is required", false) - return - } - if err := h.Store.ReplayCommand(key, r.Header.Get("X-Tenant-ID"), sourceCommandID, req.Reason); err != nil { - if errors.Is(err, sql.ErrNoRows) { - writeProblem(w, r, http.StatusNotFound, "not_found", "source command not found", false) - return - } - writeProblem(w, r, http.StatusInternalServerError, "replay_failed", err.Error(), true) - return - } - writeJSON(w, http.StatusAccepted, map[string]any{"command_id": req.CommandID, "status": "accepted", "snapshot_cutoff": h.now().UTC().Format(time.RFC3339Nano)}) -} - -func decodeStrict(r *http.Request, dst any) error { - decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(dst); err != nil { - return err - } - var extra any - if err := decoder.Decode(&extra); err != io.EOF { - return fmt.Errorf("request has multiple JSON values") - } - return nil -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} - -func writeProblem(w http.ResponseWriter, r *http.Request, status int, code, detail string, retryable bool) { - writeJSON(w, status, map[string]any{"type": "about:blank", "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "request_id": r.Header.Get("X-Request-ID"), "retryable": retryable}) -} - -func (h Handler) now() time.Time { - if h.Now != nil { - return h.Now() - } - return time.Now() -} diff --git a/internal/control/http_test.go b/internal/control/http_test.go deleted file mode 100644 index 15d5a23..0000000 --- a/internal/control/http_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package control - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/store" -) - -func setupHandler(t *testing.T) (*Handler, *store.Store) { - t.Helper() - s, err := store.Open(":memory:") - if err != nil { - t.Fatal(err) - } - raw, err := contracts.Read("examples/call.execute.json") - if err != nil { - t.Fatal(err) - } - if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { - t.Fatal(err) - } - h := &Handler{Store: s, BearerToken: "test-token"} - t.Cleanup(func() { _ = s.Close() }) - return h, s -} - -func request(h http.Handler, method, path, body string) *httptest.ResponseRecorder { - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r.Header.Set("Authorization", "Bearer test-token") - r.Header.Set("X-Tenant-ID", "tenant-demo") - r.Header.Set("X-Request-ID", "req-1") - r.Header.Set("Idempotency-Key", "idem-1") - w := httptest.NewRecorder() - h.ServeHTTP(w, r) - return w -} - -func TestControlEndpointUsesCASAndStrictJSON(t *testing.T) { - h, _ := setupHandler(t) - w := request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-1","action":"pause","expected_task_revision":1,"reason":"operator"}`) - if w.Code != http.StatusAccepted { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - w = request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-2","action":"resume","expected_task_revision":99,"reason":"stale"}`) - if w.Code != http.StatusConflict { - t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) - } - w = request(h, http.MethodPost, "/internal/v1/outbound/tasks/task-demo/controls", `{"command_id":"ctrl-3","action":"pause","expected_task_revision":1,"reason":"operator","extra":true}`) - if w.Code != http.StatusBadRequest { - t.Fatalf("strict JSON status=%d body=%s", w.Code, w.Body.String()) - } -} - -func TestCommandQueryAndReplayAreTenantScopedAndDurable(t *testing.T) { - h, s := setupHandler(t) - w := request(h, http.MethodGet, "/internal/v1/outbound/commands/cmd_demo_001", "") - if w.Code != http.StatusOK { - t.Fatalf("query status=%d body=%s", w.Code, w.Body.String()) - } - var body map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["command_id"] != "cmd_demo_001" || body["task_id"] != "task-demo" || body["execution_id"] != "exec_demo_001" || body["aggregate_version"] != float64(1) { - t.Fatalf("query body=%s err=%v", w.Body.String(), err) - } - w = request(h, http.MethodPost, "/internal/v1/outbound/commands/cmd_demo_001/replays", `{"command_id":"replay-1","reason":"repair"}`) - if w.Code != http.StatusAccepted { - t.Fatalf("replay status=%d body=%s", w.Code, w.Body.String()) - } - rows, err := s.DB().Query(`SELECT status FROM outbox WHERE event_id = 'replay-idem-1'`) - if err != nil { - t.Fatal(err) - } - defer rows.Close() - if !rows.Next() { - t.Fatal("replay was not persisted to outbox") - } -} diff --git a/internal/dispatcher/agent_test.go b/internal/dispatcher/agent_test.go index e5ae550..e13c5ce 100644 --- a/internal/dispatcher/agent_test.go +++ b/internal/dispatcher/agent_test.go @@ -6,10 +6,10 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/contract" rpcserver "git.ipao.vip/rogee/go-sip/internal/rpc" + "git.ipao.vip/rogee/go-sip/internal/testfixture" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" @@ -81,7 +81,7 @@ func TestAgentCoordinatorActivatesExecutesAndControlsWithoutRetryingOriginate(t t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/dispatcher/ai_mq_integration_test.go b/internal/dispatcher/ai_mq_integration_test.go new file mode 100644 index 0000000..0ab84de --- /dev/null +++ b/internal/dispatcher/ai_mq_integration_test.go @@ -0,0 +1,165 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" +) + +func TestLocalMQAIConfigurationAuthorizationRoundTrip(t *testing.T) { + address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") + if address == "" { + t.Skip("dedicated local RabbitMQ vhost not configured") + } + endpoint, err := url.Parse(address) + if err != nil || (endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "localhost" && endpoint.Hostname() != "::1") { + t.Fatal("loopback RabbitMQ required") + } + id, key := uuid.NewString(), "ai."+uuid.NewString() + broker, err := mq.Open(address, id) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + queue, err := broker.DeclareTenantQueue(key) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, key) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "dispatcher.db") + s, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { s.Close() }() + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + d, err := New(s, broker, nil) + if err != nil { + t.Fatal(err) + } + connection, err := amqp.Dial(address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + channel, err := connection.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + defer channel.QueueDelete(route.InboxQueue, false, false, false) + defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) + defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) + if err := channel.Confirm(false); err != nil { + t.Fatal(err) + } + load := func(name string) map[string]any { + t.Helper() + raw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/" + name + ".json") + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + return value + } + encode := func(value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw + } + now := time.Now().UTC() + request := load("ai-config-request") + requestID := uuid.NewString() + request["dispatcher_id"], request["tenant_key"], request["message_id"] = id, key, requestID + request["issued_at"], request["not_after"] = now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) + requestRaw := encode(request) + if err := s.QueueAIConfigRequest(requestRaw); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if count, err := d.FlushOutbox(ctx, 10); err != nil || count != 1 { + t.Fatalf("request publish count=%d error=%v", count, err) + } + delivery, ok, err := channel.Get(mq.SaaSQueue, false) + if err != nil || !ok { + t.Fatalf("missing AI request: %v", err) + } + if string(delivery.Body) != string(requestRaw) || delivery.MessageId != requestID || delivery.DeliveryMode != amqp.Persistent || delivery.RoutingKey != route.OutboundKey { + t.Fatal("AI request lost identity, scope or persistence") + } + if err := delivery.Ack(false); err != nil { + t.Fatal(err) + } + reply := load("ai-config-result") + reply["dispatcher_id"], reply["tenant_key"], reply["correlation_id"] = id, key, requestID + reply["message_id"], reply["issued_at"] = uuid.NewString(), now.Format(time.RFC3339Nano) + payload := reply["payload"].(map[string]any) + auth := payload["authorization"].(map[string]any) + auth["tenant_key"], auth["config_sha256"] = key, payload["snapshot"].(map[string]any)["content_sha256"] + auth["issued_at"], auth["expires_at"] = now.Add(-time.Second).Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) + replyRaw := encode(reply) + for attempt := 0; attempt < 2; attempt++ { + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, MessageId: reply["message_id"].(string), ContentType: "application/json", Body: replyRaw}) + if err != nil { + t.Fatal(err) + } + if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { + t.Fatalf("reply confirm: %v", err) + } + received, ok, err := channel.Get(queue, false) + if err != nil || !ok { + t.Fatalf("reply absent from D queue: %v", err) + } + if err := d.AcceptMQMessage(received.Body, received.RoutingKey); err != nil { + t.Fatal(err) + } + if err := received.Ack(false); err != nil { + t.Fatal(err) + } + } + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + snapshot, authorization, err := s.LoadAuthorizedAI("tenant-a", key, "version-a", "egress-mock") + if err != nil || len(authorization) == 0 || snapshot.Digest != payload["snapshot"].(map[string]any)["content_sha256"] { + t.Fatalf("authorized snapshot recovery failed: %v", err) + } + if _, _, err := s.LoadAuthorizedAI("tenant-a", key, "version-a", "other-egress"); err == nil { + t.Fatal("unauthorized egress accepted") + } + if _, _, err := s.LoadAuthorizedAI("other-tenant", key, "version-a", "egress-mock"); err == nil { + t.Fatal("cross-tenant authorization accepted") + } + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM ai_mq_requests`).Scan(&count); err != nil || count != 1 { + t.Fatalf("request duplication: count=%d error=%v", count, err) + } +} diff --git a/internal/dispatcher/consumer.go b/internal/dispatcher/consumer.go index ff80678..ac5c9a9 100644 --- a/internal/dispatcher/consumer.go +++ b/internal/dispatcher/consumer.go @@ -2,13 +2,70 @@ package dispatcher import ( "context" + "encoding/json" "errors" + "fmt" + "log/slog" "strings" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" ) +// AcceptMQMessage returns only after the input decision and response are durable. +func (d *Dispatcher) AcceptMQMessage(body []byte, routingKey string) error { + var discriminator struct { + MessageType string `json:"message_type"` + CommandType string `json:"command_type"` + } + if err := json.Unmarshal(body, &discriminator); err != nil { + return mq.Permanent(fmt.Errorf("decode MQ message: %w", err)) + } + var err error + switch discriminator.MessageType { + case "ai.config.result": + err = d.store.StoreAIConfigResponse(body, routingKey) + if err == nil { + slog.Info("MQ AI configuration response persisted") + } + case "command.query", "call.query": + var responseID string + var duplicate bool + responseID, duplicate, err = d.store.HandleQuery(body, routingKey) + if err == nil { + slog.Info("MQ query response persisted", "message_type", discriminator.MessageType, "response_id", responseID, "duplicate", duplicate) + } + case "": + switch discriminator.CommandType { + case "task.control": + var responseID string + var duplicate bool + d.executionMu.Lock() + responseID, duplicate, err = d.store.HandleTaskControl(body, routingKey) + d.executionMu.Unlock() + if err == nil { + slog.Info("MQ task control persisted", "response_id", responseID, "duplicate", duplicate) + } + case "call.replay", "command.replay": + var responseID string + var duplicate bool + responseID, duplicate, err = d.store.HandleReplay(body, routingKey) + if err == nil { + slog.Info("MQ replay decision persisted", "command_type", discriminator.CommandType, "response_id", responseID, "duplicate", duplicate) + } + default: + _, err = d.AcceptCommand(body, routingKey) + } + default: + return mq.Permanent(errors.New("unsupported inbound MQ service message")) + } + if err != nil && isPermanentCommandError(err) { + return mq.Permanent(err) + } + return err +} + func (d *Dispatcher) ConsumeTenant(ctx context.Context, broker *mq.Broker, tenantKey string) error { if broker == nil { return errors.New("broker is required") @@ -21,18 +78,12 @@ func (d *Dispatcher) ConsumeTenant(ctx context.Context, broker *mq.Broker, tenan return err } return broker.Consume(ctx, queue, func(ctx context.Context, routingKey string, body []byte) error { - if _, err := d.AcceptCommand(body, routingKey); err != nil { - if isPermanentCommandError(err) { - return mq.Permanent(err) - } - return err - } - return nil + return d.AcceptMQMessage(body, routingKey) }) } func isPermanentCommandError(err error) bool { - if errors.Is(err, contract.ErrInvalidTenantKey) { + if errors.Is(err, contract.ErrInvalidServiceMessage) || errors.Is(err, contract.ErrInvalidTenantKey) || errors.Is(err, store.ErrMessageScope) || errors.Is(err, store.ErrIdempotencyConflict) || errors.Is(err, store.ErrAIConfigResponse) || errors.Is(err, store.ErrCommandConflict) { return true } message := err.Error() diff --git a/internal/dispatcher/consumer_test.go b/internal/dispatcher/consumer_test.go index 4a71a93..a5a4017 100644 --- a/internal/dispatcher/consumer_test.go +++ b/internal/dispatcher/consumer_test.go @@ -1,19 +1,185 @@ package dispatcher import ( + "bytes" + "encoding/json" "errors" "testing" + "time" + "git.ipao.vip/rogee/go-sip/contracts" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" ) +func TestMQCommandQueryPersistsBeforeAcceptance(t *testing.T) { + s, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + const id = "c046b893-8628-4589-ae50-619d049248a6" + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + d, err := New(s, nil, nil) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/command-query.json") + if err != nil { + t.Fatal(err) + } + var message map[string]any + if err := json.Unmarshal(raw, &message); err != nil { + t.Fatal(err) + } + message["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) + message["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) + raw, err = json.Marshal(message) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, "tenant-a") + if err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected one persistent response before ACK, got %d", count) + } +} + +func TestMQReplayPersistsDecisionBeforeAcknowledgment(t *testing.T) { + for _, name := range []string{"call-replay", "command-replay"} { + t.Run(name, func(t *testing.T) { + s, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + const id = "c046b893-8628-4589-ae50-619d049248a6" + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + d, err := New(s, nil, nil) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/" + name + ".json") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatal(err) + } + request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) + request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) + raw, err = json.Marshal(request) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, "tenant-a") + if err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM mq_command_receipts`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("replay decision not durably deduplicated") + } + }) + } +} + +func TestMQCallQueryPersistsResponse(t *testing.T) { + s, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + const id = "c046b893-8628-4589-ae50-619d049248a6" + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + d, err := New(s, nil, nil) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/call-query.json") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatal(err) + } + request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) + request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) + raw, err = json.Marshal(request) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, "tenant-a") + if err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + var count int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE json_extract(body,'$.message_type')='call.query.result'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatal("call query was acknowledged without a persisted response") + } +} + +func TestInvalidServiceEncodingIsPermanent(t *testing.T) { + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/command-query.json") + if err != nil { + t.Fatal(err) + } + raw = bytes.Replace(raw, []byte("command-a"), []byte{0xff}, 1) + if !bytes.Contains(raw, []byte{0xff}) { + t.Fatal("invalid UTF-8 fixture was not constructed") + } + _, err = contract.DecodeService(raw) + if err == nil || !isPermanentCommandError(err) { + t.Fatalf("invalid encoding would be requeued: %v", err) + } +} + func TestPermanentCommandClassification(t *testing.T) { if !isPermanentCommandError(contract.ErrInvalidTenantKey) { t.Fatal("tenant validation must be permanent") } - if !isPermanentCommandError(errors.New("mq schema validation: required field")) { - t.Fatal("schema validation must be permanent") + for _, raw := range [][]byte{[]byte(`{}`), []byte(`{"schema_version":"invalid"}`), []byte(`{`)} { + _, _, err := contract.DecodeExecute(raw) + if err == nil || !isPermanentCommandError(err) { + t.Fatalf("actual malformed message must be permanent: %v", err) + } } if isPermanentCommandError(errors.New("sqlite busy")) { t.Fatal("storage failure must be retried") diff --git a/internal/dispatcher/control_mq_integration_test.go b/internal/dispatcher/control_mq_integration_test.go new file mode 100644 index 0000000..592f9fb --- /dev/null +++ b/internal/dispatcher/control_mq_integration_test.go @@ -0,0 +1,225 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "git.ipao.vip/rogee/go-sip/internal/testfixture" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" +) + +func TestLocalMQActiveControlReplyRecovery(t *testing.T) { + address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") + if address == "" { + t.Skip("dedicated local RabbitMQ vhost not configured") + } + endpoint, err := url.Parse(address) + if err != nil || (endpoint.Hostname() != "localhost" && endpoint.Hostname() != "127.0.0.1" && endpoint.Hostname() != "::1") { + t.Fatal("loopback RabbitMQ required") + } + id, key := uuid.NewString(), "control."+uuid.NewString() + broker, err := mq.Open(address, id) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + queue, err := broker.DeclareTenantQueue(key) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, key) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "dispatcher.db") + s, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { s.Close() }() + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + if err := s.SetQuota("global", 1); err != nil { + t.Fatal(err) + } + d, err := New(s, broker, nil) + if err != nil { + t.Fatal(err) + } + connection, err := amqp.Dial(address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + channel, err := connection.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + defer channel.QueueDelete(route.InboxQueue, false, false, false) + defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) + defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) + if err := channel.Confirm(false); err != nil { + t.Fatal(err) + } + encode := func(value any) []byte { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return raw + } + raw, err := testfixture.Execute() + if err != nil { + t.Fatal(err) + } + var command map[string]any + if err := json.Unmarshal(raw, &command); err != nil { + t.Fatal(err) + } + command["dispatcher_id"], command["tenant_key"] = id, key + raw = encode(command) + if _, err := d.AcceptCommand(raw, route.InboundKey); err != nil { + t.Fatal(err) + } + task, err := d.ReserveTask(key, "reservation-mq-control", []string{"global"}) + if err != nil { + t.Fatal(err) + } + mockNow := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) + coordinator := NewAgentCoordinator(func() time.Time { return mockNow }) + agentStatus := agentStatusForLocalFlow() + client := startMockAgent(t, &agentStatus) + if err := coordinator.Register("agent-a", client); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := coordinator.Activate(ctx, "agent-a", "cell-a", "boot-a", "epoch-a", 1); err != nil { + t.Fatal(err) + } + if result, err := d.ExecuteReserved(ctx, coordinator, "agent-a", task, raw, "reservation-mq-control", strings.Repeat("a", 64)); err != nil || result.Unknown { + t.Fatalf("prepare execution: %v", err) + } + controlRaw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/task-control.json") + if err != nil { + t.Fatal(err) + } + var control map[string]any + if err := json.Unmarshal(controlRaw, &control); err != nil { + t.Fatal(err) + } + commandID := uuid.NewString() + control["dispatcher_id"], control["tenant_key"], control["tenant_id"], control["command_id"] = id, key, task.TenantID, commandID + now := time.Now().UTC() + control["issued_at"], control["not_after"] = now.Format(time.RFC3339Nano), now.Add(time.Minute).Format(time.RFC3339Nano) + payload := control["payload"].(map[string]any) + payload["task_id"], payload["expected_task_revision"] = task.TaskID, task.TaskRevision + controlRaw = encode(control) + send := func() { + t.Helper() + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, ContentType: "application/json", MessageId: commandID, Body: controlRaw}) + if err != nil { + t.Fatal(err) + } + if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { + t.Fatalf("control confirm: %v", err) + } + delivery, ok, err := channel.Get(queue, false) + if err != nil || !ok { + t.Fatalf("control missing: %v", err) + } + if err := d.AcceptMQMessage(delivery.Body, delivery.RoutingKey); err != nil { + t.Fatal(err) + } + if err := delivery.Ack(false); err != nil { + t.Fatal(err) + } + } + send() + if count, err := d.ProcessTaskControls(ctx, lostControlReply{coordinator}, 10); err == nil || count != 0 { + t.Fatal("lost reply acknowledged as applied") + } + // Dispatcher restart after the Agent applied the control but before its reply + // was persisted must recover the original target and operation, not execute. + if err := s.Close(); err != nil { + t.Fatal(err) + } + s, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + d, err = New(s, broker, nil) + if err != nil { + t.Fatal(err) + } + if count, err := d.ProcessTaskControls(ctx, coordinator, 10); err != nil || count != 1 { + t.Fatalf("control recovery count=%d error=%v", count, err) + } + var finalID string + for attempt := 0; attempt < 2; attempt++ { + if attempt > 0 { + send() + } + if _, err := d.FlushOutbox(ctx, 10); err != nil { + t.Fatal(err) + } + found := false + for { + delivery, ok, err := channel.Get(mq.SaaSQueue, false) + if err != nil { + t.Fatal(err) + } + if !ok { + break + } + var event map[string]any + if err := json.Unmarshal(delivery.Body, &event); err != nil { + t.Fatal(err) + } + if event["aggregate_id"] == commandID { + body := event["payload"].(map[string]any) + if body["status"] == "applied" { + if delivery.DeliveryMode != amqp.Persistent || delivery.RoutingKey != route.OutboundKey || delivery.MessageId != event["event_id"] { + t.Fatal("final control receipt lost identity or persistence") + } + if finalID != "" && finalID != delivery.MessageId { + t.Fatal("duplicate control created a new final receipt") + } + finalID = delivery.MessageId + found = true + } + } + if err := delivery.Ack(false); err != nil { + t.Fatal(err) + } + } + if !found { + t.Fatal("no final applied control receipt reached SaaS queue") + } + } + if count, err := d.ProcessTaskControls(ctx, coordinator, 10); err != nil || count != 0 { + t.Fatal("duplicate control changed Agent again") + } + var tasks, revision int + if err := s.DB().QueryRow(`SELECT COUNT(*),MAX(task_revision) FROM tasks`).Scan(&tasks, &revision); err != nil { + t.Fatal(err) + } + if tasks != 1 || int64(revision) != task.TaskRevision+1 { + t.Fatalf("duplicate execution/revision: tasks=%d revision=%d", tasks, revision) + } +} diff --git a/internal/dispatcher/control_worker.go b/internal/dispatcher/control_worker.go new file mode 100644 index 0000000..e8ffa2a --- /dev/null +++ b/internal/dispatcher/control_worker.go @@ -0,0 +1,83 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +type TaskController interface { + Control(context.Context, string, *agentv1.ExecutionBinding, agentv1.ControlAction, agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) +} + +// ProcessTaskControls retries only the original control, never an execution or +// originate. Uncertain acknowledgements leave the durable target pending. +func (d *Dispatcher) ProcessTaskControls(ctx context.Context, controller TaskController, limit int) (int, error) { + if controller == nil { + return 0, errors.New("task controller is required") + } + ctx, cancelBatch := context.WithTimeout(ctx, 10*time.Second) + defer cancelBatch() + // ponytail: one Agent per P1 Dispatcher; serialization orders control against + // last execution admission. Revisit granularity only if this scope expands. + d.executionMu.Lock() + defer d.executionMu.Unlock() + targets, err := d.store.PendingTaskControls(limit) + if err != nil { + return 0, err + } + completed := 0 + var failures []error + for _, target := range targets { + if err := ctx.Err(); err != nil { + return completed, errors.Join(append(failures, err)...) + } + if target.Binding == nil || target.AgentID == "" { + failures = append(failures, fmt.Errorf("control %s execution %s has no durable Agent assignment", target.CommandID, target.ExecutionID)) + continue + } + var action agentv1.ControlAction + switch target.Action { + case "pause": + action = agentv1.ControlAction_CONTROL_ACTION_PAUSE + case "resume": + action = agentv1.ControlAction_CONTROL_ACTION_RESUME + case "stop": + action = agentv1.ControlAction_CONTROL_ACTION_STOP + default: + failures = append(failures, errors.New("invalid persisted control action")) + continue + } + var policy agentv1.ActiveCallPolicy + switch target.Policy { + case "drain": + policy = agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN + case "hangup": + policy = agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP + default: + failures = append(failures, errors.New("invalid persisted active-call policy")) + continue + } + attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + response, err := controller.Control(attemptCtx, target.AgentID, target.Binding, action, policy) + cancel() + if err == nil && (response.GetReceipt().GetResult() != agentv1.ResultCode_RESULT_CODE_APPLIED || response.GetAppliedTaskRevision() != target.ExpectedRevision+1) { + err = errors.New("Agent has not confirmed the requested applied revision") + } + if err == nil { + err = d.store.CompleteTaskControl(target, response.AppliedTaskRevision) + } + if err != nil { + slog.Error("task control remains pending", "command_id", target.CommandID, "execution_id", target.ExecutionID, "error", err) + failures = append(failures, fmt.Errorf("control %s: %w", target.CommandID, err)) + continue + } + slog.Info("Agent task control application persisted", "command_id", target.CommandID, "execution_id", target.ExecutionID, "revision", response.AppliedTaskRevision) + completed++ + } + return completed, errors.Join(failures...) +} diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go index 2885b87..b14e0b9 100644 --- a/internal/dispatcher/dispatcher.go +++ b/internal/dispatcher/dispatcher.go @@ -15,9 +15,10 @@ import ( ) type Dispatcher struct { - store *store.Store - publisher mq.Publisher - now func() time.Time + executionMu sync.Mutex + store *store.Store + publisher mq.Publisher + now func() time.Time } func New(s *store.Store, publisher mq.Publisher, now func() time.Time) (*Dispatcher, error) { @@ -40,15 +41,27 @@ func (d *Dispatcher) FlushOutbox(ctx context.Context, limit int) (int, error) { if d.publisher == nil { return 0, errors.New("outbox publisher is not configured") } - records, err := d.store.ClaimOutbox(limit) - if err != nil { - return 0, err + if limit <= 0 { + return 0, errors.New("outbox limit must be positive") } published := 0 - for _, record := range records { + for published < limit { + if err := ctx.Err(); err != nil { + return published, err + } + // Claim only the message being sent. A failure must not strand the + // remainder of a preclaimed batch until a process restart. + records, err := d.store.ClaimOutbox(1) + if err != nil { + return published, err + } + if len(records) == 0 { + break + } + record := records[0] if err := d.publisher.Publish(ctx, record.Exchange, record.RoutingKey, record.Body); err != nil { - _ = d.store.MarkOutboxRetry(record.ID, err) - return published, fmt.Errorf("publish outbox %s: %w", record.EventID, err) + retryErr := d.store.MarkOutboxRetry(record.ID, err) + return published, fmt.Errorf("publish outbox %s: %w", record.EventID, errors.Join(err, retryErr)) } if err := d.store.MarkOutboxPublished(record.ID); err != nil { return published, fmt.Errorf("mark outbox %s published: %w", record.EventID, err) @@ -66,10 +79,6 @@ func (d *Dispatcher) ReserveTask(tenantKey, reservationID string, scopes []strin if err := d.store.Reserve(reservationID, task.ExecutionID, tenantKey, scopes); err != nil { return store.Task{}, err } - if err := d.store.MarkTaskReserved(task.ExecutionID); err != nil { - _ = d.store.ReleaseReservationWithScopes(reservationID, scopes, false) - return store.Task{}, err - } task.Status = "reserved" return task, nil } @@ -78,6 +87,10 @@ func (d *Dispatcher) ReserveTask(tenantKey, reservationID string, scopes []strin // Dispatcher reservation and the fenced Agent RPC. It never retries a remote // unknown result: the reservation is moved to unknown and remains counted. func (d *Dispatcher) ExecuteReserved(ctx context.Context, coordinator *AgentCoordinator, agentID string, task store.Task, raw []byte, reservationID, configSHA256 string) (DispatchResult, error) { + d.executionMu.Lock() + defer d.executionMu.Unlock() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() if coordinator == nil || task.ExecutionID == "" || reservationID == "" { return DispatchResult{}, errors.New("coordinator, reserved task and reservation are required") } @@ -106,6 +119,9 @@ func (d *Dispatcher) ExecuteReserved(ctx context.Context, coordinator *AgentCoor RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID, } + if err := d.store.BindExecutionAgent(agentID, binding); err != nil { + return DispatchResult{}, errors.Join(err, d.store.FinalizeReservation(reservationID, task.ExecutionID, false)) + } result, err := coordinator.ExecuteRaw(ctx, agentID, binding, raw, reservationID, configSHA256) if err != nil { if finalizeErr := d.store.FinalizeReservation(reservationID, task.ExecutionID, result.Unknown); finalizeErr != nil { diff --git a/internal/dispatcher/dispatcher_test.go b/internal/dispatcher/dispatcher_test.go index c50bf9b..58ce22f 100644 --- a/internal/dispatcher/dispatcher_test.go +++ b/internal/dispatcher/dispatcher_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/testfixture" _ "modernc.org/sqlite" ) @@ -39,24 +39,27 @@ func TestFlushOutboxPublishesAfterDurableIngest(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) pub := &fakePublisher{} d, err := New(st, pub, func() time.Time { return now }) if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } count, err := d.FlushOutbox(context.Background(), 10) if err != nil || count != 1 || len(pub.bodies) != 1 { t.Fatalf("flush count=%d err=%v published=%d", count, err, len(pub.bodies)) } - if pub.exchanges[0] != "agent-call.events.v1" { + if pub.exchanges[0] != "agent-call.saas.v2" { t.Fatalf("exchange=%q", pub.exchanges[0]) } } @@ -67,15 +70,18 @@ func TestOutboxClaimRecoveryPublishesAfterRestart(t *testing.T) { if err != nil { t.Fatal(err) } + if err := first.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } firstDispatcher, err := New(first, nil, time.Now) if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := firstDispatcher.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := firstDispatcher.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } claimed, err := first.ClaimOutbox(1) @@ -95,6 +101,9 @@ func TestOutboxClaimRecoveryPublishesAfterRestart(t *testing.T) { t.Error(err) } }) + if err := second.RecoverOutbox(); err != nil { + t.Fatal(err) + } publisher := &fakePublisher{} secondDispatcher, err := New(second, publisher, time.Now) if err != nil { @@ -126,17 +135,21 @@ func TestOutboxProcessCrashRecovery(t *testing.T) { fmt.Fprintln(os.Stderr, err) os.Exit(2) } + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } d, err := New(st, nil, time.Now) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(3) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(4) } - if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(5) } @@ -163,6 +176,9 @@ func TestOutboxProcessCrashRecovery(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = st.Close() }) + if err := st.RecoverOutbox(); err != nil { + t.Fatal(err) + } publisher := &fakePublisher{} d, err := New(st, publisher, time.Now) if err != nil { @@ -180,16 +196,19 @@ func TestFlushOutboxMarksRetryOnPublishFailure(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } pub := &fakePublisher{err: errors.New("broker unavailable")} d, err := New(st, pub, time.Now) if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } if _, err := d.FlushOutbox(context.Background(), 10); err == nil { @@ -218,6 +237,9 @@ func TestExecuteReservedBindsQuotaAndAgentExecution(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-1"} { if err := st.SetQuota(scope, 1); err != nil { t.Fatal(err) @@ -228,11 +250,11 @@ func TestExecuteReservedBindsQuotaAndAgentExecution(t *testing.T) { if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(raw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } task, err := d.ReserveTask("tenant-demo-key", "reservation-integration", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}) @@ -302,6 +324,9 @@ func TestFairSchedulerPersistsCursorAcrossRestart(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } second, err := NewFairSchedulerFromStore(st, "tenant-rotation", []string{"a", "b", "c"}) if err != nil { t.Fatal(err) diff --git a/internal/dispatcher/local_flow_test.go b/internal/dispatcher/local_flow_test.go index 27154c3..5974336 100644 --- a/internal/dispatcher/local_flow_test.go +++ b/internal/dispatcher/local_flow_test.go @@ -2,6 +2,8 @@ package dispatcher import ( "context" + "encoding/json" + "errors" "testing" "time" @@ -12,6 +14,7 @@ import ( "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/health" "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestLocalContractBackedFlowEvidence(t *testing.T) { @@ -41,7 +44,7 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v3/examples/ai-authorization.json") if err != nil { t.Fatal(err) } @@ -59,6 +62,9 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-a"} { if err := st.SetQuota(scope, 1); err != nil { t.Fatal(err) @@ -68,11 +74,11 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { if err != nil { t.Fatal(err) } - callRaw, err := contracts.Read("examples/call.execute.json") + callRaw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := d.AcceptCommand(callRaw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + if _, err := d.AcceptCommand(callRaw, testfixture.InboundKey("tenant-demo-key")); err != nil { t.Fatal(err) } task, err := d.ReserveTask("tenant-demo-key", "reservation-local-flow", []string{"tenant:tenant-demo-key", "global", "cell:cell-a"}) @@ -93,6 +99,40 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { t.Fatalf("local execution result=%+v err=%v", result, err) } + controlRaw, err := contracts.Files.ReadFile("upstream/" + contract.MQSourceCommit + "/examples/task-control.json") + if err != nil { + t.Fatal(err) + } + var control map[string]any + if err := json.Unmarshal(controlRaw, &control); err != nil { + t.Fatal(err) + } + control["dispatcher_id"], control["tenant_id"], control["tenant_key"] = testfixture.DispatcherID, task.TenantID, task.TenantKey + controlIssued := time.Now().UTC() + control["issued_at"], control["not_after"] = controlIssued.Format(time.RFC3339Nano), controlIssued.Add(time.Minute).Format(time.RFC3339Nano) + payload := control["payload"].(map[string]any) + payload["task_id"], payload["expected_task_revision"] = task.TaskID, task.TaskRevision + controlRaw, err = json.Marshal(control) + if err != nil { + t.Fatal(err) + } + if err := d.AcceptMQMessage(controlRaw, testfixture.InboundKey(task.TenantKey)); err != nil { + t.Fatal(err) + } + if count, err := d.ProcessTaskControls(context.Background(), lostControlReply{coordinator}, 10); err == nil || count != 0 { + t.Fatal("lost reply incorrectly recorded applied") + } + if count, err := d.ProcessTaskControls(context.Background(), coordinator, 10); err != nil || count != 1 { + t.Fatalf("control recovery: count=%d err=%v", count, err) + } + if count, err := d.ProcessTaskControls(context.Background(), coordinator, 10); err != nil || count != 0 { + t.Fatal("applied target repeated") + } + controlled, err := st.FindTask(task.TenantID, task.TaskID) + if err != nil || controlled.Status != "paused" || controlled.TaskRevision != task.TaskRevision+1 { + t.Fatalf("control result: %+v %v", controlled, err) + } + event, err := (agent.EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-local"}).TranscriptUpdated(now, "event-local", "call-local", "turn-local", "segment-local", "customer", "hello", 1, true, 0, 100) if err != nil { t.Fatal(err) @@ -102,6 +142,15 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { } } +type lostControlReply struct{ coordinator *AgentCoordinator } + +func (c lostControlReply) Control(ctx context.Context, id string, binding *agentv1.ExecutionBinding, action agentv1.ControlAction, policy agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) { + if _, err := c.coordinator.Control(ctx, id, binding, action, policy); err != nil { + return nil, err + } + return nil, errors.New("injected loss of applied control reply") +} + func agentStatusForLocalFlow() agentv1.AgentStatus { return agentv1.AgentStatus{AgentId: "agent-a", CellId: "cell-a"} } diff --git a/internal/dispatcher/mq_integration_test.go b/internal/dispatcher/mq_integration_test.go index b8c1e17..69c0eed 100644 --- a/internal/dispatcher/mq_integration_test.go +++ b/internal/dispatcher/mq_integration_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" "git.ipao.vip/rogee/go-sip/internal/mq" "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestLocalDispatcherConsumesCommandIntoSQLiteAndPublishesOutbox(t *testing.T) { @@ -41,7 +41,7 @@ func TestLocalDispatcherConsumesCommandIntoSQLiteAndPublishesOutbox(t *testing.T if err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/dispatcher/outbox_failure_test.go b/internal/dispatcher/outbox_failure_test.go new file mode 100644 index 0000000..76208ef --- /dev/null +++ b/internal/dispatcher/outbox_failure_test.go @@ -0,0 +1,42 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestOutboxFailureDoesNotStrandUnsentBatch(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for i := 0; i < 3; i++ { + if _, err := st.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,'tenant-a','exchange','key','{}','pending','2026-09-21T00:00:00Z')`, fmt.Sprintf("event-%d", i)); err != nil { + t.Fatal(err) + } + } + publisher := &fakePublisher{err: errors.New("confirm unavailable")} + d, err := New(st, publisher, nil) + if err != nil { + t.Fatal(err) + } + if _, err := d.FlushOutbox(context.Background(), 3); err == nil { + t.Fatal("publish failure hidden") + } + var stranded int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE status='dispatching'`).Scan(&stranded); err != nil { + t.Fatal(err) + } + if stranded != 0 { + t.Fatalf("%d unsent messages require unnecessary restart", stranded) + } + publisher.err = nil + if count, err := d.FlushOutbox(context.Background(), 3); err != nil || count != 3 { + t.Fatalf("recovery count=%d err=%v", count, err) + } +} diff --git a/internal/dispatcher/query_mq_integration_test.go b/internal/dispatcher/query_mq_integration_test.go new file mode 100644 index 0000000..72df560 --- /dev/null +++ b/internal/dispatcher/query_mq_integration_test.go @@ -0,0 +1,256 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "net/url" + "os" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" +) + +// A dedicated local vhost prevents other package tests from consuming these +// messages from the contract's fixed SaaS queue. +func TestLocalMQRequestRoundTrip(t *testing.T) { + address := os.Getenv("GO_SIP_LOCAL_QUERY_MQ_URL") + if address == "" { + t.Skip("dedicated local RabbitMQ vhost not configured") + } + u, err := url.Parse(address) + if err != nil || (u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" && u.Hostname() != "localhost") { + t.Fatal("test requires loopback RabbitMQ") + } + for _, fixture := range []string{"command-query", "call-query", "call-replay", "command-replay", "call-execute"} { + t.Run(fixture, func(t *testing.T) { runLocalMQRequest(t, address, fixture) }) + } +} + +func runLocalMQRequest(t *testing.T, address, fixture string) { + t.Helper() + id, key := uuid.NewString(), "request."+uuid.NewString() + broker, err := mq.Open(address, id) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + s, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + if err := s.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + d, err := New(s, broker, nil) + if err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, key) + if err != nil { + t.Fatal(err) + } + if _, err := broker.DeclareTenantQueue(key); err != nil { + t.Fatal(err) + } + connection, err := amqp.Dial(address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + channel, err := connection.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + defer channel.QueueDelete(route.InboxQueue, false, false, false) + defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) + defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) + if err := channel.Confirm(false); err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/" + fixture + ".json") + if err != nil { + t.Fatal(err) + } + var request map[string]any + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatal(err) + } + requestID := uuid.NewString() + request["dispatcher_id"], request["tenant_key"] = id, key + _, isCommand := request["command_type"] + identityField := "message_id" + if isCommand { + identityField = "command_id" + } + request[identityField] = requestID + request["issued_at"] = time.Now().UTC().Format(time.RFC3339Nano) + request["not_after"] = time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano) + raw, err = json.Marshal(request) + if err != nil { + t.Fatal(err) + } + isExecute := fixture == "call-execute" + isReplay := isCommand && !isExecute + var originalFact []byte + if isReplay { + originalFact = seedReplayMQFact(t, s, id, key, route.OutboundKey) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- d.ConsumeTenant(ctx, broker, key) }() + defer func() { cancel(); <-finished }() + originalResponseID := "" + factDeliveries := 0 + for attempt := 0; attempt < 2; attempt++ { + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, mq.DefaultExchange, route.InboundKey, true, false, amqp.Publishing{ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: raw}) + if err != nil { + t.Fatal(err) + } + if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { + t.Fatalf("input not queued: %v", err) + } + ticker := time.NewTicker(10 * time.Millisecond) + delivered := false + for !delivered { + select { + case <-ctx.Done(): + ticker.Stop() + t.Fatal("MQ response timed out") + case <-ticker.C: + n, err := d.FlushOutbox(ctx, 1) + if err != nil { + ticker.Stop() + t.Fatal(err) + } + if n == 0 { + continue + } + message, ok, err := channel.Get(mq.SaaSQueue, true) + if err != nil || !ok { + ticker.Stop() + t.Fatalf("confirmed output absent: %v", err) + } + if err := contract.ValidateMQMessage(message.Body); err != nil { + ticker.Stop() + t.Fatal(err) + } + var response struct { + DispatcherID string `json:"dispatcher_id"` + TenantKey string `json:"tenant_key"` + MessageID string `json:"message_id"` + CorrelationID string `json:"correlation_id"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Payload struct { + CommandID string `json:"command_id"` + Status string `json:"status"` + } `json:"payload"` + } + if err := json.Unmarshal(message.Body, &response); err != nil { + ticker.Stop() + t.Fatal(err) + } + if response.DispatcherID != id || response.TenantKey != key || message.DeliveryMode != amqp.Persistent || message.RoutingKey != route.OutboundKey { + ticker.Stop() + t.Fatal("output scope or durability mismatch") + } + if isReplay && response.EventType != "command.result" { + if string(message.Body) != string(originalFact) { + ticker.Stop() + t.Fatal("replay rewrote the original business fact") + } + factDeliveries++ + continue + } + responseID := response.MessageID + if isCommand { + responseID = response.EventID + wantStatus := "applied" + if isExecute { + wantStatus = "accepted" + } + if response.Payload.CommandID != requestID || response.Payload.Status != wantStatus { + ticker.Stop() + t.Fatal("incorrect replay receipt") + } + } else if response.CorrelationID != requestID { + ticker.Stop() + t.Fatal("incorrect query correlation") + } + if attempt == 0 { + originalResponseID = responseID + } else if responseID != originalResponseID { + ticker.Stop() + t.Fatal("duplicate created another response identity") + } + delivered = true + } + } + ticker.Stop() + } + if isReplay && factDeliveries != 1 { + t.Fatalf("original fact delivered %d times; duplicate re-executed replay", factDeliveries) + } + var tasks int + if err := s.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&tasks); err != nil { + t.Fatal(err) + } + wantTasks := 0 + if isExecute { + wantTasks = 1 + } + if tasks != wantTasks { + t.Fatalf("created %d tasks, want %d", tasks, wantTasks) + } +} + +func seedReplayMQFact(t *testing.T, s *store.Store, id, key, routingKey string) []byte { + t.Helper() + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/call-execute.json") + if err != nil { + t.Fatal(err) + } + var command map[string]any + if err := json.Unmarshal(raw, &command); err != nil { + t.Fatal(err) + } + command["dispatcher_id"], command["tenant_key"], command["command_id"] = id, key, "command-a" + command["payload"].(map[string]any)["execution_id"] = "execution-1" + raw, err = json.Marshal(command) + if err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`INSERT INTO inbox(tenant_id,command_id,tenant_key,command_type,body_hash,body,status,received_at) VALUES('tenant-a','command-a',?,'call.execute','fixture',?,'persisted','2026-09-21T00:00:00Z')`, key, raw); err != nil { + t.Fatal(err) + } + raw, err = contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/examples/event-call-status.json") + if err != nil { + t.Fatal(err) + } + var event map[string]any + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } + event["dispatcher_id"], event["tenant_key"] = id, key + raw, err = json.Marshal(event) + if err != nil { + t.Fatal(err) + } + if err := contract.ValidateMQMessage(raw); err != nil { + t.Fatal(err) + } + if _, err := s.DB().Exec(`INSERT INTO outbox(event_id,tenant_key,exchange,routing_key,body,status,created_at) VALUES(?,?,'agent-call.saas.v2',?,?,'published',?)`, event["event_id"], key, routingKey, raw, event["occurred_at"]); err != nil { + t.Fatal(err) + } + return raw +} diff --git a/internal/dispatcher/two_cell_test.go b/internal/dispatcher/two_cell_test.go index e92b440..6115df5 100644 --- a/internal/dispatcher/two_cell_test.go +++ b/internal/dispatcher/two_cell_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/contract" rpcserver "git.ipao.vip/rogee/go-sip/internal/rpc" + "git.ipao.vip/rogee/go-sip/internal/testfixture" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/test/bufconn" @@ -33,7 +33,7 @@ func TestTwoMockCellsKeepAgentSessionsAndPermitsSeparate(t *testing.T) { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/mq/amqp.go b/internal/mq/amqp.go index b62448e..a35472f 100644 --- a/internal/mq/amqp.go +++ b/internal/mq/amqp.go @@ -4,10 +4,13 @@ package mq import ( "context" + "encoding/json" "errors" "fmt" + "log/slog" "sync" "sync/atomic" + "time" "git.ipao.vip/rogee/go-sip/internal/tenant" amqp "github.com/rabbitmq/amqp091-go" @@ -16,10 +19,12 @@ import ( var consumerSequence atomic.Uint64 const ( - DefaultExchange = tenant.CommandExchange - EventExchange = tenant.EventExchange - DeadLetterExchange = "agent-call.dead-letter.v1" + DefaultExchange = "agent-call.dispatchers.v2" + EventExchange = "agent-call.saas.v2" + DeadLetterExchange = "agent-call.dead-letter.v2" + SaaSQueue = "agent-call.saas.events.v2" DefaultPrefetch = 1 + MaxMessageBytes = 256 << 10 ) type Publisher interface { @@ -44,30 +49,29 @@ func IsPermanent(err error) bool { } type Broker struct { - conn *amqp.Connection - channel *amqp.Channel - exchange string - eventExchange string - prefetch int - mu sync.Mutex + conn *amqp.Connection + channel *amqp.Channel + dispatcherID string + prefetch int + outbound map[string]bool + inboxes map[string]bool + closed <-chan *amqp.Error + mu sync.Mutex } -func Open(url, exchange string) (*Broker, error) { - return OpenWithPrefetch(url, exchange, DefaultPrefetch) +func Open(url, dispatcherID string) (*Broker, error) { + return OpenWithPrefetch(url, dispatcherID, DefaultPrefetch) } -func OpenWithPrefetch(url, exchange string, prefetch int) (*Broker, error) { +func OpenWithPrefetch(url, dispatcherID string, prefetch int) (*Broker, error) { if url == "" { return nil, errors.New("rabbitmq URL is required") } if prefetch <= 0 { return nil, errors.New("prefetch must be positive") } - if exchange == "" { - exchange = DefaultExchange - } - if exchange != DefaultExchange { - return nil, fmt.Errorf("unsupported command exchange %q", exchange) + if err := tenant.ValidateDispatcherID(dispatcherID); err != nil { + return nil, err } conn, err := amqp.Dial(url) if err != nil { @@ -78,67 +82,103 @@ func OpenWithPrefetch(url, exchange string, prefetch int) (*Broker, error) { _ = conn.Close() return nil, fmt.Errorf("open rabbitmq channel: %w", err) } - if err := channel.ExchangeDeclare(exchange, "direct", true, false, false, false, nil); err != nil { - _ = channel.Close() + owner := "agent-call.d." + dispatcherID + ".owner.v2" + if _, err := channel.QueueDeclare(owner, false, false, true, false, nil); err != nil { _ = conn.Close() - return nil, fmt.Errorf("declare command exchange: %w", err) + return nil, fmt.Errorf("claim dispatcher identity %s: %w", dispatcherID, err) } - if err := channel.ExchangeDeclare(EventExchange, "topic", true, false, false, false, nil); err != nil { - _ = channel.Close() - _ = conn.Close() - return nil, fmt.Errorf("declare event exchange: %w", err) + for _, exchange := range []string{DefaultExchange, EventExchange, DeadLetterExchange} { + if err := channel.ExchangeDeclare(exchange, "topic", true, false, false, false, nil); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("declare topic exchange %s: %w", exchange, err) + } } - if err := channel.Confirm(false); err != nil { - _ = channel.Close() - _ = conn.Close() - return nil, fmt.Errorf("enable publisher confirms: %w", err) - } - if err := channel.ExchangeDeclare(DeadLetterExchange, "topic", true, false, false, false, nil); err != nil { - _ = channel.Close() - _ = conn.Close() - return nil, fmt.Errorf("declare dead-letter exchange: %w", err) - } - return &Broker{conn: conn, channel: channel, exchange: exchange, eventExchange: EventExchange, prefetch: prefetch}, nil + return &Broker{conn: conn, channel: channel, dispatcherID: dispatcherID, prefetch: prefetch, + outbound: make(map[string]bool), inboxes: make(map[string]bool), closed: conn.NotifyClose(make(chan *amqp.Error, 1))}, nil } func (b *Broker) Close() error { b.mu.Lock() defer b.mu.Unlock() - if b.channel != nil { - _ = b.channel.Close() + if b.conn == nil { + return nil } - if b.conn != nil { - return b.conn.Close() - } - return nil + err := b.conn.Close() + b.channel, b.conn = nil, nil + return err } +// Done reports loss of the connection that owns this Dispatcher identity. +// The caller must stop admission when that ownership connection is lost. +func (b *Broker) Done() <-chan *amqp.Error { return b.closed } + func (b *Broker) Publish(ctx context.Context, exchange, routingKey string, body []byte) error { - if exchange == "" || routingKey == "" || len(body) == 0 { - return errors.New("routing key and body are required") + if exchange != EventExchange || routingKey == "" || len(body) == 0 || len(body) > MaxMessageBytes { + return errors.New("publish requires SaaS exchange, declared route and 1..262144 body bytes") } + var identity struct { + EventID string `json:"event_id"` + MessageID string `json:"message_id"` + } + if err := json.Unmarshal(body, &identity); err != nil { + return fmt.Errorf("decode outbound message identity: %w", err) + } + if (identity.EventID == "") == (identity.MessageID == "") { + return errors.New("outbound message requires exactly one event_id or message_id") + } + messageID := identity.EventID + identity.MessageID + if len(messageID) > 255 { + return errors.New("outbound message identity exceeds AMQP limit") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() b.mu.Lock() defer b.mu.Unlock() - if b.channel == nil { - return errors.New("rabbitmq channel is closed") + if err := ctx.Err(); err != nil { + return err } - if exchange != b.exchange && exchange != b.eventExchange { - return fmt.Errorf("unsupported publish exchange %q", exchange) + if b.conn == nil || b.conn.IsClosed() { + return errors.New("rabbitmq connection is closed") } - confirmation, err := b.channel.PublishWithDeferredConfirmWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ - ContentType: "application/json", - DeliveryMode: amqp.Persistent, - Body: body, + if !b.outbound[routingKey] { + return errors.New("outbound route does not belong to a declared Dispatcher/tenant") + } + // A separate channel isolates late returns/confirms after an ambiguous send. + // Connections are reused; a timed-out publication can never acknowledge the next one. + channel, err := b.conn.Channel() + if err != nil { + return fmt.Errorf("open publication channel: %w", err) + } + defer channel.Close() + if _, err := channel.QueueDeclarePassive(SaaSQueue, true, false, false, false, nil); err != nil { + return fmt.Errorf("required SaaS queue unavailable: %w", err) + } + if err := channel.Confirm(false); err != nil { + return fmt.Errorf("enable publisher confirms: %w", err) + } + returned := channel.NotifyReturn(make(chan amqp.Return, 1)) + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, exchange, routingKey, true, false, amqp.Publishing{ + ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: body, MessageId: messageID, }) if err != nil { - return err + return fmt.Errorf("publish: %w", err) } if confirmation == nil { return errors.New("rabbitmq publisher confirmation unavailable") } acked, err := confirmation.WaitContext(ctx) if err != nil { - return err + return fmt.Errorf("wait publisher confirmation: %w", err) + } + // RabbitMQ sends basic.return before its confirm; the SDK dispatches it + // before completing the deferred confirmation. A positive confirm alone is insufficient. + select { + case result, ok := <-returned: + if !ok { + return errors.New("publication channel closed before routing was established") + } + return fmt.Errorf("publication returned: code=%d", result.ReplyCode) + default: } if !acked { return errors.New("rabbitmq publisher was negatively acknowledged") @@ -147,7 +187,7 @@ func (b *Broker) Publish(ctx context.Context, exchange, routingKey string, body } func (b *Broker) DeclareTenantQueue(tenantKey string) (string, error) { - queue, err := tenant.CommandQueue(tenantKey) + route, err := tenant.NewDispatcherRoute(b.dispatcherID, tenantKey) if err != nil { return "", err } @@ -156,14 +196,7 @@ func (b *Broker) DeclareTenantQueue(tenantKey string) (string, error) { if b.channel == nil { return "", errors.New("rabbitmq channel is closed") } - routingKey, err := tenant.CommandRoutingKey(tenantKey) - if err != nil { - return "", err - } - deadLetterQueue, err := tenant.DeadLetterQueue(tenantKey) - if err != nil { - return "", err - } + queue, routingKey, deadLetterQueue := route.InboxQueue, route.InboundKey, route.DeadLetterQueue queueArgs := amqp.Table{ "x-dead-letter-exchange": DeadLetterExchange, "x-dead-letter-routing-key": routingKey, @@ -174,12 +207,19 @@ func (b *Broker) DeclareTenantQueue(tenantKey string) (string, error) { if _, err := b.channel.QueueDeclare(deadLetterQueue, true, false, false, false, nil); err != nil { return "", fmt.Errorf("declare tenant dead-letter queue: %w", err) } - if err := b.channel.QueueBind(queue, routingKey, b.exchange, false, nil); err != nil { + if err := b.channel.QueueBind(queue, routingKey, DefaultExchange, false, nil); err != nil { return "", fmt.Errorf("bind tenant queue: %w", err) } if err := b.channel.QueueBind(deadLetterQueue, routingKey, DeadLetterExchange, false, nil); err != nil { return "", fmt.Errorf("bind tenant dead-letter queue: %w", err) } + if _, err := b.channel.QueueDeclare(SaaSQueue, true, false, false, false, nil); err != nil { + return "", fmt.Errorf("declare SaaS queue: %w", err) + } + if err := b.channel.QueueBind(SaaSQueue, route.OutboundKey, EventExchange, false, nil); err != nil { + return "", fmt.Errorf("bind SaaS queue: %w", err) + } + b.outbound[route.OutboundKey], b.inboxes[queue] = true, true return queue, nil } @@ -197,10 +237,11 @@ func (b *Broker) Consume(ctx context.Context, queue string, handler MessageHandl b.mu.Unlock() return errors.New("rabbitmq channel is closed") } - prefetch := b.prefetch - if prefetch <= 0 { - prefetch = DefaultPrefetch + if !b.inboxes[queue] { + b.mu.Unlock() + return errors.New("queue does not belong to a declared Dispatcher/tenant") } + prefetch := b.prefetch if err := b.channel.Qos(prefetch, 0, false); err != nil { b.mu.Unlock() return fmt.Errorf("set tenant prefetch: %w", err) @@ -226,6 +267,13 @@ func (b *Broker) Consume(ctx context.Context, queue string, handler MessageHandl if !ok { return errors.New("rabbitmq delivery channel closed") } + if len(d.Body) == 0 || len(d.Body) > MaxMessageBytes { + if err := d.Reject(false); err != nil { + return fmt.Errorf("reject invalid message size: %w", err) + } + slog.Warn("MQ message rejected", "dispatcher_id", b.dispatcherID, "delivery_tag", d.DeliveryTag, "reason", "invalid_message_size", "bytes", len(d.Body)) + continue + } if err := handler(ctx, d.RoutingKey, d.Body); err != nil { if IsPermanent(err) { if rejectErr := d.Reject(false); rejectErr != nil { diff --git a/internal/mq/disconnect_integration_test.go b/internal/mq/disconnect_integration_test.go new file mode 100644 index 0000000..bb2e38d --- /dev/null +++ b/internal/mq/disconnect_integration_test.go @@ -0,0 +1,44 @@ +package mq + +import ( + "context" + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" +) + +func TestV2LocalBrokerDisconnectStopsPublisher(t *testing.T) { + address := localBrokerURL(t) + broker, err := Open(address, uuid.NewString()) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + tenantKey := "disconnect-" + uuid.NewString() + if _, err := broker.DeclareTenantQueue(tenantKey); err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(broker.dispatcherID, tenantKey) + if err != nil { + t.Fatal(err) + } + // Closing the underlying AMQP connection models a broker disconnect while + // leaving the Broker object in the state observed by the runtime watcher. + if err := broker.conn.Close(); err != nil { + t.Fatal(err) + } + select { + case <-broker.Done(): + case <-time.After(3 * time.Second): + t.Fatal("broker disconnect was not surfaced") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + err = broker.Publish(ctx, EventExchange, route.OutboundKey, []byte(`{"event_id":"after-disconnect"}`)) + if err == nil || !strings.Contains(err.Error(), "closed") { + t.Fatalf("publisher accepted a disconnected broker: %v", err) + } +} diff --git a/internal/mq/integration_test.go b/internal/mq/integration_test.go index 72c48e6..8d129e8 100644 --- a/internal/mq/integration_test.go +++ b/internal/mq/integration_test.go @@ -11,6 +11,8 @@ import ( "time" "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" ) func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { @@ -18,7 +20,7 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { if url == "" { t.Skip("RABBITMQ_URL is not configured") } - broker, err := OpenWithPrefetch(url, "", 1) + broker, err := OpenWithPrefetch(url, uuid.NewString(), 1) if err != nil { t.Fatal(err) } @@ -29,18 +31,47 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { if err != nil { t.Fatal(err) } - routingKey, err := tenant.CommandRoutingKey(tenantKey) - if err != nil { - t.Fatal(err) - } - deadLetterQueue, err := tenant.DeadLetterQueue(tenantKey) + route, err := tenant.NewDispatcherRoute(broker.dispatcherID, tenantKey) if err != nil { t.Fatal(err) } + routingKey := route.InboundKey + deadLetterQueue := route.DeadLetterQueue body := []byte(`{"command_id":"integration-command"}`) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() + publishCommand := func(ctx context.Context) error { + channel, err := broker.conn.Channel() + if err != nil { + return err + } + defer channel.Close() + if err := channel.Confirm(false); err != nil { + return err + } + returned := channel.NotifyReturn(make(chan amqp.Return, 1)) + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, DefaultExchange, routingKey, true, false, amqp.Publishing{ + DeliveryMode: amqp.Persistent, + Body: body, + }) + if err != nil { + return err + } + acked, err := confirmation.WaitContext(ctx) + if err != nil { + return err + } + select { + case result := <-returned: + return fmt.Errorf("command publication returned: code=%d", result.ReplyCode) + default: + } + if !acked { + return errors.New("command publication was negatively acknowledged") + } + return nil + } acked := make(chan struct{}, 1) consumeDone := make(chan error, 1) go func() { @@ -52,7 +83,7 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { return nil }) }() - if err := broker.Publish(ctx, DefaultExchange, routingKey, body); err != nil { + if err := publishCommand(ctx); err != nil { t.Fatal(err) } select { @@ -77,7 +108,7 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { return Permanent(errors.New("synthetic permanent command error")) }) }() - if err := broker.Publish(permanentCtx, DefaultExchange, routingKey, body); err != nil { + if err := publishCommand(permanentCtx); err != nil { t.Fatal(err) } select { @@ -92,27 +123,49 @@ func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { t.Fatal("permanent consumer did not stop") } - dlqCtx, dlqCancel := context.WithTimeout(context.Background(), 15*time.Second) - defer dlqCancel() + dlqChannel, err := broker.conn.Channel() + if err != nil { + t.Fatal(err) + } + defer dlqChannel.Close() + if _, err := dlqChannel.QueueDeclarePassive(deadLetterQueue, true, false, false, false, nil); err != nil { + t.Fatal(err) + } + dlqDeliveries, err := dlqChannel.Consume(deadLetterQueue, "sip-go-agent-dlq", false, false, false, false, nil) + if err != nil { + t.Fatal(err) + } deadLettered := make(chan struct{}, 1) dlqDone := make(chan error, 1) go func() { - dlqDone <- broker.Consume(dlqCtx, deadLetterQueue, func(_ context.Context, _ string, gotBody []byte) error { - if string(gotBody) != string(body) { - return fmt.Errorf("dead-letter body mismatch: %q", gotBody) + for delivery := range dlqDeliveries { + if string(delivery.Body) != string(body) { + dlqDone <- fmt.Errorf("dead-letter body mismatch: %q", delivery.Body) + return + } + if err := delivery.Ack(false); err != nil { + dlqDone <- err + return } deadLettered <- struct{}{} - return nil - }) + dlqDone <- nil + return + } + dlqDone <- errors.New("dead-letter delivery channel closed") }() select { case <-deadLettered: - dlqCancel() - case <-dlqCtx.Done(): - t.Fatal(dlqCtx.Err()) + _ = dlqChannel.Cancel("sip-go-agent-dlq", false) + case err := <-dlqDone: + t.Fatal(err) + case <-time.After(15 * time.Second): + t.Fatal("dead-letter message did not arrive") } select { - case <-dlqDone: + case err := <-dlqDone: + if err != nil { + t.Fatal(err) + } case <-time.After(3 * time.Second): t.Fatal("dead-letter consumer did not stop") } diff --git a/internal/mq/v2_integration_test.go b/internal/mq/v2_integration_test.go new file mode 100644 index 0000000..b9068d3 --- /dev/null +++ b/internal/mq/v2_integration_test.go @@ -0,0 +1,172 @@ +package mq + +import ( + "context" + "net/url" + "os" + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" +) + +// This opt-in is deliberately separate from any external broker setting. +func localBrokerURL(t *testing.T) string { + t.Helper() + value := os.Getenv("GO_SIP_LOCAL_MQ_URL") + if value == "" { + t.Skip("isolated local RabbitMQ not configured") + } + parsed, err := url.Parse(value) + if err != nil || (parsed.Hostname() != "127.0.0.1" && parsed.Hostname() != "::1" && parsed.Hostname() != "localhost") { + t.Fatal("MQ integration requires a loopback broker") + } + return value +} + +func TestV2LocalBrokerIdentityIsolationAndReliableRouting(t *testing.T) { + address := localBrokerURL(t) + id1, id2 := uuid.NewString(), uuid.NewString() + first, err := Open(address, id1) + if err != nil { + t.Fatal(err) + } + defer first.Close() + second, err := Open(address, id2) + if err != nil { + t.Fatal(err) + } + defer second.Close() + if duplicate, err := Open(address, id1); err == nil { + duplicate.Close() + t.Fatal("duplicate live identity accepted") + } + key := "租户." + uuid.NewString() + q1, err := first.DeclareTenantQueue(key) + if err != nil { + t.Fatal(err) + } + q2, err := second.DeclareTenantQueue(key) + if err != nil { + t.Fatal(err) + } + if q1 == q2 { + t.Fatal("different Dispatchers share a queue") + } + r1, _ := tenant.NewDispatcherRoute(id1, key) + r2, _ := tenant.NewDispatcherRoute(id2, key) + connection, err := amqp.Dial(address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + channel, err := connection.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + defer channel.QueueDelete(q1, false, false, false) + defer channel.QueueDelete(q2, false, false, false) + defer channel.QueueDelete(r1.DeadLetterQueue, false, false, false) + defer channel.QueueDelete(r2.DeadLetterQueue, false, false, false) + defer channel.QueueUnbind(SaaSQueue, r1.OutboundKey, EventExchange, nil) + defer channel.QueueUnbind(SaaSQueue, r2.OutboundKey, EventExchange, nil) + if err := channel.Confirm(false); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + confirmation, err := channel.PublishWithDeferredConfirmWithContext(ctx, DefaultExchange, r1.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, Body: []byte(`{"target":"first"}`)}) + if err != nil { + t.Fatal(err) + } + if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { + t.Fatalf("command publication: ack=%v error=%v", ack, err) + } + if _, ok, err := channel.Get(q2, true); err != nil || ok { + t.Fatalf("second Dispatcher received first's message: ok=%v error=%v", ok, err) + } + message, ok, err := channel.Get(q1, true) + if err != nil || !ok || string(message.Body) != `{"target":"first"}` { + t.Fatalf("first did not receive its own message: ok=%v error=%v", ok, err) + } + if err := first.Consume(ctx, q2, func(context.Context, string, []byte) error { return nil }); err == nil { + t.Fatal("foreign queue consumption accepted") + } + if err := first.Publish(ctx, EventExchange, r2.OutboundKey, []byte(`{}`)); err == nil { + t.Fatal("foreign publication accepted") + } + if err := first.Publish(ctx, EventExchange, r1.OutboundKey, []byte(`{"event_id":"uploaded"}`)); err != nil { + t.Fatal(err) + } + message, ok, err = channel.Get(SaaSQueue, true) + if err != nil || !ok || message.RoutingKey != r1.OutboundKey || message.DeliveryMode != amqp.Persistent { + t.Fatalf("expected persistent target-queue delivery: ok=%v error=%v", ok, err) + } + if err := channel.QueueUnbind(SaaSQueue, r1.OutboundKey, EventExchange, nil); err != nil { + t.Fatal(err) + } + if err := first.Publish(ctx, EventExchange, r1.OutboundKey, []byte(`{"event_id":"unroutable"}`)); err == nil || !strings.Contains(err.Error(), "returned") { + t.Fatalf("unroutable confirm treated as delivery: %v", err) + } + if err := channel.QueueBind(SaaSQueue, r1.OutboundKey, EventExchange, false, nil); err != nil { + t.Fatal(err) + } + if err := first.Publish(ctx, EventExchange, r1.OutboundKey, []byte(`{"event_id":"recovered"}`)); err != nil { + t.Fatalf("return contaminated later publication: %v", err) + } + message, ok, err = channel.Get(SaaSQueue, true) + if err != nil || !ok || string(message.Body) != `{"event_id":"recovered"}` || message.MessageId != "recovered" { + t.Fatalf("recovery delivery missing: ok=%v error=%v", ok, err) + } + consumeCtx, stop := context.WithCancel(ctx) + defer stop() + called := make(chan struct{}, 1) + finished := make(chan error, 1) + go func() { + finished <- first.Consume(consumeCtx, q1, func(context.Context, string, []byte) error { called <- struct{}{}; return nil }) + }() + confirmation, err = channel.PublishWithDeferredConfirmWithContext(ctx, DefaultExchange, r1.InboundKey, true, false, amqp.Publishing{DeliveryMode: amqp.Persistent, Body: []byte(strings.Repeat("x", MaxMessageBytes+1))}) + if err != nil { + t.Fatal(err) + } + if ack, err := confirmation.WaitContext(ctx); err != nil || !ack { + t.Fatalf("large message publication: %v", err) + } + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-called: + t.Fatal("oversized message reached the business handler") + case <-deadline.C: + t.Fatal("oversized message was not durably dead-lettered") + case <-ticker.C: + message, ok, err := channel.Get(r1.DeadLetterQueue, true) + if err != nil { + t.Fatal(err) + } + if ok { + if len(message.Body) != MaxMessageBytes+1 { + t.Fatal("wrong dead-lettered message") + } + stop() + <-finished + return + } + } + } +} + +func TestOpenRejectsNonCanonicalIdentityBeforeDial(t *testing.T) { + for _, id := range []string{"", DefaultExchange, "dispatcher", "00000000-0000-0000-0000-000000000000"} { + if _, err := Open("amqp://unused.invalid", id); err == nil || !strings.Contains(err.Error(), "dispatcher_id") { + t.Fatalf("identity %q not rejected before dial: %v", id, err) + } + } +} diff --git a/internal/oss/aliyun.go b/internal/oss/aliyun.go index 218df7e..70618a7 100644 --- a/internal/oss/aliyun.go +++ b/internal/oss/aliyun.go @@ -43,8 +43,8 @@ func (c Config) Validate() error { if err != nil || parsed.Host == "" || parsed.Scheme != "https" { return errors.New("OSS endpoint must be a valid HTTPS URL") } - if c.GrantTTL <= 0 || c.GrantTTL > 7*24*time.Hour { - return errors.New("OSS grant TTL must be between 1 second and 7 days") + if c.GrantTTL != defaultGrantTTL { + return errors.New("OSS grant TTL must be exactly 15 minutes") } if c.MaxAssetBytes <= 0 { return errors.New("OSS max asset bytes must be positive") @@ -88,7 +88,7 @@ func (c *Client) Grant(ctx context.Context, uploadID, objectKey, checksum string return nil, errors.New("upload ID, object key and checksum are required") } if maxBytes <= 0 || maxBytes > c.config.MaxAssetBytes { - maxBytes = c.config.MaxAssetBytes + return nil, errors.New("requested upload size must be positive and within the configured limit") } if now.IsZero() { now = time.Now() @@ -119,36 +119,3 @@ func (c *Client) Grant(ctx context.Context, uploadID, objectKey, checksum string MaxBytes: maxBytes, }, nil } - -func (c *Client) Verify(ctx context.Context, objectKey, checksum string, size int64) error { - if c == nil || c.client == nil { - return errors.New("OSS client is not configured") - } - result, err := c.client.HeadObject(ctx, &aliyunoss.HeadObjectRequest{ - Bucket: aliyunoss.Ptr(c.config.Bucket), - Key: aliyunoss.Ptr(objectKey), - }) - if err != nil { - return fmt.Errorf("head OSS object: %w", err) - } - if result.ContentLength != size { - return fmt.Errorf("OSS object size mismatch: expected %d got %d", size, result.ContentLength) - } - storedChecksum := "" - for key, value := range result.Metadata { - key = strings.ToLower(strings.TrimSpace(key)) - key = strings.TrimPrefix(key, "x-oss-meta-") - if key == "sha256" { - storedChecksum = value - break - } - } - if !strings.EqualFold(strings.TrimSpace(checksum), strings.TrimSpace(storedChecksum)) { - return errors.New("OSS object checksum metadata mismatch") - } - return nil -} - -func (c *Client) ObjectID(objectKey string) string { - return "oss://" + c.config.Bucket + "/" + strings.TrimPrefix(objectKey, "/") -} diff --git a/internal/oss/aliyun_integration_test.go b/internal/oss/aliyun_integration_test.go index 9a9c712..70ef34f 100644 --- a/internal/oss/aliyun_integration_test.go +++ b/internal/oss/aliyun_integration_test.go @@ -13,7 +13,7 @@ import ( ossclient "git.ipao.vip/rogee/go-sip/internal/oss" ) -func TestAlibabaOSSGrantPutHeadIntegration(t *testing.T) { +func TestAlibabaOSSGrantPutIntegration(t *testing.T) { if os.Getenv("AGENT_CALL_OSS_INTEGRATION") != "1" { t.Skip("set AGENT_CALL_OSS_INTEGRATION=1 to use the authorized Alibaba OSS test bucket") } @@ -58,9 +58,6 @@ func TestAlibabaOSSGrantPutHeadIntegration(t *testing.T) { if result.SizeBytes != int64(len(payload)) || !strings.EqualFold(result.SHA256, checksum) { t.Fatalf("upload result mismatch: %+v", result) } - if err := client.Verify(context.Background(), objectKey, checksum, int64(len(payload))); err != nil { - t.Fatal(err) - } } func redactError(err error, accessKeyID string) string { diff --git a/internal/oss/aliyun_test.go b/internal/oss/aliyun_test.go new file mode 100644 index 0000000..18af23f --- /dev/null +++ b/internal/oss/aliyun_test.go @@ -0,0 +1,53 @@ +package oss + +import ( + "context" + "strings" + "testing" + "time" +) + +func signingFixture() Config { + return Config{Endpoint: "https://oss.example.invalid", Region: "cn-test", Bucket: "test-bucket", AccessKeyID: "local-test-key", AccessKeySecret: "local-test-secret", GrantTTL: 15 * time.Minute, MaxAssetBytes: 1024} +} + +func TestGrantLifetimeIsFixed(t *testing.T) { + for _, ttl := range []time.Duration{-time.Second, time.Second, 14 * time.Minute, 16 * time.Minute, time.Hour} { + cfg := signingFixture() + cfg.GrantTTL = ttl + if _, err := NewClient(cfg); err == nil { + t.Fatalf("unsupported TTL accepted: %v", ttl) + } + } + for _, ttl := range []time.Duration{0, 15 * time.Minute} { + cfg := signingFixture() + cfg.GrantTTL = ttl + client, err := NewClient(cfg) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + grant, err := client.Grant(context.Background(), "upload-a", "recordings/a", strings.Repeat("a", 64), 100, now) + if err != nil { + t.Fatal(err) + } + if grant.ExpiresAtUnixMs != now.Add(15*time.Minute).UnixMilli() { + t.Fatal("grant is not exactly 15 minutes") + } + if grant.MaxBytes != 100 { + t.Fatal("requested object size was changed") + } + } +} + +func TestGrantRejectsInvalidBoundsInsteadOfClamping(t *testing.T) { + client, err := NewClient(signingFixture()) + if err != nil { + t.Fatal(err) + } + for _, size := range []int64{-1, 0, 1025} { + if _, err := client.Grant(context.Background(), "upload-a", "recordings/a", strings.Repeat("a", 64), size, time.Now()); err == nil { + t.Fatalf("invalid size %d silently accepted", size) + } + } +} diff --git a/internal/rpc/ai_authorization_test.go b/internal/rpc/ai_authorization_test.go index d4d1278..0ef9d79 100644 --- a/internal/rpc/ai_authorization_test.go +++ b/internal/rpc/ai_authorization_test.go @@ -9,6 +9,7 @@ import ( agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/ai" "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -22,7 +23,7 @@ func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v3/examples/ai-authorization.json") if err != nil { t.Fatal(err) } @@ -34,7 +35,7 @@ func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) { }) activateTestServer(t, server) - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/rpc/calllog_test.go b/internal/rpc/calllog_test.go index 1eadb1f..e1ccd92 100644 --- a/internal/rpc/calllog_test.go +++ b/internal/rpc/calllog_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/calllog" "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" ) func TestServerWritesPhoneCorrelatedCallBusinessLog(t *testing.T) { @@ -30,7 +30,7 @@ func TestServerWritesPhoneCorrelatedCallBusinessLog(t *testing.T) { }); err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } diff --git a/internal/rpc/control_policy_test.go b/internal/rpc/control_policy_test.go new file mode 100644 index 0000000..bba1222 --- /dev/null +++ b/internal/rpc/control_policy_test.go @@ -0,0 +1,32 @@ +package rpc + +import ( + "context" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" +) + +func TestStopDrainPreservesCallAndCannotResume(t *testing.T) { + s := activatedServer(time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC), t) + raw, err := testfixture.Execute() + require.NoError(t, err) + envelope, payload, err := contract.DecodeExecute(raw) + require.NoError(t, err) + binding := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID} + _, err = s.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: testMeta("execute", "execute-key", 1), Binding: binding, CallExecuteJson: raw}) + require.NoError(t, err) + s.executions[binding.ExecutionId].state = agentv1.ExecutionState_EXECUTION_STATE_OBSERVED + stopped, err := s.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("stop", "stop-key", 1), Binding: binding, ExpectedTaskRevision: binding.TaskRevision, Action: agentv1.ControlAction_CONTROL_ACTION_STOP, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, stopped.Receipt.Result) + require.Equal(t, agentv1.ExecutionState_EXECUTION_STATE_OBSERVED, stopped.State) + binding.TaskRevision = stopped.AppliedTaskRevision + resumed, err := s.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("resume", "resume-key", 1), Binding: binding, ExpectedTaskRevision: binding.TaskRevision, Action: agentv1.ControlAction_CONTROL_ACTION_RESUME, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, resumed.Receipt.Result) + require.Equal(t, stopped.AppliedTaskRevision, resumed.AppliedTaskRevision) +} diff --git a/internal/rpc/control_policy_validation_test.go b/internal/rpc/control_policy_validation_test.go new file mode 100644 index 0000000..ec370e3 --- /dev/null +++ b/internal/rpc/control_policy_validation_test.go @@ -0,0 +1,48 @@ +package rpc + +import ( + "context" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func TestControlPolicyDoesNotInventMediaCompletion(t *testing.T) { + for _, tc := range []struct { + name, mode string + action agentv1.ControlAction + policy agentv1.ActiveCallPolicy + wantError, terminal bool + }{ + {name: "missing policy", mode: "mock", action: agentv1.ControlAction_CONTROL_ACTION_STOP, wantError: true}, + {name: "invalid policy", mode: "mock", action: agentv1.ControlAction_CONTROL_ACTION_STOP, policy: agentv1.ActiveCallPolicy(99), wantError: true}, + {name: "mixed hangup requires media adapter", mode: "mixed", action: agentv1.ControlAction_CONTROL_ACTION_STOP, policy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP, wantError: true}, + {name: "real pause hangup requires media adapter", mode: "real", action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, policy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP, wantError: true}, + {name: "mock pause hangup", mode: "mock", action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, policy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP, terminal: true}, + {name: "mock pause drain", mode: "mock", action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, policy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN}, + } { + t.Run(tc.name, func(t *testing.T) { + s := activatedServer(time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC), t) + // Unit-only adapter mode selection: no provider or network call is made. + s.mode = tc.mode + binding := &agentv1.ExecutionBinding{ExecutionId: "execution-policy", TenantId: "tenant-1", TenantKey: "tenant-key", TaskId: "task-1", TaskItemId: "item-1", TaskRevision: 1} + s.executions[binding.ExecutionId] = &executionRecord{binding: binding, taskRevision: 1, state: agentv1.ExecutionState_EXECUTION_STATE_OBSERVED} + response, err := s.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control", "control-key", 1), Binding: binding, ExpectedTaskRevision: 1, Action: tc.action, ActiveCallPolicy: tc.policy}) + if tc.wantError { + if err == nil || response != nil { + t.Fatal("unsupported policy reported applied") + } + if s.executions[binding.ExecutionId].taskRevision != 1 || s.executions[binding.ExecutionId].state != agentv1.ExecutionState_EXECUTION_STATE_OBSERVED { + t.Fatal("failed policy changed execution") + } + return + } + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, response.Receipt.Result) + if (response.State == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL) != tc.terminal { + t.Fatal("incorrect call termination") + } + }) + } +} diff --git a/internal/rpc/dispatcher_events.go b/internal/rpc/dispatcher_events.go index 441eed6..05da852 100644 --- a/internal/rpc/dispatcher_events.go +++ b/internal/rpc/dispatcher_events.go @@ -9,6 +9,7 @@ import ( agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" "git.ipao.vip/rogee/go-sip/internal/store" "git.ipao.vip/rogee/go-sip/internal/tenant" "google.golang.org/grpc/codes" @@ -82,6 +83,14 @@ func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *a if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } + dispatcherID, err := s.store.DispatcherID() + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "execution facts require bound Dispatcher identity: %v", err) + } + route, err := tenant.NewDispatcherRoute(dispatcherID, binding.TenantKey) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } eventID := "" var eventBuilder store.FactEventBuilder if eventType != "" { @@ -91,7 +100,7 @@ func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *a TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId, EventType: eventType, Aggregate: aggregateType, AggregateID: aggregateID, Version: aggregateVersion, Payload: eventPayload, - }).Marshal(s.now(), eventID) + }).MarshalMQ(dispatcherID, s.now(), eventID) } } bindingJSON, err := protojson.Marshal(binding) @@ -108,9 +117,9 @@ func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *a } routingKey := "" if eventType != "" { - routingKey = "agent-call." + eventType + routingKey = route.OutboundKey } - result, err := s.store.RecordExecutionFact(record, tenant.EventExchange, routingKey, eventBuilder) + result, err := s.store.RecordExecutionFact(record, mq.EventExchange, routingKey, eventBuilder) if err != nil { if errors.Is(err, store.ErrFactConflict) { return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, err.Error(), fact.FactId, fact.ContentSha256)}, nil diff --git a/internal/rpc/dispatcher_events_test.go b/internal/rpc/dispatcher_events_test.go index 58fb567..2289e24 100644 --- a/internal/rpc/dispatcher_events_test.go +++ b/internal/rpc/dispatcher_events_test.go @@ -9,7 +9,11 @@ import ( "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "git.ipao.vip/rogee/go-sip/internal/testfixture" "google.golang.org/protobuf/proto" ) @@ -19,6 +23,9 @@ func TestDispatcherServerReportsFactAndEmitsOneAuthoritativeEvent(t *testing.T) t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID(testfixture.DispatcherID); err != nil { + t.Fatal(err) + } clock := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) events, err := NewDispatcherEventServer(st, DispatcherEventServerOptions{Now: func() time.Time { return clock }}) if err != nil { @@ -64,6 +71,20 @@ func TestDispatcherServerReportsFactAndEmitsOneAuthoritativeEvent(t *testing.T) if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&body); err != nil { t.Fatal(err) } + if err := contract.ValidateMQMessage(body); err != nil { + t.Fatal(err) + } + var exchange, routingKey string + if err := st.DB().QueryRow(`SELECT exchange,routing_key FROM outbox WHERE event_id=?`, "execution-fact-fact-1").Scan(&exchange, &routingKey); err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(testfixture.DispatcherID, binding.TenantKey) + if err != nil { + t.Fatal(err) + } + if exchange != mq.EventExchange || routingKey != route.OutboundKey { + t.Fatal("fact escaped Dispatcher/tenant MQ route") + } var eventEnvelope map[string]any if err := json.Unmarshal(body, &eventEnvelope); err != nil { t.Fatal(err) diff --git a/internal/rpc/dispatcher_upload.go b/internal/rpc/dispatcher_upload.go index 72deb32..1164997 100644 --- a/internal/rpc/dispatcher_upload.go +++ b/internal/rpc/dispatcher_upload.go @@ -7,11 +7,11 @@ import ( "encoding/hex" "errors" "fmt" + "github.com/google/uuid" "strings" "time" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" - "git.ipao.vip/rogee/go-sip/internal/agent" "git.ipao.vip/rogee/go-sip/internal/contract" ossclient "git.ipao.vip/rogee/go-sip/internal/oss" "git.ipao.vip/rogee/go-sip/internal/store" @@ -77,51 +77,56 @@ func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1 record, err := s.store.LoadUpload(req.UploadId) if err == nil { - binding, asset, grant, decodeErr := decodeUploadRecord(record) + binding, asset, _, decodeErr := decodeUploadRecord(record) if decodeErr != nil { return nil, status.Errorf(codes.Internal, "decode durable upload %q: %v", req.UploadId, decodeErr) } if !proto.Equal(binding, req.Binding) || !proto.Equal(asset, req.Asset) { return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload ID is bound to a different execution or asset", false), nil } - detail := "duplicate upload request" - if record.State == "granted" && grant.ExpiresAtUnixMs <= s.now().UnixMilli() { - // This path is reached only when the caller explicitly requests a new - // token after the previous one expired. Agent upload flow does not - // renew or retry automatically. - replacement, grantErr := s.oss.Grant(ctx, req.UploadId, record.ObjectKey, asset.ChecksumSha256, asset.SizeBytes, s.now()) - if grantErr != nil { - return nil, status.Errorf(codes.Internal, "issue replacement OSS upload grant: %v", grantErr) - } - replacementRaw, marshalErr := proto.Marshal(replacement) - if marshalErr != nil { - return nil, status.Errorf(codes.Internal, "encode replacement upload grant: %v", marshalErr) - } - if replaceErr := s.store.ReplaceUploadGrant(req.UploadId, record.ObjectKey, replacementRaw); replaceErr != nil { - return nil, status.Errorf(codes.Internal, "persist replacement upload grant: %v", replaceErr) - } - grant = replacement - detail = "expired upload grant replaced after explicit request" + if record.State == "uploaded" { + return nil, status.Error(codes.Unavailable, "upload already reported; retry completion notification, not PUT") + } + if record.State == "completed" { + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload notification delivered to MQ", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil } - return &agentv1.RequestUploadResponse{ - Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, detail, false), - Grant: proto.Clone(grant).(*agentv1.UploadGrant), - State: uploadStateForRecord(record.State), - }, nil } else if !errors.Is(err, sql.ErrNoRows) { return nil, status.Errorf(codes.Internal, "load upload: %v", err) } + if req.Meta.OperationId == "" || req.Meta.IdempotencyKey == "" { + return nil, status.Error(codes.InvalidArgument, "upload request operation and idempotency identities are required") + } + requestRaw, err := (proto.MarshalOptions{Deterministic: true}).Marshal(req) + if err != nil { + return nil, status.Error(codes.InvalidArgument, "encode upload request") + } + requestSum := sha256.Sum256(requestRaw) + requestHash := hex.EncodeToString(requestSum[:]) + previous, err := s.store.LoadUploadGrantRequest(req.UploadId, req.Meta.OperationId, requestHash) + if err == nil { + var original agentv1.UploadGrant + if err := proto.Unmarshal(previous, &original); err != nil { + return nil, status.Errorf(codes.Internal, "decode original grant: %v", err) + } + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "original upload grant", false), Grant: &original, State: agentv1.UploadState_UPLOAD_STATE_REQUESTED}, nil + } + if errors.Is(err, store.ErrIdempotencyConflict) { + return nil, status.Error(codes.AlreadyExists, "upload request identity conflicts") + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, status.Errorf(codes.Internal, "load original grant: %v", err) + } objectKey := s.objectKey(req.Binding, req.Asset) grant, err := s.oss.Grant(ctx, req.UploadId, objectKey, req.Asset.ChecksumSha256, req.Asset.SizeBytes, s.now()) if err != nil { return nil, status.Errorf(codes.Internal, "create OSS upload grant: %v", err) } - bindingRaw, err := proto.Marshal(req.Binding) + bindingRaw, err := (proto.MarshalOptions{Deterministic: true}).Marshal(req.Binding) if err != nil { return nil, status.Errorf(codes.Internal, "encode upload binding: %v", err) } - assetRaw, err := proto.Marshal(req.Asset) + assetRaw, err := (proto.MarshalOptions{Deterministic: true}).Marshal(req.Asset) if err != nil { return nil, status.Errorf(codes.Internal, "encode upload asset: %v", err) } @@ -129,23 +134,21 @@ func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1 if err != nil { return nil, status.Errorf(codes.Internal, "encode upload grant: %v", err) } - if err := s.store.InsertUpload(store.UploadRecord{ + persisted, err := s.store.IssueUploadGrant(store.UploadRecord{ UploadID: req.UploadId, Binding: bindingRaw, Asset: assetRaw, Grant: grantRaw, ObjectKey: objectKey, + Bucket: s.oss.Config().Bucket, State: "granted", CreatedAt: s.now().UTC(), - }); err != nil { - // A concurrent duplicate is safe to reconcile by reading the durable row. - if existing, loadErr := s.store.LoadUpload(req.UploadId); loadErr == nil { - binding, asset, grant, decodeErr := decodeUploadRecord(existing) - if decodeErr == nil && proto.Equal(binding, req.Binding) && proto.Equal(asset, req.Asset) { - return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "duplicate upload request", false), Grant: grant, State: uploadStateForRecord(existing.State)}, nil - } - } - return nil, status.Errorf(codes.Internal, "persist upload grant: %v", err) + }, req.Meta.OperationId, requestHash) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "persist upload grant: %v", err) + } + if err := proto.Unmarshal(persisted, grant); err != nil { + return nil, status.Errorf(codes.Internal, "decode persisted grant: %v", err) } return &agentv1.RequestUploadResponse{ Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload grant issued", false), @@ -179,33 +182,38 @@ func (s *DispatcherUploadServer) CompleteUpload(ctx context.Context, req *agentv return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded asset does not match grant", false), nil } if record.State == "completed" { - if record.OSSID == "" { - return nil, status.Error(codes.Internal, "completed upload has no OSS ID") - } - return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "duplicate upload completion", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, OssId: record.OSSID}, nil + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload notification delivered to MQ", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil } - if grant.ExpiresAtUnixMs <= s.now().UnixMilli() { - return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "upload grant has expired", true), nil - } - if err := s.oss.Verify(ctx, record.ObjectKey, req.UploadedChecksumSha256, req.UploadedSizeBytes); err != nil { - return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, fmt.Sprintf("verify OSS object: %v", err), true), nil + if record.State == "uploaded" { + return nil, status.Error(codes.Unavailable, "upload fact retained; original notification awaits MQ delivery") } if asset.Kind != agentv1.AssetKind_ASSET_KIND_RECORDING { - return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "only recording assets can be marked recording.ready", false), nil + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "only recording upload facts are supported", false), nil } - ossID := s.oss.ObjectID(record.ObjectKey) - completedAt := s.now().UTC() - eventID := "recording-ready-" + req.UploadId - event, err := (agent.EventWriter{ - TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId, - }).RecordingReady(completedAt, eventID, asset.CallId, asset.AssetId, ossID, asset.Format, asset.Channels, asset.SampleRateHz, asset.DurationMs, asset.SizeBytes, asset.ChecksumSha256) + dispatcherID, err := s.store.DispatcherID() if err != nil { - return nil, status.Errorf(codes.Internal, "build verified recording event: %v", err) + return nil, status.Errorf(codes.FailedPrecondition, "upload requires bound Dispatcher identity: %v", err) } - if err := s.store.CompleteUploadAndOutbox(req.UploadId, ossID, completedAt, eventID, binding.TenantKey, tenant.EventExchange, "agent-call.recording.ready", event); err != nil { - return nil, status.Errorf(codes.Internal, "persist completed upload and event: %v", err) + route, err := tenant.NewDispatcherRoute(dispatcherID, binding.TenantKey) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "upload notification route: %v", err) } - return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "OSS upload verified and recording.ready persisted", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, OssId: ossID}, nil + uploadedAt := s.now().UTC() + eventID := uuid.NewSHA1(uuid.NameSpaceURL, []byte("recording.uploaded:"+dispatcherID+":"+req.UploadId)).String() + event, err := (contract.EventBuilder{ + EventType: "recording.uploaded", TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId, + Aggregate: "recording", AggregateID: asset.AssetId, Version: 1, + Payload: map[string]any{"call_id": asset.CallId, "recording_id": asset.AssetId, "upload_id": req.UploadId, + "bucket": record.Bucket, "object_key": record.ObjectKey, "format": asset.Format, "channels": asset.Channels, + "sample_rate_hz": asset.SampleRateHz, "duration_ms": asset.DurationMs, "size_bytes": asset.SizeBytes, "checksum_sha256": asset.ChecksumSha256}, + }).MarshalMQ(dispatcherID, uploadedAt, eventID) + if err != nil { + return nil, status.Errorf(codes.Internal, "encode upload notification: %v", err) + } + if err := s.store.RecordUploadNotification(req.UploadId, eventID, binding.TenantKey, route.OutboundKey, event, uploadedAt); err != nil { + return nil, status.Errorf(codes.Internal, "persist upload fact and notification: %v", err) + } + return nil, status.Error(codes.Unavailable, "upload fact retained; original notification awaits MQ delivery") } func (s *DispatcherUploadServer) validateRequest(ctx context.Context, meta *agentv1.RequestMeta, binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor, uploadID string) error { @@ -265,16 +273,6 @@ func decodeUploadRecord(record store.UploadRecord) (*agentv1.ExecutionBinding, * return binding, asset, grant, nil } -func uploadStateForRecord(state string) agentv1.UploadState { - if state == "completed" { - return agentv1.UploadState_UPLOAD_STATE_COMPLETED - } - if state == "failed" { - return agentv1.UploadState_UPLOAD_STATE_FAILED - } - return agentv1.UploadState_UPLOAD_STATE_REQUESTED -} - func (s *DispatcherUploadServer) responseMeta(meta *agentv1.RequestMeta) *agentv1.ResponseMeta { return &agentv1.ResponseMeta{ProtocolVersion: meta.ProtocolVersion, RequestId: meta.RequestId, TraceId: meta.TraceId, OperationId: meta.OperationId, ObservedAtUnixMs: s.now().UnixMilli(), DispatcherEpoch: meta.DispatcherEpoch, AgentId: meta.AgentId, CellId: meta.CellId, BootId: meta.BootId, SessionGeneration: meta.SessionGeneration} } diff --git a/internal/rpc/dispatcher_upload_integration_test.go b/internal/rpc/dispatcher_upload_integration_test.go index b914a20..a4c0e5d 100644 --- a/internal/rpc/dispatcher_upload_integration_test.go +++ b/internal/rpc/dispatcher_upload_integration_test.go @@ -15,6 +15,8 @@ import ( "git.ipao.vip/rogee/go-sip/internal/agent" ossclient "git.ipao.vip/rogee/go-sip/internal/oss" "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) @@ -60,6 +62,9 @@ func TestAlibabaOSSDispatcherUploadDurableIntegration(t *testing.T) { t.Fatal(err) } defer st.Close() + if err := st.BindDispatcherID("11111111-1111-4111-8111-111111111111"); err != nil { + t.Fatal(err) + } clock := time.Now() server, err := NewDispatcherUploadServer(st, client, func() time.Time { return clock }, false) if err != nil { @@ -74,6 +79,9 @@ func TestAlibabaOSSDispatcherUploadDurableIntegration(t *testing.T) { t.Fatalf("grant rejected: %+v", grantResponse.Receipt) } clock = clock.Add(16 * time.Minute) + requestMeta = proto.Clone(requestMeta).(*agentv1.RequestMeta) + requestMeta.OperationId = "renew-upload-" + uploadID + requestMeta.IdempotencyKey = "renew-upload-key-" + uploadID retryMeta := proto.Clone(requestMeta).(*agentv1.RequestMeta) retryMeta.RequestId = "request-oss-integration-retry" retryMeta.TraceId = "trace-oss-integration-retry" @@ -96,33 +104,22 @@ func TestAlibabaOSSDispatcherUploadDurableIntegration(t *testing.T) { if err != nil { t.Fatal(err) } - completeResponse, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: result.SizeBytes, UploadedChecksumSha256: result.SHA256}) - if err != nil { - t.Fatal(err) - } - if completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" { - t.Fatalf("completion rejected: %+v", completeResponse.Receipt) + completion := &agentv1.CompleteUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: result.SizeBytes, UploadedChecksumSha256: result.SHA256} + for i := 0; i < 2; i++ { + if _, err := server.CompleteUpload(context.Background(), completion); status.Code(err) != codes.Unavailable { + t.Fatalf("notification without MQ publication must remain pending: %v", err) + } } var outboxCount int - if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "recording-ready-"+uploadID).Scan(&outboxCount); err != nil { + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM upload_notifications WHERE upload_id=?`, uploadID).Scan(&outboxCount); err != nil { t.Fatal(err) } if outboxCount != 1 { - t.Fatalf("recording.ready outbox rows = %d, want 1", outboxCount) + t.Fatalf("upload notification rows = %d, want 1", outboxCount) } - - requestAgain, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID}) - if err != nil { - t.Fatal(err) - } - if requestAgain.Grant.ObjectKey != grantResponse.Grant.ObjectKey || requestAgain.Grant.ExpiresAtUnixMs != grantResponse.Grant.ExpiresAtUnixMs { - t.Fatal("duplicate request changed a completed upload grant") - } - completeAgain, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID, UploadedSizeBytes: result.SizeBytes, UploadedChecksumSha256: result.SHA256}) - if err != nil { - t.Fatal(err) - } - if completeAgain.OssId != completeResponse.OssId || completeAgain.State != agentv1.UploadState_UPLOAD_STATE_COMPLETED { - t.Fatalf("duplicate completion changed result: %+v", completeAgain) + if _, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID}); status.Code(err) != codes.Unavailable { + t.Fatal("uploaded object was reauthorized") } + // This opt-in test establishes the upload fact only. Queue-delivery evidence + // comes from isolated RabbitMQ tests, not this real-object-storage test. } diff --git a/internal/rpc/dispatcher_upload_notification_test.go b/internal/rpc/dispatcher_upload_notification_test.go new file mode 100644 index 0000000..23f4e7c --- /dev/null +++ b/internal/rpc/dispatcher_upload_notification_test.go @@ -0,0 +1,70 @@ +package rpc + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestUploadFactWaitsOnlyForMQPublication(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.BindDispatcherID("11111111-1111-4111-8111-111111111111"); err != nil { + t.Fatal(err) + } + client, err := ossclient.NewClient(ossclient.Config{Endpoint: "https://oss.invalid", Region: "cn-beijing", Bucket: "local-test", AccessKeyID: "local-test", AccessKeySecret: "local-test", GrantTTL: 15 * time.Minute, MaxAssetBytes: 1024}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + server, err := NewDispatcherUploadServer(st, client, func() time.Time { return now }, false) + if err != nil { + t.Fatal(err) + } + meta := &agentv1.RequestMeta{AgentId: "agent-a", CellId: "cell-a", OperationId: "op-a", IdempotencyKey: "key-a", TraceId: "trace-a"} + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", TaskId: "task-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-a", CallId: "call-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 10, SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64)} + grant, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}) + if err != nil || grant.GetGrant() == nil { + t.Fatalf("grant: %v", err) + } + // A delayed notification is not a request for a fresh PUT token. + now = now.Add(20 * time.Minute) + request := &agentv1.CompleteUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a", UploadedSizeBytes: 4, UploadedChecksumSha256: asset.ChecksumSha256} + for i := 0; i < 2; i++ { + _, err := server.CompleteUpload(context.Background(), request) + if status.Code(err) != codes.Unavailable { + t.Fatalf("unpublished notification must remain pending, got %v", err) + } + } + var id int64 + var count int + if err := st.DB().QueryRow(`SELECT COUNT(*), MIN(id) FROM outbox`).Scan(&count, &id); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("notifications=%d", count) + } + if err := st.MarkOutboxPublished(id); err != nil { + t.Fatal(err) + } + completed, err := server.CompleteUpload(context.Background(), request) + if err != nil || completed.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED { + t.Fatalf("completion: %v %v", completed, err) + } + again, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}) + if err != nil || again.GetGrant() != nil || again.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED { + t.Fatal("completed upload was reauthorized") + } +} diff --git a/internal/rpc/execution_journal.go b/internal/rpc/execution_journal.go new file mode 100644 index 0000000..42ccb21 --- /dev/null +++ b/internal/rpc/execution_journal.go @@ -0,0 +1,171 @@ +package rpc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +type savedOperation struct { + Digest string `json:"digest"` + Receipt *agentv1.OperationReceipt `json:"receipt"` + Control *agentv1.ApplyTaskControlResponse `json:"control,omitempty"` +} +type savedExecution struct { + Binding *agentv1.ExecutionBinding `json:"binding"` + Digest string `json:"digest"` + State agentv1.ExecutionState `json:"state"` + Revision int64 `json:"revision"` + CallState string `json:"call_state"` + ControlAction agentv1.ControlAction `json:"control_action"` +} +type executionJournal struct { + Version int `json:"version"` + Mode string `json:"mode"` + AgentID string `json:"agent_id"` + CellID string `json:"cell_id"` + Operations map[string]savedOperation `json:"operations"` + Executions map[string]savedExecution `json:"executions"` +} + +func (s *Server) loadExecutionJournal() error { + if s.executionPath == "" { + return nil + } + raw, err := os.ReadFile(s.executionPath) + if errors.Is(err, os.ErrNotExist) { + if _, sessionErr := os.Stat(s.sessions.statePath); sessionErr == nil { + return errors.New("execution journal missing beside existing session journal") + } else if !errors.Is(sessionErr, os.ErrNotExist) { + return sessionErr + } + // Establish the empty execution journal before any activation can be + // acknowledged; later absence must not silently erase execution history. + return s.persistExecutionJournalLocked() + } + if err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var journal executionJournal + if err := decoder.Decode(&journal); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return errors.New("execution journal has trailing data") + } + if journal.Version != 1 || journal.Mode != s.mode || journal.AgentID != s.status.AgentId || journal.CellID != s.status.CellId || journal.Operations == nil || journal.Executions == nil { + return errors.New("execution journal version or identity is invalid") + } + for key, record := range journal.Operations { + if record.Receipt == nil || record.Receipt.Meta == nil || len(record.Digest) != 64 { + return errors.New("invalid persisted operation") + } + if record.Control != nil && !proto.Equal(record.Control.Receipt, record.Receipt) { + return errors.New("control receipt differs from operation receipt") + } + s.operations[key] = operationRecord{digest: record.Digest, receipt: record.Receipt, control: record.Control} + } + for id, record := range journal.Executions { + if record.Binding == nil || record.Binding.ExecutionId != id || record.Revision != record.Binding.TaskRevision { + return errors.New("invalid persisted execution binding") + } + if _, ok := agentv1.ExecutionState_name[int32(record.State)]; !ok { + return errors.New("invalid persisted execution state") + } + if _, ok := agentv1.ControlAction_name[int32(record.ControlAction)]; !ok { + return errors.New("invalid persisted control action") + } + execution := &executionRecord{binding: record.Binding, executeDigest: record.Digest, taskRevision: record.Revision, callState: record.CallState, controlAction: record.ControlAction, state: record.State} + // A new process cannot infer Asterisk's state from an old local snapshot. + // Never restore permits or clear unknown occupancy because a process restarted. + if execution.state != agentv1.ExecutionState_EXECUTION_STATE_TERMINAL { + execution.state = agentv1.ExecutionState_EXECUTION_STATE_UNKNOWN + execution.unknown = true + } + s.executions[id] = execution + } + return nil +} + +// Caller holds s.mu. Failure poisons further admission: in-memory state must +// never be acknowledged as durable after an unsuccessful journal write. +func (s *Server) persistExecutionJournalLocked() error { + if s.executionErr != nil { + return status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + if s.executionPath == "" { + return nil + } + journal := executionJournal{Version: 1, Mode: s.mode, AgentID: s.status.AgentId, CellID: s.status.CellId, Operations: make(map[string]savedOperation, len(s.operations)), Executions: make(map[string]savedExecution, len(s.executions))} + for key, record := range s.operations { + journal.Operations[key] = savedOperation{Digest: record.digest, Receipt: record.receipt, Control: record.control} + } + for id, record := range s.executions { + journal.Executions[id] = savedExecution{Binding: record.binding, Digest: record.executeDigest, State: record.state, Revision: record.taskRevision, CallState: record.callState, ControlAction: record.controlAction} + } + if err := writeRPCJournal(s.executionPath, journal); err != nil { + s.executionErr = err + return status.Errorf(codes.Internal, "persist execution journal: %v", err) + } + return nil +} +func (s *Server) executionJournalReady() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.executionErr != nil { + return status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + return nil +} + +func writeRPCJournal(path string, value any) error { + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0700); err != nil { + return err + } + file, err := os.CreateTemp(directory, ".rpc-journal-*") + if err != nil { + return err + } + name := file.Name() + defer os.Remove(name) + if err := file.Chmod(0600); err != nil { + _ = file.Close() + return err + } + if err := json.NewEncoder(file).Encode(value); err != nil { + _ = file.Close() + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + dir, err := os.Open(directory) + if err != nil { + return err + } + syncErr := dir.Sync() + closeErr := dir.Close() + if err := errors.Join(syncErr, closeErr); err != nil { + return fmt.Errorf("sync journal directory: %w", err) + } + return nil +} diff --git a/internal/rpc/execution_journal_failure_test.go b/internal/rpc/execution_journal_failure_test.go new file mode 100644 index 0000000..43f3027 --- /dev/null +++ b/internal/rpc/execution_journal_failure_test.go @@ -0,0 +1,42 @@ +package rpc + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" +) + +func TestExecutionJournalFailureCannotReplayMemoryAsSuccess(t *testing.T) { + s := activatedServer(time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC), t) + blocker := filepath.Join(t.TempDir(), "not-a-directory") + require.NoError(t, os.WriteFile(blocker, []byte("block"), 0600)) + s.executionPath = filepath.Join(blocker, "journal") + raw, err := testfixture.Execute() + require.NoError(t, err) + envelope, payload, err := contract.DecodeExecute(raw) + require.NoError(t, err) + req := &agentv1.ExecuteRequest{Meta: testMeta("execute", "execute-key", 1), Binding: &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID}, CallExecuteJson: raw} + response, err := s.Execute(context.Background(), req) + if err == nil || response != nil { + t.Fatal("failed write acknowledged") + } + response, err = s.Execute(context.Background(), req) + if err == nil || response != nil { + t.Fatal("memory replay bypassed failed journal") + } +} + +func TestMissingExecutionJournalWithExistingSessionFailsClosed(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.json") + require.NoError(t, os.WriteFile(path, []byte(`{"generations":{"agent-1":1}}`), 0600)) + s := NewServer(ServerOptions{StatePath: path, Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"}}) + if s.executionJournalReady() == nil { + t.Fatal("missing execution history silently reset") + } +} diff --git a/internal/rpc/execution_journal_mode_test.go b/internal/rpc/execution_journal_mode_test.go new file mode 100644 index 0000000..2013b65 --- /dev/null +++ b/internal/rpc/execution_journal_mode_test.go @@ -0,0 +1,25 @@ +package rpc + +import ( + "path/filepath" + "testing" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func TestExecutionJournalCannotCrossAdapterModes(t *testing.T) { + for _, mode := range []string{"mixed", "real"} { + t.Run(mode, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.json") + status := &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"} + original := NewServer(ServerOptions{Mode: "mock", StatePath: path, Status: status}) + require.NoError(t, original.executionJournalReady()) + recovered := NewServer(ServerOptions{Mode: mode, StatePath: path, Status: status}) + if recovered.executionJournalReady() == nil { + t.Fatal("mock execution state reused by another adapter mode") + } + same := NewServer(ServerOptions{Mode: "mock", StatePath: path, Status: status}) + require.NoError(t, same.executionJournalReady()) + }) + } +} diff --git a/internal/rpc/execution_journal_test.go b/internal/rpc/execution_journal_test.go new file mode 100644 index 0000000..6e8f3ca --- /dev/null +++ b/internal/rpc/execution_journal_test.go @@ -0,0 +1,74 @@ +package rpc + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" + "google.golang.org/protobuf/proto" +) + +func TestControlReceiptSurvivesRestartAndNewSession(t *testing.T) { + now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) + path := filepath.Join(t.TempDir(), "session.json") + start := func(generation uint64) *Server { + bootID := "boot-1" + if generation > 1 { + bootID = "boot-2" + } + s := NewServer(ServerOptions{Mode: "mock", Now: func() time.Time { return now }, StatePath: path, Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: bootID}}) + activationMeta := testMeta("activate", "", 0) + activationMeta.BootId = bootID + _, err := s.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: activationMeta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: bootID, DispatcherEpoch: "epoch-1", SessionGeneration: generation}, ActivationOperationId: "activate"}) + require.NoError(t, err) + return s + } + s := start(1) + raw, err := testfixture.Execute() + require.NoError(t, err) + envelope, payload, err := contract.DecodeExecute(raw) + require.NoError(t, err) + binding := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID} + _, err = s.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: testMeta("execute", "execute-key", 1), Binding: binding, CallExecuteJson: raw}) + require.NoError(t, err) + request := &agentv1.ApplyTaskControlRequest{Meta: testMeta("pause", "pause-key", 1), Binding: binding, Action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN, ExpectedTaskRevision: binding.TaskRevision} + applied, err := s.ApplyTaskControl(context.Background(), request) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, applied.Receipt.Result) + recovered := start(2) + request.Meta.SessionGeneration = 2 + request.Meta.BootId = "boot-2" + freshMeta := func(operation, key string) *agentv1.RequestMeta { + meta := testMeta(operation, key, 2) + meta.BootId = "boot-2" + return meta + } + replayed, err := recovered.ApplyTaskControl(context.Background(), request) + require.NoError(t, err) + if !proto.Equal(applied, replayed) { + t.Fatal("lost original control receipt after restart") + } + snapshot, err := recovered.QueryExecution(context.Background(), &agentv1.QueryExecutionRequest{Meta: freshMeta("query", "query-key"), Binding: binding}) + require.NoError(t, err) + if snapshot.Snapshot == nil || !snapshot.Snapshot.Unknown || snapshot.Snapshot.Binding.TaskRevision != applied.AppliedTaskRevision { + t.Fatal("recovered active work must remain unknown with applied control revision") + } + retry, err := recovered.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: freshMeta("new-execute", "new-key"), Binding: binding, CallExecuteJson: raw}) + require.NoError(t, err) + if retry.Receipt.Result == agentv1.ResultCode_RESULT_CODE_ACCEPTED { + t.Fatal("restarted execution was prepared again") + } +} + +func TestCorruptExecutionJournalPreventsActivation(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.json") + require.NoError(t, os.WriteFile(path+".executions", []byte(`{"version":999}`), 0600)) + s := NewServer(ServerOptions{Mode: "mock", StatePath: path, Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"}}) + _, err := s.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: testMeta("activate", "", 0), Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, ActivationOperationId: "activate"}) + require.Error(t, err) +} diff --git a/internal/rpc/server.go b/internal/rpc/server.go index a52dc49..89311a0 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "os" - "path/filepath" "sync" "time" @@ -71,17 +70,20 @@ type Server struct { callLogger *calllog.Logger sessions *SessionRegistry - mu sync.Mutex - operations map[string]operationRecord - admissions map[string]admissionRecord - executions map[string]*executionRecord - facts map[string]string - uploads map[string]uploadRecord + executionPath string + executionErr error + mu sync.Mutex + operations map[string]operationRecord + admissions map[string]admissionRecord + executions map[string]*executionRecord + facts map[string]string + uploads map[string]uploadRecord } type operationRecord struct { digest string receipt *agentv1.OperationReceipt + control *agentv1.ApplyTaskControlResponse } type admissionRecord struct { @@ -90,6 +92,7 @@ type admissionRecord struct { } type executionRecord struct { + executeDigest string binding *agentv1.ExecutionBinding state agentv1.ExecutionState taskRevision int64 @@ -145,7 +148,7 @@ func NewServer(options ServerOptions) *Server { aiSnapshot, aiConfigError = ai.Validate(options.AISnapshotRaw) } } - return &Server{ + server := &Server{ mode: mode, now: now, status: statusValue, @@ -169,6 +172,11 @@ func NewServer(options ServerOptions) *Server { facts: make(map[string]string), uploads: make(map[string]uploadRecord), } + if options.StatePath != "" { + server.executionPath = options.StatePath + ".executions" + server.executionErr = server.loadExecutionJournal() + } + return server } func (s *Server) validateAIExecution(binding *agentv1.ExecutionBinding, configSHA256 string) error { @@ -247,41 +255,7 @@ func (r *SessionRegistry) persistLocked() error { if r.statePath == "" { return nil } - directory := filepath.Dir(r.statePath) - if err := os.MkdirAll(directory, 0o700); err != nil { - return err - } - file, err := os.CreateTemp(directory, ".rpc-session-*") - if err != nil { - return err - } - name := file.Name() - removeTemp := true - defer func() { - if removeTemp { - _ = os.Remove(name) - } - }() - if err := file.Chmod(0o600); err != nil { - _ = file.Close() - return err - } - if err := json.NewEncoder(file).Encode(sessionJournal{Generations: r.generations}); err != nil { - _ = file.Close() - return err - } - if err := file.Sync(); err != nil { - _ = file.Close() - return err - } - if err := file.Close(); err != nil { - return err - } - if err := os.Rename(name, r.statePath); err != nil { - return err - } - removeTemp = false - return nil + return writeRPCJournal(r.statePath, sessionJournal{Generations: r.generations}) } func (r *SessionRegistry) Activate(binding *agentv1.AgentBinding, activationOperationID, digest string, now time.Time) (*agentv1.Session, bool, error) { @@ -393,6 +367,9 @@ func (s *Server) GetAgentStatus(ctx context.Context, req *agentv1.GetAgentStatus } func (s *Server) ActivateAgent(ctx context.Context, req *agentv1.ActivateAgentRequest) (*agentv1.ActivateAgentResponse, error) { + if err := s.executionJournalReady(); err != nil { + return nil, err + } if req == nil || req.Meta == nil || req.Binding == nil { return nil, status.Error(codes.InvalidArgument, "activation metadata and binding are required") } @@ -521,6 +498,32 @@ func (s *Server) Execute(ctx context.Context, req *agentv1.ExecuteRequest) (*age return nil, status.Error(codes.FailedPrecondition, err.Error()) } } + s.mu.Lock() + defer s.mu.Unlock() + if s.executionErr != nil { + return nil, status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + // Recheck after acquiring the execution lock: concurrent deliveries may + // have passed the earlier read-only replay check together. + if previous, ok := s.operations[s.operationKey(req.Meta)]; ok { + if previous.digest != digest { + return &agentv1.ExecuteResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "idempotency key content conflict", false)}, nil + } + return &agentv1.ExecuteResponse{Receipt: proto.Clone(previous.receipt).(*agentv1.OperationReceipt), State: agentv1.ExecutionState_EXECUTION_STATE_PREPARED}, nil + } + var priorPermit *agentv1.ExecutionPermit + if previous := s.executions[req.Binding.ExecutionId]; previous != nil { + if previous.unknown { + return &agentv1.ExecuteResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_UNKNOWN, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "execution requires reconciliation", false), State: agentv1.ExecutionState_EXECUTION_STATE_UNKNOWN}, nil + } + if previous.controlAction == agentv1.ControlAction_CONTROL_ACTION_PAUSE || previous.controlAction == agentv1.ControlAction_CONTROL_ACTION_STOP || previous.state == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL { + return &agentv1.ExecuteResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "execution control blocks preparation", false)}, nil + } + if previous.executeDigest != "" { + return &agentv1.ExecuteResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "execution already prepared under another operation", false)}, nil + } + priorPermit = previous.permit + } if s.callLogger != nil { if err := s.callLogger.Append(calllog.Event{ EventID: "execution:" + payload.ExecutionID + ":prepared", EventType: "execution.prepared", Phone: payload.Callee, @@ -532,15 +535,16 @@ func (s *Server) Execute(ctx context.Context, req *agentv1.ExecuteRequest) (*age return nil, status.Errorf(codes.Internal, "write call business log: %v", err) } } - s.mu.Lock() state := agentv1.ExecutionState_EXECUTION_STATE_PREPARED if req.PermitId != "" { state = agentv1.ExecutionState_EXECUTION_STATE_PERMIT_GRANTED } - s.executions[req.Binding.ExecutionId] = &executionRecord{binding: proto.Clone(req.Binding).(*agentv1.ExecutionBinding), state: state, taskRevision: req.Binding.TaskRevision, callState: "prepared", phone: phoneIdentity} + s.executions[req.Binding.ExecutionId] = &executionRecord{executeDigest: digest, binding: proto.Clone(req.Binding).(*agentv1.ExecutionBinding), state: state, taskRevision: req.Binding.TaskRevision, callState: "prepared", phone: phoneIdentity, permit: priorPermit} receipt := s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false) s.operations[s.operationKey(req.Meta)] = operationRecord{digest: digest, receipt: proto.Clone(receipt).(*agentv1.OperationReceipt)} - s.mu.Unlock() + if err := s.persistExecutionJournalLocked(); err != nil { + return nil, err + } return &agentv1.ExecuteResponse{Receipt: receipt, State: state}, nil } @@ -566,37 +570,44 @@ func (s *Server) GetExecutionPermit(ctx context.Context, req *agentv1.GetExecuti } } digest := messageDigest(req) - if receipt, conflict := s.replayOperation(req.Meta, digest); receipt != nil || conflict != nil { - if conflict != nil { - return &agentv1.GetExecutionPermitResponse{Receipt: conflict}, nil + s.mu.Lock() + defer s.mu.Unlock() + if s.executionErr != nil { + return nil, status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + execution := s.executions[req.Binding.ExecutionId] + // Control and permission decisions share one lock: neither a fresh grant + // nor a replayed grant may cross an already-applied pause/stop barrier. + if execution != nil && (execution.unknown || execution.controlAction == agentv1.ControlAction_CONTROL_ACTION_PAUSE || execution.controlAction == agentv1.ControlAction_CONTROL_ACTION_STOP || execution.state == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL) { + return &agentv1.GetExecutionPermitResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "execution control blocks permission", false)}, nil + } + if previous, ok := s.operations[s.operationKey(req.Meta)]; ok { + if previous.digest != digest { + return &agentv1.GetExecutionPermitResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "idempotency key content conflict", false)}, nil } - s.mu.Lock() - execution := s.executions[req.Binding.ExecutionId] var permit *agentv1.ExecutionPermit if execution != nil && execution.permit != nil { permit = proto.Clone(execution.permit).(*agentv1.ExecutionPermit) } - s.mu.Unlock() - return &agentv1.GetExecutionPermitResponse{Receipt: receipt, Permit: permit}, nil + return &agentv1.GetExecutionPermitResponse{Receipt: proto.Clone(previous.receipt).(*agentv1.OperationReceipt), Permit: permit}, nil } permitID := fmt.Sprintf("permit-%s", req.Binding.ExecutionId) fencingToken := randomToken() permit := &agentv1.ExecutionPermit{PermitId: permitID, ResourceReservationId: req.ResourceReservationId, IssuedAtUnixMs: s.now().UnixMilli(), ExpiresAtUnixMs: s.now().Add(time.Second).UnixMilli(), DispatcherEpoch: req.Meta.DispatcherEpoch, SessionGeneration: req.Meta.SessionGeneration, FencingToken: fencingToken, ConfigSha256: req.ConfigSha256} - s.mu.Lock() - execution := s.executions[req.Binding.ExecutionId] if execution == nil { execution = &executionRecord{binding: proto.Clone(req.Binding).(*agentv1.ExecutionBinding), taskRevision: req.Binding.TaskRevision, callState: "prepared"} s.executions[req.Binding.ExecutionId] = execution } if execution.permit != nil && execution.permit.ResourceReservationId != req.ResourceReservationId { - s.mu.Unlock() return &agentv1.GetExecutionPermitResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "execution already has a different permit", false)}, nil } execution.permit = proto.Clone(permit).(*agentv1.ExecutionPermit) execution.state = agentv1.ExecutionState_EXECUTION_STATE_PERMIT_GRANTED receipt := s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_APPLIED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false) s.operations[s.operationKey(req.Meta)] = operationRecord{digest: digest, receipt: proto.Clone(receipt).(*agentv1.OperationReceipt)} - s.mu.Unlock() + if err := s.persistExecutionJournalLocked(); err != nil { + return nil, err + } return &agentv1.GetExecutionPermitResponse{Receipt: receipt, Permit: permit}, nil } @@ -610,32 +621,81 @@ func (s *Server) ApplyTaskControl(ctx context.Context, req *agentv1.ApplyTaskCon if err := requireIdempotency(req.Meta); err != nil { return nil, err } - if req.Action == agentv1.ControlAction_CONTROL_ACTION_UNSPECIFIED { - return nil, status.Error(codes.InvalidArgument, "control action is required") + if req.Action != agentv1.ControlAction_CONTROL_ACTION_PAUSE && req.Action != agentv1.ControlAction_CONTROL_ACTION_RESUME && req.Action != agentv1.ControlAction_CONTROL_ACTION_STOP { + return nil, status.Error(codes.InvalidArgument, "a supported control action is required") } + if req.ActiveCallPolicy != agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN && req.ActiveCallPolicy != agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP { + return nil, status.Error(codes.InvalidArgument, "an explicit drain or hangup policy is required") + } + // Authorization above checks the live session. Recovery may use a new + // session or trace, but must retain the original operation and business body. + identity := proto.Clone(req).(*agentv1.ApplyTaskControlRequest) + identity.Meta = &agentv1.RequestMeta{ + ProtocolVersion: req.Meta.ProtocolVersion, + AgentId: req.Meta.AgentId, CellId: req.Meta.CellId, + OperationId: req.Meta.OperationId, IdempotencyKey: req.Meta.IdempotencyKey, + } + encoded, err := (proto.MarshalOptions{Deterministic: true}).Marshal(identity) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "encode control request: %v", err) + } + hash := sha256.Sum256(encoded) + digest := hex.EncodeToString(hash[:]) + key := s.operationKey(req.Meta) s.mu.Lock() defer s.mu.Unlock() + if s.executionErr != nil { + return nil, status.Errorf(codes.Internal, "execution journal unavailable: %v", s.executionErr) + } + if previous, ok := s.operations[key]; ok { + if previous.digest != digest || previous.control == nil { + return &agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "idempotency key content conflict", false)}, nil + } + return proto.Clone(previous.control).(*agentv1.ApplyTaskControlResponse), nil + } + save := func(response *agentv1.ApplyTaskControlResponse) (*agentv1.ApplyTaskControlResponse, error) { + s.operations[key] = operationRecord{digest: digest, receipt: proto.Clone(response.Receipt).(*agentv1.OperationReceipt), control: proto.Clone(response).(*agentv1.ApplyTaskControlResponse)} + if err := s.persistExecutionJournalLocked(); err != nil { + return nil, err + } + return response, nil + } execution := s.executions[req.Binding.ExecutionId] if execution == nil { - return &agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "execution not found", false)}, nil + return save(&agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "execution not found", false)}) + } + if !proto.Equal(execution.binding, req.Binding) { + return save(&agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "execution control binding mismatch", false)}) } if execution.taskRevision != req.ExpectedTaskRevision { - return &agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "task revision conflict", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}, nil + return save(&agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "task revision conflict", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}) } - if execution.state == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL && req.Action != agentv1.ControlAction_CONTROL_ACTION_STOP { - return &agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "stopped execution cannot resume", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}, nil + if (execution.state == agentv1.ExecutionState_EXECUTION_STATE_TERMINAL || execution.controlAction == agentv1.ControlAction_CONTROL_ACTION_STOP) && req.Action != agentv1.ControlAction_CONTROL_ACTION_STOP { + return save(&agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "stopped execution cannot resume", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}) + } + if req.Action != agentv1.ControlAction_CONTROL_ACTION_RESUME && req.ActiveCallPolicy == agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP && s.mode != "mock" { + return nil, status.Error(codes.FailedPrecondition, "media hangup adapter is not configured; control was not applied") } execution.taskRevision++ + execution.binding.TaskRevision = execution.taskRevision execution.controlAction = req.Action + execution.permit = nil if req.Action == agentv1.ControlAction_CONTROL_ACTION_STOP { - execution.state = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL - execution.callState = "stopped" + if req.ActiveCallPolicy == agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN { + execution.callState = "draining" + } else { + execution.state = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL + execution.callState = "stopped" + } } else if req.Action == agentv1.ControlAction_CONTROL_ACTION_PAUSE { execution.callState = "paused" + if req.ActiveCallPolicy == agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP { + execution.state = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL + } } else { execution.callState = "resumed" } - return &agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_APPLIED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}, nil + return save(&agentv1.ApplyTaskControlResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_APPLIED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false), AppliedTaskRevision: execution.taskRevision, State: execution.state}) } func (s *Server) QueryExecution(ctx context.Context, req *agentv1.QueryExecutionRequest) (*agentv1.QueryExecutionResponse, error) { @@ -913,7 +973,7 @@ func (s *Server) CompleteUpload(ctx context.Context, req *agentv1.CompleteUpload return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded asset does not match grant", false), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil } if upload.completed { - return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "duplicate upload completion", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED, OssId: "mock://" + req.UploadId}, nil + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "duplicate mock upload completion", false), State: agentv1.UploadState_UPLOAD_STATE_COMPLETED}, nil } if upload.grant.ExpiresAtUnixMs <= s.now().UnixMilli() { return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "upload grant has expired", true), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil @@ -921,7 +981,7 @@ func (s *Server) CompleteUpload(ctx context.Context, req *agentv1.CompleteUpload upload.completed = true upload.state = agentv1.UploadState_UPLOAD_STATE_COMPLETED s.uploads[req.UploadId] = upload - return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "mock OSS completion accepted", false), State: upload.state, OssId: "mock://" + req.UploadId}, nil + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "mock upload notification completion accepted", false), State: upload.state}, nil } func requireIdempotency(meta *agentv1.RequestMeta) error { @@ -932,6 +992,9 @@ func requireIdempotency(meta *agentv1.RequestMeta) error { } func (s *Server) authorize(ctx context.Context, meta *agentv1.RequestMeta) error { + if err := s.executionJournalReady(); err != nil { + return err + } if meta == nil { return status.Error(codes.InvalidArgument, "request metadata is required") } @@ -1025,6 +1088,9 @@ func (s *Server) replayOperation(meta *agentv1.RequestMeta, digest string) (*age } s.mu.Lock() defer s.mu.Unlock() + if s.executionErr != nil { + return nil, s.receipt(meta, agentv1.ResultCode_RESULT_CODE_UNKNOWN, agentv1.FailureCode_FAILURE_CODE_UNAVAILABLE, "execution journal unavailable", false) + } record, ok := s.operations[key] if !ok { return nil, nil diff --git a/internal/rpc/server_test.go b/internal/rpc/server_test.go index 9479384..aec2f6a 100644 --- a/internal/rpc/server_test.go +++ b/internal/rpc/server_test.go @@ -8,9 +8,9 @@ import ( "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials" @@ -115,7 +115,7 @@ func TestSessionGenerationFencesOlderRequests(t *testing.T) { func TestExecuteIdempotencyAndBinding(t *testing.T) { now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) server := activatedServer(now, t) - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() require.NoError(t, err) envelope, payload, err := contract.DecodeExecute(raw) require.NoError(t, err) @@ -168,7 +168,7 @@ func TestAdmissionAndControlCAS(t *testing.T) { require.NoError(t, err) require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result) - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() require.NoError(t, err) envelope, payload, err := contract.DecodeExecute(raw) require.NoError(t, err) @@ -176,13 +176,55 @@ func TestAdmissionAndControlCAS(t *testing.T) { _, err = server.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: executeMeta, Binding: &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID}, CallExecuteJson: raw, ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) require.NoError(t, err) controlMeta := testMeta("control-1", "control-key", 1) - paused, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: controlMeta, Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: payload.TaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, ExpectedTaskRevision: payload.TaskRevision}) + binding := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID} + pauseRequest := &agentv1.ApplyTaskControlRequest{Meta: controlMeta, Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), Action: agentv1.ControlAction_CONTROL_ACTION_PAUSE, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN, ExpectedTaskRevision: payload.TaskRevision} + foreign := proto.Clone(pauseRequest).(*agentv1.ApplyTaskControlRequest) + foreign.Meta = testMeta("foreign-control", "foreign-control-key", 1) + foreign.Binding.TenantKey = "different-tenant" + foreignResponse, err := server.ApplyTaskControl(context.Background(), foreign) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, foreignResponse.Receipt.Result) + paused, err := server.ApplyTaskControl(context.Background(), pauseRequest) require.NoError(t, err) require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, paused.Receipt.Result) - stopped, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-2", "control-key-2", 1), Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: paused.AppliedTaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_STOP, ExpectedTaskRevision: paused.AppliedTaskRevision}) + duplicate, err := server.ApplyTaskControl(context.Background(), pauseRequest) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_APPLIED, duplicate.Receipt.Result) + require.Equal(t, paused.AppliedTaskRevision, duplicate.AppliedTaskRevision) + blockedPermit, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{Meta: testMeta("permit-paused", "permit-paused-key", 1), Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: paused.AppliedTaskRevision}, ResourceReservationId: "paused-reservation"}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, blockedPermit.Receipt.Result) + if blockedPermit.Permit != nil { + t.Fatal("paused execution received a permit") + } + resetAttempt, err := server.Execute(context.Background(), &agentv1.ExecuteRequest{Meta: testMeta("execute-after-pause", "execute-after-pause-key", 1), Binding: &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID}, CallExecuteJson: raw, ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, resetAttempt.Receipt.Result) + retraced := proto.Clone(pauseRequest).(*agentv1.ApplyTaskControlRequest) + retraced.Meta.RequestId = "recovered-control-request" + retraced.Meta.TraceId = "recovered-control-trace" + replayed, err := server.ApplyTaskControl(context.Background(), retraced) + require.NoError(t, err) + if !proto.Equal(paused, replayed) { + t.Fatal("transport metadata changed a control's business identity") + } + changed := proto.Clone(pauseRequest).(*agentv1.ApplyTaskControlRequest) + changed.Reason = "different control content" + conflicted, err := server.ApplyTaskControl(context.Background(), changed) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflicted.Receipt.Result) + binding.TaskRevision = paused.AppliedTaskRevision + stopped, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-2", "control-key-2", 1), Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), Action: agentv1.ControlAction_CONTROL_ACTION_STOP, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP, ExpectedTaskRevision: paused.AppliedTaskRevision}) require.NoError(t, err) require.Equal(t, agentv1.ExecutionState_EXECUTION_STATE_TERMINAL, stopped.State) - resumed, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-3", "control-key-3", 1), Binding: &agentv1.ExecutionBinding{ExecutionId: payload.ExecutionID, TaskRevision: stopped.AppliedTaskRevision}, Action: agentv1.ControlAction_CONTROL_ACTION_RESUME, ExpectedTaskRevision: stopped.AppliedTaskRevision}) + original, err := server.ApplyTaskControl(context.Background(), pauseRequest) + require.NoError(t, err) + require.Equal(t, paused.AppliedTaskRevision, original.AppliedTaskRevision) + snapshot, err := server.QueryExecution(context.Background(), &agentv1.QueryExecutionRequest{Meta: testMeta("query-after-stop", "", 1), Binding: pauseRequest.Binding}) + require.NoError(t, err) + require.Equal(t, stopped.AppliedTaskRevision, snapshot.Snapshot.Binding.TaskRevision) + binding.TaskRevision = stopped.AppliedTaskRevision + resumed, err := server.ApplyTaskControl(context.Background(), &agentv1.ApplyTaskControlRequest{Meta: testMeta("control-3", "control-key-3", 1), Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), Action: agentv1.ControlAction_CONTROL_ACTION_RESUME, ActiveCallPolicy: agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN, ExpectedTaskRevision: stopped.AppliedTaskRevision}) require.NoError(t, err) require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, resumed.Receipt.Result) } diff --git a/internal/rpc/upload_destination_test.go b/internal/rpc/upload_destination_test.go new file mode 100644 index 0000000..c505c74 --- /dev/null +++ b/internal/rpc/upload_destination_test.go @@ -0,0 +1,65 @@ +package rpc + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestUploadFactKeepsOriginallyGrantedBucketAfterConfigurationChange(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.BindDispatcherID("11111111-1111-4111-8111-111111111111"); err != nil { + t.Fatal(err) + } + makeServer := func(bucket string) *DispatcherUploadServer { + signer, err := ossclient.NewClient(ossclient.Config{Endpoint: "https://oss.invalid", Region: "cn-beijing", Bucket: bucket, AccessKeyID: "local-test", AccessKeySecret: "local-test", MaxAssetBytes: 1024}) + if err != nil { + t.Fatal(err) + } + server, err := NewDispatcherUploadServer(st, signer, time.Now, false) + if err != nil { + t.Fatal(err) + } + return server + } + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", TaskId: "task-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-a", CallId: "call-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 10, SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64)} + meta := &agentv1.RequestMeta{AgentId: "agent-a", CellId: "cell-a", OperationId: "op-a", IdempotencyKey: "key-a", TraceId: "trace-a"} + first := makeServer("original-bucket") + response, err := first.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}) + if err != nil || response.GetGrant() == nil { + t.Fatalf("grant: %v", err) + } + restarted := makeServer("new-bucket") + _, err = restarted.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a", UploadedSizeBytes: 4, UploadedChecksumSha256: asset.ChecksumSha256}) + if status.Code(err) != codes.Unavailable { + t.Fatalf("pending notification: %v", err) + } + var body []byte + if err := st.DB().QueryRow(`SELECT body FROM outbox`).Scan(&body); err != nil { + t.Fatal(err) + } + var event struct { + Payload struct { + Bucket string `json:"bucket"` + } `json:"payload"` + } + if err := json.Unmarshal(body, &event); err != nil { + t.Fatal(err) + } + if event.Payload.Bucket != "original-bucket" { + t.Fatalf("reported wrong object location: %s", event.Payload.Bucket) + } +} diff --git a/internal/rpc/upload_grant_idempotency_test.go b/internal/rpc/upload_grant_idempotency_test.go new file mode 100644 index 0000000..229657c --- /dev/null +++ b/internal/rpc/upload_grant_idempotency_test.go @@ -0,0 +1,64 @@ +package rpc + +import ( + "context" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +func TestUploadGrantRedeliveryDoesNotRenewExpiredToken(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + signer, err := ossclient.NewClient(ossclient.Config{Endpoint: "https://oss.invalid", Region: "cn-beijing", Bucket: "local-test", AccessKeyID: "local-test", AccessKeySecret: "local-test", MaxAssetBytes: 1024}) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + server, err := NewDispatcherUploadServer(st, signer, func() time.Time { return now }, false) + if err != nil { + t.Fatal(err) + } + request := &agentv1.RequestUploadRequest{ + Meta: &agentv1.RequestMeta{AgentId: "agent-a", CellId: "cell-a", OperationId: "original-request", IdempotencyKey: "original-request"}, + Binding: &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: "tenant-a", TaskId: "task-a", ExecutionId: "execution-a", CallId: "call-a"}, + Asset: &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-a", CallId: "call-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 10, SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64)}, UploadId: "upload-a", + } + first, err := server.RequestUpload(context.Background(), request) + if err != nil { + t.Fatal(err) + } + now = now.Add(16 * time.Minute) + duplicate, err := server.RequestUpload(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if !proto.Equal(first.Grant, duplicate.Grant) { + t.Fatal("redelivery silently renewed the original token") + } + explicit := proto.Clone(request).(*agentv1.RequestUploadRequest) + explicit.Meta.OperationId = "explicit-new-request" + explicit.Meta.IdempotencyKey = "explicit-new-request" + renewed, err := server.RequestUpload(context.Background(), explicit) + if err != nil { + t.Fatal(err) + } + if renewed.Grant.ExpiresAtUnixMs != now.Add(15*time.Minute).UnixMilli() { + t.Fatal("explicit request did not receive a new fixed-duration grant") + } + old, err := server.RequestUpload(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if !proto.Equal(first.Grant, old.Grant) { + t.Fatal("old request gained access to a newer grant") + } +} diff --git a/internal/rpc/upload_mq_integration_test.go b/internal/rpc/upload_mq_integration_test.go new file mode 100644 index 0000000..c0b6659 --- /dev/null +++ b/internal/rpc/upload_mq_integration_test.go @@ -0,0 +1,182 @@ +package rpc_test + +import ( + "bytes" + "context" + "errors" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/dispatcher" + "git.ipao.vip/rogee/go-sip/internal/mq" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/rpc" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type lostPublicationResult struct{ broker *mq.Broker } + +func (p lostPublicationResult) Publish(ctx context.Context, exchange, key string, body []byte) error { + if err := p.broker.Publish(ctx, exchange, key, body); err != nil { + return err + } + return errors.New("injected loss of publication result") +} + +func TestLocalUploadNoticeSurvivesUnroutableAndRestart(t *testing.T) { + address := os.Getenv("GO_SIP_LOCAL_UPLOAD_MQ_URL") + if address == "" { + t.Skip("dedicated local upload RabbitMQ vhost not configured") + } + u, err := url.Parse(address) + if err != nil || (u.Hostname() != "127.0.0.1" && u.Hostname() != "::1" && u.Hostname() != "localhost") { + t.Fatal("loopback RabbitMQ required") + } + id, key := uuid.NewString(), "upload."+uuid.NewString() + broker, err := mq.Open(address, id) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + if _, err := broker.DeclareTenantQueue(key); err != nil { + t.Fatal(err) + } + route, err := tenant.NewDispatcherRoute(id, key) + if err != nil { + t.Fatal(err) + } + conn, err := amqp.Dial(address) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + channel, err := conn.Channel() + if err != nil { + t.Fatal(err) + } + defer channel.Close() + defer channel.QueueDelete(route.InboxQueue, false, false, false) + defer channel.QueueDelete(route.DeadLetterQueue, false, false, false) + defer channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil) + if err := channel.QueueUnbind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, nil); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "dispatcher.db") + st, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { st.Close() }() + if err := st.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + signer, err := ossclient.NewClient(ossclient.Config{Endpoint: "https://oss.invalid", Region: "cn-beijing", Bucket: "local-test", AccessKeyID: "local-test", AccessKeySecret: "local-test", GrantTTL: 15 * time.Minute, MaxAssetBytes: 1024}) + if err != nil { + t.Fatal(err) + } + server, err := rpc.NewDispatcherUploadServer(st, signer, time.Now, false) + if err != nil { + t.Fatal(err) + } + meta := &agentv1.RequestMeta{AgentId: "agent-a", CellId: "cell-a", OperationId: "op-a", IdempotencyKey: "key-a", TraceId: "trace-a"} + binding := &agentv1.ExecutionBinding{TenantId: "tenant-a", TenantKey: key, TaskId: "task-a", ExecutionId: "execution-a", CallId: "call-a"} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-a", CallId: "call-a", Format: "wav", Channels: 1, SampleRateHz: 8000, DurationMs: 10, SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64)} + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + grant, err := server.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a"}) + if err != nil || grant.GetGrant() == nil { + t.Fatalf("grant: %v", err) + } + request := &agentv1.CompleteUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: "upload-a", UploadedSizeBytes: 4, UploadedChecksumSha256: asset.ChecksumSha256} + if _, err := server.CompleteUpload(ctx, request); status.Code(err) != codes.Unavailable { + t.Fatalf("premature completion: %v", err) + } + var original []byte + var eventID string + if err := st.DB().QueryRow(`SELECT event_id,body FROM outbox`).Scan(&eventID, &original); err != nil { + t.Fatal(err) + } + d, err := dispatcher.New(st, broker, nil) + if err != nil { + t.Fatal(err) + } + if _, err := d.FlushOutbox(ctx, 10); err == nil { + t.Fatal("unroutable notice counted as delivery") + } + if record, err := st.LoadUpload("upload-a"); err != nil || record.State != "uploaded" { + t.Fatalf("upload fact lost after return: %v", err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = store.Open(path) + if err != nil { + t.Fatal(err) + } + if err := st.BindDispatcherID(id); err != nil { + t.Fatal(err) + } + if err := st.RecoverOutbox(); err != nil { + t.Fatal(err) + } + if err := channel.QueueBind(mq.SaaSQueue, route.OutboundKey, mq.EventExchange, false, nil); err != nil { + t.Fatal(err) + } + // Model the application losing the publication result after RabbitMQ has + // accepted the persistent message. This is fault injection, not a simulated + // broker protocol failure: the underlying publication is real. + d, err = dispatcher.New(st, lostPublicationResult{broker}, nil) + if err != nil { + t.Fatal(err) + } + if _, err := d.FlushOutbox(ctx, 10); err == nil { + t.Fatal("lost confirmation incorrectly completed delivery") + } + if record, err := st.LoadUpload("upload-a"); err != nil || record.State != "uploaded" { + t.Fatal("unknown publication result was treated as complete") + } + d, err = dispatcher.New(st, broker, nil) + if err != nil { + t.Fatal(err) + } + if n, err := d.FlushOutbox(ctx, 10); err != nil || n != 1 { + t.Fatalf("recovery: count=%d err=%v", n, err) + } + // No SaaS consumer exists. Delivery completes on queue publication alone. + server, err = rpc.NewDispatcherUploadServer(st, signer, time.Now, false) + if err != nil { + t.Fatal(err) + } + response, err := server.CompleteUpload(ctx, request) + if err != nil || response.GetState() != agentv1.UploadState_UPLOAD_STATE_COMPLETED { + t.Fatalf("completion: %v", err) + } + for delivery := 0; delivery < 2; delivery++ { + message, ok, err := channel.Get(mq.SaaSQueue, false) + if err != nil || !ok { + t.Fatalf("queue reception: %v", err) + } + if message.DeliveryMode != amqp.Persistent || message.MessageId != eventID || !bytes.Equal(message.Body, original) { + t.Fatal("original persistent notification identity/body was not retained") + } + if err := message.Ack(false); err != nil { + t.Fatal(err) + } + } + if _, err := server.CompleteUpload(ctx, request); err != nil { + t.Fatal(err) + } + if n, err := d.FlushOutbox(ctx, 10); err != nil || n != 0 { + t.Fatalf("duplicate completion republished: %d %v", n, err) + } +} diff --git a/internal/store/ai_authorization.go b/internal/store/ai_authorization.go new file mode 100644 index 0000000..a56bad0 --- /dev/null +++ b/internal/store/ai_authorization.go @@ -0,0 +1,69 @@ +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, egressPool string) (ai.Snapshot, []byte, error) { + if egressPool == "" { + return ai.Snapshot{}, nil, errors.New("AI admission requires the deployment egress pool") + } + 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, egressPool, 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 new file mode 100644 index 0000000..0e9c70f --- /dev/null +++ b/internal/store/ai_authorization_test.go @@ -0,0 +1,74 @@ +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) + 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", "egress-mock") + if err != nil || len(authorization) == 0 || snapshot.AgentVersionID != "version-a" { + t.Fatalf("authorized cache: %v", err) + } + if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "different-pool"); err == nil { + t.Fatal("egress policy bypass") + } + if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", ""); err == nil { + t.Fatal("missing egress policy accepted") + } + now = now.Add(time.Hour) + if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "egress-mock"); err == nil { + t.Fatal("expired cached authorization admitted work") + } +} diff --git a/internal/store/ai_mq.go b/internal/store/ai_mq.go new file mode 100644 index 0000000..a028020 --- /dev/null +++ b/internal/store/ai_mq.go @@ -0,0 +1,172 @@ +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 new file mode 100644 index 0000000..2af1eae --- /dev/null +++ b/internal/store/ai_mq_test.go @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..f3b510e --- /dev/null +++ b/internal/store/ai_revocation_test.go @@ -0,0 +1,75 @@ +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", "egress-mock") + 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 new file mode 100644 index 0000000..2a28626 --- /dev/null +++ b/internal/store/call_query.go @@ -0,0 +1,110 @@ +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 new file mode 100644 index 0000000..bd80e19 --- /dev/null +++ b/internal/store/call_query_test.go @@ -0,0 +1,122 @@ +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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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 new file mode 100644 index 0000000..ea2a2bd --- /dev/null +++ b/internal/store/control_ack.go @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..a327430 --- /dev/null +++ b/internal/store/control_ack_test.go @@ -0,0 +1,85 @@ +package store + +import ( + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/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 := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} + if err := s.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 new file mode 100644 index 0000000..35d33d8 --- /dev/null +++ b/internal/store/control_order_test.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..cc6c134 --- /dev/null +++ b/internal/store/control_pending.go @@ -0,0 +1,53 @@ +package store + +import ( + "database/sql" + "errors" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/protobuf/proto" +) + +type PendingTaskControl struct { + TenantID, CommandID, ExecutionID, AgentID, Action, Policy string + ExpectedRevision int64 + Binding *agentv1.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 = &agentv1.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 new file mode 100644 index 0000000..2532d2f --- /dev/null +++ b/internal/store/control_routes.go @@ -0,0 +1,52 @@ +package store + +import ( + "bytes" + "database/sql" + "errors" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "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 *agentv1.ExecutionBinding) error { + if agentID == "" || binding == nil { + return errors.New("agent and execution binding are required") + } + encoded, err := (proto.MarshalOptions{Deterministic: true}).Marshal(binding) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + 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 + } + if _, err := tx.Exec(`INSERT INTO execution_agents(execution_id,agent_id,binding) VALUES(?,?,?)`, binding.ExecutionId, agentID, encoded); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/control_routes_test.go b/internal/store/control_routes_test.go new file mode 100644 index 0000000..61b60d2 --- /dev/null +++ b/internal/store/control_routes_test.go @@ -0,0 +1,49 @@ +package store + +import ( + "testing" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/testfixture" +) + +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 := &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, ExecutionId: payload.ExecutionID, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} + if err := s.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 new file mode 100644 index 0000000..bef6f0d --- /dev/null +++ b/internal/store/execute_business_duplicate_test.go @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..6de4e84 --- /dev/null +++ b/internal/store/execute_deadline_test.go @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..db69e0f --- /dev/null +++ b/internal/store/execute_mq_test.go @@ -0,0 +1,53 @@ +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/2026-09-21-p1-v2/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/finalize_control_test.go b/internal/store/finalize_control_test.go new file mode 100644 index 0000000..02aa619 --- /dev/null +++ b/internal/store/finalize_control_test.go @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..967f0d3 --- /dev/null +++ b/internal/store/identity.go @@ -0,0 +1,50 @@ +package store + +import ( + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/tenant" +) + +var ErrDispatcherIdentityMismatch = errors.New("database belongs to another dispatcher") + +// 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 new file mode 100644 index 0000000..9314359 --- /dev/null +++ b/internal/store/identity_test.go @@ -0,0 +1,81 @@ +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 index 53ace66..2c1b337 100644 --- a/internal/store/lease_test.go +++ b/internal/store/lease_test.go @@ -83,6 +83,9 @@ func TestOutboxRecoverySurvivesSQLiteReopen(t *testing.T) { 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) diff --git a/internal/store/migrations/005_dispatcher_identity.sql b/internal/store/migrations/005_dispatcher_identity.sql new file mode 100644 index 0000000..1b3cdc4 --- /dev/null +++ b/internal/store/migrations/005_dispatcher_identity.sql @@ -0,0 +1,7 @@ +-- 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 new file mode 100644 index 0000000..8517be2 --- /dev/null +++ b/internal/store/migrations/006_mq_queries.sql @@ -0,0 +1,16 @@ +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 new file mode 100644 index 0000000..516c6b3 --- /dev/null +++ b/internal/store/migrations/007_mq_command_receipts.sql @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..bb103d5 --- /dev/null +++ b/internal/store/migrations/008_upload_notifications.sql @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..c4dd4b4 --- /dev/null +++ b/internal/store/migrations/009_ai_mq.sql @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..6083fc7 --- /dev/null +++ b/internal/store/migrations/010_upload_grant_requests.sql @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..b6704a1 --- /dev/null +++ b/internal/store/migrations/011_upload_destinations.sql @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..1c1bedd --- /dev/null +++ b/internal/store/migrations/012_mq_task_controls.sql @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..a5c714a --- /dev/null +++ b/internal/store/migrations/013_execution_agents.sql @@ -0,0 +1,5 @@ +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/mq_scope.go b/internal/store/mq_scope.go new file mode 100644 index 0000000..6843b9f --- /dev/null +++ b/internal/store/mq_scope.go @@ -0,0 +1,40 @@ +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 new file mode 100644 index 0000000..eadbec2 --- /dev/null +++ b/internal/store/query.go @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..f2e1727 --- /dev/null +++ b/internal/store/query_test.go @@ -0,0 +1,248 @@ +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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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 new file mode 100644 index 0000000..bb22b9a --- /dev/null +++ b/internal/store/replay_mq.go @@ -0,0 +1,184 @@ +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 new file mode 100644 index 0000000..6357b4c --- /dev/null +++ b/internal/store/replay_mq_test.go @@ -0,0 +1,172 @@ +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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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/2026-09-21-p1-v2/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 new file mode 100644 index 0000000..e40dfab --- /dev/null +++ b/internal/store/reservation_control_test.go @@ -0,0 +1,46 @@ +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 index c25912f..b1f8872 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -15,6 +15,7 @@ import ( "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" _ "modernc.org/sqlite" ) @@ -51,10 +52,6 @@ func Open(path string) (*Store, error) { _ = db.Close() return nil, err } - if err := store.RecoverOutbox(); err != nil { - _ = db.Close() - return nil, err - } return store, nil } @@ -71,9 +68,6 @@ func New(db *sql.DB, now func() time.Time) (*Store, error) { if err := store.migrate(); err != nil { return nil, err } - if err := store.RecoverOutbox(); err != nil { - return nil, err - } return store, nil } @@ -171,19 +165,18 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro if err != nil { return IngestResult{}, err } - if routingKey != "" { - if err := verifyRouting(envelope.TenantKey, routingKey); 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 } - if expired { - return IngestResult{}, fmt.Errorf("command admission deadline has expired") - } hash := sha256.Sum256(raw) bodyHash := hex.EncodeToString(hash[:]) @@ -194,6 +187,9 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro 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) @@ -202,6 +198,12 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro 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) @@ -212,34 +214,51 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro return IngestResult{}, fmt.Errorf("persist inbox: %w", err) } - variables, err := json.Marshal(payload.Variables) - if err != nil { - return IngestResult{}, fmt.Errorf("encode variables: %w", err) - } - result, err := tx.Exec(`INSERT INTO tasks( + 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 { - 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 duplicate inbox: %w", err) + 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) } - if err := tx.Commit(); err != nil { - return IngestResult{}, fmt.Errorf("commit duplicate command: %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 } - return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil } event, err := (contract.EventBuilder{ @@ -248,15 +267,15 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro Payload: map[string]any{ "command_id": envelope.CommandID, "command_type": envelope.CommandType, "execution_id": payload.ExecutionID, - "status": "accepted", "reason_code": "accepted", + "status": admissionStatus, "reason_code": admissionReason, "requested_task_revision": payload.TaskRevision, }, - }).Marshal(now, envelope.CommandID+"-result") + }).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, tenant.EventExchange, "agent-call.command.result", event, now.Format(time.RFC3339Nano)); err != nil { + 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 { @@ -265,7 +284,7 @@ func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, erro if err := tx.Commit(); err != nil { return IngestResult{}, fmt.Errorf("commit ingest: %w", err) } - return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, PersistedAt: now}, nil + return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: businessDuplicate, PersistedAt: now}, nil } func verifyRouting(tenantKey, routingKey string) error { @@ -329,9 +348,22 @@ func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) { } 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) - _, err := s.db.Exec(`UPDATE outbox SET status = 'published', published_at = ?, last_error = NULL WHERE id = ?`, now, id) - return err + 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 + } + return tx.Commit() } func (s *Store) MarkOutboxRetry(id int64, cause error) error { @@ -389,26 +421,6 @@ func (s *Store) NextTask(tenantKey string) (Task, error) { return scanTask(row) } -func (s *Store) MarkTaskReserved(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 = 'reserved', updated_at = ? WHERE execution_id = ? AND status = 'accepted'`, 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 -} - func (s *Store) MarkTaskRunning(executionID string) error { if executionID == "" { return errors.New("execution id is required") @@ -443,6 +455,13 @@ func (s *Store) FinalizeReservation(reservationID, executionID string, unknown b 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 { @@ -464,6 +483,7 @@ func (s *Store) FinalizeReservation(reservationID, executionID string, unknown b 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 { @@ -489,7 +509,7 @@ func (s *Store) FinalizeReservation(reservationID, executionID string, unknown b 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 = ?, updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, newTaskStatus, now, executionID) + 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 } @@ -500,7 +520,7 @@ func (s *Store) FinalizeReservation(reservationID, executionID string, unknown b if count != 1 { return ErrCASConflict } - return tx.Commit() + return nil } type CommandRecord struct { @@ -622,6 +642,17 @@ func (s *Store) Reserve(reservationID, executionID, tenantKey string, scopes []s 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 { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 6cedf7f..4cfb1f4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,12 +2,12 @@ package store import ( "database/sql" + "encoding/json" "errors" "testing" "time" - "git.ipao.vip/rogee/go-sip/contracts" - "git.ipao.vip/rogee/go-sip/internal/tenant" + "git.ipao.vip/rogee/go-sip/internal/testfixture" _ "modernc.org/sqlite" ) @@ -22,24 +22,27 @@ func testStore(t *testing.T) *Store { 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 := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - result, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute") + 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, tenant.CommandRoutingPrefix+"tenant-demo-key"+tenant.CommandRoutingSuffix) + second, err := s.IngestCommand(raw, testfixture.InboundKey("tenant-demo-key")) if err != nil { t.Fatal(err) } @@ -57,7 +60,7 @@ func TestIngestIsDurableAndIdempotent(t *testing.T) { func TestIngestRejectsRoutingMismatch(t *testing.T) { s := testStore(t) - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } @@ -68,6 +71,26 @@ func TestIngestRejectsRoutingMismatch(t *testing.T) { 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) @@ -92,19 +115,16 @@ func TestFinalizeReservationRequeuesBeforeRemoteSubmission(t *testing.T) { if err := s.SetQuota("global", 1); err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + 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.MarkTaskReserved("exec_demo_001"); err != nil { - t.Fatal(err) - } if err := s.FinalizeReservation("r-requeue", "exec_demo_001", false); err != nil { t.Fatal(err) } @@ -129,19 +149,16 @@ func TestFinalizeReservationKeepsUnknownCounted(t *testing.T) { if err := s.SetQuota("global", 1); err != nil { t.Fatal(err) } - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + 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.MarkTaskReserved("exec_demo_001"); err != nil { - t.Fatal(err) - } if err := s.FinalizeReservation("r-unknown", "exec_demo_001", true); err != nil { t.Fatal(err) } @@ -163,11 +180,11 @@ func TestFinalizeReservationKeepsUnknownCounted(t *testing.T) { func TestControlCASAndStopBarrier(t *testing.T) { s := testStore(t) - raw, err := contracts.Read("examples/call.execute.json") + raw, err := testfixture.Execute() if err != nil { t.Fatal(err) } - if _, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + 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 { diff --git a/internal/store/task_control_mq.go b/internal/store/task_control_mq.go new file mode 100644 index 0000000..ccf8eb1 --- /dev/null +++ b/internal/store/task_control_mq.go @@ -0,0 +1,216 @@ +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 new file mode 100644 index 0000000..29b41b8 --- /dev/null +++ b/internal/store/task_control_mq_test.go @@ -0,0 +1,114 @@ +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 new file mode 100644 index 0000000..8a0dc7f --- /dev/null +++ b/internal/store/upload_grants.go @@ -0,0 +1,89 @@ +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() + 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=?)`, record.Grant, 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 new file mode 100644 index 0000000..a13e064 --- /dev/null +++ b/internal/store/upload_notification.go @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..7e6cbe6 --- /dev/null +++ b/internal/store/upload_notification_test.go @@ -0,0 +1,83 @@ +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.InsertUpload(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}); err != nil { + t.Fatal(err) + } + raw, err := contracts.Files.ReadFile("upstream/2026-09-21-p1-v2/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 index 328bfd5..539cc92 100644 --- a/internal/store/uploads.go +++ b/internal/store/uploads.go @@ -13,8 +13,8 @@ type UploadRecord struct { Asset []byte Grant []byte ObjectKey string + Bucket string State string - OSSID string CreatedAt time.Time CompletedAt *time.Time } @@ -28,25 +28,17 @@ func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) { var record UploadRecord var createdAt string var completedAt sql.NullString - var ossID sql.NullString - err := s.db.QueryRow(`SELECT upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at - FROM uploads WHERE upload_id = ?`, uploadID).Scan( - &record.UploadID, &record.Binding, &record.Asset, &record.Grant, &record.ObjectKey, - &record.State, &ossID, &createdAt, &completedAt, - ) + err := s.db.QueryRow(`SELECT uploads.upload_id,binding,asset,grant,object_key,d.bucket, + CASE WHEN state='granted' AND EXISTS(SELECT 1 FROM upload_notifications n WHERE n.upload_id=uploads.upload_id) THEN 'uploaded' ELSE state END, + 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 { - if errors.Is(err, sql.ErrNoRows) { - return UploadRecord{}, err - } return UploadRecord{}, fmt.Errorf("load upload: %w", err) } - record.OSSID = ossID.String - parsed, err := time.Parse(time.RFC3339Nano, createdAt) + record.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) if err != nil { return UploadRecord{}, fmt.Errorf("parse upload created_at: %w", err) } - record.CreatedAt = parsed - if completedAt.Valid && completedAt.String != "" { + if completedAt.Valid { parsed, err := time.Parse(time.RFC3339Nano, completedAt.String) if err != nil { return UploadRecord{}, fmt.Errorf("parse upload completed_at: %w", err) @@ -56,114 +48,27 @@ func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) { return record, nil } -func (s *Store) ReplaceUploadGrant(uploadID, objectKey string, grant []byte) error { - if uploadID == "" || objectKey == "" || len(grant) == 0 { - return errors.New("upload ID, object key and grant are required") - } - s.mu.Lock() - defer s.mu.Unlock() - result, err := s.db.Exec(`UPDATE uploads SET grant = ?, object_key = ? WHERE upload_id = ? AND state = 'granted'`, grant, objectKey, uploadID) - if err != nil { - return fmt.Errorf("replace upload grant: %w", err) - } - updated, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("inspect replaced upload grant: %w", err) - } - if updated == 0 { - var state string - if err := s.db.QueryRow(`SELECT state FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state); err != nil { - return err - } - if state == "completed" { - return nil - } - return errors.New("upload is not in granted state") - } - return nil -} - func (s *Store) InsertUpload(record UploadRecord) error { - if record.UploadID == "" || len(record.Binding) == 0 || len(record.Asset) == 0 || len(record.Grant) == 0 || record.ObjectKey == "" { - return errors.New("complete upload record is required") + if record.UploadID == "" || len(record.Binding) == 0 || len(record.Asset) == 0 || len(record.Grant) == 0 || record.ObjectKey == "" || record.Bucket == "" || record.State != "granted" { + return errors.New("complete granted upload record is required") } if record.CreatedAt.IsZero() { record.CreatedAt = s.now().UTC() } s.mu.Lock() defer s.mu.Unlock() - _, err := s.db.Exec(`INSERT INTO uploads(upload_id, binding, asset, grant, object_key, state, oss_id, created_at, completed_at) - VALUES(?, ?, ?, ?, ?, ?, NULL, ?, NULL)`, record.UploadID, record.Binding, record.Asset, record.Grant, - record.ObjectKey, record.State, record.CreatedAt.UTC().Format(time.RFC3339Nano)) + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + _, err = tx.Exec(`INSERT INTO uploads(upload_id,binding,asset,grant,object_key,state,created_at,completed_at) + VALUES(?,?,?,?,?,?,?,NULL)`, record.UploadID, record.Binding, record.Asset, record.Grant, record.ObjectKey, record.State, record.CreatedAt.UTC().Format(time.RFC3339Nano)) if err != nil { return fmt.Errorf("insert upload: %w", err) } - return nil -} - -func (s *Store) CompleteUpload(uploadID, ossID string, completedAt time.Time) error { - return s.completeUpload(uploadID, ossID, completedAt, nil, "", "", "", "") -} - -// CompleteUploadAndOutbox atomically records verified OSS completion and the -// recording.ready event. A successful RPC therefore cannot lose the MQ handoff -// between the upload state update and outbox persistence. -func (s *Store) CompleteUploadAndOutbox(uploadID, ossID string, completedAt time.Time, eventID, tenantKey, exchange, routingKey string, body []byte) error { - if eventID == "" || tenantKey == "" || exchange == "" || routingKey == "" || len(body) == 0 { - return errors.New("verified upload outbox event is required") + if _, err := tx.Exec(`INSERT INTO upload_destinations(upload_id,bucket) VALUES(?,?)`, record.UploadID, record.Bucket); err != nil { + return err } - return s.completeUpload(uploadID, ossID, completedAt, body, eventID, tenantKey, exchange, routingKey) -} - -func (s *Store) completeUpload(uploadID, ossID string, completedAt time.Time, body []byte, eventID, tenantKey, exchange, routingKey string) error { - if uploadID == "" || ossID == "" { - return errors.New("upload ID and OSS ID are required") - } - if completedAt.IsZero() { - completedAt = s.now().UTC() - } - if eventID != "" && (exchange == "" || routingKey == "") { - return errors.New("exchange and routing key are required") - } - s.mu.Lock() - defer s.mu.Unlock() - tx, err := s.db.Begin() - if err != nil { - return fmt.Errorf("begin complete upload: %w", err) - } - defer func() { _ = tx.Rollback() }() - result, err := tx.Exec(`UPDATE uploads SET state = 'completed', oss_id = ?, completed_at = ? WHERE upload_id = ? AND state = 'granted'`, - ossID, completedAt.UTC().Format(time.RFC3339Nano), uploadID) - if err != nil { - return fmt.Errorf("complete upload: %w", err) - } - updated, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("inspect completed upload: %w", err) - } - if updated == 0 { - var state string - var existingOSSID sql.NullString - lookupErr := tx.QueryRow(`SELECT state, oss_id FROM uploads WHERE upload_id = ?`, uploadID).Scan(&state, &existingOSSID) - if lookupErr != nil { - if errors.Is(lookupErr, sql.ErrNoRows) { - return lookupErr - } - return fmt.Errorf("inspect upload state: %w", lookupErr) - } - if state == "completed" && existingOSSID.Valid && existingOSSID.String == ossID { - return tx.Commit() - } - return errors.New("upload is not in granted state") - } - if eventID != "" { - if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) - VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, exchange, routingKey, body, completedAt.UTC().Format(time.RFC3339Nano)); err != nil { - return fmt.Errorf("persist verified upload outbox: %w", err) - } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit completed upload: %w", err) - } - return nil + return tx.Commit() } diff --git a/internal/tenant/dispatcher.go b/internal/tenant/dispatcher.go new file mode 100644 index 0000000..f15438f --- /dev/null +++ b/internal/tenant/dispatcher.go @@ -0,0 +1,58 @@ +package tenant + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +// DispatcherRoute contains exact v2 bindings. Tenant keys are never rewritten. +type DispatcherRoute struct { + InboxQueue string + DeadLetterQueue string + InboundKey string + OutboundKey string +} + +// ValidateDispatcherID requires the stable, deployment-assigned UUID v4 form. +func ValidateDispatcherID(id string) error { + parsed, err := uuid.Parse(id) + if err != nil || parsed.Version() != 4 || parsed.Variant() != uuid.RFC4122 || parsed.String() != id { + return fmt.Errorf("dispatcher_id must be a canonical lowercase UUID v4") + } + return nil +} + +// NewDispatcherRoute rejects Topic wildcard words rather than changing a tenant +// identity. The longest queue name, including its dead-letter suffix, determines +// the 196-byte tenant budget for a 36-byte Dispatcher ID. +func NewDispatcherRoute(dispatcherID, tenantKey string) (DispatcherRoute, error) { + if err := ValidateDispatcherID(dispatcherID); err != nil { + return DispatcherRoute{}, err + } + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return DispatcherRoute{}, err + } + for _, word := range strings.Split(tenantKey, ".") { + if word == "*" || word == "#" { + return DispatcherRoute{}, fmt.Errorf("tenant_key contains a Topic wildcard word; preserve the source task without publishing") + } + } + queuePrefix := "agent-call.d." + dispatcherID + ".t." + tenantKey + keyPrefix := "d." + dispatcherID + ".t." + tenantKey + route := DispatcherRoute{ + InboxQueue: queuePrefix + ".v2", + DeadLetterQueue: queuePrefix + ".dlq.v2", + InboundKey: keyPrefix + ".in", + OutboundKey: keyPrefix + ".out", + } + for _, name := range []string{route.InboxQueue, route.DeadLetterQueue, route.InboundKey, route.OutboundKey} { + if len(name) > 255 { + return DispatcherRoute{}, fmt.Errorf("Dispatcher route exceeds AMQP's 255-byte limit; tenant_key maximum is 196 UTF-8 bytes") + } + } + return route, nil +} diff --git a/internal/tenant/dispatcher_test.go b/internal/tenant/dispatcher_test.go new file mode 100644 index 0000000..c12f08d --- /dev/null +++ b/internal/tenant/dispatcher_test.go @@ -0,0 +1,100 @@ +package tenant + +import ( + "strings" + "testing" +) + +const ( + testDispatcherA = "c046b893-8628-4589-ae50-619d049248a6" + testDispatcherB = "bd72ec77-7296-4da6-8742-732bdb3dbf97" +) + +func TestDispatcherRouteExactNames(t *testing.T) { + route, err := NewDispatcherRoute(testDispatcherA, "tenant-a") + if err != nil { + t.Fatal(err) + } + want := DispatcherRoute{ + InboxQueue: "agent-call.d." + testDispatcherA + ".t.tenant-a.v2", + DeadLetterQueue: "agent-call.d." + testDispatcherA + ".t.tenant-a.dlq.v2", + InboundKey: "d." + testDispatcherA + ".t.tenant-a.in", + OutboundKey: "d." + testDispatcherA + ".t.tenant-a.out", + } + if route != want { + t.Fatalf("route = %#v, want %#v", route, want) + } + other, err := NewDispatcherRoute(testDispatcherB, "tenant-a") + if err != nil { + t.Fatal(err) + } + if route.InboxQueue == other.InboxQueue || route.InboundKey == other.InboundKey || route.OutboundKey == other.OutboundKey || route.DeadLetterQueue == other.DeadLetterQueue { + t.Fatal("different Dispatcher identities share a route") + } +} + +func TestDispatcherRoutePreservesSafeTenantKeys(t *testing.T) { + for _, key := range []string{"tenant-a", "租户甲", "a.b", ".a..b.", "a*b", "a#b", "a*", "#a", "A B/甲"} { + t.Run(key, func(t *testing.T) { + route, err := NewDispatcherRoute(testDispatcherA, key) + if err != nil { + t.Fatal(err) + } + if route.InboundKey != "d."+testDispatcherA+".t."+key+".in" || route.InboxQueue != "agent-call.d."+testDispatcherA+".t."+key+".v2" { + t.Fatalf("tenant key changed: %#v", route) + } + }) + } +} + +func TestDispatcherRouteRejectsWildcardWords(t *testing.T) { + for _, key := range []string{"*", "#", "a.*", "#.a", "a.#.b", ".*.", "a..#", "*.#"} { + t.Run(key, func(t *testing.T) { + if _, err := NewDispatcherRoute(testDispatcherA, key); err == nil { + t.Fatalf("unsafe Topic binding accepted: %q", key) + } + }) + } +} + +func TestDispatcherRouteByteBudget(t *testing.T) { + for _, key := range []string{strings.Repeat("a", 196), strings.Repeat("甲", 65) + "a"} { + route, err := NewDispatcherRoute(testDispatcherA, key) + if err != nil { + t.Fatal(err) + } + if len(route.DeadLetterQueue) != 255 { + t.Fatalf("dead-letter queue = %d bytes, want 255", len(route.DeadLetterQueue)) + } + for _, name := range []string{route.InboxQueue, route.DeadLetterQueue, route.InboundKey, route.OutboundKey} { + if len(name) > 255 { + t.Fatalf("AMQP short-string budget exceeded: %d", len(name)) + } + } + } + for _, key := range []string{"", string([]byte{0xff}), strings.Repeat("a", 197), strings.Repeat("甲", 66), strings.Repeat("a", 224)} { + if _, err := NewDispatcherRoute(testDispatcherA, key); err == nil { + t.Fatalf("invalid or over-budget tenant key accepted: %q", key) + } + } +} + +func TestDispatcherIdentityRequiresCanonicalV4(t *testing.T) { + if err := ValidateDispatcherID(testDispatcherA); err != nil { + t.Fatal(err) + } + for _, id := range []string{ + "", "dispatcher-a", strings.ToUpper(testDispatcherA), + "00000000-0000-0000-0000-000000000000", + "c046b893-8628-1589-ae50-619d049248a6", + "c046b893-8628-4589-7e50-619d049248a6", + "c046b89386284589ae50619d049248a6", "urn:uuid:" + testDispatcherA, + } { + if err := ValidateDispatcherID(id); err == nil { + t.Fatalf("invalid Dispatcher identity accepted: %q", id) + } + if _, err := NewDispatcherRoute(id, "tenant-a"); err == nil { + t.Fatalf("route accepted invalid identity: %q", id) + } + } +} diff --git a/internal/testfixture/execute.go b/internal/testfixture/execute.go new file mode 100644 index 0000000..82eb6a5 --- /dev/null +++ b/internal/testfixture/execute.go @@ -0,0 +1,29 @@ +// Package testfixture provides protocol fixtures for repository tests only. +package testfixture + +import ( + "encoding/json" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +const DispatcherID = "c046b893-8628-4589-ae50-619d049248a6" + +// Execute preserves the established business identifiers used by scheduler +// tests while supplying the current external envelope. Historical bundles are +// read unchanged; no production decoder accepts their old MQ envelope. +func Execute() ([]byte, error) { + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + return nil, err + } + var envelope map[string]any + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, err + } + envelope["schema_version"] = "2.0" + envelope["dispatcher_id"] = DispatcherID + return json.MarshalIndent(envelope, "", " ") +} + +func InboundKey(tenantKey string) string { return "d." + DispatcherID + ".t." + tenantKey + ".in" } diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index 383c860..2c55984 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -446,5 +446,6 @@ message CompleteUploadRequest { message CompleteUploadResponse { OperationReceipt receipt = 1; UploadState state = 2; - string oss_id = 3; + reserved 3; + reserved "oss_id"; } diff --git a/proto/manifest.json b/proto/manifest.json index fe622c9..137a257 100644 --- a/proto/manifest.json +++ b/proto/manifest.json @@ -35,12 +35,12 @@ { "path": "proto/agent/v1/agent.proto", "bytes": 10681, - "sha256": "083d17eb761566e533d91d63cb118da056de498ab4187663de0294704c93e7fa" + "sha256": "7eec2489bc7e18dfd6ea5a11f4eebfc27fe02431cbf6c6f123924b0e91c58d72" }, { "path": "gen/agent/v1/agent.pb.go", "bytes": 144947, - "sha256": "01ce926ed3d3e90888719d361ffad2e6da84bb2bc9df7b06f0bbab3ec901a9df" + "sha256": "af13ba8a2a6a5c8b0f7c66aa7c8a6d2a09829be6bc3a38dd4ab5ca79730fca0e" }, { "path": "gen/agent/v1/agent_grpc.pb.go", diff --git a/scripts/acceptance-local.sh b/scripts/acceptance-local.sh index 57c412c..3bd086c 100755 --- a/scripts/acceptance-local.sh +++ b/scripts/acceptance-local.sh @@ -12,10 +12,26 @@ go build -trimpath -buildvcs=false -o dist/sip-go-agent ./cmd/sip-go-agent tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT +cp deploys/config/dispatcher.json.example "$tmp/dispatcher.json" +chmod 600 "$tmp/dispatcher.json" +# The strict Dispatcher file requires explicitly referenced credentials even in +# mock mode. These are local-test values and never reach an OSS endpoint. +export GO_SIP_OSS_ACCESS_KEY_ID=local-mock-access-key +export GO_SIP_OSS_ACCESS_KEY_SECRET=local-mock-access-secret ./dist/sip-go-agent agent --mode mock --spool "$tmp/spool" >/dev/null -./dist/sip-go-agent dispatcher --mode mock --db "$tmp/dispatcher.db" >/dev/null +rabbit_url=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-}} +if [ -z "$rabbit_url" ]; then + echo "GO_SIP_LOCAL_MQ_URL or RABBITMQ_URL is required for Dispatcher acceptance" >&2 + exit 1 +fi +export GO_SIP_LOCAL_MQ_URL=${GO_SIP_LOCAL_MQ_URL:-$rabbit_url} +export GO_SIP_LOCAL_QUERY_MQ_URL=${GO_SIP_LOCAL_QUERY_MQ_URL:-$rabbit_url} +export GO_SIP_LOCAL_UPLOAD_MQ_URL=${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$rabbit_url} +export RABBITMQ_URL=${RABBITMQ_URL:-$rabbit_url} +./dist/sip-go-agent dispatcher --mode mock --config "$tmp/dispatcher.json" --db "$tmp/dispatcher.db" --rabbit-url "$rabbit_url" --tenant-key local-acceptance-tenant --once >/dev/null +./scripts/mq-only-acceptance-local.sh >/dev/null -if ./dist/sip-go-agent dispatcher --mode real --db "$tmp/real.db" >/dev/null 2>&1; then +if env -u GO_SIP_LOCAL_MQ_URL -u GO_SIP_LOCAL_QUERY_MQ_URL -u GO_SIP_LOCAL_UPLOAD_MQ_URL -u RABBITMQ_URL ./dist/sip-go-agent dispatcher --mode real --config "$tmp/dispatcher.json" --db "$tmp/real.db" --tenant-key local-acceptance-tenant --once >/dev/null 2>&1; then echo "real mode unexpectedly started without broker credentials" >&2 exit 1 fi diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..d1c30e1 --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +profile=${COVER_PROFILE:-/tmp/go-sip-coverage.out} +business_profile=${COVER_BUSINESS_PROFILE:-/tmp/go-sip-coverage-business.out} + +GOMAXPROCS=${GOMAXPROCS:-2} go test -p 1 -coverpkg=./... -coverprofile="$profile" ./... +awk 'NR == 1 || index($0, "/gen/") == 0' "$profile" > "$business_profile" +go tool cover -func="$business_profile" diff --git a/scripts/mq-only-acceptance-local.sh b/scripts/mq-only-acceptance-local.sh new file mode 100755 index 0000000..b53005d --- /dev/null +++ b/scripts/mq-only-acceptance-local.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT" + +# A missing broker must fail the command, never turn these tests into skips. +BASE_URL=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-amqp://guest:guest@127.0.0.1:33252/}} +export GO_SIP_LOCAL_MQ_URL="$BASE_URL" +export GO_SIP_LOCAL_QUERY_MQ_URL="${GO_SIP_LOCAL_QUERY_MQ_URL:-$BASE_URL}" +export GO_SIP_LOCAL_UPLOAD_MQ_URL="${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$BASE_URL}" +export RABBITMQ_URL="${RABBITMQ_URL:-$BASE_URL}" + +LOG_FILE=${MQ_ONLY_ACCEPTANCE_LOG:-/tmp/go-sip-mq-only-acceptance.log} +: > "$LOG_FILE" +run() { + printf '$' + printf ' %q' "$@" + printf '\n' | tee -a "$LOG_FILE" + "$@" 2>&1 | tee -a "$LOG_FILE" +} + +run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/dispatcher \ + -run 'TestLocalMQ(AIConfigurationAuthorizationRoundTrip|ActiveControlReplyRecovery|RequestRoundTrip)$' -count=1 +run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/rpc \ + -run '^TestLocalUploadNoticeSurvivesUnroutableAndRestart$' -count=1 +run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v -tags integration ./internal/mq \ + -run 'TestV2LocalBrokerIdentityIsolationAndReliableRouting|TestLocalRabbitMQConfirmAckAndDeadLetter|TestV2LocalBrokerDisconnectStopsPublisher' -count=1 +run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/store \ + -run 'TestMQControlRejectsLateRevisionWithoutRegressingTask|TestMQReplayOnlyRequeuesOriginalBusinessFacts' -count=1 + +if grep -Eq -- '--- SKIP:|\(no tests to run\)' "$LOG_FILE"; then + echo "targeted MQ acceptance unexpectedly skipped a test; see $LOG_FILE" >&2 + exit 1 +fi +echo "targeted MQ acceptance passed without skipped tests; log=$LOG_FILE" diff --git a/scripts/publish-mq-v2.py b/scripts/publish-mq-v2.py new file mode 100644 index 0000000..8fccbac --- /dev/null +++ b/scripts/publish-mq-v2.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Reproduce the project-local v2 draft from approved rules and pinned v1 inputs. + +The v1 input is never changed. Upload delivery ends at the designated durable +MQ queue: no upload-session request, verified response or SaaS OSS ID protocol. +""" +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +OLD = ROOT / "contracts/upstream/2026-09-19-p1-v1" +NEW = ROOT / "contracts/upstream/2026-09-21-p1-v2" +BASE = "https://go-sip.local/contracts/2026-09-21-p1-v2/" +DID = "c046b893-8628-4589-ae50-619d049248a6" +AT = "2026-09-21T00:00:00Z" +SCHEMA = "https://json-schema.org/draft/2020-12/schema" +WRITTEN = set() + + +def emit_text(name, text): + path = NEW / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + WRITTEN.add(name) + + +def emit(name, value): + emit_text(name, json.dumps(value, ensure_ascii=False, indent=2) + "\n") + + +def load_json(path): + try: + return json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"Cannot load pinned source schema {path}") from exc + + +def ref(name): + return {"$ref": "#/$defs/" + name} + + +def obj(properties, required=None): + return {"type": "object", "additionalProperties": False, + "required": list(properties) if required is None else required, + "properties": properties} + + +def rewrite_refs(value, prefix): + if isinstance(value, list): + return [rewrite_refs(v, prefix) for v in value] + if not isinstance(value, dict): + return value + result = {k: rewrite_refs(v, prefix) for k, v in value.items()} + r = result.get("$ref", "") + if r.startswith("#/components/schemas/"): + result["$ref"] = "#/$defs/" + prefix + r.split("/")[-1] + return result + + +def uploaded_event_schema(value: Any) -> Any: + if isinstance(value, list): + return [uploaded_event_schema(v) for v in value] + if isinstance(value, dict): + return {k: uploaded_event_schema(v) for k, v in value.items()} + if value == "recording.ready": + return "recording.uploaded" + if value == "#/$defs/recording_ready": + return "#/$defs/recording_uploaded" + return value + + +# Preserve unrelated business schemas; the old package and its IDs stay intact. +for path in sorted(OLD.glob("*.schema.json")): + schema = load_json(path) + schema["$id"] = BASE + path.name + emit(path.name, schema) + +mq = load_json(OLD / "mq.schema.json") +events = load_json(OLD / "event-payloads.schema.json") +defs = mq["$defs"] +dispatcher_id = {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"} +tenant_key = {"type": "string", "minLength": 1, "maxLength": 196, + "not": {"pattern": "(^|\\.)[*#](\\.|$)"}, + "$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget."} +command_types = ["call.execute", "task.control", "call.replay", "command.replay"] + +# Replace ready, rather than falsely reporting SaaS processing or an OSS ID. +uploaded = events["$defs"].pop("recording_ready") +uploaded["properties"].pop("oss_id") +uploaded["required"].remove("oss_id") +for name, schema in {"upload_id": ref("id"), "bucket": {"type": "string", "minLength": 1, "maxLength": 63}, "object_key": {"type": "string", "minLength": 1, "maxLength": 1024}}.items(): + uploaded["properties"][name] = schema + uploaded["required"].append(name) +events["$defs"]["recording_uploaded"] = uploaded +events = uploaded_event_schema(events) +if not isinstance(events, dict): + raise TypeError("event schema transformation must produce an object") +events["$id"] = BASE + "event-payloads.schema.json" +events["properties"]["schema_version"] = {"const": "2.0"} +events["properties"]["dispatcher_id"] = dispatcher_id +events["properties"]["tenant_key"] = tenant_key +events["required"].append("dispatcher_id") +events["$defs"]["command_result"]["properties"]["command_type"]["enum"] = command_types +emit("event-payloads.schema.json", events) + +defs["dispatcherId"] = dispatcher_id +defs["tenantKey"] = tenant_key +for filename, prefix in [("executor.openapi.yaml", "executor_"), ("ai-config.openapi.yaml", "ai_")]: + document = yaml.safe_load((OLD / filename).read_text()) + for name, schema in document["components"]["schemas"].items(): + defs[prefix + name] = rewrite_refs(schema, prefix) + + +def event_collection(kinds): + return {"type": "array", "items": { + "allOf": [{"$ref": "event-payloads.schema.json"}, + {"properties": {"event_type": {"enum": kinds}}}]}} + + +# Approved query gap closure: typed event collections and outbox counts. +defs["executor_Command"]["additionalProperties"] = False +call = defs["executor_Call"] +call["additionalProperties"] = False +call["properties"]["attempts"] = event_collection(["call.status"]) +call["properties"]["transcript"] = obj({"events": event_collection(["transcript.updated", "transcript.failed"])}) +call["properties"]["recordings"] = event_collection(["recording.uploaded", "recording.failed"]) +call["properties"]["delivery"] = obj({k: {"type": "integer", "minimum": 0} for k in ["pending", "retry", "dispatching", "published"]}) + +command = copy.deepcopy(defs.pop("executeCommand")) +command["properties"].update({"schema_version": {"const": "2.0"}, "dispatcher_id": ref("dispatcherId"), "tenant_key": ref("tenantKey"), "command_type": {"enum": command_types}, "payload": {"type": "object"}}) +command["required"].append("dispatcher_id") +control = copy.deepcopy(defs["executor_ControlRequest"]) +# The existing MQ envelope is the sole command_id authority, not a second body ID. +control["properties"].pop("command_id") +control["required"].remove("command_id") +control["properties"]["task_id"] = ref("id") +control["required"].append("task_id") +defs["taskControl"] = control +for name, target in [("callReplay", "call_id"), ("commandReplay", "source_command_id")]: + value = copy.deepcopy(defs["executor_ReplayRequest"]) + value["properties"].pop("command_id") + value["required"].remove("command_id") + value["properties"][target] = ref("id") + value["required"].append(target) + defs[name] = value +command["allOf"] = [{"if": {"properties": {"command_type": {"const": kind}}}, "then": {"properties": {"payload": ref(payload)}}} for kind, payload in zip(command_types, ["executePayload", "taskControl", "callReplay", "commandReplay"], strict=True)] +defs["command"] = command +defs["event"] = {"$ref": "event-payloads.schema.json"} + +# Flatten allOf so the source receipt's closed object permits the known config. +version = copy.deepcopy(defs["ai_AgentVersionReceipt"]) +version["properties"]["config"] = {"$ref": "ai-config.schema.json"} +version["required"].append("config") +defs["ai_AgentVersion"] = version +defs["aiConfigResult"] = obj({"snapshot": ref("ai_AgentVersion"), "authorization": {"$ref": "ai-authorization.schema.json"}}) +request_payloads = {"command.query": obj({"command_id": ref("id")}), "call.query": obj({"call_id": ref("id")}), "ai.config.request": obj({"agent_version_id": ref("id")})} +response_payloads = {"command.query.result": ref("executor_Command"), "call.query.result": ref("executor_Call"), "ai.config.result": ref("aiConfigResult")} +common = {"schema_version": {"const": "2.0"}, "message_type": {"type": "string"}, "message_id": ref("id"), "dispatcher_id": ref("dispatcherId"), "tenant_id": ref("id"), "tenant_key": ref("tenantKey"), "trace_id": ref("id"), "issued_at": {"type": "string", "format": "date-time"}} +request = obj({**common, "not_after": {"type": "string", "format": "date-time"}, "payload": {"type": "object"}}) +request["properties"]["message_type"] = {"enum": list(request_payloads)} +request["allOf"] = [{"if": {"properties": {"message_type": {"const": kind}}}, "then": {"properties": {"payload": payload}}} for kind, payload in request_payloads.items()] +defs["request"] = request +reasons = ["invalid_request", "not_found", "conflict", "expired", "not_authorized", "unavailable", "unsupported"] +response = obj({**common, "correlation_id": ref("id"), "status": {"enum": ["ok", "pending", "rejected"]}, "reason_code": {"type": "string"}, "payload": {"type": "object"}}) +response["properties"]["message_type"] = {"enum": list(response_payloads)} +response["allOf"] = [ + {"if": {"properties": {"status": {"const": "pending"}}}, "then": {"properties": {"reason_code": {"const": "waiting"}, "payload": obj({})}}}, + {"if": {"properties": {"status": {"const": "rejected"}}}, "then": {"properties": {"reason_code": {"enum": reasons}, "payload": obj({"detail": {"type": "string", "maxLength": 1024}, "retryable": {"type": "boolean"}})}}}, +] +for kind, payload in response_payloads.items(): + response["allOf"].append({"if": {"properties": {"status": {"const": "ok"}, "message_type": {"const": kind}}}, "then": {"properties": {"reason_code": {"const": "ok"}, "payload": payload}}}) +defs["response"] = response +mq["$id"] = BASE + "mq.schema.json" +mq["oneOf"] = [ref(k) for k in ["command", "event", "request", "response"]] + + +def local_refs(value): + if isinstance(value, list): + return set().union(*(local_refs(v) for v in value)) + if not isinstance(value, dict): + return set() + found = set() + if value.get("$ref", "").startswith("#/$defs/"): + found.add(value["$ref"].split("/")[2]) + for k, v in value.items(): + if k != "$defs": + found.update(local_refs(v)) + return found + + +# Do not publish unused HTTP-specific definitions as a second supported contract. +pending, reachable = local_refs(mq), set() +while pending: + name = pending.pop() + if name not in reachable: + reachable.add(name) + pending.update(local_refs(defs[name]) - reachable) +mq["$defs"] = {name: defs[name] for name in sorted(reachable)} +emit("mq.schema.json", mq) + +config = obj({"schema_version": {"const": "1.0"}, "dispatcher_id": dispatcher_id, "oss": obj({k: {"type": "string", "minLength": 1} for k in ["endpoint", "region", "bucket", "object_prefix", "access_key_id_env", "access_key_secret_env"]})}) +config.update({"$schema": SCHEMA, "$id": BASE + "dispatcher-config.schema.json"}) +config["properties"]["oss"]["properties"]["endpoint"].update({"format": "uri", "pattern": "^https?://"}) +for key in ["access_key_id_env", "access_key_secret_env"]: + config["properties"]["oss"]["properties"][key]["pattern"] = "^[A-Za-z_][A-Za-z0-9_]*$" +emit("dispatcher-config.schema.json", config) +emit("mq-topology.json", {"version": "2.0", "exchanges": {name: {"type": "topic", "durable": True} for name in ["agent-call.dispatchers.v2", "agent-call.saas.v2", "agent-call.dead-letter.v2"]}, "inbox_queue": "agent-call.d..t..v2", "dead_letter_queue": "agent-call.d..t..dlq.v2", "inbound_key": "d..t..in", "outbound_key": "d..t..out", "saas_queue": "agent-call.saas.events.v2", "owner_queue": "agent-call.d..owner.v2", "owner_exclusive": True, "business_queues_durable": True, "message_persistent": True, "publish_mandatory": True, "publisher_confirms": True, "tenant_key_max_utf8_bytes": 196, "message_max_bytes": 262144, "service_request_deadline_seconds": 30, "upload_token_seconds": 900}) + +cases = [] + + +def fixture(name, value, valid=True, schema="mq.schema.json"): + filename = "examples/" + name + ".json" + emit(filename, value) + cases.append({"file": filename, "schema": schema, "valid": valid}) + + +base = {"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a", "trace_id": "trace-v2", "issued_at": AT} +execute = {**base, "command_id": "command-a", "command_type": "call.execute", "not_after": "2026-09-21T00:00:30Z", "payload": {"execution_id": "execution-a", "task_id": "task-a", "task_item_id": "item-a", "task_revision": 1, "callee": "15003164745", "route_policy_id": "route-a", "caller_profile_id": "caller-a", "agent_version_id": "version-a", "variables": {}, "ring_timeout_ms": 1000, "max_call_duration_ms": 10000}} +fixture("call-execute", execute) +for kind, payload in [("task.control", {"task_id": "task-a", "action": "pause", "expected_task_revision": 1, "active_call_policy": "drain", "reason": "local-test"}), ("call.replay", {"call_id": "call-a", "reason": "local-test"}), ("command.replay", {"source_command_id": "command-a", "reason": "local-test"})]: + fixture(kind.replace(".", "-"), {**execute, "command_id": kind + "-a", "command_type": kind, "payload": payload}) +for kind, payload in [("command.query", {"command_id": "command-a"}), ("call.query", {"call_id": "call-a"}), ("ai.config.request", {"agent_version_id": "version-a"})]: + fixture(kind.replace(".", "-"), {**base, "message_id": kind + "-a", "message_type": kind, "not_after": "2026-09-21T00:00:30Z", "payload": payload}) +reply = {**base, "message_id": "query-reply-a", "message_type": "command.query.result", "correlation_id": "command.query-a", "status": "ok", "reason_code": "ok"} +fixture("query-pending", {**reply, "status": "pending", "reason_code": "waiting", "payload": {}}) +fixture("query-rejected", {**reply, "status": "rejected", "reason_code": "not_found", "payload": {"detail": "command not found", "retryable": False}}) +fixture("dispatcher-config", {"schema_version": "1.0", "dispatcher_id": DID, "oss": {"endpoint": "https://oss.example.invalid", "region": "example-region", "bucket": "example-bucket", "object_prefix": "recordings", "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET"}}, schema="dispatcher-config.schema.json") +fixture("invalid-v1", {**execute, "schema_version": "1.0"}, False) +fixture("invalid-wildcard", {**execute, "tenant_key": "tenant.#"}, False) +fixture("invalid-dispatcher", {**execute, "dispatcher_id": "dispatcher-a"}, False) + +# Every event and successful response has a fixture, not just a compilable schema. +event_examples = {} +for path in sorted((OLD / "examples").glob("event-*.json")): + event = load_json(path) + event.update({"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a"}) + if "call_id" in event["payload"]: + event["payload"]["call_id"] = "call-a" + if event["aggregate_type"] == "call": + event["aggregate_id"] = "call-a" + name = path.stem + if event["event_type"] == "recording.ready": + event["event_type"] = "recording.uploaded" + event["payload"].pop("oss_id") + event["payload"].update({"upload_id": "upload-a", "bucket": "example-bucket", "object_key": "recordings/recording-a.wav"}) + name = "event-recording-uploaded" + event["event_id"] = event["event_type"] + "-event-a" + event_examples[event["event_type"]] = event + fixture(name, event, schema="event-payloads.schema.json") +command_result = {"command_id": "command-a", "command_type": "call.execute", "tenant_id": "tenant-a", "tenant_key": "tenant-a", "status": "accepted", "aggregate_version": 1} +fixture("command-query-result", {**reply, "payload": command_result}) +fixture("invalid-correlation", {k: v for k, v in {**reply, "payload": command_result}.items() if k != "correlation_id"}, False) +call_result = {"call_id": "call-a", "execution_id": event_examples["call.status"]["payload"]["execution_id"], "call_state": event_examples["call.status"]["payload"]["call_state"], "call_version": 1, "attempts": [event_examples["call.status"]], "transcript": {"events": [event_examples["transcript.updated"], event_examples["transcript.failed"]]}, "recordings": [event_examples["recording.uploaded"], event_examples["recording.failed"]], "delivery": {"pending": 1, "retry": 0, "dispatching": 0, "published": 0}, "snapshot_at": AT} +fixture("call-query-result", {**reply, "message_id": "call-query-reply-a", "message_type": "call.query.result", "correlation_id": "call.query-a", "payload": call_result}) +invalid_query = copy.deepcopy(call_result) +invalid_query["delivery"]["raw"] = {} +fixture("invalid-call-query-extra", {**reply, "message_type": "call.query.result", "payload": invalid_query}, False) +ai_config = load_json(OLD / "examples/agent-version-full-explicit.json") +ai_config["agent_version_id"] = "version-a" +config_digest = hashlib.sha256(json.dumps(ai_config, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest() +snapshot = {"tenant_id": "tenant-a", "agent_version_id": "version-a", "status": "published", "immutable": True, "content_sha256": config_digest, "config": ai_config} +authorization = load_json(OLD / "examples/ai-authorization.json") +authorization.update({"tenant_id": "tenant-a", "tenant_key": "tenant-a", "agent_version_id": "version-a", "config_sha256": config_digest, "mode": ai_config["mode"]}) +fixture("ai-config-result", {**reply, "message_id": "ai-config-reply-a", "message_type": "ai.config.result", "correlation_id": "ai.config.request-a", "payload": {"snapshot": snapshot, "authorization": authorization}}) +emit("fixtures.json", cases) +emit_text("README.md", "# Project-local MQ-only v2\n\nApproved design: docs/contracts/mq-only-v2-freeze-proposal.md, amended by the enqueue-only upload goal.\nThis is a local project contract, not external SaaS acceptance. Old v1 files are untouched.\nPublisher: scripts/publish-mq-v2.py. Validator: go test ./contracts -run TestV2.\n\nUpload notification: recording.uploaded, no recording.ready, upload session or verified reply. Completion means persistent delivery to the designated durable MQ queue, mandatory routing and publisher confirm; not SaaS consumption.\nPayload: call_id, recording_id, upload_id, bucket, object_key, format, channels, sample_rate_hz, duration_ms, size_bytes, checksum_sha256. No TOKEN, credentials, signed URL or SaaS OSS ID.\n\nControl/replay command_id belongs solely to the envelope. Query nested structures reuse typed events and delivery counts. AI receipt/config composition is flattened without widening fields.\nSchema limits characters; runtime also checks UTF-8 bytes and aggregate response size. Oversized snapshots fail explicitly, never silently truncate.\n") +actual = {str(p.relative_to(NEW)) for p in NEW.rglob("*") if p.is_file() and p.name != "manifest.json"} +if extra := actual - WRITTEN: + raise RuntimeError(f"Unpublished/stale files remain in draft bundle; inspect explicitly: {sorted(extra)}") +files = {name: hashlib.sha256((NEW / name).read_bytes()).hexdigest() for name in sorted(WRITTEN)} +emit("manifest.json", {"version": "2026-09-21-p1-v2", "source": "project-approved-mq-only-v2", "derived_from": "2026-09-19-p1-v1", "source_files": {str(p.relative_to(OLD)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(OLD.rglob("*")) if p.is_file()}, "files": files}) +print(f"Published {len(files)} pinned project-local draft files in {NEW.relative_to(ROOT)}") diff --git a/scripts/publish-mq-v3.py b/scripts/publish-mq-v3.py new file mode 100644 index 0000000..69ab9fb --- /dev/null +++ b/scripts/publish-mq-v3.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Publish the approved JCS digest revision without modifying older bundles.""" +import hashlib +import importlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +import jsonschema +from referencing import Registry, Resource + +ROOT = Path(__file__).resolve().parent.parent +OLD_VERSION = "2026-09-21-p1-v2" +VERSION = "2026-09-21-p1-v3" +OLD = ROOT / "contracts/upstream" / OLD_VERSION +NEW = OLD.parent / VERSION +MODULE = "github.com/cyberphone/json-canonicalization" +REVISION = "v0.0.0-20241213102144-19d51d7fe467" +info = json.loads(subprocess.check_output(["go", "mod", "download", "-json", MODULE + "@" + REVISION], cwd=ROOT)) +if info.get("Error") or info.get("Version") != REVISION: + raise RuntimeError("pinned JCS implementation unavailable") +sys.path.insert(0, str(Path(info["Dir"]) / "python3/src")) +canonicalize = importlib.import_module("org.webpki.json.Canonicalize").canonicalize + + +def encoded(value): + return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode() + + +def digest(value): + return hashlib.sha256(canonicalize(value)).hexdigest() + + +manifest = json.loads((OLD / "manifest.json").read_text()) +files = {} +for name, expected in manifest["files"].items(): + raw = (OLD / name).read_bytes() + if hashlib.sha256(raw).hexdigest() != expected: + raise RuntimeError("source bundle hash mismatch: " + name) + files[name] = raw.replace(OLD_VERSION.encode(), VERSION.encode()) + +result = json.loads(files["examples/ai-config-result.json"]) +result["payload"]["snapshot"]["content_sha256"] = digest(result["payload"]["snapshot"]["config"]) +files["examples/ai-config-result.json"] = encoded(result) +oldest = ROOT / "contracts/upstream/2026-09-19-p1-v1/examples" +for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"): + files["examples/" + name] = (oldest / name).read_bytes() +authorization = json.loads((oldest / "ai-authorization.json").read_text()) +authorization["config_sha256"] = digest(json.loads(files["examples/agent-version-asr-only.json"])) +files["examples/ai-authorization.json"] = encoded(authorization) + +vectors = [ + {"name": "numbers", "input": '{"b":1.0,"a":[-0.0,1e30,4.50,2e-3]}'}, + {"name": "utf16-key-order", "input": '{"\\ue000":1,"\\ud800\\udc00":2}'}, + {"name": "strings", "input": '{"s":"\\u20ac/\\n","flag":false,"zero":0}'}, +] +for vector in vectors: + value = json.loads(vector["input"]) + vector["canonical"] = canonicalize(value).decode() + vector["sha256"] = digest(value) +files["jcs-golden.json"] = encoded(vectors) +files["ai-digest.json"] = encoded({ + "scope": "AI snapshot.config and authorization config_sha256 only", + "canonicalization": "RFC 8785 (JCS)", "digest": "SHA-256", "encoding": "lowercase hex", + "implementation": MODULE, "revision": REVISION, + "external_acceptance": False, + "invariants": ["No Unicode normalization", "Reject invalid UTF-8 and duplicate object keys", + "Preserve explicit zero/false versus absent fields", "No old-digest fallback"], +}) +files["README.md"] = (f"# {VERSION}\n\nProject-local JCS/SHA-256 revision approved by the user. " + f"Derived from {OLD_VERSION}; older packages are unchanged. Wire schema_version remains 2.0.\n\n" + "AI digests use RFC 8785 canonical UTF-8 bytes followed by SHA-256, lowercase hexadecimal. " + "This does not change file checksums, upload facts, command-body identity, or other hashes. " + "Numbers follow JCS IEEE-754 rules; no Unicode normalization is performed. " + "Explicit zero/false remain distinct from absent fields. No legacy digest fallback.\n\n" + "Local agreement and generated fixtures are not external SaaS acceptance.\n").encode() +# Validate positive and negative fixtures against an entirely offline registry +# before the directory becomes an immutable published bundle. +schemas = {name: json.loads(raw) for name, raw in files.items() if name.endswith(".schema.json")} +registry = Registry().with_resources((value["$id"], Resource.from_contents(value)) for value in schemas.values()) +for fixture in json.loads(files["fixtures.json"]): + validator = jsonschema.Draft202012Validator(schemas[fixture["schema"]], registry=registry, + format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER) + valid = validator.is_valid(json.loads(files[fixture["file"]])) + if valid != fixture["valid"]: + raise RuntimeError("fixture validation mismatch: " + fixture["file"]) +for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"): + jsonschema.Draft202012Validator(schemas["ai-config.schema.json"], registry=registry).validate(json.loads(files["examples/" + name])) +jsonschema.Draft202012Validator(schemas["ai-authorization.schema.json"], registry=registry).validate(authorization) + +files["manifest.json"] = encoded({ + "version": VERSION, "source": "project-approved-jcs-sha256", "derived_from": OLD_VERSION, + "source_manifest_sha256": hashlib.sha256((OLD / "manifest.json").read_bytes()).hexdigest(), + "files": {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(files.items())}, +}) +if NEW.exists(): + existing = {str(p.relative_to(NEW)): p.read_bytes() for p in NEW.rglob("*") if p.is_file()} + if existing != files: + raise RuntimeError("immutable destination differs; publish a new version instead") +else: + with tempfile.TemporaryDirectory(prefix=".jcs-publish-", dir=OLD.parent) as temporary: + stage = Path(temporary) / VERSION + for name, raw in files.items(): + target = stage / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(raw) + os.rename(stage, NEW) +print(VERSION, hashlib.sha256(files["manifest.json"]).hexdigest())