commit 643b11b21f7607eb69f77dbcb697dfd1de3a5ed9 Author: Rogee Date: Mon Sep 21 08:56:04 2026 +0800 chore: initialize go-sip repository diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56fde03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Local build/runtime state +/bin/ +/dist/ +/spool/ +/tmp/ +*.db +*.db-shm +*.db-wal +*.log +*.pid + +# Local credentials and operator overrides +.env +.env.* +*.env +!configs/example.env +*.secret +*.key +*.pem +*.crt +.local/ +.pi/ +.codegraph/ + +# Go/tool caches +.coverage +coverage.out +*.coverprofile +*.test +*.prof +/go.work +/go.work.sum +/vendor/ + +# Captures and generated release archives +*.pcap +*.pcapng +*.wav +*.flac +*.ogg +*.opus +*.tar +*.tar.gz +*.tar.bz2 +*.zip + +# OS/editor metadata +.DS_Store + +# Temporary merge artifacts +agents.md.pending_merge diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a3d2bdd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,180 @@ +# go-sip:独立项目约束 + +## 宪法 + +- 任何涉及文件的调研或修改,如果当前是 git 仓库,需要先同步远程提交到本地,避免调研过时问题。 +- 基于 TDD 进行功能的开发与业务变更,单元测试覆盖率要保证 65% 以上 +- 任何时候我提出任何需求均需要理解并**结构化复述后与我进行确认,避免理解偏差**。 +- 不要在代码里藏兜底逻辑来吞掉错误、隐藏问题。出了问题就应该让它爆出来,否则你永远找不到真实问题。 +- 当一个问题出现时,不要用各种 small fix、针对性补丁来掩盖它。**必须定位真实根因,彻底修复**。在 bug 上糊纸只会让系统积累你不知道的危险暗病。 +- 即使问题很难定位,也**绝不要偷懒做表面修复**。应该给项目增加充分的日志和可观测性,保证下次问题再现时你有足够信息去定位。问题无法修复时,只需要诚实告诉我信息不足、需新增日志,不要假装修好了。 +- 始终注意在关键路径上给自己留足排查日志,确保每一个**关键节点都是可追溯**的。 +- 当项目关键技术栈或产品方向发生变更时,同步更新 agents.md。文档必须随代码一起演进,不能让它变成过时的谎言。 +- 大规模重构或实验性改动前,必须先切新分支。 + +- 不以维护向后兼容性为目标。**对于已经废弃的代码路径,应直接移除**,不再通过兼容层、回退机制或迁移方案予以保留。 +- 在充分满足当前需求的前提下,采用**尽可能简单的实现方案**。避免引入缺乏实际需求依据的抽象、配置项和间接层。 +- **采用渐进式、分层的方式构建系统**。首先完成能够端到端运行的最小版本,再基于稳定可用的产品逐步增加功能。不要以尚未成熟的复杂性取代已经可用的产品。 +- **保持组件的模块化**,并明确划分不同职责与关注点。 +- 当成熟且维护良好的库能够降低整体复杂度或提高可靠性时,应优先采用。除非有明确理由,不要重复实现通用功能。 +- 在自行实现功能或新增依赖之前,应优先评估项目现有依赖的能力。应先查阅相关文档和类型定义,不应未经确认就认定某个库不具备所需能力。 +- 架构决策**应着眼于长期演进**。不要采用仅能解决当前问题、且预期需要在后续替换的权宜方案。 +- 在设计解决方案之前,**先研究成熟产品如何解决同类问题**。优先采用经过验证的模式和约定,避免从零开始另行设计一套方案。 + +## 禁止清单(不主动考虑、不主动提议、不实现,遇到只记入 TODO 技术债列表) + +1. 法律合规:商业库授权、开源协议合规、GDPR/个保、隐私政策(法务负责)。 +2. 依赖安全:NPM 及第三方包漏洞、安全补丁、依赖升级策略。 +3. 访问安全:服务只需支持局域网访问(host 绑定 0.0.0.0 即可),不考虑公网暴露、HTTPS、认证/权限体系(登录、RBAC)、限流、防爬、数据加密、审计日志。 + +## 红线清单(快速阶段也不能省,现在便宜、以后极贵) + +1. 数据模型/表结构:认真设计,建表慎重——改表成本远高于写代码。 +2. 目录结构与模块边界:保持简单清晰,不堆一坨代码。 +3. 基础错误日志:出错时至少能看到发生了什么。 +4. Git:小步提交,保持历史清晰。 +5. 基础输入校验:仅防止程序崩溃,不做安全加固。 +6. 环境差异配置(端口、地址等)与代码分离(.env 或配置项)。 + +## 沟通方式 + +- 向使用者回报时,使用清楚直白的语言说明做了什么、结果如何。最终回复禁用术语、技术实现细节与工程腔。写法是:对一个聪明但没在看代码的人解释。 + +- 实际执行过程(思考、规划、写程序、除错、解决问题)保持完整的技术严谨度,这条规范只适用于对使用者的沟通方式。 + +## 回复风格 + +- 只写结论、实际改动、原因、验证结果 +- 不描述推进动作,禁用「我先……再……」等叙述句式 +- 不使用工程汇报腔(「落地」「落到」「推进」等类似用语) +- 直接、专业、去表演化 +- 回复文字永远使用与对方相同的语系,专有名词维持英文 +- 不使用口语化表达,说重点,简单明了 +- 需要时搭配条列式与表格加强输出可读性 + +## 决策规则 + +- 当方案有多个选项时,列出每个选项的优缺点,并明确指出推荐选项与原因,先问我。 +- 有多种实现方式时,选最简单能跑通的。 +- 遇到"禁止清单"中的问题:不展开、不实现,追加到 TODO 技术债列表即可。 + +## Sub-Agent 使用时机 + +当任务符合以下任一条件时,直接 spawn sub-agent 分工执行,无需询问使用者: + +- 任务可拆分为多个**平行且无依赖**的子任务 +- 各子任务职责明确分离,合并执行会造成 context 混杂 +- 大量结构相同的重复性任务(可用 `spawn_agents_on_csv` batch 执行) +- 各子任务需要不同的 model 配置或 sandbox 权限,例如: + + - 探索型任务使用轻量 model + `read-only` sandbox + - 审查型任务使用高推理 model + `read-only` sandbox + - 修改型任务使用执行导向 model + `workspace-write` sandbox + +## 验证标准 + +开始任务前先定义完成标准。交付前依此验证,发现问题就修好再测,不把未完成的工作交回给使用者。只有确认完成,或遇到真正需要使用者介入的障碍时,才回报。 + +## 当前范围 + +- 后续Agent必须先读 `docs/plan-0918.md`:映射W/子任务,按索引读取需求/契约正文,确认I/M/G前置、授权及写入边界后实施;并行时按§9认领,子Agent只写独占模块/证据,§8总台账、公共文件及合并状态由集成负责人单写。计划不替代权威Schema或验收,不重复审批已确认方向,不自动授权真实云/付费/拨号。 +- 用户指定:若启动开发及配套审查子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`。 +- 开发准备见 `docs/G0开发准备与契约冻结提案_v0.1.md`:D01–D10的方案方向、双模式/许可/恢复机制及内部PoC初始profile已获用户确认;本项目已自行交付 W01/W02 开发基线,但外部权威发布、真实预算、供应商签收和 G0/PoC 仍需分别验证,不能混写为生产合同。缺失字段细节、实际预算及方案变更另行确认。本轮验收基线收敛为单节点/单 Agent/单 Cell/单租户;双节点、第二 Cell、第二租户及其公平/故障矩阵不在本轮开发或验收范围,跨 Cell/多租户能力保留为后续阶段。 +- 用户已确认完整 Go Agent、分阶段替换:调度、Cell 执行、ARI/RTP/录音、AI 流及 Cell 配置接收。最终没有 Python 运行依赖;不重写 Asterisk、不实现第二套管理后台。 +- 本项目已独立拆仓运营,源码、文档、依赖、配置、迁移、测试、部署、发布入口全部留在本目录。普通构建/测试/运行不读取父目录,不使用其它项目内部模块、环境文件或夹具,不共享其业务数据库。 +- 当前已初始化独立 Git 仓库并绑定公开远程 `git.ipao.vip/rogee/go-sip`;未经授权不要重新 `git init`、改为 submodule、移动历史或更改父仓库跟踪关系。 + +## SIP 接入信息 + +以下为用户提供的 SIP 参数,服务商名称待补充;已记录不代表已完成真实线路验证。 + +| 服务商 | SIP 服务端 | 主叫号码/标识 | 被叫前缀 | +| --- | --- | --- | --- | +| 数企 | `61.132.228.221:5060` | `BD93205882` | `7089` | +| 中鼎 | `60.171.24.90:5060` | `mbkq` | 无 | +| 百应 | `160.202.254.79:5060` | `KQ91526` | `mka755` | + +## SIP 全局共用定义:外呼号码白名单 + +- 外呼号码白名单:`15003164745`、`15830461047`。 +- 所有 SIP 线路仅允许在此列表范围内发起外呼;不在列表内的号码必须拒绝。该列表仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。 +- SIP 外呼时间窗口固定为 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含);窗口外 Dispatcher/Agent 必须 fail-closed,禁止等待、自动延迟、重试或换线。mock 测试可注入时间验证边界,不能用 mock 结果宣称 real 放行。 +- 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。 +- 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。 +- 传输协议、IP/Digest 鉴权、是否注册及并发限制仍需供应商确认;当前供应商已反馈需使用 PCMA,Asterisk 配置以 `allow=alaw` 表示,仍需真实线路验证。 +- 每条线路目前只提供一个服务端地址,未提供独立备用地址。不能把同一地址重复填写成主备并宣称具备容灾;三条已登记线路应作为独立 trunk 配置,不为凑主备虚构供应商。 + +## ECS 部署环境与 Asterisk 运行约束 + +- ECS 部署环境固定优先使用 Debian 13(Trixie)minimal;仅当阿里云北京区域没有可用的 Debian 13 镜像时,才允许使用 Ubuntu 24.04 LTS。不得擅自切换到其他操作系统。 +- Asterisk 必须直接部署在实际承载它的 ECS 主机上,由 systemd service 统一管理并启用开机自启动;部署验收必须确认 service 已 enabled 且 active,不得以手工前台进程或容器入口替代生产启动方式。 + +## 开发与非生产环境强制部署/诊断步骤 + +- 开发、Mock、mixed、real 的**非生产环境**必须默认开启环境部署与诊断步骤;这些步骤是必需的,不得因“只是开发”“环境已存在”“时间紧”或调用方参数而跳过、关闭、静默降级或默认禁用。任何显式关闭均视为配置错误,应失败并阻止验收。 +- 每次新主机、新版本或新 Cell 验证至少执行并留存脱敏事实:ECS/EIP/网络资源只读核验、Debian/架构/磁盘/权限核验、`rogee` SSH 与 SSH 加固核验、发布包及依赖 SHA-256、Asterisk/systemd `enabled+active`、ARI/PJSIP endpoint/contact 状态、媒体 profile/监听端口和运行版本。 +- 每次非生产 `mixed`/`real` 外呼验证,必须先通过 Asia/Shanghai `09:00`–`20:00`(左闭右开)时间门禁,再在拨号前启动受限 SIP/RTP 抓包和 Asterisk PJSIP logger,并在结束后采集 SIP 响应码、INVITE/180/183/200/4xx/5xx/BYE 或 CANCEL 时间线、SDP codec/媒体地址端口、RTP 包/字节计数、录音与 ASR/LLM/TTS 事实及 SHA-256;无拨号前抓包、状态快照或时间门禁不得宣称验证通过。失败呼叫同样必须保留状态和抓包证据,不能只报告一个 hangup cause。 +- 抓包、日志、录音和识别文本只写入受限的非生产证据目录,聊天、源码、配置样例、提交和长期证据不得保存密钥、完整用户音频或完整用户对话;交付证据默认保存脱敏摘要、计数、状态码和哈希。若 tcpdump/CAP_NET_RAW、PJSIP logger、ARI/PJSIP 状态采集任一不可用,必须 fail-closed 报告阻塞,不得静默改成无抓包流程。 +- 上述步骤由统一部署/验收入口自动执行;`mock`、`mixed`、`real` 只替换适配器,不能绕开同一套部署、诊断、状态和证据门禁。生产环境仍须另行授权和通过生产安全屏障,非生产默认强制开启不等于生产放行。 + +## 本次上线目标与分期(用户已确认) + +- P1以稳定快速内测上线为目标:1个节点、1个Agent、1套Asterisk、1个单活Dispatcher/SQLite、1个启用租户;本轮不开发、不验收双节点、第二 Cell/第二 Asterisk或第二租户。 +- 至少3家独立SIP trunk 的静态配置、路由/主叫/前缀/codec/额度约束和协议 Mock/mixed 覆盖仍需保持;真实供应商外呼和 ECS 仅作为第二阶段联调,不是本轮前置。 +- 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 联调延期第二阶段。 +- 88项验收为跨阶段基线,当前只签收单节点/单 Cell/单租户适用子场景;双节点、第二 Cell、第二租户、真实 ECS/生产联调、容量/N+1及切换均不作为本轮门禁。 + +## 语言与工程 + +- 工具链基线为 Go 1.27.1。实施时锁定 CI/构建镜像和依赖,并校验实际工具链;不得自动修改其它项目的 Go 基线。 +- 标准库优先、单Go module/二进制,用Cobra显式提供agent、dispatcher两个业务子命令,无默认双角色启动;同一制品分进程/权限/目录,升级受版本兼容和排空约束,不另造CLI框架。 +- JSON v2、UUID 和新测试 API 的采用以契约兼容和实测为前提;既有标识、哈希规范不得随 API 更换。实验性 SIMD 不在当前范围。 +- 用户已确认不接PG:独立Dispatcher持有SQLite权威任务/配额/outbox,Agent无业务DB,文本/录音及执行/上传恢复信息落文件。禁止NFS共享SQLite/两份DB双活发额度,自动跨机热备不在已实现承诺内。 +- 已确认Unary gRPC,Dispatcher预配置Agent Endpoint;Agent业务只需D Endpoint,证书/监听/ARI/持久目录由部署提供。SDK复用连接,不增内部MQ或双向流,不因RPC超时重拨。 +- 所有Agent共用mTLS证书,D身份独立;必须通过受控Endpoint主动激活/节点会话授权,不信自报身份/地址。共享私钥泄露影响整组,轮换/撤销和风险要签收,不关闭SAN/SNI校验。 +- D感知健康/负载/软件协议/能力及供应商applied配置版本;样本过期/缺失为unknown,低CPU不突破租户/供应商/Cell/AI配额,新boot不清旧未知占用。 +- 实施后至少执行本模块的格式化检查、`go vet ./...`、`go test -race ./...` 和构建;当前代码已有 W01/W02/本地 RPC/AI Mock 入口,真实 DB/MQ、媒体、供应商、容量及切换验收仍分别报告,不能用本地通过记录代签。 + +## 开源复用硬约束(用户已确认) + +- SIP 及其它组件有适用开源库/官方 SDK 必须复用,禁止从零手写替代协议栈或客户端。优先标准库、Asterisk 原生能力、现成 SDK;自有代码限业务状态机、事务、权限、配额和薄适配。 +- 不自写 SIP/ARI、RTP/RTCP 编解包、G.711、WS/AMQP/数据库驱动、OSS 签名、已有 SDK 覆盖的 AI 协议或 Schema 解析。库不满足先选替代、修上游或报告阻塞;例外须用户另行批准。 +- 采用前核验 module/tag/commit、Go1.27.1、许可证/NOTICE、传递依赖/漏洞及真实协议兼容,留存 PoC;不得将 main README、未归档或可下载等同于生产通过。 +- SDK 自动重试不得造成二次 originate、旧音频重播或重复收费;不因 SIP 库存在而用 sipgo/diago 替换已选定 Asterisk 架构。 + +## AI配置与参数(用户已确认) + +- P1采用百炼/火山ASR、OpenAI兼容LLM、火山TTS;SDK首选及未通过门禁见组件清单§1.3/§4.3。基础栈方向确定不等于精确版本、许可证或参数能力已验收;不为补字段改为自写协议。 +- Dispatcher按MQ任务agent_version_id调用SaaS已有AI版本GET,校验租户/源Schema/不可变摘要/能力后向Agent交付执行快照。Agent不直连SaaS,不从CLI/env/源码常量或SDK默认覆盖AI业务值,不新增task-config猜测路径、MQ模式字段或调参后台。 +- 已有model/prompt/voice/speed/ASR输入与识别/temperature/max_tokens/timeout及对话控制必须实际传入SDK或控制器;热词/VAD/top_p/音量/阶段时限等所需扩展先在上游补GAP-09,再生成校验。严格additionalProperties不放宽,不借metadata/raw_request透传。 +- SaaS新版本供新任务引用,无需改代码/重启D/A;在途/原排队任务固定快照,同版本异内容拒绝。缓存按租户+版本隔离,断SaaS无有效授权缓存拒新准入;显式0/false与未提供保真,并发通话不得共享可变SDK参数。 +- 凭据/供应商端点来自受控引用且有授权/出口校验,不能因可调参数绕过安全硬限额或启用不安全重试。OpenAI默认自动重试显式关闭;日志只留脱敏版本/摘要/有效参数,不打印prompt/变量/密钥。 +- 静态发布只约束SIP/节点制品,不将AI配置硬编码;GAP-08/09及SDK参数PoC为P1门禁,验证入口见验收§5.1(现有E/L项子场景,不新增虚假通过数)。 + +## 契约与可靠性 + +- 本项目设计/运行/验收文档只在自身 `docs/` 维护。上游共享接口有唯一权威来源;导入带版本、来源和哈希的不可变契约包,再生成类型/校验,不维护重复手写 Schema。 +- 新内部消息/许可/fencing 协议需先获批;不擅自改变 SaaS 路径、字段、状态、路由或控制语义。 +- SaaS外呼命令/业务结果只经Dispatcher走RabbitMQ;内部Unary是已批准的执行通道,不新增对外HTTP拨号/业务回调。OSS配置源SaaS,D承接recording-uploads/upload_id/complete;Agent从Dispatcher取得受限目标/凭证/headers等配置后**直连OSS上传文件**,D不接收或转发录音内容、不替Agent上传。Agent经R13提交上传元信息,D协调SaaS verified后通过MQ返回OSS ID;Agent不直连SaaS不禁止其直连OSS。 +- 文本实时事件准确名为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 字节的既有路由预算时停止发布并保留源任务。 +- 持久 inbox 后 ACK;状态与 outbox 同事务;confirm 不等于 SaaS 应用收讫。重复投递、未知执行和恢复不能触发重复拨号。 +- 配额覆盖所有 Cell/实例及未知占用;租约过期不自动释放不明通话。控制CAS为 expected_task_revision,pause与stop的drain/hangup区分;paused可按新授权恢复,stopped不可恢复。整体补传仅call_id/source_command_id,禁止新增task/execution补传。最后发起许可、权限和屏障须故障注入。 +- management是SIP配置唯一编辑/审批面。P1通过批准的版本化静态制品和受控部署入口交付,D核验目标/准入屏障,Agent加载并报告;不要求在线发布控制面。静态交接合同须批准,旧直接写Agent面不能同时启用;成功必须证明精确快照已被Asterisk加载。 + +## 安全和真实验证 + +- mock/mixed/real 明确隔离;Mock 默认隔离真实外网,正式模式拒绝 Mock/测试凭据,不静默回退。 +- 只允许复用既有 ASR 协议;禁止复用 `voice_test` 的 LLM/TTS。P1必须完成新LLM/TTS规范、SDK适配和本地/协议隔离验收;真实供应商联调延期第二阶段,不能以Mock冒充真实供应商通过,也不能把真实联调延期误写成当前 P1 已实测。 +- 真实外呼只允许原始号码 `15003164745`、`15830461047`,但白名单和文档不是拨号授权;每次真实验证仍需明确安排,且仅可在 Asia/Shanghai `09:00`–`20:00`(左闭右开)执行。每条 SIP trunk 对每个原始手机号每天最多 3 次;某条线路失败时可在额度内经当前会话确认后改测另一条线路,但不得在窗口外等待、自动延迟、在同一条线路/号码上超额、自动重试或静默换线。 +- 不把 SIP 白名单出口 IP 当作 SIP 服务端,不虚构备用线路,不逐呼重写共享配置,不自动重拨已接通/未知的执行。 +- 生产使用多机器、多 EIP 直连;1000 路指完整 ASR/LLM/TTS 已接通通话,N+1 与供应商能力须实测,不采用单 EIP+NAT。 +- 独立开发和测试不要求云账号。云创建、EIP 改绑、网络放行、供应商消费和测试资源清理必须另获明确授权;不得触碰无关资源。 +- 不在源码、配置样例、文档、日志或证据中保存密钥、密码、私钥、完整用户音频/对话;注入受控凭据,诊断端点仅管理网可达。 +- 旧 Python 与新 Go 不得同时写同一资源或各自发放共享额度。没有可验证的所有权/状态回迁方案就暂停切换,不以回滚镜像冒险重拨。 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b2a5825 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: fmt test test-race vet build security release mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check + +GO ?= go +BINARY ?= dist/sip-go-agent + +fmt: + find contracts internal cmd -name '*.go' -print0 | xargs -0 gofmt -w + +test: + $(GO) test ./... + +test-race: + $(GO) test -race ./... + +vet: + $(GO) vet ./... + +security: + @command -v govulncheck >/dev/null || { echo "govulncheck is required for the security target" >&2; exit 1; } + govulncheck ./... + +build: + mkdir -p $(dir $(BINARY)) + $(GO) build -trimpath -buildvcs=false -o $(BINARY) ./cmd/sip-go-agent + +release: + ./scripts/build-release.sh $(RELEASE_DIR) + +mq-integration-local: + ./scripts/mq-integration-local.sh + +proto-lint: + buf lint + +proto-generate: + buf generate + +proto-check: + ./scripts/check-proto.sh + +contract-check: + ./scripts/check-contracts.sh + +acceptance-local: + ./scripts/acceptance-local.sh + +check: fmt proto-check acceptance-local diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0f0880 --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# go-sip + +面向生产的Go SIP调度与执行项目:同一module/二进制通过Cobra提供 `dispatcher`、`agent` 两个业务子命令,分阶段替换Python,保留现有SaaS契约。 + +> **当前状态:已完成本阶段项目内 W01–W14 的单节点/单 Cell/单租户契约、实现与本地/隔离验收;真实 SaaS/MQ、真实供应商/ECS、生产切换及双节点/第二 Cell/双租户延期第二阶段。** +> 本项目已独立拆仓运营;源码、配置、依赖、迁移、测试、部署和文档均在此目录内维护。远程仓库为 `git.ipao.vip/rogee/go-sip`,本地 Git 默认分支为 `main`;真实外呼仍受逐次授权、capture-first、白名单和时间门禁约束。 + +## 已确认的范围 + +- 使用 **Go 1.27.1**、Cobra单项目双命令和同一发布制品;角色分进程、权限/目录分离。 +- 不接PG:Dispatcher独立SQLite统一任务/配额/outbox;Agent无业务数据库,文本/录音/必要恢复信息落文件。 +- 内部 **Unary gRPC**;Dispatcher配置Agent Endpoint列表,Agent业务启动只需Dispatcher Endpoint,证书/监听/本地资源通过部署提供。 +- 所有Agent共用mTLS证书、Dispatcher身份独立;需Endpoint主动激活/受限节点会话,不能把群组证书当单节点身份。 +- 本次以稳定、快速单节点上线为目标:**1 个 Agent、1 套 Asterisk、1 个单活 Dispatcher、单 Cell、至少 3 家独立 SIP trunk 的契约/协议 fixture、1 个内测商务租户**;双节点、第二 Cell、双租户延期第二阶段。 +- SIP 使用管理平台批准的不可变静态快照;维护窗口发布、排空和实际加载确认保留,在线动态发布/回滚编排暂缓。外呼仅允许 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含),窗口外 Dispatcher/Agent fail-closed;按供应商授权登记单 Cell/出口 fixture。 +- 有适用开源库/官方 SDK 时强制复用,不从零手写 SIP/ARI、RTP、MQ、数据库、OSS 或 AI 协议客户端;选库失败先报告阻塞,不静默转自研。 +- 重写完整 Agent:调度控制面、Cell 外呼执行、ARI/RTP/录音、AI 流式适配、Cell 配置接收。 +- 分阶段迁移,最终构建、测试和运行不依赖 Python、父仓库目录或其它业务项目内部代码。 +- Asterisk 继续负责 SIP;独立 SIP 管理平台继续拥有配置管理权,均不纳入重写。 +- SaaS指令/业务结果仍走RabbitMQ;OSS配置源SaaS,Dispatcher统一授权/验证与回传,Agent直传OSS。实时文字事件为 `transcript.updated`,OSS文本用于归档,不能只回文件ID替代文字事件。 +- 本次必须支持 **ASR-only** 与 **ASR + LLM + TTS** 两种模式;本阶段已完成本地/协议隔离双模式验收。百炼/火山ASR、OpenAI兼容LLM、火山TTS的真实供应商能力和生产参数联调仍标第二阶段/未启用,不能将 Mock 写成真实供应商通过;禁止复用旧LLM/TTS。 +- **AI业务配置由Dispatcher按任务agent_version_id调用SaaS已有AI配置GET获取,校验后传给Agent。** 模型、提示词、音色/语速、识别、超时/打断等参数不写死;新版本用于新任务,无需重启,在途通话固定快照。静态SIP发布不代表AI配置静态硬编码。 +- 本轮验收范围收敛为单节点、单 Agent、单 Cell、单租户;保留 tenant_key 原值、独立队列、复合幂等和有界窗口。双节点、第二 Cell、双租户公平/背压/恢复不在本轮开发或验收范围,作为后续阶段。 +- `upload-session/complete/verified`、RabbitMQ ACL/TLS 和 application receipt 本阶段按版本化契约、Schema、fixture 和本地隔离状态机验收;真实 SaaS/MQ 联调延期第二阶段。 + +## 当前本地实现 + +- `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/ai/` 对固定 AI Schema 做不可变快照校验,支持项目内显式 `full_ai`/`asr_only` 分支;`mock_pipeline` 只用于隔离协议/取消/参数测试,不宣称真实供应商已启用。 +- `internal/control/` 提供契约定义的内部查询/控制/replay HTTP 面;外呼命令仍只从 RabbitMQ 进入。 +- `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)。 +- 直接依赖版本/本地许可证清单见 [`docs/evidence/20260918-dependencies.md`](docs/evidence/20260918-dependencies.md);门禁结论见 [`docs/evidence/20260918-acceptance-matrix.md`](docs/evidence/20260918-acceptance-matrix.md);SIP 注册/认证/From/PAI/前缀/选路对比见 [`docs/evidence/20260919-sip-routing-implementation-comparison.md`](docs/evidence/20260919-sip-routing-implementation-comparison.md),手机号业务日志边界见 [`docs/evidence/20260919-phone-call-business-log.md`](docs/evidence/20260919-phone-call-business-log.md)。 + +这些证据签收本阶段单节点/单 Cell/单租户 P1 适用范围;不等于真实供应商、生产 SaaS/MQ receipt、生产切换或第二阶段拓扑通过。范围和延期项见 [`docs/evidence/20260920-scope-amendment.md`](docs/evidence/20260920-scope-amendment.md)。 + +## 文档 + +后续Agent先读 [项目开发计划与需求阅读索引](docs/plan-0918.md),按W/子任务确认I/M/G前置并阅读详细设计/权威契约。并行开发按§9登记单写范围、隔离工作区/测试资源和合并回归,由集成负责人统一维护总台账;开发子Agent固定使用 **gpt-5.6-luna+max+fast**,不可用时报告阻塞,不静默降级。本轮未启动开发子Agent。 + +| 文档 | 内容 | +| --- | --- | +| [plan-0918:开发计划与需求阅读索引](docs/plan-0918.md) | 首读入口:W00–W16及并行子任务、I/M/G关口、R0–R7索引、认领/单写/隔离/合并规则;部署候选前置到W13-a | +| [Go重写方案 v0.3](docs/Go重写方案_v0.3.md) | 本次单节点/单 Cell/单租户目标、至少3 SIP fixture、双AI模式、P0/P1/第二阶段分期 | +| [通信与事件数据交互 v0.1](docs/通信与事件数据交互_v0.1.md) | 首发Unary、静态SIP与双模式、§6.1–6.3 SaaS配置来源/参数矩阵/安全边界 | +| [OpenAPI与MQ字段索引 v0.1](docs/OpenAPI与MQ字段索引_v0.1.md) | 从上游自动提取42个HTTP操作/115个命名组件、Schema与源哈希 | +| [deploys:物理机 systemd 发布包](deploys/README.md) | 锁定 Debian 13/Go 1.27.1/发布版本,构建并上传不依赖 Docker 的 Agent/Dispatcher 安装包 | +| [验证与切换验收 v0.3](docs/验证与切换验收_v0.3.md) | 10个首发汇总门禁、88项基线按阶段适用、P2公平性及独立容量验收 | +| [开源组件选型与复用清单 v0.2](docs/开源组件选型与复用清单_v0.2.md) | §1.3首发基础库/AI SDK选择,§4.3参数能力及锁版/许可/PoC门禁 | +| [G0开发准备与契约冻结方案 v0.1](docs/G0开发准备与契约冻结提案_v0.1.md) | D01–D10方案已确认;本阶段项目内契约/Proto/PoC按单节点范围签收,真实外部发布和生产联调延期第二阶段 | +| [AGENTS.md](AGENTS.md) | 本独立子项目的开发与安全约束 | + +## 独立项目原则 + +- 当前设计文档以本目录 `docs/` 为唯一维护位置,不再向父项目另存一份。 +- 对接协议的现有权威来源仍属于上游;后续在本项目导入带来源、版本和 SHA-256 的不可变契约发布包,不能另写一套同名 Schema。 +- 后续发行包、CI、数据库和运行配置独立。不能靠 `../agent_call`、`../management`、`../sip_mock_server` 或父项目环境文件运行。 +- 业务代码范围仅包括 SIP Agent/Dispatcher 与 Asterisk;RabbitMQ、OSS、AI 供应商及 SaaS API 是 SaaS 提供的基础设施,不在本项目生产包中部署。独立集成测试使用自有隔离数据库、RabbitMQ 和契约 fixture;OSS 数据面使用 Alibaba 官方 Go SDK,Dispatcher 仅签发短期 grant,Agent 直传且不持有 AK/SK;外部 SIP Mock 只能以固定镜像及版本化协议接入,不导入其源码。 +- 真实外呼、云创建、供应商调用和消费授权均是独立门禁,不能由测试成功或本文档自动授权。 + +## 阶段与下一步 + +1. **P0:首发契约与依赖门禁。** 冻结8种事件payload、双AI模式(GAP-08)、SaaS任务AI配置读取/调参(GAP-09)、首发Unary职责、单 Cell 静态快照来源/加载回执、SaaS录音授权和至少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、第二租户公平调度。 +4. **后续另立项:** 在线动态发布、自动跨供应商FALLBACK、多Dispatcher HA/分布式配额、权重借用、文本OSS归档、1000路完整AI/N+1。既有call/command整体补传不是通用回放平台,当前单节点首发仍保留。 + +阶段目标详见主方案§1/§10,首发验收见验收方案§1.1–§1.2;文件名保持不变。本阶段单节点/单 Cell/单租户本地 P1 已签收;真实依赖、供应商、云/拨号、生产 receipt、容量和切换属于第二阶段,证据见 `docs/evidence/20260920-local-p1-acceptance.md`。 diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..b00d637 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,11 @@ +version: v2 + +plugins: + - local: protoc-gen-go + out: gen + opt: + - paths=source_relative + - local: protoc-gen-go-grpc + out: gen + opt: + - paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..06779db --- /dev/null +++ b/buf.yaml @@ -0,0 +1,13 @@ +--- +version: v2 + +modules: + - path: proto + +lint: + use: + - STANDARD + +breaking: + use: + - FILE diff --git a/cmd/sip-go-agent/main.go b/cmd/sip-go-agent/main.go new file mode 100644 index 0000000..8bbbbb4 --- /dev/null +++ b/cmd/sip-go-agent/main.go @@ -0,0 +1,706 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/callflow" + "git.ipao.vip/rogee/go-sip/internal/calllog" + "git.ipao.vip/rogee/go-sip/internal/callruntime" + "git.ipao.vip/rogee/go-sip/internal/callwindow" + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/control" + "git.ipao.vip/rogee/go-sip/internal/dispatcher" + "git.ipao.vip/rogee/go-sip/internal/health" + "git.ipao.vip/rogee/go-sip/internal/mq" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/rpc" + "git.ipao.vip/rogee/go-sip/internal/store" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func main() { + if err := newRootCommand().Execute(); err != nil { + slog.Error("command failed", "error", err) + os.Exit(1) + } +} + +func newRootCommand() *cobra.Command { + root := &cobra.Command{ + Use: "sip-go-agent", + Short: "SIP Go Agent and Dispatcher", + SilenceUsage: true, + SilenceErrors: true, + } + root.AddCommand(newAgentCommand(), newDispatcherCommand()) + return root +} + +func newAgentCommand() *cobra.Command { + cfg := config.FromEnv() + var realCall bool + cmd := &cobra.Command{ + Use: "agent", + Short: "run the file-backed Agent process", + RunE: func(_ *cobra.Command, _ []string) error { + if realCall { + return runCallOnce(cfg) + } + if err := cfg.Validate("agent"); err != nil { + return err + } + spool, err := agent.NewSpool(cfg.SpoolRoot, nil) + if err != nil { + return err + } + report, err := spool.MarkUnknownOnBoot() + if err != nil { + return err + } + if cfg.GRPCListen == "" { + return writeResult(map[string]any{ + "role": "agent", "mode": cfg.Mode, "agent_id": cfg.AgentID, + "version": cfg.Version, "spool": spool.Root(), + "unknown_executions": report.Unknown, "quarantined": report.Quarantined, + }) + } + return serveAgentRPC(cfg, spool, report) + }, + } + cmd.Flags().StringVar(&cfg.Mode, "mode", cfg.Mode, "mock, mixed, or real") + cmd.Flags().StringVar(&cfg.SpoolRoot, "spool", cfg.SpoolRoot, "Agent file spool root") + cmd.Flags().StringVar(&cfg.AgentID, "agent-id", cfg.AgentID, "stable Agent identifier") + cmd.Flags().StringVar(&cfg.CellID, "cell-id", cfg.CellID, "stable Cell identifier") + cmd.Flags().StringVar(&cfg.Version, "version", cfg.Version, "Agent software version") + cmd.Flags().StringVar(&cfg.StaticArtifactPath, "static-artifact", cfg.StaticArtifactPath, "management-approved static Cell artifact path") + cmd.Flags().BoolVar(&realCall, "call-once", false, "run one explicit call through the configured transport and AI adapters") + cmd.Flags().StringVar(&cfg.CallTarget, "call-target", cfg.CallTarget, "raw target number allowed by the static artifact") + cmd.Flags().StringVar(&cfg.CallTrunkID, "call-trunk-id", cfg.CallTrunkID, "enabled trunk ID from the static artifact") + cmd.Flags().StringVar(&cfg.CallCallerID, "call-caller-id", cfg.CallCallerID, "deployment-approved caller ID") + cmd.Flags().StringVar(&cfg.CallAISnapshotPath, "call-ai-snapshot", cfg.CallAISnapshotPath, "immutable AI snapshot path") + cmd.Flags().IntVar(&cfg.CallMediaPort, "call-media-port", cfg.CallMediaPort, "ExternalMedia UDP port") + cmd.Flags().StringVar(&cfg.CallRecordingDirectory, "call-recording-dir", cfg.CallRecordingDirectory, "local recording directory") + return cmd +} + +func mediaForCall(artifact contract.StaticCellArtifact, trunkID string) (contract.StaticMedia, error) { + if artifact.Media == nil { + return contract.StaticMedia{}, errors.New("static artifact has no default media profile") + } + for _, trunk := range artifact.Trunks { + if trunk.TrunkID != trunkID || !trunk.Enabled { + continue + } + media := *artifact.Media + if trunk.MediaProfileID == "" { + return media, nil + } + profile, ok := artifact.MediaProfiles[trunk.MediaProfileID] + if !ok { + return contract.StaticMedia{}, fmt.Errorf("call trunk %q references unknown media profile %q", trunkID, trunk.MediaProfileID) + } + media.Format = profile.Format + media.SampleRateHz = profile.SampleRateHz + media.Channels = profile.Channels + media.PayloadType = profile.PayloadType + return media, nil + } + return contract.StaticMedia{}, fmt.Errorf("call trunk %q is not enabled in the static artifact", trunkID) +} + +func buildCallEndpoint(artifact contract.StaticCellArtifact, trunkID, target string) (string, error) { + if target == "" { + return "", errors.New("call target is required") + } + allowedTarget := false + for _, allowed := range artifact.AllowedTargets { + if allowed == target { + allowedTarget = true + break + } + } + if !allowedTarget { + return "", fmt.Errorf("call target is not allowed by the static artifact") + } + for _, trunk := range artifact.Trunks { + if trunk.TrunkID != trunkID || !trunk.Enabled { + continue + } + if trunk.SIPEndpointRef == "" { + return "", errors.New("selected trunk has no SIP endpoint reference") + } + return fmt.Sprintf("PJSIP/%s%s@%s", trunk.DialPrefix, target, trunk.SIPEndpointRef), nil + } + return "", fmt.Errorf("call trunk %q is not enabled in the static artifact", trunkID) +} + +func validateCallAISnapshot(raw []byte) (ai.Snapshot, error) { + snapshot, err := ai.Validate(raw) + if err != nil { + return ai.Snapshot{}, fmt.Errorf("validate AI snapshot: %w", err) + } + return snapshot, nil +} + +func runCallOnce(cfg config.Config) error { + if cfg.StaticArtifactPath == "" || cfg.CallAISnapshotPath == "" { + return errors.New("--call-once requires --static-artifact and --call-ai-snapshot") + } + artifactRaw, err := os.ReadFile(cfg.StaticArtifactPath) + if err != nil { + return fmt.Errorf("read static artifact: %w", err) + } + artifact, err := contract.ValidateStaticArtifact(artifactRaw, contract.StaticArtifactExpectation{CellID: cfg.CellID, Mode: cfg.Mode}) + if err != nil { + return fmt.Errorf("validate static artifact: %w", err) + } + if cfg.CallTrunkID == "" { + return errors.New("--call-trunk-id is required") + } + trunkBound := false + for _, trunk := range artifact.Trunks { + if trunk.TrunkID == cfg.CallTrunkID && trunk.Enabled { + trunkBound = true + break + } + } + if !trunkBound { + return fmt.Errorf("call trunk %q is not enabled in the static artifact", cfg.CallTrunkID) + } + if cfg.Mode != "mock" { + if err := callwindow.Check(time.Now()); err != nil { + return err + } + } + snapshotRaw, err := os.ReadFile(cfg.CallAISnapshotPath) + if err != nil { + return fmt.Errorf("read immutable AI snapshot: %w", err) + } + snapshot, err := validateCallAISnapshot(snapshotRaw) + if err != nil { + return err + } + var snapshotPolicy struct { + Conversation struct { + Opening string `json:"opening"` + MaxTurns int `json:"max_turns"` + MaxMillis int `json:"max_duration_ms"` + } `json:"conversation"` + } + if err := json.Unmarshal(snapshotRaw, &snapshotPolicy); err != nil { + return fmt.Errorf("read conversation policy from AI snapshot: %w", err) + } + conversation := snapshotPolicy.Conversation + + var pipeline ai.Pipeline + switch cfg.Mode { + case "mock", "mixed": + pipeline = ai.MockPipeline{MaxAudioBytes: 16 << 20} + case "real": + providerCfg, providerErr := ai.LoadProviderPipelineConfigFromEnv() + if providerErr != nil { + return providerErr + } + pipeline, err = ai.NewProviderPipeline(providerCfg) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported call mode %q", cfg.Mode) + } + callCtx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + if cfg.Mode == "mock" { + input := make([]byte, 6400) + flowResult, flowErr := callflow.Execute(callCtx, callflow.NewMemorySession(input), pipeline, snapshot, "您好,这是测试流程。", 10*time.Millisecond) + if flowErr != nil { + return flowErr + } + return writeResult(map[string]any{ + "role": "agent", "mode": cfg.Mode, "ai_mode": snapshot.Mode, "call": "completed", + "transcript_chars": len([]rune(flowResult.Turn.Transcript)), + "reply_chars": len([]rune(flowResult.Turn.Reply)), + "rtp": map[string]any{"received_packets": flowResult.RTP.ReceivedPackets, "received_bytes": flowResult.RTP.ReceivedBytes, "sent_packets": flowResult.RTP.SentPackets, "sent_bytes": flowResult.RTP.SentBytes}, + }) + } + if artifact.Media == nil || artifact.ARI == nil || artifact.Recording == nil { + return errors.New("static artifact is missing transport/media/recording sections") + } + if cfg.CallTarget == "" || cfg.ARIUsername == "" || cfg.ARIPassword == "" { + return errors.New("--call-once requires target and ARI credentials") + } + callEndpoint, err := buildCallEndpoint(artifact, cfg.CallTrunkID, cfg.CallTarget) + if err != nil { + return err + } + selectedMedia, err := mediaForCall(artifact, cfg.CallTrunkID) + if err != nil { + return err + } + if cfg.CallMediaPort == 0 { + cfg.CallMediaPort = selectedMedia.Port + } + recordingDir := cfg.CallRecordingDirectory + if recordingDir == "" { + recordingDir = artifact.Recording.Directory + } + maxCallDuration := time.Duration(conversation.MaxMillis) * time.Millisecond + result, err := callruntime.Run(callCtx, callruntime.Config{ + ARIURL: cfg.ARIURL, ARIWebsocketURL: cfg.ARIWebsocketURL, ARIApplication: artifact.ARI.Application, + ARIUsername: cfg.ARIUsername, ARIPassword: cfg.ARIPassword, Endpoint: callEndpoint, + CallerID: cfg.CallCallerID, MediaBind: selectedMedia.BindAddress, MediaPort: cfg.CallMediaPort, + MediaFormat: selectedMedia.Format, MediaSampleRate: selectedMedia.SampleRateHz, + PayloadType: uint8(selectedMedia.PayloadType), RecordingDirectory: recordingDir, + MaxTurns: conversation.MaxTurns, MaxCallDuration: maxCallDuration, OpeningPrompt: conversation.Opening, + Snapshot: snapshot, Pipeline: pipeline, + }) + if err != nil { + return err + } + turns := make([]map[string]any, 0, len(result.Turns)) + includeText := os.Getenv("AGENT_CALL_INCLUDE_TEXT") == "1" + for index, turn := range result.Turns { + fact := map[string]any{ + "turn": index + 1, + "transcript_chars": len([]rune(turn.Transcript)), + "reply_chars": len([]rune(turn.Reply)), + "invalid_call": turn.InvalidCall, + "invalid_reason": turn.InvalidReason, + } + if includeText { + fact["transcript"] = turn.Transcript + fact["reply"] = turn.Reply + } + turns = append(turns, fact) + } + inboundRecordings := make([]map[string]any, 0, len(result.InboundRecordings)) + for _, recording := range result.InboundRecordings { + inboundRecordings = append(inboundRecordings, map[string]any{"segment": recording.Segment, "path": recording.Path, "bytes": recording.Bytes, "sha256": recording.SHA256}) + } + outboundRecordings := make([]map[string]any, 0, len(result.OutboundRecordings)) + for _, recording := range result.OutboundRecordings { + outboundRecordings = append(outboundRecordings, map[string]any{"segment": recording.Segment, "path": recording.Path, "bytes": recording.Bytes, "sha256": recording.SHA256}) + } + uploads, err := uploadCallRecordings(callCtx, cfg, result) + if err != nil { + return err + } + output := map[string]any{ + "role": "agent", "mode": cfg.Mode, "call": "completed", "channel_id": result.ChannelID, + "transcript_chars": len([]rune(result.Transcript)), "reply_chars": len([]rune(result.Reply)), + "invalid_call": result.InvalidCall, "invalid_reason": result.InvalidReason, + "turns": turns, + "inbound_recording": map[string]any{"path": result.InboundPath, "bytes": result.InboundBytes, "sha256": result.InboundSHA256}, + "outbound_recording": map[string]any{"path": result.OutboundPath, "bytes": result.OutboundBytes, "sha256": result.OutboundSHA256}, + "inbound_recordings": inboundRecordings, "outbound_recordings": outboundRecordings, + "rtp": map[string]any{"received_packets": result.RTP.ReceivedPackets, "received_bytes": result.RTP.ReceivedBytes, "sent_packets": result.RTP.SentPackets, "sent_bytes": result.RTP.SentBytes}, + } + if uploads != nil { + output["oss_uploads"] = uploads + } + return writeResult(output) +} + +func serveAgentRPC(cfg config.Config, spool *agent.Spool, report agent.RecoveryReport) error { + var staticArtifactRaw []byte + if cfg.StaticArtifactPath != "" { + var err error + staticArtifactRaw, err = os.ReadFile(cfg.StaticArtifactPath) + if err != nil { + return fmt.Errorf("read static Cell artifact: %w", err) + } + } + peerCertificateFingerprints, err := config.ParseCertificateFingerprints(cfg.MTLSPeerCertificateFingerprints) + if err != nil { + return fmt.Errorf("parse MTLS_PEER_CERT_FINGERPRINTS: %w", err) + } + tlsConfig, err := rpc.LoadServerTLSConfig(cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile) + if err != nil { + return err + } + var callLogger *calllog.Logger + if cfg.CallLogPhoneKey != "" { + path := cfg.CallLogPath + if path == "" { + path = filepath.Join(spool.Root(), "call-business.jsonl") + } + callLogger, err = calllog.New(path, []byte(cfg.CallLogPhoneKey), nil) + if err != nil { + return fmt.Errorf("configure call business log: %w", err) + } + } + listener, err := net.Listen("tcp", cfg.GRPCListen) + if err != nil { + return fmt.Errorf("listen Agent gRPC: %w", err) + } + defer listener.Close() + + bootID := fmt.Sprintf("%s-%d", cfg.AgentID, time.Now().UnixNano()) + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + handler := rpc.NewServer(rpc.ServerOptions{ + Mode: cfg.Mode, + Status: &agentv1.AgentStatus{ + AgentId: cfg.AgentID, + CellId: cfg.CellID, + BootId: bootID, + SoftwareVersion: cfg.Version, + ProtocolVersion: "agent.v1", + AdmissionState: agentv1.AdmissionState_ADMISSION_STATE_CLOSED, + StatusReason: fmt.Sprintf("recovered_unknown=%d quarantined=%d spool=%s", len(report.Unknown), len(report.Quarantined), spool.Root()), + Capabilities: []*agentv1.Capability{{Name: "mode", Value: cfg.Mode}, {Name: "grpc_transport", Value: "unary-mtls"}, {Name: "resource_sample", Value: "partial-unknown"}}, + Resources: health.Sampler{}.Sample(context.Background(), spool.Root()), + }, + UploadPolicy: &agentv1.UploadPolicy{Enabled: cfg.Mode == "mock", MaxAssetBytes: 16 << 20}, + StaticArtifactRaw: staticArtifactRaw, + StaticArtifactExpected: contract.StaticArtifactExpectation{CellID: cfg.CellID, Mode: cfg.Mode}, + RequirePeerCertificate: true, + PeerCertificateFingerprints: peerCertificateFingerprints, + StatePath: filepath.Join(cfg.SpoolRoot, "rpc-session.json"), + CallLogger: callLogger, + }) + agentv1.RegisterAgentControlServiceServer(grpcServer, handler) + serveCtx, cancel := signalContext() + defer cancel() + go func() { + <-serveCtx.Done() + grpcServer.GracefulStop() + }() + if err := grpcServer.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return err + } + return nil +} + +type connectedAgentRuntime struct { + coordinator *dispatcher.AgentCoordinator + clients []*rpc.Client + sessions []dispatcher.AgentSession +} + +// connectConfiguredAgents performs the Dispatcher startup binding for the +// deployment-owned endpoint inventory. It is intentionally separate from SaaS +// commands: tenant input never selects an endpoint or identity. +func connectConfiguredAgents(ctx context.Context, cfg config.Config) (*connectedAgentRuntime, error) { + endpoints, err := config.LoadAgentEndpoints(cfg.AgentEndpointsFile) + if err != nil { + return nil, err + } + runtime := &connectedAgentRuntime{coordinator: dispatcher.NewAgentCoordinator(nil)} + fail := func(err error) (*connectedAgentRuntime, error) { + return nil, errors.Join(err, runtime.Close()) + } + epoch := fmt.Sprintf("%s-%d", cfg.DispatcherID, time.Now().UnixNano()) + for _, endpoint := range endpoints { + client, dialErr := rpc.DialFromFiles(endpoint.Address, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, endpoint.ServerName) + if dialErr != nil { + return fail(fmt.Errorf("connect Agent %q: %w", endpoint.AgentID, dialErr)) + } + runtime.clients = append(runtime.clients, client) + if registerErr := runtime.coordinator.Register(endpoint.AgentID, client.Agent); registerErr != nil { + return fail(fmt.Errorf("register Agent %q: %w", endpoint.AgentID, registerErr)) + } + status, probeErr := runtime.coordinator.Probe(ctx, endpoint.AgentID, endpoint.CellID) + if probeErr != nil { + return fail(fmt.Errorf("probe Agent %q: %w", endpoint.AgentID, probeErr)) + } + // Zero lets Agent-side durable session state allocate the next generation + // after a Dispatcher restart; hard-coding 1 would self-fence recovery. + session, activateErr := runtime.coordinator.Activate(ctx, endpoint.AgentID, endpoint.CellID, status.BootId, epoch, 0) + if activateErr != nil { + return fail(fmt.Errorf("activate Agent %q: %w", endpoint.AgentID, activateErr)) + } + runtime.sessions = append(runtime.sessions, session) + } + return runtime, nil +} + +func (r *connectedAgentRuntime) Close() error { + if r == nil { + return nil + } + var firstErr error + for index := len(r.clients) - 1; index >= 0; index-- { + if err := r.clients[index].Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +const dispatcherOutboxFlushInterval = 250 * time.Millisecond + +func flushDispatcherOutbox(ctx context.Context, d *dispatcher.Dispatcher, batch int, interval time.Duration) { + flush := func() { + published, err := d.FlushOutbox(ctx, batch) + if err != nil { + if ctx.Err() == nil { + slog.Error("flush Dispatcher outbox", "error", err) + } + return + } + if published > 0 { + slog.Debug("flushed Dispatcher outbox", "published", published) + } + } + flush() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + flush() + } + } +} + +func newDispatcherCommand() *cobra.Command { + cfg := config.FromEnv() + var once bool + var consume bool + var tenantKey string + cmd := &cobra.Command{ + Use: "dispatcher", + Short: "run the single-active Dispatcher process", + RunE: func(cmd *cobra.Command, _ []string) error { + if err := cfg.Validate("dispatcher"); err != nil { + return err + } + st, err := store.Open(cfg.DBPath) + if err != nil { + return err + } + defer st.Close() + leaseCtx, cancelLease := signalContext() + defer cancelLease() + lease, err := dispatcher.StartLease(leaseCtx, st, "dispatcher-active-"+cfg.DispatcherID, "dispatcher", cfg.DispatcherID, 30*time.Second) + if err != nil { + return err + } + defer lease.Stop() + var connectedAgents *connectedAgentRuntime + if cfg.AgentEndpointsFile != "" { + startupCtx, cancelStartup := context.WithTimeout(leaseCtx, 15*time.Second) + connectedAgents, err = connectConfiguredAgents(startupCtx, cfg) + cancelStartup() + if err != nil { + return err + } + defer func() { + if closeErr := connectedAgents.Close(); closeErr != nil { + slog.Error("close Agent connections", "error", closeErr) + } + }() + } + var publisher mq.Publisher + var broker *mq.Broker + if cfg.RabbitURL != "" { + broker, err = mq.Open(cfg.RabbitURL, cfg.Exchange) + if err != nil { + return err + } + defer broker.Close() + publisher = broker + } + d, err := dispatcher.New(st, publisher, nil) + if err != nil { + return err + } + var dispatcherGRPC *grpc.Server + var dispatcherListener net.Listener + if cfg.DispatcherGRPCListen != "" && !once { + uploadClient, ossErr := ossclient.NewClient(ossclient.Config{ + Endpoint: cfg.OSSEndpoint, Region: cfg.OSSRegion, Bucket: cfg.OSSBucket, + AccessKeyID: cfg.OSSAccessKeyID, AccessKeySecret: cfg.OSSAccessKeySecret, + KeyPrefix: cfg.OSSKeyPrefix, GrantTTL: cfg.OSSGrantTTL, MaxAssetBytes: cfg.OSSMaxAssetBytes, + }) + if ossErr != nil { + return fmt.Errorf("configure Dispatcher OSS: %w", ossErr) + } + peerFingerprints, fingerprintErr := config.ParseCertificateFingerprints(cfg.MTLSPeerCertificateFingerprints) + if fingerprintErr != nil { + return fmt.Errorf("parse Dispatcher gRPC mTLS peer fingerprints: %w", fingerprintErr) + } + if len(peerFingerprints) == 0 { + return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled") + } + allowedAgentIDs := parseCSVSet(cfg.DispatcherGRPCAllowedAgentIDs) + uploadHandler, handlerErr := rpc.NewDispatcherUploadServerWithOptions(st, uploadClient, time.Now, rpc.DispatcherUploadOptions{ + RequirePeer: true, PeerCertificateFingerprints: peerFingerprints, AllowedAgentIDs: allowedAgentIDs, + }) + if handlerErr != nil { + return handlerErr + } + eventHandler, eventErr := rpc.NewDispatcherEventServer(st, rpc.DispatcherEventServerOptions{ + RequirePeer: true, PeerCertificateFingerprints: peerFingerprints, + AllowedAgentIDs: allowedAgentIDs, Now: time.Now, + }) + if eventErr != nil { + return eventErr + } + dispatcherHandler := rpc.NewDispatcherServer(uploadHandler, eventHandler) + tlsConfig, tlsErr := rpc.LoadServerTLSConfig(cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile) + if tlsErr != nil { + return fmt.Errorf("configure Dispatcher gRPC mTLS: %w", tlsErr) + } + dispatcherListener, err = net.Listen("tcp", cfg.DispatcherGRPCListen) + if err != nil { + return fmt.Errorf("listen Dispatcher gRPC: %w", err) + } + dispatcherGRPC = grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) + agentv1.RegisterAgentControlServiceServer(dispatcherGRPC, dispatcherHandler) + go func() { + if serveErr := dispatcherGRPC.Serve(dispatcherListener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { + slog.Error("Dispatcher gRPC stopped", "error", serveErr) + } + }() + defer func() { + dispatcherGRPC.GracefulStop() + _ = dispatcherListener.Close() + }() + } + result := map[string]any{"role": "dispatcher", "mode": cfg.Mode, "db": cfg.DBPath} + if dispatcherGRPC != nil { + result["dispatcher_grpc"] = cfg.DispatcherGRPCListen + } + if connectedAgents != nil { + sessions := make([]map[string]any, 0, len(connectedAgents.sessions)) + for _, session := range connectedAgents.sessions { + sessions = append(sessions, map[string]any{ + "agent_id": session.AgentID, "cell_id": session.CellID, + "boot_id": session.BootID, "session_generation": session.SessionGeneration, + }) + } + result["agent_sessions"] = sessions + } + if once && consume { + return errors.New("--once cannot be combined with --consume") + } + if consume { + if broker == nil || tenantKey == "" { + return errors.New("--consume requires --rabbit-url and --tenant-key") + } + if cfg.ControlListen != "" { + return errors.New("--consume cannot be combined with --control-listen") + } + flushCtx, cancelFlush := context.WithCancel(leaseCtx) + flushDone := make(chan struct{}) + go func() { + defer close(flushDone) + flushDispatcherOutbox(flushCtx, d, cfg.OutboxBatch, dispatcherOutboxFlushInterval) + }() + defer func() { + cancelFlush() + <-flushDone + }() + if err := d.ConsumeTenant(leaseCtx, broker, tenantKey); err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil + } + if once { + if publisher == nil { + return errors.New("--once requires RABBITMQ_URL or a configured publisher") + } + count, err := d.FlushOutbox(cmd.Context(), cfg.OutboxBatch) + if err != nil { + return err + } + result["published"] = count + } + if cfg.ControlListen == "" { + if dispatcherGRPC == nil { + return writeResult(result) + } + select { + case <-leaseCtx.Done(): + return nil + case err := <-lease.Lost(): + return fmt.Errorf("dispatcher lease lost: %w", err) + } + } + if once { + return errors.New("--once cannot be combined with --control-listen") + } + server := &http.Server{Addr: cfg.ControlListen, Handler: control.Handler{Store: st, BearerToken: cfg.ControlToken}} + go func() { + select { + case <-leaseCtx.Done(): + case <-lease.Lost(): + } + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + _ = server.Shutdown(shutdownCtx) + }() + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + return err + } + select { + case err := <-lease.Lost(): + return fmt.Errorf("dispatcher lease lost: %w", err) + default: + return nil + } + }, + } + cmd.Flags().StringVar(&cfg.Mode, "mode", cfg.Mode, "mock, mixed, or real") + cmd.Flags().StringVar(&cfg.DBPath, "db", cfg.DBPath, "Dispatcher SQLite path") + cmd.Flags().StringVar(&cfg.DispatcherID, "dispatcher-id", cfg.DispatcherID, "single-active Dispatcher holder identity") + cmd.Flags().StringVar(&cfg.RabbitURL, "rabbit-url", cfg.RabbitURL, "RabbitMQ URL") + cmd.Flags().StringVar(&cfg.Exchange, "exchange", cfg.Exchange, "durable command exchange") + cmd.Flags().IntVar(&cfg.OutboxBatch, "outbox-batch", cfg.OutboxBatch, "maximum outbox messages per run") + cmd.Flags().StringVar(&cfg.ControlListen, "control-listen", cfg.ControlListen, "internal control HTTP listen address; empty disables server") + cmd.Flags().StringVar(&cfg.ControlToken, "control-token", cfg.ControlToken, "bearer token for internal control HTTP") + cmd.Flags().StringVar(&cfg.AgentEndpointsFile, "agent-endpoints-file", cfg.AgentEndpointsFile, "strict JSON file of Dispatcher-owned Agent endpoints") + cmd.Flags().StringVar(&tenantKey, "tenant-key", "", "tenant key to consume from its command queue") + cmd.Flags().BoolVar(&consume, "consume", false, "consume one tenant command queue") + cmd.Flags().BoolVar(&once, "once", false, "flush one outbox batch and exit") + return cmd +} + +func parseCSVSet(raw string) map[string]struct{} { + result := make(map[string]struct{}) + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item != "" { + result[item] = struct{}{} + } + } + return result +} + +func marshalResultForTest(value any) ([]byte, error) { + return json.Marshal(value) +} + +func writeResult(value any) error { + data, err := marshalResultForTest(value) + if err != nil { + return err + } + _, err = os.Stdout.Write(append(data, '\n')) + return err +} + +func signalContext() (context.Context, context.CancelFunc) { + return signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +} diff --git a/cmd/sip-go-agent/main_test.go b/cmd/sip-go-agent/main_test.go new file mode 100644 index 0000000..721ec1f --- /dev/null +++ b/cmd/sip-go-agent/main_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "bytes" + "context" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/dispatcher" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestRootHasExplicitRoles(t *testing.T) { + root := newRootCommand() + if _, _, err := root.Find([]string{"agent"}); err != nil { + t.Fatal(err) + } + if _, _, err := root.Find([]string{"dispatcher"}); err != nil { + t.Fatal(err) + } +} + +func TestWriteResultIsJSON(t *testing.T) { + var b bytes.Buffer + data, err := marshalResultForTest(map[string]string{"status": "ok"}) + if err != nil || len(data) == 0 { + t.Fatalf("data=%q err=%v", data, err) + } + b.Write(data) + if b.Len() == 0 { + t.Fatal("empty result") + } +} + +func TestValidateCallAISnapshotAllowsASROnly(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := validateCallAISnapshot(raw) + if err != nil { + t.Fatal(err) + } + if snapshot.Mode != "asr_only" { + t.Fatalf("snapshot mode=%q, want asr_only", snapshot.Mode) + } +} + +func TestBuildCallEndpointUsesArtifactRoute(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json") + if err != nil { + t.Fatal(err) + } + artifact, err := contract.ValidateStaticArtifact(raw, contract.StaticArtifactExpectation{CellID: "cell-single", Mode: "real"}) + if err != nil { + t.Fatal(err) + } + endpoint, err := buildCallEndpoint(artifact, "provider-third", "15830461047") + if err != nil { + t.Fatal(err) + } + if endpoint != "PJSIP/mka75515830461047@provider-third" { + t.Fatalf("endpoint=%q", endpoint) + } + media, err := mediaForCall(artifact, "provider-third") + if err != nil { + t.Fatal(err) + } + if media.Format != "alaw" || media.SampleRateHz != 8000 || media.PayloadType != 8 { + t.Fatalf("media=%+v", media) + } + if _, err := buildCallEndpoint(artifact, "provider-third", "10000000000"); err == nil { + t.Fatal("disallowed target was accepted") + } +} + +type recordingPublisher struct{} + +func (recordingPublisher) Publish(context.Context, string, string, []byte) error { return nil } + +func TestFlushDispatcherOutboxPublishesPending(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + d, err := dispatcher.New(st, recordingPublisher{}, time.Now) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go flushDispatcherOutbox(ctx, d, 1, time.Millisecond) + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + var status string + if err := st.DB().QueryRow(`SELECT status FROM outbox LIMIT 1`).Scan(&status); err == nil && status == "published" { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("pending outbox was not published") +} diff --git a/cmd/sip-go-agent/upload.go b/cmd/sip-go-agent/upload.go new file mode 100644 index 0000000..a83b170 --- /dev/null +++ b/cmd/sip-go-agent/upload.go @@ -0,0 +1,166 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/agent" + "git.ipao.vip/rogee/go-sip/internal/callruntime" + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/rpc" +) + +func uploadCallRecordings(ctx context.Context, cfg config.Config, result callruntime.Result) ([]map[string]any, error) { + if strings.TrimSpace(cfg.DispatcherGRPCEndpoint) == "" { + return nil, nil + } + for name, value := range map[string]string{ + "AGENT_CALL_TENANT_ID": cfg.CallTenantID, + "AGENT_CALL_TENANT_KEY": cfg.CallTenantKey, + "AGENT_CALL_TASK_ID": cfg.CallTaskID, + "AGENT_CALL_TASK_ITEM_ID": cfg.CallTaskItemID, + } { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("%s is required when Dispatcher OSS upload is enabled", name) + } + } + if result.ChannelID == "" { + return nil, errors.New("call result has no channel ID for upload binding") + } + executionID := cfg.CallExecutionID + if executionID == "" { + executionID = result.ChannelID + } + binding := &agentv1.ExecutionBinding{ + TenantId: cfg.CallTenantID, + TenantKey: cfg.CallTenantKey, + ExecutionId: executionID, + TaskId: cfg.CallTaskID, + TaskItemId: cfg.CallTaskItemID, + TaskRevision: 1, + CallId: result.ChannelID, + AttemptId: result.ChannelID, + AgentVersionId: cfg.Version, + } + client, err := rpc.DialFromFiles(cfg.DispatcherGRPCEndpoint, cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile, cfg.DispatcherGRPCServerName) + if err != nil { + return nil, fmt.Errorf("dial Dispatcher gRPC service: %w", err) + } + defer client.Close() + uploader := agent.UploadClient{Now: time.Now} + facts := append(append([]callruntime.RecordingFact(nil), result.InboundRecordings...), result.OutboundRecordings...) + if len(facts) == 0 { + return nil, errors.New("call produced no recordings for OSS upload") + } + uploaded := make([]map[string]any, 0, len(facts)) + for index, recording := range facts { + if recording.Path == "" || recording.Bytes <= 0 || recording.SHA256 == "" { + return nil, fmt.Errorf("recording %d is missing path, size or checksum", index+1) + } + assetID := recordingAssetID(recording.Segment, recording.Path, index+1) + asset := &agentv1.AssetDescriptor{ + Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, + AssetId: assetID, + CallId: result.ChannelID, + ExecutionId: executionID, + Format: "wav", + SizeBytes: int64(recording.Bytes), + ChecksumSha256: recording.SHA256, + Channels: 1, + SampleRateHz: 16000, + } + uploadID := stableUploadID(binding, asset) + meta := uploadMeta(cfg, "request", uploadID) + grantResponse, err := client.RequestUpload(ctx, &agentv1.RequestUploadRequest{Meta: meta, Binding: binding, Asset: asset, UploadId: uploadID}) + if err != nil { + return nil, fmt.Errorf("request upload %s: %w", uploadID, err) + } + if grantResponse == nil || grantResponse.Grant == nil || grantResponse.Receipt == nil || grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { + return nil, fmt.Errorf("request upload %s was rejected", uploadID) + } + parsed, err := url.Parse(grantResponse.Grant.TargetUrl) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("upload %s returned invalid target URL", uploadID) + } + uploader.AllowedHosts = map[string]struct{}{strings.ToLower(parsed.Host): {}} + uploadResult, err := uploader.UploadFile(ctx, grantResponse.Grant, recording.Path) + if err != nil { + return nil, fmt.Errorf("upload %s data plane: %w", uploadID, err) + } + if uploadResult.SizeBytes != asset.SizeBytes || !strings.EqualFold(uploadResult.SHA256, asset.ChecksumSha256) { + return nil, fmt.Errorf("upload %s local result does not match asset", uploadID) + } + completeResponse, err := client.CompleteUpload(ctx, &agentv1.CompleteUploadRequest{ + Meta: uploadMeta(cfg, "complete", uploadID), + Binding: binding, + Asset: asset, + UploadId: uploadID, + UploadedSizeBytes: uploadResult.SizeBytes, + UploadedChecksumSha256: uploadResult.SHA256, + }) + if err != nil { + return nil, fmt.Errorf("complete upload %s: %w", uploadID, err) + } + if completeResponse == nil || completeResponse.Receipt == nil || completeResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || completeResponse.OssId == "" { + return nil, fmt.Errorf("complete upload %s was not verified", uploadID) + } + uploaded = append(uploaded, map[string]any{ + "upload_id": uploadID, "asset_id": asset.AssetId, "object_key": grantResponse.Grant.ObjectKey, + "oss_id": completeResponse.OssId, "bytes": uploadResult.SizeBytes, "sha256": uploadResult.SHA256, + "status_code": uploadResult.StatusCode, + }) + } + return uploaded, nil +} + +func recordingAssetID(segment, path string, index int) string { + raw := segment + if raw == "" { + raw = fmt.Sprintf("segment-%d", index) + } + raw += "-" + filepath.Base(path) + var builder strings.Builder + for _, r := range raw { + if r == '/' || r == '\\' || r == ' ' || r == '\t' || r == '\n' || r == '\r' { + builder.WriteByte('-') + continue + } + builder.WriteRune(r) + } + assetID := strings.Trim(builder.String(), "-") + if assetID == "" { + assetID = fmt.Sprintf("segment-%d", index) + } + if len([]byte(assetID)) > 120 { + digest := sha256.Sum256([]byte(assetID)) + assetID = "recording-" + hex.EncodeToString(digest[:16]) + } + return assetID +} + +func stableUploadID(binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor) string { + value := binding.ExecutionId + "\x00" + asset.AssetId + "\x00" + asset.ChecksumSha256 + digest := sha256.Sum256([]byte(value)) + return "upload-" + hex.EncodeToString(digest[:16]) +} + +func uploadMeta(cfg config.Config, phase, uploadID string) *agentv1.RequestMeta { + operationID := "recording-" + phase + "-" + uploadID + return &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", + RequestId: operationID, + TraceId: operationID, + OperationId: operationID, + IdempotencyKey: operationID, + AgentId: cfg.AgentID, + CellId: cfg.CellID, + } +} diff --git a/configs/agent-endpoints.example.json b/configs/agent-endpoints.example.json new file mode 100644 index 0000000..371879a --- /dev/null +++ b/configs/agent-endpoints.example.json @@ -0,0 +1,14 @@ +[ + { + "agent_id": "agent-cell-a", + "cell_id": "cell-a", + "address": "agent-cell-a.internal:19090", + "server_name": "agent-cell-a.internal" + }, + { + "agent_id": "agent-cell-b", + "cell_id": "cell-b", + "address": "agent-cell-b.internal:19090", + "server_name": "agent-cell-b.internal" + } +] diff --git a/configs/example.env b/configs/example.env new file mode 100644 index 0000000..8faf3fa --- /dev/null +++ b/configs/example.env @@ -0,0 +1,29 @@ +# Safe local defaults. Do not put credentials in this file. +SIP_GO_AGENT_MODE=mock +DISPATCHER_DB=./dispatcher.db +AGENT_SPOOL=./spool +AGENT_ID=agent-local +AGENT_VERSION=dev +CELL_ID=cell-local +# Optional Agent Unary gRPC listener; enabling it requires all three mTLS files. +# AGENT_GRPC_LISTEN=127.0.0.1:19090 +# MTLS_CA_FILE=/etc/sip-go-agent/pki/ca.pem +# MTLS_CERT_FILE=/etc/sip-go-agent/pki/agent.pem +# MTLS_KEY_FILE=/etc/sip-go-agent/pki/agent.key +# MTLS_SERVER_NAME=agent.example.internal +# Optional Agent-side certificate allowlist for independent Dispatcher identity. +# Use comma-separated SHA-256 leaf fingerprints; omit to trust the configured CA group. +# MTLS_PEER_CERT_FINGERPRINTS=aa:bb:cc:dd:... +# Optional Dispatcher-owned Agent endpoint inventory. Each entry is bound to +# one Agent/Cell identity and is probed/activated before Dispatcher startup. +# DISPATCHER_AGENT_ENDPOINTS_FILE=/etc/sip-go-agent/agent-endpoints.json +# Real/mixed SIP outbound calls are allowed only 09:00-20:00 Asia/Shanghai; +# the Agent/Dispatcher reject requests outside this fixed window. +# Real Agent mode also requires a management-approved static Cell artifact. +# AGENT_STATIC_ARTIFACT=/etc/sip-go-agent/artifacts/cell-a.json +# Optional redacted per-phone business log. The key is injected, never written here. +# AGENT_CALL_BUSINESS_LOG=/var/lib/sip-go-agent/call-business.jsonl +# AGENT_CALL_PHONE_LOG_KEY= +RABBITMQ_EXCHANGE=agent-call.commands.v1 +# Set RABBITMQ_URL only in a controlled mixed/real environment. +# RABBITMQ_URL=amqp://user:password@broker/vhost diff --git a/contracts/contracts.go b/contracts/contracts.go new file mode 100644 index 0000000..19ef1fc --- /dev/null +++ b/contracts/contracts.go @@ -0,0 +1,32 @@ +package contracts + +import ( + "embed" + "encoding/json" + "fmt" + "path" +) + +// Files is the immutable contract bundle imported from the parent repository. +// The bundle is copied from a recorded parent commit; runtime code never reads +// the parent worktree. +// +//go:embed upstream +var Files embed.FS + +const SourceCommit = "2026-09-19-p1-v1" + +func Read(name string) ([]byte, error) { + return Files.ReadFile(path.Join("upstream", SourceCommit, name)) +} + +func ReadJSON(name string, dst any) error { + data, err := Read(name) + if err != nil { + return fmt.Errorf("read contract %s: %w", name, err) + } + if err := json.Unmarshal(data, dst); err != nil { + return fmt.Errorf("decode contract %s: %w", name, err) + } + return nil +} diff --git a/contracts/upstream/2026-09-17-snapshot/SNAPSHOT.json b/contracts/upstream/2026-09-17-snapshot/SNAPSHOT.json new file mode 100644 index 0000000..180e4c9 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/SNAPSHOT.json @@ -0,0 +1,70 @@ +{ + "snapshot_version": "2026-09-17", + "source_repository": "ai-call", + "source_head": "fa6925010ba47976d2e99893eac92140b3cb0d09", + "source_worktree": "dirty", + "dirty_patch_sha256": "be7a3a66cc5fb3d800aa38092b8ab70c17118e8bf9aba5b54b183ac357076a5d", + "runtime_dependency": "none (files are a versioned snapshot; parent paths are never read at runtime)", + "files": [ + { + "path": "ai-config.openapi.yaml", + "sha256": "d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a", + "bytes": 4516 + }, + { + "path": "ai-config.schema.json", + "sha256": "62d9090adcca619e2bb3a6c5d6aa249c92601fe47b3278e24df82c640987c5db", + "bytes": 4316 + }, + { + "path": "cell-agent.openapi.yaml", + "sha256": "79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41", + "bytes": 7633 + }, + { + "path": "examples/README.md", + "sha256": "6fee21522bad463eaa385848431c072ed371cf31dc51eb8c30757952740f157c", + "bytes": 513 + }, + { + "path": "examples/agent-version.json", + "sha256": "d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e", + "bytes": 1079 + }, + { + "path": "examples/call.execute.json", + "sha256": "0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742", + "bytes": 656 + }, + { + "path": "executor.openapi.yaml", + "sha256": "b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329", + "bytes": 10244 + }, + { + "path": "mock-profile.json", + "sha256": "4d43097602fed9a68821e765117580706896ac82d4bce361a80a4cea544ab638", + "bytes": 2549 + }, + { + "path": "mq-topology.md", + "sha256": "a85b26596e1b1db7405dcabc967df56d5eb6713cc9908bc05ecaa2075ff1092f", + "bytes": 2211 + }, + { + "path": "mq.schema.json", + "sha256": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a", + "bytes": 2953 + }, + { + "path": "saas.openapi.yaml", + "sha256": "368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb", + "bytes": 5750 + }, + { + "path": "sip-management.openapi.yaml", + "sha256": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281", + "bytes": 38240 + } + ] +} diff --git a/contracts/upstream/2026-09-17-snapshot/ai-config.openapi.yaml b/contracts/upstream/2026-09-17-snapshot/ai-config.openapi.yaml new file mode 100644 index 0000000..e310606 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/ai-config.openapi.yaml @@ -0,0 +1,145 @@ +openapi: 3.1.0 +info: + title: agent-call immutable AI configuration API + version: 1.0.0 + description: >- + Internal configuration publication/read surface. The call.execute business + command remains RabbitMQ-only; secrets, URLs, and provider credentials are + resolved by the execution environment and never enter MQ messages. +servers: + - url: / +paths: + /internal/v1/ai/agent-versions: + post: + operationId: publishAgentVersion + security: + - aiConfigPublish: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionPublishRequest' + responses: + '200': + description: Identical immutable content already exists + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '201': + description: Immutable version published + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Existing version has different content + /internal/v1/ai/agent-versions/{agent_version_id}: + get: + operationId: getAgentVersion + security: + - aiConfigRead: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - name: agent_version_id + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + responses: + '200': + description: Trusted immutable snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Agent version not found +components: + parameters: + TenantId: + name: X-Tenant-Id + in: header + required: true + schema: {type: string, minLength: 1} + RequestId: + name: X-Request-Id + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + securitySchemes: + aiConfigPublish: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.publish and the AI-config issuer/audience. + aiConfigRead: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.read and the AI-config issuer/audience. + schemas: + AgentVersionPublishRequest: + type: object + additionalProperties: false + required: [agent_version_id, config] + properties: + agent_version_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + config: + $ref: 'ai-config.schema.json' + AgentVersionReceipt: + type: object + required: [tenant_id, agent_version_id, status, immutable, content_sha256] + 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}$'} + AgentVersion: + allOf: + - $ref: '#/components/schemas/AgentVersionReceipt' + - type: object + required: [config] + properties: + config: + $ref: 'ai-config.schema.json' + created_at: {type: string, format: date-time} + published_at: {type: string, format: date-time} + created_by: {type: string} + Error: + type: object + required: [error] + properties: + error: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid configuration + content: + application/json: + schema: {$ref: '#/components/schemas/Error'} + Unauthorized: + description: Missing or invalid AI-config token + Forbidden: + description: Token lacks the AI-config permission diff --git a/contracts/upstream/2026-09-17-snapshot/ai-config.schema.json b/contracts/upstream/2026-09-17-snapshot/ai-config.schema.json new file mode 100644 index 0000000..e4d2797 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/ai-config.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": ["agent_version_id", "immutable", "llm", "prompt", "tts", "asr", "conversation"], + "properties": { + "agent_version_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}, + "immutable": {"const": true}, + "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": ["opening", "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} + } +} diff --git a/contracts/upstream/2026-09-17-snapshot/cell-agent.openapi.yaml b/contracts/upstream/2026-09-17-snapshot/cell-agent.openapi.yaml new file mode 100644 index 0000000..f37e4c0 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/cell-agent.openapi.yaml @@ -0,0 +1,232 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: >- + Revision or expected local snapshot is stale; the Cell never + guesses over an unknown baseline + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: {type: string} + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, expected_local_revision, config, + config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + expected_local_revision: {type: integer, minimum: 0} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/contracts/upstream/2026-09-17-snapshot/examples/README.md b/contracts/upstream/2026-09-17-snapshot/examples/README.md new file mode 100644 index 0000000..02f9c1a --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/examples/README.md @@ -0,0 +1,5 @@ +# Contract fixtures + +`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes. + +All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration. diff --git a/contracts/upstream/2026-09-17-snapshot/examples/agent-version.json b/contracts/upstream/2026-09-17-snapshot/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/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-17-snapshot/examples/call.execute.json b/contracts/upstream/2026-09-17-snapshot/examples/call.execute.json new file mode 100644 index 0000000..23f9fdc --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/examples/call.execute.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": "cmd_demo_001", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-11T08:00:00Z", + "not_after": "2099-09-11T08:05:00Z", + "payload": { + "execution_id": "exec_demo_001", + "task_id": "task-demo", + "task_item_id": "item_demo", + "task_revision": 1, + "callee": "18601013734", + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": "agent_v1", + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000 + } +} diff --git a/contracts/upstream/2026-09-17-snapshot/executor.openapi.yaml b/contracts/upstream/2026-09-17-snapshot/executor.openapi.yaml new file mode 100644 index 0000000..a9f02fb --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/executor.openapi.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Executor Control API + version: 1.0.0 + description: >- + Internal control, query, replay, and recording hand-off API. Call execution + enters through RabbitMQ, not HTTP. +servers: + - url: https://executor.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/tasks/{task_id}/controls: + post: + operationId: controlTask + summary: Persist a pause, resume, or stop barrier + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlRequest' + responses: + '202': + description: Reliably persisted, not yet necessarily applied + headers: + Location: + schema: {type: string} + content: + application/json: + schema: {$ref: '#/components/schemas/ControlAccepted'} + '409': {$ref: '#/components/responses/Conflict'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/commands/{command_id}: + get: + operationId: getCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CommandId' + responses: + '200': + description: Command snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Command'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}: + get: + operationId: getCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CallId' + responses: + '200': + description: Call snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Call'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}/replays: + post: + operationId: replayCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CallId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} + /internal/v1/outbound/commands/{source_command_id}/replays: + post: + operationId: replayCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/SourceCommandId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + TaskId: + name: task_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CommandId: + name: command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + SourceCommandId: + name: source_command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CallId: + name: call_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + ControlRequest: + type: object + additionalProperties: false + required: [command_id, action, expected_task_revision, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + 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} + ControlAccepted: + type: object + required: + - command_id + - tenant_id + - tenant_key + - task_id + - status + - requested_task_revision + - accepted_at + properties: + command_id: {$ref: '#/components/schemas/Id'} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + requested_task_revision: {type: integer} + accepted_at: {type: string, format: date-time} + ReplayRequest: + type: object + additionalProperties: false + required: [command_id, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + reason: {type: string, minLength: 1, maxLength: 512} + ReplayAccepted: + type: object + required: [command_id, status, snapshot_cutoff] + properties: + command_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + snapshot_cutoff: {type: string, format: date-time} + Command: + type: object + required: + - command_id + - command_type + - tenant_id + - tenant_key + - status + - aggregate_version + properties: + command_id: {$ref: '#/components/schemas/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} + 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: {type: object}} + transcript: {type: object} + recordings: {type: array, items: {type: object}} + delivery: {type: object} + snapshot_at: {type: string, format: date-time} + Problem: + type: object + additionalProperties: false + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string, format: uri-reference} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + NotFound: + description: Not found without cross-tenant enumeration + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Conflict: + description: Idempotency or revision conflict + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + ReplayExpired: + description: Retention window expired + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} diff --git a/contracts/upstream/2026-09-17-snapshot/integration-manifest.json b/contracts/upstream/2026-09-17-snapshot/integration-manifest.json new file mode 100644 index 0000000..fcb5dba --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/integration-manifest.json @@ -0,0 +1,32 @@ +{ + "schema_version": "1.0", + "snapshot": "SNAPSHOT.json", + "purpose": "Public-contract inputs for the standalone SIP mock; parent source paths are not runtime dependencies.", + "mq": { + "command_schema": "mq.schema.json", + "topology": "mq-topology.md", + "example": "examples/call.execute.json", + "tenant_key_policy": "exact opaque value; no normalization" + }, + "dut": { + "protocols": ["SIP/SDP", "ARI HTTP/WebSocket", "RTP/PCMA", "RabbitMQ", "HTTPS/SSE/WSS"], + "parent_runtime_imports": false, + "runtime_package": {"status": "missing", "required_for": ["AI-01", "MEDIA-01/external-no-inbound"]}, + "worker_rtp_ingress_observer": { + "status": "missing", + "required_for": ["MEDIA-01/external-no-inbound"], + "required_output": "versioned, run-scoped proof of RTP received by the DUT worker before first send" + }, + "worker_ledger_outbox_export": { + "status": "missing", + "required_for": ["AI-01"], + "required_output": "versioned, redacted read-only execution/ledger/outbox/AI stream evidence" + } + }, + "ai_fixture": { + "mode": "mock", + "accepted_transports": ["HTTPS", "SSE", "WSS"], + "audio_gate": "final ASR is emitted only after received audio matches a registered fixture fingerprint" + }, + "status": "snapshot-verified" +} diff --git a/contracts/upstream/2026-09-17-snapshot/mock-profile.json b/contracts/upstream/2026-09-17-snapshot/mock-profile.json new file mode 100644 index 0000000..4a79b80 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/mock-profile.json @@ -0,0 +1,64 @@ +{ + "profile_version": "1.0.0", + "mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"}, + "limits": { + "admission_window_s": 30, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "max_mq_bytes": 262144, + "max_queue_messages": 1000, + "max_queue_bytes": 16777216, + "disk_warn_pct": 0.70, + "disk_stop_pct": 0.80, + "max_http_bytes": 65536, + "recording_max_bytes": 16777216, + "max_recording_bytes": 16777216, + "replay_retention_s": 604800, + "upload_ttl_s": 300, + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "tenant_publish_rate": 10, + "scheduler_lease_ttl_s": 10, + "pending_window_per_tenant": 16, + "pending_window_global": 64, + "max_unacked_per_tenant": 4, + "max_replay_attempts": 6, + "cell_capacity": 4, + "turns": 2, + "hold_ms": 0 + }, + "random_seed": 7, + "tenants": [ + {"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true}, + {"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true}, + {"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true} + ], + "tasks": [ + {"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1}, + {"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1}, + {"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1} + ], + "routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}], + "caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}], + "agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}], + "cells": [ + {"cell_id": "cell-a", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}, + {"cell_id": "cell-b", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"} + ], + "failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "customer_silent", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"] +} diff --git a/contracts/upstream/2026-09-17-snapshot/mq-topology.md b/contracts/upstream/2026-09-17-snapshot/mq-topology.md new file mode 100644 index 0000000..60f8501 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/mq-topology.md @@ -0,0 +1,40 @@ +# agent-call MQ topology v1.0 + +This file is an implementation companion to the field authority in +`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`. + +| Element | Value | +| --- | --- | +| Namespace | `agent-call` | +| Command exchange | `agent-call.commands.v1`, durable `direct` | +| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding | +| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` | +| Event exchange | `agent-call.events.v1`, durable `topic` | +| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` | +| Event routing key | `agent-call.{event_type}` | +| Body limit | `262144` UTF-8 bytes in the Mock profile | +| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes | + +## Delivery rules + +1. SaaS persists the command publication record before publishing. A mandatory + publisher confirmation is required; an unroutable/full queue leaves the + original record retained for bounded retry. +2. The executor consumes only its trusted tenant queue. RabbitMQ messages are + acknowledged after durable SQLite acceptance or durable dead-lettering, not + when they are fetched. +3. Executor business events are written to the same database transaction as + the state transition. The outbox dispatcher publishes them durably and the + SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown + after broker confirmation; it does not trigger unbounded republishing. +4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and + routing key. It is not normalized, encoded, truncated or cleaned. A route + over the byte budget is retained and not sent. +5. Replay publishes the original event body and original `event_id` from a + fixed retention cutoff. It never creates a new business fact and never + includes events written after that cutoff. +6. HTTP has no call execution or redial endpoint. Control, query, replay and + recording metadata paths require bearer scope and tenant scope. + +The in-process broker is only for deterministic tests. Docker Compose uses the +same topology through the `pika` adapter and RabbitMQ durable queues. diff --git a/contracts/upstream/2026-09-17-snapshot/mq.schema.json b/contracts/upstream/2026-09-17-snapshot/mq.schema.json new file mode 100644 index 0000000..b5ab065 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/mq.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}], + "$defs": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"}, + "tenantKey": {"type": "string", "minLength": 1}, + "time": {"type": "string", "format": "date-time"}, + "executeCommand": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "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": {"$ref": "#/$defs/executePayload"} + } + }, + "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} + } + }, + "event": { + "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"], + "properties": { + "schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"} + } + } + } +} diff --git a/contracts/upstream/2026-09-17-snapshot/saas.openapi.yaml b/contracts/upstream/2026-09-17-snapshot/saas.openapi.yaml new file mode 100644 index 0000000..e3f47c2 --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/saas.openapi.yaml @@ -0,0 +1,169 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call SaaS Recording Handoff API + version: 1.0.0 + description: >- + Internal storage handshake. Business results still return through RabbitMQ. +servers: + - url: https://saas.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/recording-uploads: + post: + operationId: createRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UploadRequest'} + responses: + '201': + description: Upload session created or existing session returned + headers: {Cache-Control: {schema: {const: no-store}}} + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '200': + description: Existing upload session + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/v1/outbound/recording-uploads/{upload_id}/complete: + post: + operationId: completeRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - name: upload_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CompleteRequest'} + responses: + '200': + description: Object independently verified + content: + application/json: + schema: {$ref: '#/components/schemas/VerifiedUpload'} + '409': {$ref: '#/components/responses/Conflict'} + '410': {$ref: '#/components/responses/Expired'} + '422': {$ref: '#/components/responses/Unprocessable'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + UploadRequest: + type: object + additionalProperties: false + required: + - recording_id + - call_id + - content_type + - size_bytes + - checksum_algorithm + - checksum + - channels + - sample_rate_hz + - duration_ms + properties: + recording_id: {$ref: '#/components/schemas/Id'} + call_id: {$ref: '#/components/schemas/Id'} + content_type: {type: string, const: audio/wav} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {type: string, const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + channels: {type: integer, const: 1} + sample_rate_hz: {type: integer, minimum: 8000} + duration_ms: {type: integer, minimum: 1} + UploadSession: + type: object + required: + - upload_id + - recording_id + - expires_at + - upload_method + - upload_url + - required_headers + - constraints + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + expires_at: {type: string, format: date-time} + upload_method: {const: PUT} + upload_url: {type: string, format: uri} + required_headers: {type: object} + constraints: {type: object} + oss_id: {type: [string, 'null']} + CompleteRequest: + type: object + additionalProperties: false + required: [recording_id, size_bytes, checksum_algorithm, checksum] + properties: + recording_id: {$ref: '#/components/schemas/Id'} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + etag: {type: [string, 'null']} + VerifiedUpload: + type: object + required: [upload_id, recording_id, status, oss_id, verified_at] + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + status: {const: verified} + oss_id: {type: string} + verified_at: {type: string, format: date-time} + Problem: + type: object + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: {description: Unauthorized} + Forbidden: {description: Forbidden} + Conflict: {description: Idempotency conflict} + Expired: {description: Upload expired} + Unprocessable: {description: Object failed independent verification} diff --git a/contracts/upstream/2026-09-17-snapshot/sip-management.openapi.yaml b/contracts/upstream/2026-09-17-snapshot/sip-management.openapi.yaml new file mode 100644 index 0000000..28fee2b --- /dev/null +++ b/contracts/upstream/2026-09-17-snapshot/sip-management.openapi.yaml @@ -0,0 +1,1125 @@ +--- +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-providers + - name: admin-trunks + - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} + - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + CellIdsFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - latest_revision + - active_revision + - active_status + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/contracts/upstream/2026-09-18-p1-baseline/README.md b/contracts/upstream/2026-09-18-p1-baseline/README.md new file mode 100644 index 0000000..05ad816 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/README.md @@ -0,0 +1,27 @@ +# 2026-09-18 P1 development contract baseline + +This is the **project-owned** W01 development release. It is self-contained and +embedded by `contracts/contracts.go`; runtime code does not read the parent +project or the source snapshot directory. + +## Scope closed for local development + +- strict envelope plus eight event payload schemas; +- explicit `full_ai` and `asr_only` immutable AI branches, with legacy full-AI + compatibility; +- Dispatcher-to-Agent AI authorization, tenant/original-key binding and digest; +- static Cell/SIP hand-off artifact shape; +- Agent-direct OSS upload request/grant/complete/verified metadata; +- P1 mock two-Cell development profile with real authorization deliberately + blocked; and +- positive and negative fixtures for each boundary. + +`release-manifest.json`, `SNAPSHOT.json`, and the parent `manifest.txt` record +source ancestry and SHA-256 hashes. The source snapshot was dirty when +imported, so this release is **not** an externally authoritative SaaS +publication and does not authorize real SIP, AI, OSS, cloud or paid activity. +The external authority/real-budget gate remains a W04/W14 concern. + +The release must be changed as a whole: update the version, regenerate hashes, +run the contract and AI tests, and update the embedded source pointer only after +reviewing compatibility. diff --git a/contracts/upstream/2026-09-18-p1-baseline/SNAPSHOT.json b/contracts/upstream/2026-09-18-p1-baseline/SNAPSHOT.json new file mode 100644 index 0000000..c6b729a --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/SNAPSHOT.json @@ -0,0 +1,17 @@ +{ + "release_version": "2026-09-18-p1-baseline", + "release_kind": "project-owned-development-baseline", + "contract_status": "locally-locked-not-external-authoritative", + "source_bundle": "2026-09-17-snapshot", + "source_repository": "git.ipao.vip/rogee/ai-call", + "source_snapshot": "2026-09-17", + "source_head": "fa6925010ba47976d2e99893eac92140b3cb0d09", + "source_worktree": "dirty", + "runtime_dependency": "none (bundle is embedded; parent paths are never read at runtime)", + "amendments": [ + "event-payloads.schema.json closes the eight event payload schemas and strict event_type dispatch", + "ai-config.schema.json adds explicit full_ai/asr_only branches with legacy full-AI compatibility", + "examples include positive and negative mode/event fixtures", + "W01 release metadata and hashes are generated from this self-contained directory" + ] +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/ai-authorization.schema.json b/contracts/upstream/2026-09-18-p1-baseline/ai-authorization.schema.json new file mode 100644 index 0000000..ef7ee1c --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/ai-authorization.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-18-p1-baseline/ai-config.openapi.yaml b/contracts/upstream/2026-09-18-p1-baseline/ai-config.openapi.yaml new file mode 100644 index 0000000..e310606 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/ai-config.openapi.yaml @@ -0,0 +1,145 @@ +openapi: 3.1.0 +info: + title: agent-call immutable AI configuration API + version: 1.0.0 + description: >- + Internal configuration publication/read surface. The call.execute business + command remains RabbitMQ-only; secrets, URLs, and provider credentials are + resolved by the execution environment and never enter MQ messages. +servers: + - url: / +paths: + /internal/v1/ai/agent-versions: + post: + operationId: publishAgentVersion + security: + - aiConfigPublish: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionPublishRequest' + responses: + '200': + description: Identical immutable content already exists + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '201': + description: Immutable version published + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Existing version has different content + /internal/v1/ai/agent-versions/{agent_version_id}: + get: + operationId: getAgentVersion + security: + - aiConfigRead: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - name: agent_version_id + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + responses: + '200': + description: Trusted immutable snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Agent version not found +components: + parameters: + TenantId: + name: X-Tenant-Id + in: header + required: true + schema: {type: string, minLength: 1} + RequestId: + name: X-Request-Id + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + securitySchemes: + aiConfigPublish: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.publish and the AI-config issuer/audience. + aiConfigRead: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.read and the AI-config issuer/audience. + schemas: + AgentVersionPublishRequest: + type: object + additionalProperties: false + required: [agent_version_id, config] + properties: + agent_version_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + config: + $ref: 'ai-config.schema.json' + AgentVersionReceipt: + type: object + required: [tenant_id, agent_version_id, status, immutable, content_sha256] + 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}$'} + AgentVersion: + allOf: + - $ref: '#/components/schemas/AgentVersionReceipt' + - type: object + required: [config] + properties: + config: + $ref: 'ai-config.schema.json' + created_at: {type: string, format: date-time} + published_at: {type: string, format: date-time} + created_by: {type: string} + Error: + type: object + required: [error] + properties: + error: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid configuration + content: + application/json: + schema: {$ref: '#/components/schemas/Error'} + Unauthorized: + description: Missing or invalid AI-config token + Forbidden: + description: Token lacks the AI-config permission diff --git a/contracts/upstream/2026-09-18-p1-baseline/ai-config.schema.json b/contracts/upstream/2026-09-18-p1-baseline/ai-config.schema.json new file mode 100644 index 0000000..cf06b62 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/ai-config.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-18-p1-baseline/cell-agent.openapi.yaml b/contracts/upstream/2026-09-18-p1-baseline/cell-agent.openapi.yaml new file mode 100644 index 0000000..f37e4c0 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/cell-agent.openapi.yaml @@ -0,0 +1,232 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: >- + Revision or expected local snapshot is stale; the Cell never + guesses over an unknown baseline + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: {type: string} + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, expected_local_revision, config, + config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + expected_local_revision: {type: integer, minimum: 0} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/contracts/upstream/2026-09-18-p1-baseline/event-payloads.schema.json b/contracts/upstream/2026-09-18-p1-baseline/event-payloads.schema.json new file mode 100644 index 0000000..db4cecc --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/event-payloads.schema.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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"], + "properties": { + "schema_version": {"const": "1.0"}, + "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"type": "string"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 224}, + "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"} + }, + "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.ready"}, "payload": {"$ref": "#/$defs/recording_ready"}}}, + {"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"]}, + "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_ready": { + "type": "object", "additionalProperties": false, + "required": ["call_id", "recording_id", "oss_id", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"], + "properties": { + "call_id": {"$ref": "#/$defs/id"}, + "recording_id": {"$ref": "#/$defs/id"}, + "oss_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}$"} + } + }, + "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"} + } + } + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/README.md b/contracts/upstream/2026-09-18-p1-baseline/examples/README.md new file mode 100644 index 0000000..ed9b3ae --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/README.md @@ -0,0 +1,5 @@ +# Contract fixtures + +`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes. + +The bundle contains legacy full-AI, explicit full-AI, ASR-only, event-positive, and event-negative fixtures. All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration. `transcript.updated` is the only valid transcript event name; `call.transcript` is intentionally invalid. diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/agent-version-asr-only.json b/contracts/upstream/2026-09-18-p1-baseline/examples/agent-version-asr-only.json new file mode 100644 index 0000000..7ecf915 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/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-18-p1-baseline/examples/agent-version-full-explicit.json b/contracts/upstream/2026-09-18-p1-baseline/examples/agent-version-full-explicit.json new file mode 100644 index 0000000..8e0bc7f --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/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-18-p1-baseline/examples/agent-version.json b/contracts/upstream/2026-09-18-p1-baseline/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/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-18-p1-baseline/examples/ai-authorization.json b/contracts/upstream/2026-09-18-p1-baseline/examples/ai-authorization.json new file mode 100644 index 0000000..1110b59 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/ai-authorization.json @@ -0,0 +1,14 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b", + "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-18-p1-baseline/examples/call.execute.json b/contracts/upstream/2026-09-18-p1-baseline/examples/call.execute.json new file mode 100644 index 0000000..23f9fdc --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/call.execute.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": "cmd_demo_001", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-11T08:00:00Z", + "not_after": "2099-09-11T08:05:00Z", + "payload": { + "execution_id": "exec_demo_001", + "task_id": "task-demo", + "task_item_id": "item_demo", + "task_revision": 1, + "callee": "18601013734", + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": "agent_v1", + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000 + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-finished.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-finished.json new file mode 100644 index 0000000..1ba64ae --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-finished.json @@ -0,0 +1,26 @@ +{ + "schema_version": "1.0", + "event_id": "event-finished-1", + "event_type": "call.finished", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:03Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 2, + "payload": { + "call_id": "call-1", + "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"}] + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-status.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-status.json new file mode 100644 index 0000000..8fc59ac --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-call-status.json @@ -0,0 +1,28 @@ +{ + "schema_version": "1.0", + "event_id": "event-status-1", + "event_type": "call.status", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-1", + "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" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-command-result.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-command-result.json new file mode 100644 index 0000000..84daa6e --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-command-result.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "event_id": "event-command-result-1", + "event_type": "command.result", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-contact-opt-out.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-contact-opt-out.json new file mode 100644 index 0000000..1f59c2a --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-contact-opt-out.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1.0", + "event_id": "event-opt-out-1", + "event_type": "contact.opt_out", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "task_id": "task-1", + "task_item_id": "item-1", + "requested_at": "2026-09-18T00:00:06Z", + "turn_id": "turn-1", + "segment_id": "segment-1" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-failed.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-failed.json new file mode 100644 index 0000000..af03187 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-failed.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1.0", + "event_id": "event-recording-failed-1", + "event_type": "recording.failed", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-ready.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-ready.json new file mode 100644 index 0000000..c0ad88f --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-recording-ready.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "event_id": "event-recording-1", + "event_type": "recording.ready", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "recording_id": "recording-1", + "oss_id": "oss://bucket/object-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-failed.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-failed.json new file mode 100644 index 0000000..8109d97 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-failed.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "event_id": "event-transcript-failed-1", + "event_type": "transcript.failed", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": ["segment-1"] + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-updated.json b/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-updated.json new file mode 100644 index 0000000..46d7796 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/event-transcript-updated.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0", + "event_id": "event-transcript-1", + "event_type": "transcript.updated", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "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" + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-ai-authorization-revoked.json b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-ai-authorization-revoked.json new file mode 100644 index 0000000..48a591b --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-ai-authorization-revoked.json @@ -0,0 +1,12 @@ +{ + "authorization_id": "auth-invalid", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "revoked": true +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-asr-only-with-llm.json b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-asr-only-with-llm.json new file mode 100644 index 0000000..c09e0f2 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-asr-only-with-llm.json @@ -0,0 +1,19 @@ +{ + "agent_version_id": "agent_invalid", + "immutable": true, + "mode": "asr_only", + "llm": {"provider_ref": "mock", "model": "must-not-be-present"}, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": {"encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2} + }, + "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-18-p1-baseline/examples/invalid-event-unknown-type.json b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-event-unknown-type.json new file mode 100644 index 0000000..25476b8 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-event-unknown-type.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "event_id": "event-invalid-1", + "event_type": "call.transcript", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": {"text": "invalid alias"} +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-oss-upload-http.json b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-oss-upload-http.json new file mode 100644 index 0000000..42033e6 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/invalid-oss-upload-http.json @@ -0,0 +1,10 @@ +{ + "kind": "grant", + "upload_id": "upload-invalid", + "recording_id": "recording-1", + "upload_url": "http://oss.example.invalid/upload/upload-invalid", + "required_headers": {}, + "expires_at": "2026-09-18T00:05:00Z", + "max_bytes": 16777216, + "object_binding": "recording-1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/oss-upload-grant.json b/contracts/upstream/2026-09-18-p1-baseline/examples/oss-upload-grant.json new file mode 100644 index 0000000..6b4ddb3 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/oss-upload-grant.json @@ -0,0 +1,10 @@ +{ + "kind": "grant", + "upload_id": "upload-1", + "recording_id": "recording-1", + "upload_url": "https://oss.example.invalid/upload/upload-1", + "required_headers": {"x-checksum-sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "expires_at": "2026-09-18T00:05:00Z", + "max_bytes": 16777216, + "object_binding": "recording-1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/examples/static-cell-artifact.json b/contracts/upstream/2026-09-18-p1-baseline/examples/static-cell-artifact.json new file mode 100644 index 0000000..8203251 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/examples/static-cell-artifact.json @@ -0,0 +1,14 @@ +{ + "artifact_id": "artifact-cell-a-1", + "source_release": "management-snapshot-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-a", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "trunks": [ + {"trunk_id": "trunk-mock", "provider_id": "provider-mock", "egress_pool_id": "egress-mock", "codec": "PCMA", "caller_profile_ids": ["caller_profile_test"], "dial_prefix": "7089", "enabled": true, "sip_endpoint_ref": "mock-sip-endpoint", "credential_ref": null} + ], + "load_evidence": null +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/executor.openapi.yaml b/contracts/upstream/2026-09-18-p1-baseline/executor.openapi.yaml new file mode 100644 index 0000000..a9f02fb --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/executor.openapi.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Executor Control API + version: 1.0.0 + description: >- + Internal control, query, replay, and recording hand-off API. Call execution + enters through RabbitMQ, not HTTP. +servers: + - url: https://executor.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/tasks/{task_id}/controls: + post: + operationId: controlTask + summary: Persist a pause, resume, or stop barrier + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlRequest' + responses: + '202': + description: Reliably persisted, not yet necessarily applied + headers: + Location: + schema: {type: string} + content: + application/json: + schema: {$ref: '#/components/schemas/ControlAccepted'} + '409': {$ref: '#/components/responses/Conflict'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/commands/{command_id}: + get: + operationId: getCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CommandId' + responses: + '200': + description: Command snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Command'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}: + get: + operationId: getCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CallId' + responses: + '200': + description: Call snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Call'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}/replays: + post: + operationId: replayCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CallId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} + /internal/v1/outbound/commands/{source_command_id}/replays: + post: + operationId: replayCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/SourceCommandId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + TaskId: + name: task_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CommandId: + name: command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + SourceCommandId: + name: source_command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CallId: + name: call_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + ControlRequest: + type: object + additionalProperties: false + required: [command_id, action, expected_task_revision, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + 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} + ControlAccepted: + type: object + required: + - command_id + - tenant_id + - tenant_key + - task_id + - status + - requested_task_revision + - accepted_at + properties: + command_id: {$ref: '#/components/schemas/Id'} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + requested_task_revision: {type: integer} + accepted_at: {type: string, format: date-time} + ReplayRequest: + type: object + additionalProperties: false + required: [command_id, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + reason: {type: string, minLength: 1, maxLength: 512} + ReplayAccepted: + type: object + required: [command_id, status, snapshot_cutoff] + properties: + command_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + snapshot_cutoff: {type: string, format: date-time} + Command: + type: object + required: + - command_id + - command_type + - tenant_id + - tenant_key + - status + - aggregate_version + properties: + command_id: {$ref: '#/components/schemas/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} + 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: {type: object}} + transcript: {type: object} + recordings: {type: array, items: {type: object}} + delivery: {type: object} + snapshot_at: {type: string, format: date-time} + Problem: + type: object + additionalProperties: false + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string, format: uri-reference} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + NotFound: + description: Not found without cross-tenant enumeration + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Conflict: + description: Idempotency or revision conflict + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + ReplayExpired: + description: Retention window expired + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} diff --git a/contracts/upstream/2026-09-18-p1-baseline/mock-profile.json b/contracts/upstream/2026-09-18-p1-baseline/mock-profile.json new file mode 100644 index 0000000..4a79b80 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/mock-profile.json @@ -0,0 +1,64 @@ +{ + "profile_version": "1.0.0", + "mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"}, + "limits": { + "admission_window_s": 30, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "max_mq_bytes": 262144, + "max_queue_messages": 1000, + "max_queue_bytes": 16777216, + "disk_warn_pct": 0.70, + "disk_stop_pct": 0.80, + "max_http_bytes": 65536, + "recording_max_bytes": 16777216, + "max_recording_bytes": 16777216, + "replay_retention_s": 604800, + "upload_ttl_s": 300, + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "tenant_publish_rate": 10, + "scheduler_lease_ttl_s": 10, + "pending_window_per_tenant": 16, + "pending_window_global": 64, + "max_unacked_per_tenant": 4, + "max_replay_attempts": 6, + "cell_capacity": 4, + "turns": 2, + "hold_ms": 0 + }, + "random_seed": 7, + "tenants": [ + {"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true}, + {"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true}, + {"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true} + ], + "tasks": [ + {"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1}, + {"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1}, + {"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1} + ], + "routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}], + "caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}], + "agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}], + "cells": [ + {"cell_id": "cell-a", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}, + {"cell_id": "cell-b", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"} + ], + "failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "customer_silent", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"] +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/mq-topology.md b/contracts/upstream/2026-09-18-p1-baseline/mq-topology.md new file mode 100644 index 0000000..60f8501 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/mq-topology.md @@ -0,0 +1,40 @@ +# agent-call MQ topology v1.0 + +This file is an implementation companion to the field authority in +`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`. + +| Element | Value | +| --- | --- | +| Namespace | `agent-call` | +| Command exchange | `agent-call.commands.v1`, durable `direct` | +| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding | +| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` | +| Event exchange | `agent-call.events.v1`, durable `topic` | +| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` | +| Event routing key | `agent-call.{event_type}` | +| Body limit | `262144` UTF-8 bytes in the Mock profile | +| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes | + +## Delivery rules + +1. SaaS persists the command publication record before publishing. A mandatory + publisher confirmation is required; an unroutable/full queue leaves the + original record retained for bounded retry. +2. The executor consumes only its trusted tenant queue. RabbitMQ messages are + acknowledged after durable SQLite acceptance or durable dead-lettering, not + when they are fetched. +3. Executor business events are written to the same database transaction as + the state transition. The outbox dispatcher publishes them durably and the + SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown + after broker confirmation; it does not trigger unbounded republishing. +4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and + routing key. It is not normalized, encoded, truncated or cleaned. A route + over the byte budget is retained and not sent. +5. Replay publishes the original event body and original `event_id` from a + fixed retention cutoff. It never creates a new business fact and never + includes events written after that cutoff. +6. HTTP has no call execution or redial endpoint. Control, query, replay and + recording metadata paths require bearer scope and tenant scope. + +The in-process broker is only for deterministic tests. Docker Compose uses the +same topology through the `pika` adapter and RabbitMQ durable queues. diff --git a/contracts/upstream/2026-09-18-p1-baseline/mq.schema.json b/contracts/upstream/2026-09-18-p1-baseline/mq.schema.json new file mode 100644 index 0000000..b5ab065 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/mq.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}], + "$defs": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"}, + "tenantKey": {"type": "string", "minLength": 1}, + "time": {"type": "string", "format": "date-time"}, + "executeCommand": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "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": {"$ref": "#/$defs/executePayload"} + } + }, + "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} + } + }, + "event": { + "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"], + "properties": { + "schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"} + } + } + } +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/oss-upload.schema.json b/contracts/upstream/2026-09-18-p1-baseline/oss-upload.schema.json new file mode 100644 index 0000000..1393b72 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/oss-upload.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-18-p1-baseline/p1-development-profile.json b/contracts/upstream/2026-09-18-p1-baseline/p1-development-profile.json new file mode 100644 index 0000000..21b0db6 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/p1-development-profile.json @@ -0,0 +1,23 @@ +{ + "profile_id": "p1-mock-two-cell-v1", + "profile_version": "1.0.0", + "environment": "mock", + "external_calls": false, + "real_authorization": "blocked", + "topology": {"dispatcher_count": 1, "agent_count": 2, "cell_count": 2, "tenant_count": 1}, + "limits": { + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "pending_window_global": 64, + "pending_window_per_tenant": 16, + "lease_ttl_ms": 10000, + "final_permit_ttl_ms": 1000 + }, + "modes": ["full_ai", "asr_only"], + "retention": {"keep_unverified_assets": true, "delete_only_after_verified": true, "backup_kind": "local-sqlite-wal-consistent"}, + "status": "development-only-not-production-approval", + "real_budget": "unknown", + "operator_approval": "not-granted" +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/p1-development-profile.schema.json b/contracts/upstream/2026-09-18-p1-baseline/p1-development-profile.schema.json new file mode 100644 index 0000000..adb8b5d --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/p1-development-profile.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-18-p1-baseline/release-manifest.json b/contracts/upstream/2026-09-18-p1-baseline/release-manifest.json new file mode 100644 index 0000000..e5d50bc --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/release-manifest.json @@ -0,0 +1,193 @@ +{ + "release_version": "2026-09-18-p1-baseline", + "release_kind": "project-owned-development-baseline", + "source_bundle": "2026-09-17-snapshot", + "source_worktree": "dirty-inherited-only", + "files": [ + { + "path": "README.md", + "bytes": 1319, + "sha256": "7f13e2f0012dfcc44bb979b71920b781a57db9cccc27099054d5919f1ba379d4" + }, + { + "path": "SNAPSHOT.json", + "bytes": 869, + "sha256": "f3c854b460e9d9a21466e9844bc3a3df5ae9c498643d4a703dd7b437e21bd862" + }, + { + "path": "ai-authorization.schema.json", + "bytes": 1749, + "sha256": "5d6bbd369bd6b406abe9b9f8619e6f299b544dacfdd4473a08257a366fd95d20" + }, + { + "path": "ai-config.openapi.yaml", + "bytes": 4516, + "sha256": "d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a" + }, + { + "path": "ai-config.schema.json", + "bytes": 4797, + "sha256": "dc915bff70e71408fcacbaad47d3554bbfa7a22a51f2f53e127c6bd8bc8ba5a6" + }, + { + "path": "cell-agent.openapi.yaml", + "bytes": 7633, + "sha256": "79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41" + }, + { + "path": "event-payloads.schema.json", + "bytes": 8683, + "sha256": "5091ded520d692f458699327b13adddeccb0c4b4d4a72ef7cdfebcd5d496eade" + }, + { + "path": "examples/README.md", + "bytes": 728, + "sha256": "a3514c7ef89b4324685bc9025139488000fa42330de72495484add042a6897cf" + }, + { + "path": "examples/agent-version-asr-only.json", + "bytes": 534, + "sha256": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b" + }, + { + "path": "examples/agent-version-full-explicit.json", + "bytes": 1051, + "sha256": "e590148448885a1e88c473ca032d5efd719270689c3cff7aa44ca9c6ffa2b321" + }, + { + "path": "examples/agent-version.json", + "bytes": 1079, + "sha256": "d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e" + }, + { + "path": "examples/ai-authorization.json", + "bytes": 467, + "sha256": "b1c0f723f083d1be45035792482f6f74bd3bca34486cc6408d4bcfe3772bc432" + }, + { + "path": "examples/call.execute.json", + "bytes": 656, + "sha256": "0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742" + }, + { + "path": "examples/event-call-finished.json", + "bytes": 783, + "sha256": "fab56aff9c5d4059b8fa65d24f2afa424148e7a07025238fc7fd30e9c5ee63f4" + }, + { + "path": "examples/event-call-status.json", + "bytes": 752, + "sha256": "5c673ceb4a98ce8c90d976356b4c5ffa6c7d96d045b5b3312513a9be93d585ba" + }, + { + "path": "examples/event-command-result.json", + "bytes": 498, + "sha256": "64fe71719573378b0caac2f7c5476a27ba7073df8c346d4748a6b0d534d4a087" + }, + { + "path": "examples/event-contact-opt-out.json", + "bytes": 513, + "sha256": "b661f65f0a195e59fe226b12ebae2feb5a9733a9a779c835245be27319aaf8f2" + }, + { + "path": "examples/event-recording-failed.json", + "bytes": 546, + "sha256": "4f5f46422d2281ffae65c99e947bc7dd44effb5e94eacc12e41b3250ac645c34" + }, + { + "path": "examples/event-recording-ready.json", + "bytes": 648, + "sha256": "2b6d731a8d7993cbbe07a5ba8c7413ec56bae8a1538476c0f1740133a488d5b4" + }, + { + "path": "examples/event-transcript-failed.json", + "bytes": 499, + "sha256": "58d000ce27de6e67054f66d4ded34ffafa983bdcced12613051f670d1eefeed8" + }, + { + "path": "examples/event-transcript-updated.json", + "bytes": 596, + "sha256": "115f276522631d7c4decee71ee238aa0d00b4256adedfc9a6a0c5f9dc89f64f6" + }, + { + "path": "examples/invalid-ai-authorization-revoked.json", + "bytes": 373, + "sha256": "0b946b43c1f773391791dfb60a2b2a03c8a69e64e5e9ea30af8591850c34b8f3" + }, + { + "path": "examples/invalid-asr-only-with-llm.json", + "bytes": 529, + "sha256": "7343ce8d6ce63e22128ab7bc721544d681a5dd9eccfc4ab4f9d6181c5748824c" + }, + { + "path": "examples/invalid-event-unknown-type.json", + "bytes": 348, + "sha256": "80c3f89dc775deec9d1a3b85c81ea4b2fd1b030bc4deba1a4dcd2823e4eb8024" + }, + { + "path": "examples/invalid-oss-upload-http.json", + "bytes": 347, + "sha256": "8e4fb4344c4bec51b47ff9b00a63afdc84d09ab827e1b6823c4e576bf6b6a3f1" + }, + { + "path": "examples/oss-upload-grant.json", + "bytes": 423, + "sha256": "268751a1a0d238636c04c004a635e3bda702bc2ef90ade0e5846b47a3c263b5d" + }, + { + "path": "examples/static-cell-artifact.json", + "bytes": 670, + "sha256": "e18b239adf260e2c9839f0b1527e67826787b7328f9b1e1f82750ded0e3bd0e5" + }, + { + "path": "executor.openapi.yaml", + "bytes": 10244, + "sha256": "b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329" + }, + { + "path": "mock-profile.json", + "bytes": 2549, + "sha256": "4d43097602fed9a68821e765117580706896ac82d4bce361a80a4cea544ab638" + }, + { + "path": "mq-topology.md", + "bytes": 2211, + "sha256": "a85b26596e1b1db7405dcabc967df56d5eb6713cc9908bc05ecaa2075ff1092f" + }, + { + "path": "mq.schema.json", + "bytes": 2953, + "sha256": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a" + }, + { + "path": "oss-upload.schema.json", + "bytes": 3039, + "sha256": "d3f6ffc5e004fba8acbb6a18495be508a63ec45766bab1ef946ece58bbad84de" + }, + { + "path": "p1-development-profile.json", + "bytes": 789, + "sha256": "2aa7cd3f4fa07f7e1a3037107fa7a56183f28370ea06a2a9b8dc18c8790eee2d" + }, + { + "path": "p1-development-profile.schema.json", + "bytes": 2460, + "sha256": "68e1b46194a1f5fa5bc763ba424411f48ada1ab52d39e6f800315a832692794b" + }, + { + "path": "saas.openapi.yaml", + "bytes": 5750, + "sha256": "368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb" + }, + { + "path": "sip-management.openapi.yaml", + "bytes": 38240, + "sha256": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281" + }, + { + "path": "static-cell-artifact.schema.json", + "bytes": 2199, + "sha256": "b5d6ad4fb8007a427088bf5f84c7ed89834c610a7714d459dbd6ee91fdb428fb" + } + ] +} diff --git a/contracts/upstream/2026-09-18-p1-baseline/saas.openapi.yaml b/contracts/upstream/2026-09-18-p1-baseline/saas.openapi.yaml new file mode 100644 index 0000000..e3f47c2 --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/saas.openapi.yaml @@ -0,0 +1,169 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call SaaS Recording Handoff API + version: 1.0.0 + description: >- + Internal storage handshake. Business results still return through RabbitMQ. +servers: + - url: https://saas.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/recording-uploads: + post: + operationId: createRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UploadRequest'} + responses: + '201': + description: Upload session created or existing session returned + headers: {Cache-Control: {schema: {const: no-store}}} + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '200': + description: Existing upload session + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/v1/outbound/recording-uploads/{upload_id}/complete: + post: + operationId: completeRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - name: upload_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CompleteRequest'} + responses: + '200': + description: Object independently verified + content: + application/json: + schema: {$ref: '#/components/schemas/VerifiedUpload'} + '409': {$ref: '#/components/responses/Conflict'} + '410': {$ref: '#/components/responses/Expired'} + '422': {$ref: '#/components/responses/Unprocessable'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + UploadRequest: + type: object + additionalProperties: false + required: + - recording_id + - call_id + - content_type + - size_bytes + - checksum_algorithm + - checksum + - channels + - sample_rate_hz + - duration_ms + properties: + recording_id: {$ref: '#/components/schemas/Id'} + call_id: {$ref: '#/components/schemas/Id'} + content_type: {type: string, const: audio/wav} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {type: string, const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + channels: {type: integer, const: 1} + sample_rate_hz: {type: integer, minimum: 8000} + duration_ms: {type: integer, minimum: 1} + UploadSession: + type: object + required: + - upload_id + - recording_id + - expires_at + - upload_method + - upload_url + - required_headers + - constraints + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + expires_at: {type: string, format: date-time} + upload_method: {const: PUT} + upload_url: {type: string, format: uri} + required_headers: {type: object} + constraints: {type: object} + oss_id: {type: [string, 'null']} + CompleteRequest: + type: object + additionalProperties: false + required: [recording_id, size_bytes, checksum_algorithm, checksum] + properties: + recording_id: {$ref: '#/components/schemas/Id'} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + etag: {type: [string, 'null']} + VerifiedUpload: + type: object + required: [upload_id, recording_id, status, oss_id, verified_at] + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + status: {const: verified} + oss_id: {type: string} + verified_at: {type: string, format: date-time} + Problem: + type: object + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: {description: Unauthorized} + Forbidden: {description: Forbidden} + Conflict: {description: Idempotency conflict} + Expired: {description: Upload expired} + Unprocessable: {description: Object failed independent verification} diff --git a/contracts/upstream/2026-09-18-p1-baseline/sip-management.openapi.yaml b/contracts/upstream/2026-09-18-p1-baseline/sip-management.openapi.yaml new file mode 100644 index 0000000..28fee2b --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/sip-management.openapi.yaml @@ -0,0 +1,1125 @@ +--- +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-providers + - name: admin-trunks + - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} + - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + CellIdsFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - latest_revision + - active_revision + - active_status + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/contracts/upstream/2026-09-18-p1-baseline/static-cell-artifact.schema.json b/contracts/upstream/2026-09-18-p1-baseline/static-cell-artifact.schema.json new file mode 100644 index 0000000..5ab801f --- /dev/null +++ b/contracts/upstream/2026-09-18-p1-baseline/static-cell-artifact.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/static-cell-artifact.schema.json", + "title": "Approved 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", "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": {"const": "mock"}, + "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"], + "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} + } + } + }, + "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": {"const": "not-yet-loaded"}}} + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/README.md b/contracts/upstream/2026-09-19-p1-v1/README.md new file mode 100644 index 0000000..3810491 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/README.md @@ -0,0 +1,31 @@ +# 2026-09-19 P1 project-owned v1 contract bundle + +This is the first project-owned W01 contract release authorized for the current +single-node production-closure iteration. It is self-contained and embedded by +`contracts/contracts.go`; runtime code never reads the parent worktree or an +external URL. + +## Scope + +- strict envelope and eight event payload schemas; +- immutable `full_ai` and `asr_only` AI branches; +- Dispatcher-to-Agent AI authorization, tenant binding and digest checks; +- real/mixed/mock static Cell artifact modes; +- single-node ARI, `slin16` ExternalMedia and WAV recording hand-off fields; +- Agent-direct OSS upload request/grant/complete/verified metadata; and +- first-version examples and negative fixtures for local and single-node + production closure. + +This bundle is **project-owned v1**, not a claim that an external SaaS contract +has been published. It is the implementation contract for this iteration and +must be promoted or superseded by an explicitly versioned external release +before any cross-project integration is declared compatible. + +The current release deliberately defers a second Cell. It permits one active +Dispatcher, one Agent, one Asterisk Cell and one approved real-mode artifact; +capacity, N+1, multi-Cell fairness and automatic failover remain separate +acceptance items. + +`release-manifest.json`, `SNAPSHOT.json`, and the parent `manifest.txt` record +source ancestry and SHA-256 hashes. Credentials are referenced by bounded names +only; no provider secret is embedded in this bundle. diff --git a/contracts/upstream/2026-09-19-p1-v1/SNAPSHOT.json b/contracts/upstream/2026-09-19-p1-v1/SNAPSHOT.json new file mode 100644 index 0000000..9eadf95 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/SNAPSHOT.json @@ -0,0 +1,17 @@ +{ + "release_version": "2026-09-19-p1-v1", + "release_kind": "project-owned-first-production-closure-v1", + "contract_status": "project-owned-v1-authorized-not-external-authoritative", + "source_bundle": "2026-09-18-p1-baseline", + "source_repository": "git.ipao.vip/rogee/ai-call", + "source_snapshot": "2026-09-17", + "source_head": "fa6925010ba47976d2e99893eac92140b3cb0d09", + "source_worktree": "project-authored-v1", + "runtime_dependency": "none (bundle is embedded; parent paths are never read at runtime)", + "amendments": [ + "project-owned first-version contract for the single-node production-closure iteration", + "static-cell-artifact.schema.json adds mock/mixed/real modes plus ARI, slin16 ExternalMedia and recording fields", + "real-mode artifact validation is allowed for one active Cell; second-Cell/N+1 remains out of scope", + "credentials remain bounded references and are never embedded in the contract bundle" + ] +} diff --git a/contracts/upstream/2026-09-19-p1-v1/ai-authorization.schema.json b/contracts/upstream/2026-09-19-p1-v1/ai-authorization.schema.json new file mode 100644 index 0000000..ef7ee1c --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/ai-authorization.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-19-p1-v1/ai-config.openapi.yaml b/contracts/upstream/2026-09-19-p1-v1/ai-config.openapi.yaml new file mode 100644 index 0000000..e310606 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/ai-config.openapi.yaml @@ -0,0 +1,145 @@ +openapi: 3.1.0 +info: + title: agent-call immutable AI configuration API + version: 1.0.0 + description: >- + Internal configuration publication/read surface. The call.execute business + command remains RabbitMQ-only; secrets, URLs, and provider credentials are + resolved by the execution environment and never enter MQ messages. +servers: + - url: / +paths: + /internal/v1/ai/agent-versions: + post: + operationId: publishAgentVersion + security: + - aiConfigPublish: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionPublishRequest' + responses: + '200': + description: Identical immutable content already exists + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '201': + description: Immutable version published + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Existing version has different content + /internal/v1/ai/agent-versions/{agent_version_id}: + get: + operationId: getAgentVersion + security: + - aiConfigRead: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - name: agent_version_id + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + responses: + '200': + description: Trusted immutable snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Agent version not found +components: + parameters: + TenantId: + name: X-Tenant-Id + in: header + required: true + schema: {type: string, minLength: 1} + RequestId: + name: X-Request-Id + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + securitySchemes: + aiConfigPublish: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.publish and the AI-config issuer/audience. + aiConfigRead: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.read and the AI-config issuer/audience. + schemas: + AgentVersionPublishRequest: + type: object + additionalProperties: false + required: [agent_version_id, config] + properties: + agent_version_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + config: + $ref: 'ai-config.schema.json' + AgentVersionReceipt: + type: object + required: [tenant_id, agent_version_id, status, immutable, content_sha256] + 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}$'} + AgentVersion: + allOf: + - $ref: '#/components/schemas/AgentVersionReceipt' + - type: object + required: [config] + properties: + config: + $ref: 'ai-config.schema.json' + created_at: {type: string, format: date-time} + published_at: {type: string, format: date-time} + created_by: {type: string} + Error: + type: object + required: [error] + properties: + error: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid configuration + content: + application/json: + schema: {$ref: '#/components/schemas/Error'} + Unauthorized: + description: Missing or invalid AI-config token + Forbidden: + description: Token lacks the AI-config permission diff --git a/contracts/upstream/2026-09-19-p1-v1/ai-config.schema.json b/contracts/upstream/2026-09-19-p1-v1/ai-config.schema.json new file mode 100644 index 0000000..cf06b62 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/ai-config.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-19-p1-v1/cell-agent.openapi.yaml b/contracts/upstream/2026-09-19-p1-v1/cell-agent.openapi.yaml new file mode 100644 index 0000000..f37e4c0 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/cell-agent.openapi.yaml @@ -0,0 +1,232 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: >- + Revision or expected local snapshot is stale; the Cell never + guesses over an unknown baseline + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: {type: string} + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, expected_local_revision, config, + config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + expected_local_revision: {type: integer, minimum: 0} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/contracts/upstream/2026-09-19-p1-v1/event-payloads.schema.json b/contracts/upstream/2026-09-19-p1-v1/event-payloads.schema.json new file mode 100644 index 0000000..db4cecc --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/event-payloads.schema.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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"], + "properties": { + "schema_version": {"const": "1.0"}, + "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"type": "string"}, + "tenant_id": {"$ref": "#/$defs/id"}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 224}, + "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"} + }, + "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.ready"}, "payload": {"$ref": "#/$defs/recording_ready"}}}, + {"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"]}, + "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_ready": { + "type": "object", "additionalProperties": false, + "required": ["call_id", "recording_id", "oss_id", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"], + "properties": { + "call_id": {"$ref": "#/$defs/id"}, + "recording_id": {"$ref": "#/$defs/id"}, + "oss_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}$"} + } + }, + "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"} + } + } + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/README.md b/contracts/upstream/2026-09-19-p1-v1/examples/README.md new file mode 100644 index 0000000..ed9b3ae --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/README.md @@ -0,0 +1,5 @@ +# Contract fixtures + +`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes. + +The bundle contains legacy full-AI, explicit full-AI, ASR-only, event-positive, and event-negative fixtures. All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration. `transcript.updated` is the only valid transcript event name; `call.transcript` is intentionally invalid. diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/agent-version-asr-only.json b/contracts/upstream/2026-09-19-p1-v1/examples/agent-version-asr-only.json new file mode 100644 index 0000000..7ecf915 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/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-19-p1-v1/examples/agent-version-full-explicit.json b/contracts/upstream/2026-09-19-p1-v1/examples/agent-version-full-explicit.json new file mode 100644 index 0000000..8e0bc7f --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/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-19-p1-v1/examples/agent-version-full-production-v1.json b/contracts/upstream/2026-09-19-p1-v1/examples/agent-version-full-production-v1.json new file mode 100644 index 0000000..0a83a96 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/agent-version-full-production-v1.json @@ -0,0 +1,54 @@ +{ + "agent_version_id": "agent_full_v1", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "bailian-openai-compatible", + "model": "qwen-plus", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000, + "credential_ref": "bailian-default" + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "bailian-openai-compatible", + "model": "qwen3-tts-flash", + "voice": "Cherry", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000, + "credential_ref": "bailian-default" + }, + "asr": { + "provider_ref": "volcengine", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000, + "credential_ref": "volcengine-default", + "model": "volc.bigasr.sauc.duration" + }, + "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-19-p1-v1/examples/agent-version.json b/contracts/upstream/2026-09-19-p1-v1/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/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-19-p1-v1/examples/ai-authorization.json b/contracts/upstream/2026-09-19-p1-v1/examples/ai-authorization.json new file mode 100644 index 0000000..1110b59 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/ai-authorization.json @@ -0,0 +1,14 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b", + "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-19-p1-v1/examples/call.execute.json b/contracts/upstream/2026-09-19-p1-v1/examples/call.execute.json new file mode 100644 index 0000000..23f9fdc --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/call.execute.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": "cmd_demo_001", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-11T08:00:00Z", + "not_after": "2099-09-11T08:05:00Z", + "payload": { + "execution_id": "exec_demo_001", + "task_id": "task-demo", + "task_item_id": "item_demo", + "task_revision": 1, + "callee": "18601013734", + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": "agent_v1", + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000 + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-call-finished.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-call-finished.json new file mode 100644 index 0000000..1ba64ae --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-call-finished.json @@ -0,0 +1,26 @@ +{ + "schema_version": "1.0", + "event_id": "event-finished-1", + "event_type": "call.finished", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:03Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 2, + "payload": { + "call_id": "call-1", + "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"}] + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-call-status.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-call-status.json new file mode 100644 index 0000000..8fc59ac --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-call-status.json @@ -0,0 +1,28 @@ +{ + "schema_version": "1.0", + "event_id": "event-status-1", + "event_type": "call.status", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:01Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": { + "call_id": "call-1", + "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" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-command-result.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-command-result.json new file mode 100644 index 0000000..84daa6e --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-command-result.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "event_id": "event-command-result-1", + "event_type": "command.result", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-contact-opt-out.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-contact-opt-out.json new file mode 100644 index 0000000..1f59c2a --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-contact-opt-out.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1.0", + "event_id": "event-opt-out-1", + "event_type": "contact.opt_out", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "task_id": "task-1", + "task_item_id": "item-1", + "requested_at": "2026-09-18T00:00:06Z", + "turn_id": "turn-1", + "segment_id": "segment-1" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-failed.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-failed.json new file mode 100644 index 0000000..af03187 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-failed.json @@ -0,0 +1,20 @@ +{ + "schema_version": "1.0", + "event_id": "event-recording-failed-1", + "event_type": "recording.failed", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "recording_id": "recording-1", + "stage": "upload", + "reason_code": "temporary_oss_unavailable", + "retryable": true, + "next_retry_at": "2026-09-18T00:01:00Z" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-ready.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-ready.json new file mode 100644 index 0000000..c0ad88f --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-recording-ready.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "event_id": "event-recording-1", + "event_type": "recording.ready", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "recording_id": "recording-1", + "oss_id": "oss://bucket/object-1", + "format": "wav", + "channels": 1, + "sample_rate_hz": 16000, + "duration_ms": 1000, + "size_bytes": 32000, + "checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-failed.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-failed.json new file mode 100644 index 0000000..8109d97 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-failed.json @@ -0,0 +1,19 @@ +{ + "schema_version": "1.0", + "event_id": "event-transcript-failed-1", + "event_type": "transcript.failed", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "reason_code": "asr_timeout", + "retryable": false, + "segment_id": "segment-1", + "affected_segments": ["segment-1"] + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-updated.json b/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-updated.json new file mode 100644 index 0000000..46d7796 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-updated.json @@ -0,0 +1,24 @@ +{ + "schema_version": "1.0", + "event_id": "event-transcript-1", + "event_type": "transcript.updated", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "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-1", + "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" + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/invalid-ai-authorization-revoked.json b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-ai-authorization-revoked.json new file mode 100644 index 0000000..48a591b --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-ai-authorization-revoked.json @@ -0,0 +1,12 @@ +{ + "authorization_id": "auth-invalid", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "revoked": true +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/invalid-asr-only-with-llm.json b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-asr-only-with-llm.json new file mode 100644 index 0000000..c09e0f2 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-asr-only-with-llm.json @@ -0,0 +1,19 @@ +{ + "agent_version_id": "agent_invalid", + "immutable": true, + "mode": "asr_only", + "llm": {"provider_ref": "mock", "model": "must-not-be-present"}, + "asr": { + "provider_ref": "mock", + "language": "zh-CN", + "input": {"encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2} + }, + "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-19-p1-v1/examples/invalid-event-unknown-type.json b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-event-unknown-type.json new file mode 100644 index 0000000..25476b8 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-event-unknown-type.json @@ -0,0 +1,13 @@ +{ + "schema_version": "1.0", + "event_id": "event-invalid-1", + "event_type": "call.transcript", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "trace_id": "trace-1", + "occurred_at": "2026-09-18T00:00:00Z", + "aggregate_type": "call", + "aggregate_id": "call-1", + "aggregate_version": 1, + "payload": {"text": "invalid alias"} +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/invalid-oss-upload-http.json b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-oss-upload-http.json new file mode 100644 index 0000000..42033e6 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/invalid-oss-upload-http.json @@ -0,0 +1,10 @@ +{ + "kind": "grant", + "upload_id": "upload-invalid", + "recording_id": "recording-1", + "upload_url": "http://oss.example.invalid/upload/upload-invalid", + "required_headers": {}, + "expires_at": "2026-09-18T00:05:00Z", + "max_bytes": 16777216, + "object_binding": "recording-1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/oss-upload-grant.json b/contracts/upstream/2026-09-19-p1-v1/examples/oss-upload-grant.json new file mode 100644 index 0000000..6b4ddb3 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/oss-upload-grant.json @@ -0,0 +1,10 @@ +{ + "kind": "grant", + "upload_id": "upload-1", + "recording_id": "recording-1", + "upload_url": "https://oss.example.invalid/upload/upload-1", + "required_headers": {"x-checksum-sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "expires_at": "2026-09-18T00:05:00Z", + "max_bytes": 16777216, + "object_binding": "recording-1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact-real-v1.json b/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact-real-v1.json new file mode 100644 index 0000000..6e2c7bb --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact-real-v1.json @@ -0,0 +1,95 @@ +{ + "artifact_id": "cell-single-real-v1", + "source_release": "asterisk-22.10.1-native-v1", + "source_digest": "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + "approval_reference": "project-auto-approved:2026-09-19:single-node-v1", + "cell_id": "cell-single", + "revision": 1, + "config_sha256": "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", + "mode": "real", + "trunks": [ + { + "trunk_id": "provider-primary", + "provider_id": "provider-primary", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-primary" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "provider-primary", + "credential_ref": "sip-provider-primary", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-second", + "provider_id": "provider-second", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-second" + ], + "dial_prefix": "", + "enabled": true, + "sip_endpoint_ref": "provider-second", + "credential_ref": "sip-provider-second", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-third", + "provider_id": "provider-third", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-third" + ], + "dial_prefix": "mka755", + "enabled": true, + "sip_endpoint_ref": "provider-third", + "credential_ref": "sip-provider-third", + "media_profile_id": "pcma-8k-pt8" + } + ], + "ari": { + "base_url": "https://127.0.0.1:8088/ari", + "websocket_url": "wss://127.0.0.1:8088/ari/events", + "application": "agent-call", + "credential_ref": "ari-single" + }, + "media": { + "bind_address": "127.0.0.1", + "port": 12000, + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "recording": { + "enabled": true, + "format": "wav", + "directory": "/var/lib/sip-go-agent/recordings", + "max_bytes": 67108864 + }, + "load_evidence": { + "status": "not-yet-loaded" + }, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact.json b/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact.json new file mode 100644 index 0000000..bc4c0b2 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact.json @@ -0,0 +1,45 @@ +{ + "artifact_id": "artifact-cell-a-1", + "source_release": "management-snapshot-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-a", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "trunks": [ + { + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "caller_profile_ids": [ + "caller_profile_test" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "mock-sip-endpoint", + "credential_ref": null, + "media_profile_id": "slin16-16k-pt118" + } + ], + "load_evidence": null, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/executor.openapi.yaml b/contracts/upstream/2026-09-19-p1-v1/executor.openapi.yaml new file mode 100644 index 0000000..a9f02fb --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/executor.openapi.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Executor Control API + version: 1.0.0 + description: >- + Internal control, query, replay, and recording hand-off API. Call execution + enters through RabbitMQ, not HTTP. +servers: + - url: https://executor.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/tasks/{task_id}/controls: + post: + operationId: controlTask + summary: Persist a pause, resume, or stop barrier + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlRequest' + responses: + '202': + description: Reliably persisted, not yet necessarily applied + headers: + Location: + schema: {type: string} + content: + application/json: + schema: {$ref: '#/components/schemas/ControlAccepted'} + '409': {$ref: '#/components/responses/Conflict'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/commands/{command_id}: + get: + operationId: getCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CommandId' + responses: + '200': + description: Command snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Command'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}: + get: + operationId: getCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CallId' + responses: + '200': + description: Call snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Call'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}/replays: + post: + operationId: replayCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CallId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} + /internal/v1/outbound/commands/{source_command_id}/replays: + post: + operationId: replayCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/SourceCommandId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + TaskId: + name: task_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CommandId: + name: command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + SourceCommandId: + name: source_command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CallId: + name: call_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + ControlRequest: + type: object + additionalProperties: false + required: [command_id, action, expected_task_revision, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + 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} + ControlAccepted: + type: object + required: + - command_id + - tenant_id + - tenant_key + - task_id + - status + - requested_task_revision + - accepted_at + properties: + command_id: {$ref: '#/components/schemas/Id'} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + requested_task_revision: {type: integer} + accepted_at: {type: string, format: date-time} + ReplayRequest: + type: object + additionalProperties: false + required: [command_id, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + reason: {type: string, minLength: 1, maxLength: 512} + ReplayAccepted: + type: object + required: [command_id, status, snapshot_cutoff] + properties: + command_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + snapshot_cutoff: {type: string, format: date-time} + Command: + type: object + required: + - command_id + - command_type + - tenant_id + - tenant_key + - status + - aggregate_version + properties: + command_id: {$ref: '#/components/schemas/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} + 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: {type: object}} + transcript: {type: object} + recordings: {type: array, items: {type: object}} + delivery: {type: object} + snapshot_at: {type: string, format: date-time} + Problem: + type: object + additionalProperties: false + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string, format: uri-reference} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + NotFound: + description: Not found without cross-tenant enumeration + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Conflict: + description: Idempotency or revision conflict + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + ReplayExpired: + description: Retention window expired + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} diff --git a/contracts/upstream/2026-09-19-p1-v1/mock-profile.json b/contracts/upstream/2026-09-19-p1-v1/mock-profile.json new file mode 100644 index 0000000..4a79b80 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/mock-profile.json @@ -0,0 +1,64 @@ +{ + "profile_version": "1.0.0", + "mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"}, + "limits": { + "admission_window_s": 30, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "max_mq_bytes": 262144, + "max_queue_messages": 1000, + "max_queue_bytes": 16777216, + "disk_warn_pct": 0.70, + "disk_stop_pct": 0.80, + "max_http_bytes": 65536, + "recording_max_bytes": 16777216, + "max_recording_bytes": 16777216, + "replay_retention_s": 604800, + "upload_ttl_s": 300, + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "tenant_publish_rate": 10, + "scheduler_lease_ttl_s": 10, + "pending_window_per_tenant": 16, + "pending_window_global": 64, + "max_unacked_per_tenant": 4, + "max_replay_attempts": 6, + "cell_capacity": 4, + "turns": 2, + "hold_ms": 0 + }, + "random_seed": 7, + "tenants": [ + {"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true}, + {"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true}, + {"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true} + ], + "tasks": [ + {"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1}, + {"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1}, + {"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1} + ], + "routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}], + "caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}], + "agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}], + "cells": [ + {"cell_id": "cell-a", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}, + {"cell_id": "cell-b", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"} + ], + "failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "customer_silent", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"] +} diff --git a/contracts/upstream/2026-09-19-p1-v1/mq-topology.md b/contracts/upstream/2026-09-19-p1-v1/mq-topology.md new file mode 100644 index 0000000..60f8501 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/mq-topology.md @@ -0,0 +1,40 @@ +# agent-call MQ topology v1.0 + +This file is an implementation companion to the field authority in +`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`. + +| Element | Value | +| --- | --- | +| Namespace | `agent-call` | +| Command exchange | `agent-call.commands.v1`, durable `direct` | +| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding | +| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` | +| Event exchange | `agent-call.events.v1`, durable `topic` | +| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` | +| Event routing key | `agent-call.{event_type}` | +| Body limit | `262144` UTF-8 bytes in the Mock profile | +| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes | + +## Delivery rules + +1. SaaS persists the command publication record before publishing. A mandatory + publisher confirmation is required; an unroutable/full queue leaves the + original record retained for bounded retry. +2. The executor consumes only its trusted tenant queue. RabbitMQ messages are + acknowledged after durable SQLite acceptance or durable dead-lettering, not + when they are fetched. +3. Executor business events are written to the same database transaction as + the state transition. The outbox dispatcher publishes them durably and the + SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown + after broker confirmation; it does not trigger unbounded republishing. +4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and + routing key. It is not normalized, encoded, truncated or cleaned. A route + over the byte budget is retained and not sent. +5. Replay publishes the original event body and original `event_id` from a + fixed retention cutoff. It never creates a new business fact and never + includes events written after that cutoff. +6. HTTP has no call execution or redial endpoint. Control, query, replay and + recording metadata paths require bearer scope and tenant scope. + +The in-process broker is only for deterministic tests. Docker Compose uses the +same topology through the `pika` adapter and RabbitMQ durable queues. diff --git a/contracts/upstream/2026-09-19-p1-v1/mq.schema.json b/contracts/upstream/2026-09-19-p1-v1/mq.schema.json new file mode 100644 index 0000000..b5ab065 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/mq.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}], + "$defs": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"}, + "tenantKey": {"type": "string", "minLength": 1}, + "time": {"type": "string", "format": "date-time"}, + "executeCommand": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "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": {"$ref": "#/$defs/executePayload"} + } + }, + "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} + } + }, + "event": { + "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"], + "properties": { + "schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"} + } + } + } +} diff --git a/contracts/upstream/2026-09-19-p1-v1/oss-upload.schema.json b/contracts/upstream/2026-09-19-p1-v1/oss-upload.schema.json new file mode 100644 index 0000000..1393b72 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/oss-upload.schema.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-19-p1-v1/p1-development-profile.json b/contracts/upstream/2026-09-19-p1-v1/p1-development-profile.json new file mode 100644 index 0000000..21b0db6 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/p1-development-profile.json @@ -0,0 +1,23 @@ +{ + "profile_id": "p1-mock-two-cell-v1", + "profile_version": "1.0.0", + "environment": "mock", + "external_calls": false, + "real_authorization": "blocked", + "topology": {"dispatcher_count": 1, "agent_count": 2, "cell_count": 2, "tenant_count": 1}, + "limits": { + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "pending_window_global": 64, + "pending_window_per_tenant": 16, + "lease_ttl_ms": 10000, + "final_permit_ttl_ms": 1000 + }, + "modes": ["full_ai", "asr_only"], + "retention": {"keep_unverified_assets": true, "delete_only_after_verified": true, "backup_kind": "local-sqlite-wal-consistent"}, + "status": "development-only-not-production-approval", + "real_budget": "unknown", + "operator_approval": "not-granted" +} diff --git a/contracts/upstream/2026-09-19-p1-v1/p1-development-profile.schema.json b/contracts/upstream/2026-09-19-p1-v1/p1-development-profile.schema.json new file mode 100644 index 0000000..adb8b5d --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/p1-development-profile.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/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-19-p1-v1/release-manifest.json b/contracts/upstream/2026-09-19-p1-v1/release-manifest.json new file mode 100644 index 0000000..780715c --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/release-manifest.json @@ -0,0 +1,205 @@ +{ + "release_version": "2026-09-19-p1-v1", + "release_kind": "project-owned-first-production-closure-v1", + "contract_status": "project-owned-v1-authorized-not-external-authoritative", + "source_bundle": "2026-09-18-p1-baseline", + "source_worktree": "project-authored-v1", + "runtime_dependency": "none (bundle is embedded; parent paths are never read at runtime)", + "files": [ + { + "path": "README.md", + "bytes": 1531, + "sha256": "3332e4c71021189b03f3031106c08b3c9c08eb2f9bb1a4f4b0a96fe1598ceaba" + }, + { + "path": "SNAPSHOT.json", + "bytes": 948, + "sha256": "a41adc5ec329a9010cc45d08f309eb3620e0d6311a5c565a9f26a35ffc4ecb42" + }, + { + "path": "ai-authorization.schema.json", + "bytes": 1749, + "sha256": "5d6bbd369bd6b406abe9b9f8619e6f299b544dacfdd4473a08257a366fd95d20" + }, + { + "path": "ai-config.openapi.yaml", + "bytes": 4516, + "sha256": "d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a" + }, + { + "path": "ai-config.schema.json", + "bytes": 4797, + "sha256": "dc915bff70e71408fcacbaad47d3554bbfa7a22a51f2f53e127c6bd8bc8ba5a6" + }, + { + "path": "cell-agent.openapi.yaml", + "bytes": 7633, + "sha256": "79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41" + }, + { + "path": "event-payloads.schema.json", + "bytes": 8683, + "sha256": "5091ded520d692f458699327b13adddeccb0c4b4d4a72ef7cdfebcd5d496eade" + }, + { + "path": "examples/README.md", + "bytes": 728, + "sha256": "a3514c7ef89b4324685bc9025139488000fa42330de72495484add042a6897cf" + }, + { + "path": "examples/agent-version-asr-only.json", + "bytes": 534, + "sha256": "24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b" + }, + { + "path": "examples/agent-version-full-explicit.json", + "bytes": 1051, + "sha256": "e590148448885a1e88c473ca032d5efd719270689c3cff7aa44ca9c6ffa2b321" + }, + { + "path": "examples/agent-version-full-production-v1.json", + "bytes": 1316, + "sha256": "6a5f35f8cc4256c048d4f1ca567a3544ea7bc92bb835cfab39a000d22b73102b" + }, + { + "path": "examples/agent-version.json", + "bytes": 1079, + "sha256": "d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e" + }, + { + "path": "examples/ai-authorization.json", + "bytes": 467, + "sha256": "b1c0f723f083d1be45035792482f6f74bd3bca34486cc6408d4bcfe3772bc432" + }, + { + "path": "examples/call.execute.json", + "bytes": 656, + "sha256": "0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742" + }, + { + "path": "examples/event-call-finished.json", + "bytes": 783, + "sha256": "fab56aff9c5d4059b8fa65d24f2afa424148e7a07025238fc7fd30e9c5ee63f4" + }, + { + "path": "examples/event-call-status.json", + "bytes": 752, + "sha256": "5c673ceb4a98ce8c90d976356b4c5ffa6c7d96d045b5b3312513a9be93d585ba" + }, + { + "path": "examples/event-command-result.json", + "bytes": 498, + "sha256": "64fe71719573378b0caac2f7c5476a27ba7073df8c346d4748a6b0d534d4a087" + }, + { + "path": "examples/event-contact-opt-out.json", + "bytes": 513, + "sha256": "b661f65f0a195e59fe226b12ebae2feb5a9733a9a779c835245be27319aaf8f2" + }, + { + "path": "examples/event-recording-failed.json", + "bytes": 546, + "sha256": "4f5f46422d2281ffae65c99e947bc7dd44effb5e94eacc12e41b3250ac645c34" + }, + { + "path": "examples/event-recording-ready.json", + "bytes": 648, + "sha256": "2b6d731a8d7993cbbe07a5ba8c7413ec56bae8a1538476c0f1740133a488d5b4" + }, + { + "path": "examples/event-transcript-failed.json", + "bytes": 499, + "sha256": "58d000ce27de6e67054f66d4ded34ffafa983bdcced12613051f670d1eefeed8" + }, + { + "path": "examples/event-transcript-updated.json", + "bytes": 596, + "sha256": "115f276522631d7c4decee71ee238aa0d00b4256adedfc9a6a0c5f9dc89f64f6" + }, + { + "path": "examples/invalid-ai-authorization-revoked.json", + "bytes": 373, + "sha256": "0b946b43c1f773391791dfb60a2b2a03c8a69e64e5e9ea30af8591850c34b8f3" + }, + { + "path": "examples/invalid-asr-only-with-llm.json", + "bytes": 529, + "sha256": "7343ce8d6ce63e22128ab7bc721544d681a5dd9eccfc4ab4f9d6181c5748824c" + }, + { + "path": "examples/invalid-event-unknown-type.json", + "bytes": 348, + "sha256": "80c3f89dc775deec9d1a3b85c81ea4b2fd1b030bc4deba1a4dcd2823e4eb8024" + }, + { + "path": "examples/invalid-oss-upload-http.json", + "bytes": 347, + "sha256": "8e4fb4344c4bec51b47ff9b00a63afdc84d09ab827e1b6823c4e576bf6b6a3f1" + }, + { + "path": "examples/oss-upload-grant.json", + "bytes": 423, + "sha256": "268751a1a0d238636c04c004a635e3bda702bc2ef90ade0e5846b47a3c263b5d" + }, + { + "path": "examples/static-cell-artifact-real-v1.json", + "bytes": 2526, + "sha256": "b4a001457e747a039677870a3ddeccc902b8f87819c3ce2d2e37ae6a14f1ffe0" + }, + { + "path": "examples/static-cell-artifact.json", + "bytes": 1147, + "sha256": "129821e4de654ef12d3e7f155e9301ecddf798f9ca3399316a20a55273fbc61f" + }, + { + "path": "executor.openapi.yaml", + "bytes": 10244, + "sha256": "b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329" + }, + { + "path": "mock-profile.json", + "bytes": 2549, + "sha256": "4d43097602fed9a68821e765117580706896ac82d4bce361a80a4cea544ab638" + }, + { + "path": "mq-topology.md", + "bytes": 2211, + "sha256": "a85b26596e1b1db7405dcabc967df56d5eb6713cc9908bc05ecaa2075ff1092f" + }, + { + "path": "mq.schema.json", + "bytes": 2953, + "sha256": "4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a" + }, + { + "path": "oss-upload.schema.json", + "bytes": 3039, + "sha256": "d3f6ffc5e004fba8acbb6a18495be508a63ec45766bab1ef946ece58bbad84de" + }, + { + "path": "p1-development-profile.json", + "bytes": 789, + "sha256": "2aa7cd3f4fa07f7e1a3037107fa7a56183f28370ea06a2a9b8dc18c8790eee2d" + }, + { + "path": "p1-development-profile.schema.json", + "bytes": 2460, + "sha256": "68e1b46194a1f5fa5bc763ba424411f48ada1ab52d39e6f800315a832692794b" + }, + { + "path": "saas.openapi.yaml", + "bytes": 5750, + "sha256": "368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb" + }, + { + "path": "sip-management.openapi.yaml", + "bytes": 38240, + "sha256": "5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281" + }, + { + "path": "static-cell-artifact.schema.json", + "bytes": 5156, + "sha256": "46333f6a161ebbfd42f4a326e2509d562164fe836e6d1c88368428795362138d" + } + ] +} diff --git a/contracts/upstream/2026-09-19-p1-v1/saas.openapi.yaml b/contracts/upstream/2026-09-19-p1-v1/saas.openapi.yaml new file mode 100644 index 0000000..e3f47c2 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/saas.openapi.yaml @@ -0,0 +1,169 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call SaaS Recording Handoff API + version: 1.0.0 + description: >- + Internal storage handshake. Business results still return through RabbitMQ. +servers: + - url: https://saas.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/recording-uploads: + post: + operationId: createRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UploadRequest'} + responses: + '201': + description: Upload session created or existing session returned + headers: {Cache-Control: {schema: {const: no-store}}} + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '200': + description: Existing upload session + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/v1/outbound/recording-uploads/{upload_id}/complete: + post: + operationId: completeRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - name: upload_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CompleteRequest'} + responses: + '200': + description: Object independently verified + content: + application/json: + schema: {$ref: '#/components/schemas/VerifiedUpload'} + '409': {$ref: '#/components/responses/Conflict'} + '410': {$ref: '#/components/responses/Expired'} + '422': {$ref: '#/components/responses/Unprocessable'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + UploadRequest: + type: object + additionalProperties: false + required: + - recording_id + - call_id + - content_type + - size_bytes + - checksum_algorithm + - checksum + - channels + - sample_rate_hz + - duration_ms + properties: + recording_id: {$ref: '#/components/schemas/Id'} + call_id: {$ref: '#/components/schemas/Id'} + content_type: {type: string, const: audio/wav} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {type: string, const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + channels: {type: integer, const: 1} + sample_rate_hz: {type: integer, minimum: 8000} + duration_ms: {type: integer, minimum: 1} + UploadSession: + type: object + required: + - upload_id + - recording_id + - expires_at + - upload_method + - upload_url + - required_headers + - constraints + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + expires_at: {type: string, format: date-time} + upload_method: {const: PUT} + upload_url: {type: string, format: uri} + required_headers: {type: object} + constraints: {type: object} + oss_id: {type: [string, 'null']} + CompleteRequest: + type: object + additionalProperties: false + required: [recording_id, size_bytes, checksum_algorithm, checksum] + properties: + recording_id: {$ref: '#/components/schemas/Id'} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + etag: {type: [string, 'null']} + VerifiedUpload: + type: object + required: [upload_id, recording_id, status, oss_id, verified_at] + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + status: {const: verified} + oss_id: {type: string} + verified_at: {type: string, format: date-time} + Problem: + type: object + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: {description: Unauthorized} + Forbidden: {description: Forbidden} + Conflict: {description: Idempotency conflict} + Expired: {description: Upload expired} + Unprocessable: {description: Object failed independent verification} diff --git a/contracts/upstream/2026-09-19-p1-v1/sip-management.openapi.yaml b/contracts/upstream/2026-09-19-p1-v1/sip-management.openapi.yaml new file mode 100644 index 0000000..28fee2b --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/sip-management.openapi.yaml @@ -0,0 +1,1125 @@ +--- +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-providers + - name: admin-trunks + - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} + - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + CellIdsFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - latest_revision + - active_revision + - active_status + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/contracts/upstream/2026-09-19-p1-v1/static-cell-artifact.schema.json b/contracts/upstream/2026-09-19-p1-v1/static-cell-artifact.schema.json new file mode 100644 index 0000000..e365a72 --- /dev/null +++ b/contracts/upstream/2026-09-19-p1-v1/static-cell-artifact.schema.json @@ -0,0 +1,102 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/2026-09-19-p1-v1/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/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.openapi.yaml b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.openapi.yaml new file mode 100644 index 0000000..e310606 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.openapi.yaml @@ -0,0 +1,145 @@ +openapi: 3.1.0 +info: + title: agent-call immutable AI configuration API + version: 1.0.0 + description: >- + Internal configuration publication/read surface. The call.execute business + command remains RabbitMQ-only; secrets, URLs, and provider credentials are + resolved by the execution environment and never enter MQ messages. +servers: + - url: / +paths: + /internal/v1/ai/agent-versions: + post: + operationId: publishAgentVersion + security: + - aiConfigPublish: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionPublishRequest' + responses: + '200': + description: Identical immutable content already exists + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '201': + description: Immutable version published + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Existing version has different content + /internal/v1/ai/agent-versions/{agent_version_id}: + get: + operationId: getAgentVersion + security: + - aiConfigRead: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - name: agent_version_id + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + responses: + '200': + description: Trusted immutable snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Agent version not found +components: + parameters: + TenantId: + name: X-Tenant-Id + in: header + required: true + schema: {type: string, minLength: 1} + RequestId: + name: X-Request-Id + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + securitySchemes: + aiConfigPublish: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.publish and the AI-config issuer/audience. + aiConfigRead: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.read and the AI-config issuer/audience. + schemas: + AgentVersionPublishRequest: + type: object + additionalProperties: false + required: [agent_version_id, config] + properties: + agent_version_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + config: + $ref: 'ai-config.schema.json' + AgentVersionReceipt: + type: object + required: [tenant_id, agent_version_id, status, immutable, content_sha256] + 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}$'} + AgentVersion: + allOf: + - $ref: '#/components/schemas/AgentVersionReceipt' + - type: object + required: [config] + properties: + config: + $ref: 'ai-config.schema.json' + created_at: {type: string, format: date-time} + published_at: {type: string, format: date-time} + created_by: {type: string} + Error: + type: object + required: [error] + properties: + error: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid configuration + content: + application/json: + schema: {$ref: '#/components/schemas/Error'} + Unauthorized: + description: Missing or invalid AI-config token + Forbidden: + description: Token lacks the AI-config permission diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.schema.json b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.schema.json new file mode 100644 index 0000000..e4d2797 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/ai-config.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": ["agent_version_id", "immutable", "llm", "prompt", "tts", "asr", "conversation"], + "properties": { + "agent_version_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}, + "immutable": {"const": true}, + "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": ["opening", "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} + } +} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/cell-agent.openapi.yaml b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/cell-agent.openapi.yaml new file mode 100644 index 0000000..f929a1b --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/cell-agent.openapi.yaml @@ -0,0 +1,225 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: Revision is stale or has a gap + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: [mode, cell_id, trunk_id, revision, config, config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/README.md b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/README.md new file mode 100644 index 0000000..02f9c1a --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/README.md @@ -0,0 +1,5 @@ +# Contract fixtures + +`call.execute.json` is the canonical valid command fixture. Runtime tests generate the remaining event fixtures from persisted facts so replay assertions compare original bytes and IDs rather than synthesized history. Invalid cases include unknown schema versions, missing required fields, cross-tenant bindings, conflicting idempotency bodies, and tenant routing keys over 224 UTF-8 bytes. + +All fixtures are synthetic. The profile is `mock`; it is never a production provider configuration. diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/agent-version.json b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/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/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/call.execute.json b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/call.execute.json new file mode 100644 index 0000000..23f9fdc --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/examples/call.execute.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": "cmd_demo_001", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "trace_id": "trace_demo_001", + "issued_at": "2026-09-11T08:00:00Z", + "not_after": "2099-09-11T08:05:00Z", + "payload": { + "execution_id": "exec_demo_001", + "task_id": "task-demo", + "task_item_id": "item_demo", + "task_revision": 1, + "callee": "18601013734", + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": "agent_v1", + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000 + } +} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/executor.openapi.yaml b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/executor.openapi.yaml new file mode 100644 index 0000000..a9f02fb --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/executor.openapi.yaml @@ -0,0 +1,303 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Executor Control API + version: 1.0.0 + description: >- + Internal control, query, replay, and recording hand-off API. Call execution + enters through RabbitMQ, not HTTP. +servers: + - url: https://executor.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/tasks/{task_id}/controls: + post: + operationId: controlTask + summary: Persist a pause, resume, or stop barrier + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/TaskId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ControlRequest' + responses: + '202': + description: Reliably persisted, not yet necessarily applied + headers: + Location: + schema: {type: string} + content: + application/json: + schema: {$ref: '#/components/schemas/ControlAccepted'} + '409': {$ref: '#/components/responses/Conflict'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/commands/{command_id}: + get: + operationId: getCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CommandId' + responses: + '200': + description: Command snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Command'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}: + get: + operationId: getCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/CallId' + responses: + '200': + description: Call snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Call'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /internal/v1/outbound/calls/{call_id}/replays: + post: + operationId: replayCall + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/CallId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} + /internal/v1/outbound/commands/{source_command_id}/replays: + post: + operationId: replayCommand + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - $ref: '#/components/parameters/SourceCommandId' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayRequest'} + responses: + '202': + description: Replay persisted for bounded broker delivery + headers: + Location: {schema: {type: string}} + content: + application/json: + schema: {$ref: '#/components/schemas/ReplayAccepted'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + '410': {$ref: '#/components/responses/ReplayExpired'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + TaskId: + name: task_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CommandId: + name: command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + SourceCommandId: + name: source_command_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + CallId: + name: call_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + ControlRequest: + type: object + additionalProperties: false + required: [command_id, action, expected_task_revision, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + 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} + ControlAccepted: + type: object + required: + - command_id + - tenant_id + - tenant_key + - task_id + - status + - requested_task_revision + - accepted_at + properties: + command_id: {$ref: '#/components/schemas/Id'} + tenant_id: {type: string} + tenant_key: {type: string} + task_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + requested_task_revision: {type: integer} + accepted_at: {type: string, format: date-time} + ReplayRequest: + type: object + additionalProperties: false + required: [command_id, reason] + properties: + command_id: {$ref: '#/components/schemas/Id'} + reason: {type: string, minLength: 1, maxLength: 512} + ReplayAccepted: + type: object + required: [command_id, status, snapshot_cutoff] + properties: + command_id: {$ref: '#/components/schemas/Id'} + status: {const: accepted} + snapshot_cutoff: {type: string, format: date-time} + Command: + type: object + required: + - command_id + - command_type + - tenant_id + - tenant_key + - status + - aggregate_version + properties: + command_id: {$ref: '#/components/schemas/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} + 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: {type: object}} + transcript: {type: object} + recordings: {type: array, items: {type: object}} + delivery: {type: object} + snapshot_at: {type: string, format: date-time} + Problem: + type: object + additionalProperties: false + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string, format: uri-reference} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: + description: Unauthorized + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Forbidden: + description: Forbidden + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + NotFound: + description: Not found without cross-tenant enumeration + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + Conflict: + description: Idempotency or revision conflict + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} + ReplayExpired: + description: Retention window expired + content: + application/problem+json: + schema: {$ref: '#/components/schemas/Problem'} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mock-profile.json b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mock-profile.json new file mode 100644 index 0000000..4a79b80 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mock-profile.json @@ -0,0 +1,64 @@ +{ + "profile_version": "1.0.0", + "mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "versions": {"schema": "1.0", "service": "0.1.0", "seed": "mock-2026-09-11"}, + "limits": { + "admission_window_s": 30, + "ring_timeout_ms": 30000, + "max_call_duration_ms": 180000, + "max_mq_bytes": 262144, + "max_queue_messages": 1000, + "max_queue_bytes": 16777216, + "disk_warn_pct": 0.70, + "disk_stop_pct": 0.80, + "max_http_bytes": 65536, + "recording_max_bytes": 16777216, + "max_recording_bytes": 16777216, + "replay_retention_s": 604800, + "upload_ttl_s": 300, + "global_concurrency": 6, + "global_cps": 3, + "tenant_concurrency": 2, + "tenant_cps": 1, + "tenant_publish_rate": 10, + "scheduler_lease_ttl_s": 10, + "pending_window_per_tenant": 16, + "pending_window_global": 64, + "max_unacked_per_tenant": 4, + "max_replay_attempts": 6, + "cell_capacity": 4, + "turns": 2, + "hold_ms": 0 + }, + "random_seed": 7, + "tenants": [ + {"tenant_id": "tenant-demo", "tenant_key": "tenant-demo-key", "enabled": true}, + {"tenant_id": "tenant-b", "tenant_key": "tenant.b", "enabled": true}, + {"tenant_id": "tenant-c", "tenant_key": "tenant#c", "enabled": true} + ], + "tasks": [ + {"task_id": "task-demo", "tenant_id": "tenant-demo", "state": "running", "revision": 1}, + {"task_id": "task-b", "tenant_id": "tenant-b", "state": "running", "revision": 1}, + {"task_id": "task-c", "tenant_id": "tenant-c", "state": "running", "revision": 1} + ], + "routes": [{"route_policy_id": "route_policy_test", "trunk_id": "trunk-mock", "egress_pool_id": "egress-mock", "dial_prefix": "7089", "allowed": true}], + "caller_profiles": [{"caller_profile_id": "caller_profile_test", "display": "BD93205882", "allowed": true}], + "agents": [{"agent_version_id": "agent_v1", "immutable": true, "llm": "mock", "tts": "mock", "asr": "mock"}], + "cells": [ + {"cell_id": "cell-a", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"}, + {"cell_id": "cell-b", "capacity": 4, "media_capacity": 4, "ai_capacity": 4, "egress_pool_id": "egress-mock", "ari_mode": "mock"} + ], + "failure_scenarios": ["success", "busy", "no_answer", "ai_timeout", "customer_silent", "ari_disconnect", "upload_missing", "upload_bad_checksum", "broker_outage", "clock_jump"] +} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq-topology.md b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq-topology.md new file mode 100644 index 0000000..60f8501 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq-topology.md @@ -0,0 +1,40 @@ +# agent-call MQ topology v1.0 + +This file is an implementation companion to the field authority in +`SaaS交互_OpenAPI与MQ契约规划_v0.1.md`. + +| Element | Value | +| --- | --- | +| Namespace | `agent-call` | +| Command exchange | `agent-call.commands.v1`, durable `direct` | +| Tenant command queue | `agent-call.executor.{tenant_key}.v1`, durable, one exact binding | +| Command routing key | `agent-call.tenant.{tenant_key}.call.execute` | +| Event exchange | `agent-call.events.v1`, durable `topic` | +| SaaS result queue | `agent-call.saas.events.v1`, durable, binding `agent-call.#` | +| Event routing key | `agent-call.{event_type}` | +| Body limit | `262144` UTF-8 bytes in the Mock profile | +| Tenant route budget | Broker limit `255` bytes; fixed prefix/suffix consume `31`, leaving `224` UTF-8 bytes | + +## Delivery rules + +1. SaaS persists the command publication record before publishing. A mandatory + publisher confirmation is required; an unroutable/full queue leaves the + original record retained for bounded retry. +2. The executor consumes only its trusted tenant queue. RabbitMQ messages are + acknowledged after durable SQLite acceptance or durable dead-lettering, not + when they are fetched. +3. Executor business events are written to the same database transaction as + the state transition. The outbox dispatcher publishes them durably and the + SaaS inbox applies each `event_id` once. `saas_applied` may remain unknown + after broker confirmation; it does not trigger unbounded republishing. +4. `tenant_key` is copied byte-for-byte into the body, queue name, binding and + routing key. It is not normalized, encoded, truncated or cleaned. A route + over the byte budget is retained and not sent. +5. Replay publishes the original event body and original `event_id` from a + fixed retention cutoff. It never creates a new business fact and never + includes events written after that cutoff. +6. HTTP has no call execution or redial endpoint. Control, query, replay and + recording metadata paths require bearer scope and tenant scope. + +The in-process broker is only for deterministic tests. Docker Compose uses the +same topology through the `pika` adapter and RabbitMQ durable queues. diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq.schema.json b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq.schema.json new file mode 100644 index 0000000..b5ab065 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/mq.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [{"$ref": "#/$defs/executeCommand"}, {"$ref": "#/$defs/event"}], + "$defs": { + "id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[^\\s/\\\\]+$"}, + "tenantKey": {"type": "string", "minLength": 1}, + "time": {"type": "string", "format": "date-time"}, + "executeCommand": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "command_type", "command_id", "tenant_id", "tenant_key", "trace_id", "issued_at", "not_after", "payload"], + "properties": { + "schema_version": {"const": "1.0"}, "command_type": {"const": "call.execute"}, "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": {"$ref": "#/$defs/executePayload"} + } + }, + "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} + } + }, + "event": { + "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"], + "properties": { + "schema_version": {"const": "1.0"}, "event_id": {"$ref": "#/$defs/id"}, + "event_type": {"enum": ["command.result", "call.status", "transcript.updated", "call.finished", "recording.ready", "recording.failed", "transcript.failed", "contact.opt_out"]}, + "tenant_id": {"$ref": "#/$defs/id"}, "tenant_key": {"$ref": "#/$defs/tenantKey"}, "trace_id": {"$ref": "#/$defs/id"}, "occurred_at": {"$ref": "#/$defs/time"}, + "aggregate_type": {"enum": ["command", "call", "transcript_segment", "recording"]}, "aggregate_id": {"$ref": "#/$defs/id"}, "aggregate_version": {"type": "integer", "minimum": 1}, "payload": {"type": "object"} + } + } + } +} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/saas.openapi.yaml b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/saas.openapi.yaml new file mode 100644 index 0000000..e3f47c2 --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/saas.openapi.yaml @@ -0,0 +1,169 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call SaaS Recording Handoff API + version: 1.0.0 + description: >- + Internal storage handshake. Business results still return through RabbitMQ. +servers: + - url: https://saas.internal +security: + - bearerAuth: [] +paths: + /internal/v1/outbound/recording-uploads: + post: + operationId: createRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UploadRequest'} + responses: + '201': + description: Upload session created or existing session returned + headers: {Cache-Control: {schema: {const: no-store}}} + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '200': + description: Existing upload session + content: + application/json: + schema: {$ref: '#/components/schemas/UploadSession'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /internal/v1/outbound/recording-uploads/{upload_id}/complete: + post: + operationId: completeRecordingUpload + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - $ref: '#/components/parameters/IdempotencyKey' + - name: upload_id + in: path + required: true + schema: {$ref: '#/components/schemas/Id'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CompleteRequest'} + responses: + '200': + description: Object independently verified + content: + application/json: + schema: {$ref: '#/components/schemas/VerifiedUpload'} + '409': {$ref: '#/components/responses/Conflict'} + '410': {$ref: '#/components/responses/Expired'} + '422': {$ref: '#/components/responses/Unprocessable'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer} + parameters: + TenantId: + name: X-Tenant-ID + in: header + required: true + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string} + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: {type: string} + schemas: + Id: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[^\s/\\]+$' + UploadRequest: + type: object + additionalProperties: false + required: + - recording_id + - call_id + - content_type + - size_bytes + - checksum_algorithm + - checksum + - channels + - sample_rate_hz + - duration_ms + properties: + recording_id: {$ref: '#/components/schemas/Id'} + call_id: {$ref: '#/components/schemas/Id'} + content_type: {type: string, const: audio/wav} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {type: string, const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + channels: {type: integer, const: 1} + sample_rate_hz: {type: integer, minimum: 8000} + duration_ms: {type: integer, minimum: 1} + UploadSession: + type: object + required: + - upload_id + - recording_id + - expires_at + - upload_method + - upload_url + - required_headers + - constraints + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + expires_at: {type: string, format: date-time} + upload_method: {const: PUT} + upload_url: {type: string, format: uri} + required_headers: {type: object} + constraints: {type: object} + oss_id: {type: [string, 'null']} + CompleteRequest: + type: object + additionalProperties: false + required: [recording_id, size_bytes, checksum_algorithm, checksum] + properties: + recording_id: {$ref: '#/components/schemas/Id'} + size_bytes: {type: integer, minimum: 1} + checksum_algorithm: {const: SHA-256} + checksum: {type: string, pattern: '^[0-9a-f]{64}$'} + etag: {type: [string, 'null']} + VerifiedUpload: + type: object + required: [upload_id, recording_id, status, oss_id, verified_at] + properties: + upload_id: {$ref: '#/components/schemas/Id'} + recording_id: {$ref: '#/components/schemas/Id'} + status: {const: verified} + oss_id: {type: string} + verified_at: {type: string, format: date-time} + Problem: + type: object + required: [type, title, status, code, detail, request_id, retryable] + properties: + type: {type: string} + title: {type: string} + status: {type: integer} + code: {type: string} + detail: {type: string} + request_id: {type: string} + retryable: {type: boolean} + responses: + Unauthorized: {description: Unauthorized} + Forbidden: {description: Forbidden} + Conflict: {description: Idempotency conflict} + Expired: {description: Upload expired} + Unprocessable: {description: Object failed independent verification} diff --git a/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/sip-management.openapi.yaml b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/sip-management.openapi.yaml new file mode 100644 index 0000000..28fee2b --- /dev/null +++ b/contracts/upstream/758c0a29e632cfbaaf12b9f6a7242ed130e62807/sip-management.openapi.yaml @@ -0,0 +1,1125 @@ +--- +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-providers + - name: admin-trunks + - name: admin-cells + - name: admin-status + - name: admin-statistics + - name: admin-audit + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/providers: + get: + tags: [admin-providers] + operationId: listProviders + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider directory + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderList'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/providers/{provider_id}: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-providers] + operationId: getProvider + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-providers] + operationId: upsertProvider + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderInput'} + responses: + '200': + description: Updated provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '201': + description: Created provider + content: + application/json: + schema: {$ref: '#/components/schemas/ProviderResponse'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/TrunkStatusFilter'} + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/validate: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: validateTrunk + security: [{SipAdminBearer: []}] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + revision: {type: integer, minimum: 1} + responses: + '200': + description: Validation issues, compatible Cells, and impact preview + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/verifications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: addTrunkVerification + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerificationRequest' + responses: + '200': + description: Versioned verification record + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-cells] + operationId: getCell + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/cells/{cell_id}/observations: + parameters: + - {$ref: '#/components/parameters/CellId'} + post: + tags: [admin-cells] + operationId: ingestCellObservation + security: [{SipAdminBearer: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ObservationInput'} + responses: + '201': + description: >- + Observation accepted for the current boot and monotonic sequence + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/egress-pools: + get: + tags: [admin-cells] + operationId: listEgressPools + security: [{SipAdminBearer: []}] + responses: + '200': + description: Fixed egress pool directory + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/sip-status: + get: + tags: [admin-status] + operationId: getSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/CellIdsFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/ProviderFilter'} + responses: + '200': + description: >- + Cell/trunk status matrix with freshness and missing sources + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}/sip-status: + parameters: + - {$ref: '#/components/parameters/CellId'} + get: + tags: [admin-status] + operationId: getCellSipStatus + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/TrunkFilter'} + responses: + '200': + description: One Cell status + content: + application/json: + schema: {$ref: '#/components/schemas/StatusItem'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/providers/{provider_id}/status: + parameters: + - {$ref: '#/components/parameters/ProviderId'} + get: + tags: [admin-status] + operationId: getProviderStatus + security: [{SipAdminBearer: []}] + responses: + '200': + description: Provider status matrix + content: + application/json: + schema: {$ref: '#/components/schemas/StatusResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/statistics/outbound/summary: + get: + tags: [admin-statistics] + operationId: getOutboundSummary + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Cohort and interval metrics with completeness metadata + content: + application/json: + schema: {$ref: '#/components/schemas/StatisticsSummary'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/statistics/outbound/timeseries: + get: + tags: [admin-statistics] + operationId: getOutboundTimeseries + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/Granularity'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Bounded UTC time series + content: + application/json: + schema: {$ref: '#/components/schemas/TimeseriesResponse'} + '401': {$ref: '#/components/responses/Unauthorized'} + '422': {$ref: '#/components/responses/BadRequest'} + /admin/v1/call-attempts: + get: + tags: [admin-statistics] + operationId: listCallAttempts + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/From'} + - {$ref: '#/components/parameters/To'} + - {$ref: '#/components/parameters/StatsMode'} + - {$ref: '#/components/parameters/ProviderFilter'} + - {$ref: '#/components/parameters/TrunkFilter'} + - {$ref: '#/components/parameters/CellIdFilter'} + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/Cursor'} + - {$ref: '#/components/parameters/EgressFilter'} + responses: + '200': + description: Redacted raw attempt facts + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/call-attempts/{attempt_id}: + parameters: + - name: attempt_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-statistics] + operationId: getCallAttempt + security: [{SipAdminBearer: []}] + responses: + '200': + description: One redacted attempt fact + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/audit: + get: + tags: [admin-audit] + operationId: listAudit + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/Limit'} + - {$ref: '#/components/parameters/ResourceFilter'} + - {$ref: '#/components/parameters/RequestFilter'} + - {$ref: '#/components/parameters/ActorFilter'} + responses: + '200': + description: Immutable audit entries + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/operations/{operation_id}: + parameters: + - name: operation_id + in: path + required: true + schema: {type: string} + get: + tags: [admin-audit] + operationId: getOperation + security: [{SipAdminBearer: []}] + responses: + '200': + description: Durable operation state for retry/recovery + content: + application/json: + schema: {type: object} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + ProviderId: + name: provider_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + CellIdsFilter: + name: cell_ids + in: query + required: false + schema: {type: string} + CellIdFilter: + name: cell_id + in: query + required: false + schema: {type: string} + ProviderFilter: + name: provider_id + in: query + required: false + schema: {type: string} + TrunkFilter: + name: trunk_id + in: query + required: false + schema: {type: string} + TrunkStatusFilter: + name: status + in: query + required: false + schema: {type: string} + ResourceFilter: + name: resource_id + in: query + required: false + schema: {type: string} + ActorFilter: + name: actor + in: query + required: false + schema: {type: string} + From: + name: from + in: query + required: false + schema: {type: string, format: date-time} + To: + name: to + in: query + required: false + schema: {type: string, format: date-time} + StatsMode: + name: mode + in: query + required: false + schema: {type: string, enum: [mock, mixed, real]} + EgressFilter: + name: egress_pool_id + in: query + required: false + schema: {type: string} + Granularity: + name: granularity + in: query + required: false + schema: {type: string, enum: [minute, hour, day]} + Limit: + name: limit + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200} + Cursor: + name: cursor + in: query + required: false + schema: {type: string} + RequestFilter: + name: request_id + in: query + required: false + schema: {type: string} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + StatsMode: + type: string + enum: [mock, mixed, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + VerificationRequest: + type: object + additionalProperties: false + required: [revision, check_name, result] + properties: + revision: {type: integer, minimum: 1} + check_name: + type: string + enum: + - transport + - registration_auth + - caller_id_rules + - codec + - capacity + - whitelist + result: + type: string + enum: [confirmed, failed, unknown, not_applicable] + evidence_ref: {type: string, maxLength: 512} + checked_by: {type: string, maxLength: 128} + TrunkConfig: + type: object + additionalProperties: false + required: + - provider_id + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + provider_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - latest_revision + - active_revision + - active_status + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + active_status: {type: string, enum: [draft, published, disabled]} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + config_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - provider_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + provider_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + cloud_instance_id: {type: string} + instance_name: {type: string} + region: {type: string} + boot_id: {type: string} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + applied_at: {type: [string, 'null'], format: date-time} + target_digest: {type: string} + local_revision: {type: integer, minimum: 0} + local_digest: {type: string} + operation_id: {type: string} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ProviderInput: + type: object + additionalProperties: false + required: [display_name, lifecycle] + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + notes: {type: string, maxLength: 2000} + lifecycle: {type: string, enum: [active, archived]} + Provider: + allOf: + - {$ref: '#/components/schemas/ProviderInput'} + - type: object + required: [provider_id, revision, trunk_count, created_at, updated_at] + properties: + provider_id: {type: string} + revision: {type: integer, minimum: 1} + trunk_count: {type: integer, minimum: 0} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + ProviderList: + type: object + required: [mode, providers] + properties: + mode: {$ref: '#/components/schemas/Mode'} + providers: {type: array, items: {$ref: '#/components/schemas/Provider'}} + ProviderResponse: + type: object + required: [mode, provider] + properties: + mode: {$ref: '#/components/schemas/Mode'} + provider: {$ref: '#/components/schemas/Provider'} + ObservationInput: + type: object + additionalProperties: false + required: [cell_id, boot_id, sequence, observed_at, source, states] + properties: + observation_id: {type: string} + cell_id: {type: string} + trunk_id: {type: string} + boot_id: {type: string, minLength: 1} + sequence: {type: integer, minimum: 1} + observed_at: {type: string, format: date-time} + source: {type: string} + config_revision: {type: integer, minimum: 0} + states: {type: object} + occupancy: {type: object} + sample_id: {type: string} + StatusItem: + type: object + required: [mode, cell_id, availability, complete, missing_sources] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + availability: {type: string, enum: [healthy, stale, unknown, disabled]} + complete: {type: boolean} + observed_at: {type: [string, 'null'], format: date-time} + received_at: {type: [string, 'null'], format: date-time} + observation_age_seconds: {type: [integer, 'null'], minimum: 0} + clock_skew: {type: boolean} + reason: {type: string} + boot_id: {type: string} + sequence: {type: integer, minimum: 1} + config_revision: {type: integer, minimum: 1} + config_status: {type: string} + egress_pool_id: {type: string} + eligibility: {type: string} + missing_sources: {type: array, items: {type: string}} + states: {type: object} + occupancy: {type: object} + trunks: {type: array, items: {type: object}} + publication: {type: object} + StatusResponse: + type: object + required: [mode, complete, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + complete: {type: boolean} + generated_at: {type: string, format: date-time} + data_as_of: {type: [string, 'null'], format: date-time} + coverage: {type: object} + cells: {type: array, items: {$ref: '#/components/schemas/StatusItem'}} + StatisticsSummary: + type: object + required: [mode, from, to, complete, metrics] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + complete: {type: boolean} + missing_sources: {type: array, items: {type: string}} + unresolved_count: {type: integer, minimum: 0} + data_as_of: {type: [string, 'null'], format: date-time} + metrics: {type: object} + failure_reasons: {type: array, items: {type: object}} + realtime: {type: object} + TimeseriesResponse: + type: object + required: [mode, from, to, granularity, complete, series] + properties: + mode: {$ref: '#/components/schemas/StatsMode'} + from: {type: string, format: date-time} + to: {type: string, format: date-time} + timezone: {type: string} + definition_version: {type: string} + filters: {type: object} + granularity: {type: string, enum: [minute, hour, day]} + complete: {type: boolean} + series: {type: array, items: {type: object}} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + fields: {type: object} + request_id: {type: string} diff --git a/contracts/upstream/manifest.txt b/contracts/upstream/manifest.txt new file mode 100644 index 0000000..2860925 --- /dev/null +++ b/contracts/upstream/manifest.txt @@ -0,0 +1,50 @@ +active_bundle=2026-09-19-p1-v1 +bundle_kind=project-owned-first-production-closure-v1 +contract_status=project-owned-v1-authorized-not-external-authoritative +source_repo=git.ipao.vip/rogee/ai-call +source_path=sip-go-agent/contracts/upstream/2026-09-18-p1-baseline +source_snapshot=2026-09-18-p1-baseline +source_head=project-authored-v1 +source_worktree=dirty-project-authored +release_source_worktree=local-self-contained +release_manifest=contracts/upstream/2026-09-19-p1-v1/release-manifest.json +sha256=3332e4c71021189b03f3031106c08b3c9c08eb2f9bb1a4f4b0a96fe1598ceaba contracts/upstream/2026-09-19-p1-v1/README.md +sha256=a41adc5ec329a9010cc45d08f309eb3620e0d6311a5c565a9f26a35ffc4ecb42 contracts/upstream/2026-09-19-p1-v1/SNAPSHOT.json +sha256=5d6bbd369bd6b406abe9b9f8619e6f299b544dacfdd4473a08257a366fd95d20 contracts/upstream/2026-09-19-p1-v1/ai-authorization.schema.json +sha256=d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a contracts/upstream/2026-09-19-p1-v1/ai-config.openapi.yaml +sha256=dc915bff70e71408fcacbaad47d3554bbfa7a22a51f2f53e127c6bd8bc8ba5a6 contracts/upstream/2026-09-19-p1-v1/ai-config.schema.json +sha256=79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41 contracts/upstream/2026-09-19-p1-v1/cell-agent.openapi.yaml +sha256=5091ded520d692f458699327b13adddeccb0c4b4d4a72ef7cdfebcd5d496eade contracts/upstream/2026-09-19-p1-v1/event-payloads.schema.json +sha256=a3514c7ef89b4324685bc9025139488000fa42330de72495484add042a6897cf contracts/upstream/2026-09-19-p1-v1/examples/README.md +sha256=24864df1fd72a59efaaaf6d1fc81a0c7db01fbdfcd821aab4069b64a8db9b60b contracts/upstream/2026-09-19-p1-v1/examples/agent-version-asr-only.json +sha256=e590148448885a1e88c473ca032d5efd719270689c3cff7aa44ca9c6ffa2b321 contracts/upstream/2026-09-19-p1-v1/examples/agent-version-full-explicit.json +sha256=6a5f35f8cc4256c048d4f1ca567a3544ea7bc92bb835cfab39a000d22b73102b contracts/upstream/2026-09-19-p1-v1/examples/agent-version-full-production-v1.json +sha256=d3d4bf2fae07192674e54bf32172a8e95146f11d70609f3cd8f57f0112633c2e contracts/upstream/2026-09-19-p1-v1/examples/agent-version.json +sha256=b1c0f723f083d1be45035792482f6f74bd3bca34486cc6408d4bcfe3772bc432 contracts/upstream/2026-09-19-p1-v1/examples/ai-authorization.json +sha256=0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742 contracts/upstream/2026-09-19-p1-v1/examples/call.execute.json +sha256=fab56aff9c5d4059b8fa65d24f2afa424148e7a07025238fc7fd30e9c5ee63f4 contracts/upstream/2026-09-19-p1-v1/examples/event-call-finished.json +sha256=5c673ceb4a98ce8c90d976356b4c5ffa6c7d96d045b5b3312513a9be93d585ba contracts/upstream/2026-09-19-p1-v1/examples/event-call-status.json +sha256=64fe71719573378b0caac2f7c5476a27ba7073df8c346d4748a6b0d534d4a087 contracts/upstream/2026-09-19-p1-v1/examples/event-command-result.json +sha256=b661f65f0a195e59fe226b12ebae2feb5a9733a9a779c835245be27319aaf8f2 contracts/upstream/2026-09-19-p1-v1/examples/event-contact-opt-out.json +sha256=4f5f46422d2281ffae65c99e947bc7dd44effb5e94eacc12e41b3250ac645c34 contracts/upstream/2026-09-19-p1-v1/examples/event-recording-failed.json +sha256=2b6d731a8d7993cbbe07a5ba8c7413ec56bae8a1538476c0f1740133a488d5b4 contracts/upstream/2026-09-19-p1-v1/examples/event-recording-ready.json +sha256=58d000ce27de6e67054f66d4ded34ffafa983bdcced12613051f670d1eefeed8 contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-failed.json +sha256=115f276522631d7c4decee71ee238aa0d00b4256adedfc9a6a0c5f9dc89f64f6 contracts/upstream/2026-09-19-p1-v1/examples/event-transcript-updated.json +sha256=0b946b43c1f773391791dfb60a2b2a03c8a69e64e5e9ea30af8591850c34b8f3 contracts/upstream/2026-09-19-p1-v1/examples/invalid-ai-authorization-revoked.json +sha256=7343ce8d6ce63e22128ab7bc721544d681a5dd9eccfc4ab4f9d6181c5748824c contracts/upstream/2026-09-19-p1-v1/examples/invalid-asr-only-with-llm.json +sha256=80c3f89dc775deec9d1a3b85c81ea4b2fd1b030bc4deba1a4dcd2823e4eb8024 contracts/upstream/2026-09-19-p1-v1/examples/invalid-event-unknown-type.json +sha256=8e4fb4344c4bec51b47ff9b00a63afdc84d09ab827e1b6823c4e576bf6b6a3f1 contracts/upstream/2026-09-19-p1-v1/examples/invalid-oss-upload-http.json +sha256=268751a1a0d238636c04c004a635e3bda702bc2ef90ade0e5846b47a3c263b5d contracts/upstream/2026-09-19-p1-v1/examples/oss-upload-grant.json +sha256=b4a001457e747a039677870a3ddeccc902b8f87819c3ce2d2e37ae6a14f1ffe0 contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact-real-v1.json +sha256=129821e4de654ef12d3e7f155e9301ecddf798f9ca3399316a20a55273fbc61f contracts/upstream/2026-09-19-p1-v1/examples/static-cell-artifact.json +sha256=b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329 contracts/upstream/2026-09-19-p1-v1/executor.openapi.yaml +sha256=4d43097602fed9a68821e765117580706896ac82d4bce361a80a4cea544ab638 contracts/upstream/2026-09-19-p1-v1/mock-profile.json +sha256=a85b26596e1b1db7405dcabc967df56d5eb6713cc9908bc05ecaa2075ff1092f contracts/upstream/2026-09-19-p1-v1/mq-topology.md +sha256=4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a contracts/upstream/2026-09-19-p1-v1/mq.schema.json +sha256=d3f6ffc5e004fba8acbb6a18495be508a63ec45766bab1ef946ece58bbad84de contracts/upstream/2026-09-19-p1-v1/oss-upload.schema.json +sha256=2aa7cd3f4fa07f7e1a3037107fa7a56183f28370ea06a2a9b8dc18c8790eee2d contracts/upstream/2026-09-19-p1-v1/p1-development-profile.json +sha256=68e1b46194a1f5fa5bc763ba424411f48ada1ab52d39e6f800315a832692794b contracts/upstream/2026-09-19-p1-v1/p1-development-profile.schema.json +sha256=251127dfbb8fe60da58312c9785bc7eafa4135ef91f241a8e07c3868a30e123a contracts/upstream/2026-09-19-p1-v1/release-manifest.json +sha256=368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb contracts/upstream/2026-09-19-p1-v1/saas.openapi.yaml +sha256=5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281 contracts/upstream/2026-09-19-p1-v1/sip-management.openapi.yaml +sha256=46333f6a161ebbfd42f4a326e2509d562164fe836e6d1c88368428795362138d contracts/upstream/2026-09-19-p1-v1/static-cell-artifact.schema.json diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..fe3e36e --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,62 @@ +# 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 new file mode 100644 index 0000000..cf70062 --- /dev/null +++ b/deploy/systemd/sip-go-agent-agent.service @@ -0,0 +1,22 @@ +[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 new file mode 100644 index 0000000..f5aa688 --- /dev/null +++ b/deploy/systemd/sip-go-agent-dispatcher.service @@ -0,0 +1,22 @@ +[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 new file mode 100644 index 0000000..44a690b --- /dev/null +++ b/deploys/README.md @@ -0,0 +1,98 @@ +# Physical-host deployment + +Production services run directly on Debian 13 (Trixie) physical/virtual host +processes managed by systemd. Docker is permitted only for disposable local or +ECS smoke validation; it is not a production runtime dependency. + +The pinned baseline is [`versions.lock.json`](versions.lock.json): Go 1.27.1, +`0.1.0-p1.20260919`, Debian 13 amd64 and Asterisk 22.10.1. The Go release +package contains only our SIP Agent/Dispatcher business binaries and their +systemd units. SaaS-provided MQ, OSS, AI and other infrastructure are endpoints, +not packages deployed by this project; local chain validation may use isolated +fixtures/mocks only. Secrets, certificates, SIP credentials, broker URLs and +phone-log keys are injected separately. + +Before every real outbound attempt, obtain a fresh user confirmation in the +current conversation and display the exact SIP channel, raw target number and +packet-capture plan. Real SIP outbound calls are permitted only from 09:00 +(inclusive) through 20:00 (exclusive), Asia/Shanghai time; outside that window +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. + +## Build an uploadable package + +From the project root: + +```sh +deploys/build-package.sh +deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz +``` + +The package includes its SHA-256 manifest, a non-root systemd deployment +layout, and the fail-closed non-production capture-first entrypoint. The +installer installs that entrypoint as `/usr/local/sbin/agent-call-nonprod-evidence` +and adds a dedicated validated sudoers rule for `rogee`; it does not install +`tcpdump` or silently weaken production gates. A dirty/unapproved source +manifest is intentionally rejected by the installer unless +`--allow-nonproduction` is supplied for smoke work. + +## Install on Debian 13 + +Upload and extract the archive on the target host, then run as root: + +```sh +tar -xzf sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz +./install.sh +``` + +The installer verifies Debian 13 amd64, package checksums and the release +manifest; creates `rogee`, `/opt/sip-go-agent`, `/etc/sip-go-agent` and +`/var/lib/sip-go-agent`, installs both systemd units, and does not overwrite +existing environment or PKI files. Configure the injected values and approved +static Cell artifact, then start explicitly: + +```sh +systemctl enable sip-go-agent-agent.service sip-go-agent-dispatcher.service +systemctl start sip-go-agent-dispatcher.service sip-go-agent-agent.service +``` + +Use `./install.sh --start` only after the environment, mTLS identity, broker +ACL and static Cell artifact have been reviewed. Production mode never silently +falls back to Mock. + +## Non-production capture-first gate + +Development, `mock`, `mixed` and non-production `real` validation must use +[`cell/nonprod-call-evidence.sh`](cell/nonprod-call-evidence.sh) as the single +capture-first entrypoint. It refuses `production`, validates the approved trunk +and whitelist target, verifies `tcpdump` raw-capture capability, records the +pre-call Debian/systemd/ECS-facing facts plus Asterisk/PJSIP/channel/media +state, enables the PJSIP logger, captures SIP UDP 5060 and RTP UDP +10000-10800 before the call command starts, and always stops capture/logger and +writes redacted status plus SHA-256 facts on success or failure. Real calls reserve a daily attempt in `/var/lib/sip-go-agent/state/real-call-attempts.tsv` per `trunk + original target`; the fourth attempt is rejected fail-closed, while `--preflight-only` does not consume quota. Existing real evidence is counted when seeding the ledger. + +Run it only after a fresh current-conversation confirmation naming the exact +trunk, raw target and capture plan; the command after `--` must execute as the +non-root `rogee` user. Do not invoke the Agent directly for a non-production +real/mixed call, do not retry inside the wrapper, and do not treat a missing +PCAP or state snapshot as a pass. The private call output and raw capture stay +under the mode-0700 evidence directory and must not be copied into repository +long-term evidence without redaction. + +## Cell boundary + +Asterisk remains the SIP owner and is installed as the separately approved +physical Asterisk 22.10.1 Cell service. Asterisk and the SIP Agent are the only +business-code services in this repository. The Go package does not rewrite +`pjsip.conf`, embed SIP credentials, or run Asterisk in Docker. Management owns +the immutable static Cell artifact and its systemd/maintenance release; the Go +Agent consumes the approved artifact and reports the applied revision. + +RabbitMQ, OSS, AI providers and SaaS APIs are external infrastructure. They are +not installed by `deploys/`; their production ACLs/endpoints are supplied by +SaaS, while local validation uses explicitly isolated test infrastructure. For +non-ECS/offline Alibaba OSS validation, use +`deploys/env/dispatcher.offline-oss.env.example` (public +`oss-cn-beijing.aliyuncs.com`); keep `dispatcher.env.example`'s internal +endpoint for the separately managed production ECS profile. diff --git a/deploys/build-package.sh b/deploys/build-package.sh new file mode 100755 index 0000000..d8c941a --- /dev/null +++ b/deploys/build-package.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +LOCK="$ROOT/deploys/versions.lock.json" +VERSION=${1:-$(awk -F'"' '/"version"[[:space:]]*:/ {print $4; exit}' "$LOCK")} +[[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]] || { echo 'invalid version' >&2; exit 2; } + +RELEASE_DIR="$ROOT/dist/release-$VERSION" +STAGE="$ROOT/dist/package-$VERSION" +ARCHIVE="$ROOT/deploys/packages/sip-go-agent-$VERSION-linux-amd64.tar.gz" +rm -rf -- "$RELEASE_DIR" "$STAGE" "$ARCHIVE" "$ARCHIVE.sha256" +RELEASE_VERSION="$VERSION" "$ROOT/scripts/build-release.sh" "$RELEASE_DIR" +mkdir -p "$STAGE" "$(dirname -- "$ARCHIVE")" +cp -a "$RELEASE_DIR/." "$STAGE/" +mkdir -p "$STAGE/systemd" "$STAGE/env" "$STAGE/config" "$STAGE/cell" +cp "$ROOT/deploys/install.sh" "$STAGE/install.sh" +cp "$ROOT/deploys/cell/nonprod-call-evidence.sh" "$STAGE/cell/nonprod-call-evidence.sh" +cp "$ROOT/deploys/systemd/"*.service "$STAGE/systemd/" +cp "$ROOT/deploys/env/"*.env.example "$STAGE/env/" +cp "$ROOT/deploys/config/agent-endpoints.example.json" "$STAGE/config/" +cp "$LOCK" "$STAGE/versions.lock.json" +chmod 0755 "$STAGE/install.sh" "$STAGE/cell/nonprod-call-evidence.sh" +chmod 0644 "$STAGE/systemd/"*.service "$STAGE/env/"*.env.example "$STAGE/config/agent-endpoints.example.json" "$STAGE/versions.lock.json" +( + cd "$STAGE" + find . -type f ! -name package.SHA256SUMS -printf '%P\n' | sort | while IFS= read -r file; do + sha256sum "$file" + done > package.SHA256SUMS +) +tar -C "$STAGE" -czf "$ARCHIVE" . +( + cd "$(dirname -- "$ARCHIVE")" + sha256sum "$(basename -- "$ARCHIVE")" > "$(basename -- "$ARCHIVE").sha256" +) +rm -rf -- "$STAGE" +printf 'package=%s\nchecksum=%s\n' "$ARCHIVE" "$ARCHIVE.sha256" diff --git a/deploys/cell/README.md b/deploys/cell/README.md new file mode 100644 index 0000000..6187fcb --- /dev/null +++ b/deploys/cell/README.md @@ -0,0 +1,32 @@ +# Physical Asterisk Cell input + +The production Cell is physical-host business software managed by the approved +SIP management release, not a Docker service and not a Go Agent subprocess. +Asterisk and the SIP Agent are the only business-code services in this project; +MQ, OSS, AI and SaaS APIs remain externally supplied infrastructure. + +The pinned source input is: + +- Asterisk `22.10.1` +- Git commit `f0e408a7b0d829c85bf15fa4b487870a50cb3000` +- Archive `../packages/asterisk-22.10.1-source.tar.gz` +- SHA-256 `373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663` + +`build-asterisk-native.sh` can reproduce a native stage from the local pinned +source/dependency archives using the Debian package list in +`debian-build-packages.lock`; `install-asterisk-native.sh` installs that stage +and the systemd unit without overwriting `/etc/asterisk`. Before production use, +the Cell owner must verify its dependencies/licence/security review, install the +management-approved static `pjsip.conf`/ARI/RTP configuration, and review/start +the systemd unit explicitly. Do not silently substitute another Asterisk version or a +container image. The Go Agent package only consumes the resulting approved static Cell artifact +and reports its applied revision. Local validation may use isolated MQ/OSS/AI +fixtures, but those are not production deployments. + +Before every real outbound attempt, the operator must obtain a fresh user +confirmation in the current conversation that names the SIP channel, raw target +number and capture plan. Real SIP outbound calls are permitted only from 09:00 +(inclusive) through 20:00 (exclusive), Asia/Shanghai time; outside that window +the Agent/Dispatcher must fail closed rather than wait, retry, delay or switch +trunks. A prior confirmation does not authorize retries or additional targets; +failed calls must stop for a new confirmation. diff --git a/deploys/cell/asterisk.service b/deploys/cell/asterisk.service new file mode 100644 index 0000000..1101f5b --- /dev/null +++ b/deploys/cell/asterisk.service @@ -0,0 +1,22 @@ +[Unit] +Description=Asterisk SIP Cell 22.10.1 (physical host) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=asterisk +Group=asterisk +WorkingDirectory=/var/lib/asterisk +ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk -vvvg +ExecStop=/usr/sbin/asterisk -rx "core stop now" +Restart=on-failure +RestartSec=5s +UMask=0077 +LimitNOFILE=65536 +PrivateTmp=yes +ProtectHome=yes +ReadWritePaths=/etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + +[Install] +WantedBy=multi-user.target diff --git a/deploys/cell/build-asterisk-native.sh b/deploys/cell/build-asterisk-native.sh new file mode 100644 index 0000000..51f91bb --- /dev/null +++ b/deploys/cell/build-asterisk-native.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +VERSION=22.10.1 +PKG="$ROOT/deploys/packages" +SRC_ARCHIVE="$PKG/asterisk-$VERSION-source.tar.gz" +SRC_SHA="$PKG/asterisk-$VERSION-source.sha256" +DEPS="$PKG/asterisk-$VERSION-deps" +OUT=${OUT_DIR:-"$PKG/asterisk-$VERSION-native"} +JOBS=${JOBS:-1} +WORK=${WORK_DIR:-"$ROOT/.local/asterisk-build-$VERSION"} + +[[ -f "$SRC_ARCHIVE" && -f "$SRC_SHA" ]] || { echo 'Asterisk source archive/checksum missing' >&2; exit 1; } +[[ -d "$DEPS" ]] || { echo 'Asterisk dependency cache missing' >&2; exit 1; } +command -v make >/dev/null || { echo 'make is required; install deploys/cell/debian-build-packages.lock' >&2; exit 1; } +sha256sum -c "$SRC_SHA" +rm -rf -- "$WORK" "$OUT" +mkdir -p "$WORK" "$OUT/cache" +cp "$DEPS"/*.tar.bz2 "$OUT/cache/" +tar -xzf "$SRC_ARCHIVE" -C "$WORK" +SRC="$WORK/asterisk-$VERSION" +cd "$SRC" +EXTERNALS_CACHE_DIR="$OUT/cache" ./configure --with-pjproject-bundled --with-jansson-bundled +EXTERNALS_CACHE_DIR="$OUT/cache" make -j"$JOBS" +STAGE="$WORK/stage" +rm -rf -- "$STAGE" +mkdir -p "$STAGE" +EXTERNALS_CACHE_DIR="$OUT/cache" make install DESTDIR="$STAGE" +# Cell configuration is management-owned and must not be overwritten by this +# binary package. +rm -rf -- "$STAGE/etc/asterisk" +tar -C "$STAGE" -cpf "$OUT/asterisk-$VERSION-native-stage.tar" . +cp "$ROOT/deploys/cell/asterisk.service" "$OUT/asterisk.service" +cp "$ROOT/deploys/cell/install-asterisk-native.sh" "$OUT/install-asterisk-native.sh" +chmod 0755 "$OUT/install-asterisk-native.sh" +( + cd "$OUT" + sha256sum "asterisk-$VERSION-native-stage.tar" > "asterisk-$VERSION-native-stage.tar.sha256" +) +printf 'native_stage=%s\nservice=%s\n' "$OUT/asterisk-$VERSION-native-stage.tar" "$OUT/asterisk.service" diff --git a/deploys/cell/debian-build-packages.lock b/deploys/cell/debian-build-packages.lock new file mode 100644 index 0000000..911338a --- /dev/null +++ b/deploys/cell/debian-build-packages.lock @@ -0,0 +1,34 @@ +# Debian 13 build inputs for the pinned native Asterisk 22.10.1 Cell. +# Install through the host's package manager; this is build tooling, not a +# deployed MQ/OSS/AI service. Review/lock repository package revisions before +# production release. +build-essential +pkg-config +autoconf-archive +libedit-dev +libjansson-dev +libsqlite3-dev +uuid-dev +libxml2-dev +libssl-dev +libcurl4-openssl-dev +bison +flex +libcap-dev +libspeex-dev +libspeexdsp-dev +libogg-dev +libvorbis-dev +libasound2-dev +portaudio19-dev +libsndfile1-dev +libspandsp-dev +libsrtp2-dev +libgsm1-dev +zlib1g-dev +libncurses-dev +libnewt-dev +libpopt-dev +libical-dev +libldap2-dev +xmlstarlet diff --git a/deploys/cell/install-asterisk-native.sh b/deploys/cell/install-asterisk-native.sh new file mode 100644 index 0000000..fb4c404 --- /dev/null +++ b/deploys/cell/install-asterisk-native.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ ${EUID} -eq 0 ]] || { echo 'install-asterisk-native.sh must run as root' >&2; exit 1; } +START=false +for arg in "$@"; do + case "$arg" in + --start) START=true ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd -- "$PACKAGE_DIR" +. /etc/os-release +[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || { echo 'Debian 13 is required' >&2; exit 1; } +[[ $(uname -m) == x86_64 ]] || { echo 'amd64 host is required' >&2; exit 1; } +STAGE=asterisk-22.10.1-native-stage.tar +sha256sum -c "$STAGE.sha256" +[[ -f asterisk.service ]] || { echo 'asterisk.service is missing' >&2; exit 1; } +getent group asterisk >/dev/null || groupadd --system asterisk +id -u asterisk >/dev/null 2>&1 || useradd --system --home-dir /var/lib/asterisk --shell /usr/sbin/nologin --gid asterisk asterisk +# Keep management-owned /etc/asterisk configuration intact. +tar --exclude='etc/asterisk/*' -xpf "$STAGE" -C / +ldconfig +install -d -o asterisk -g asterisk -m 0750 /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk +install -o root -g root -m 0644 asterisk.service /etc/systemd/system/asterisk.service +systemctl daemon-reload +systemctl enable asterisk.service +if [[ "$START" == true ]]; then + systemctl restart asterisk.service +fi +/usr/sbin/asterisk -V +printf 'installed asterisk=22.10.1 start=%s config_preserved=true\n' "$START" diff --git a/deploys/cell/nonprod-call-evidence.sh b/deploys/cell/nonprod-call-evidence.sh new file mode 100755 index 0000000..4076921 --- /dev/null +++ b/deploys/cell/nonprod-call-evidence.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# Capture-first entrypoint for every non-production mixed/real call attempt. +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +usage: nonprod-call-evidence.sh --trunk TRUNK --target NUMBER [options] -- COMMAND [ARG...] + +Options: + --environment NAME Defaults to AGENT_ENVIRONMENT or development; production is refused. + --call-id ID Evidence directory suffix (default: UTC timestamp). + --evidence-dir DIR Evidence root (default: /var/lib/sip-go-agent/evidence/). + --interface IFACE Capture interface (default: default-route interface; any fallback). + --run-as USER Run COMMAND as this non-root user (default: rogee). + --sip-port PORT SIP UDP port (default: 5060). + --rtp-start PORT RTP range start (default: 10000). + --rtp-end PORT RTP range end (default: 10800). + --preflight-only Start/stop capture and diagnostics without a call; do not require packets. + --attempt-ledger FILE Daily trunk/number attempt ledger (default: /var/lib/sip-go-agent/state/real-call-attempts.tsv). +EOF + exit 2 +} + +[[ "$(id -u)" == 0 ]] || { echo 'must run as root for tcpdump and Asterisk diagnostics' >&2; exit 1; } + +environment="${AGENT_ENVIRONMENT:-development}" +call_id="$(date -u +%Y%m%dT%H%M%SZ)" +evidence_dir="" +recording_dir="/var/lib/sip-go-agent/recordings" +interface="any" +run_as="rogee" +sip_port=5060 +rtp_start=10000 +rtp_end=10800 +trunk="" +target="" +call_command=() +preflight_only=0 +attempt_ledger="/var/lib/sip-go-agent/state/real-call-attempts.tsv" +attempt_number=0 + +while (($#)); do + case "$1" in + --environment) [[ $# -ge 2 ]] || usage; environment=$2; shift 2 ;; + --call-id) [[ $# -ge 2 ]] || usage; call_id=$2; shift 2 ;; + --evidence-dir) [[ $# -ge 2 ]] || usage; evidence_dir=$2; shift 2 ;; + --recording-dir) [[ $# -ge 2 ]] || usage; recording_dir=$2; shift 2 ;; + --interface) [[ $# -ge 2 ]] || usage; interface=$2; shift 2 ;; + --run-as) [[ $# -ge 2 ]] || usage; run_as=$2; shift 2 ;; + --sip-port) [[ $# -ge 2 ]] || usage; sip_port=$2; shift 2 ;; + --rtp-start) [[ $# -ge 2 ]] || usage; rtp_start=$2; shift 2 ;; + --rtp-end) [[ $# -ge 2 ]] || usage; rtp_end=$2; shift 2 ;; + --preflight-only) preflight_only=1; shift ;; + --attempt-ledger) [[ $# -ge 2 ]] || usage; attempt_ledger=$2; shift 2 ;; + --trunk) [[ $# -ge 2 ]] || usage; trunk=$2; shift 2 ;; + --target) [[ $# -ge 2 ]] || usage; target=$2; shift 2 ;; + --) shift; call_command=("$@"); break ;; + -h|--help) usage ;; + *) echo "unknown option: $1" >&2; usage ;; + esac +done + +[[ "$environment" != production ]] || { echo 'production requires the separate production gate' >&2; exit 1; } +[[ "$call_id" =~ ^[A-Za-z0-9._-]+$ ]] || { echo 'invalid call id' >&2; exit 1; } +[[ "$trunk" =~ ^(provider-primary|provider-second|provider-third|trunk-[A-Za-z0-9._-]+)$ ]] || { echo 'trunk is not an approved non-production trunk id' >&2; exit 1; } +[[ "$target" =~ ^(15003164745|15830461047)$ ]] || { echo 'target is outside the approved outbound whitelist' >&2; exit 1; } +[[ "$attempt_ledger" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo 'invalid attempt ledger path' >&2; exit 1; } +[[ ${#call_command[@]} -gt 0 ]] || { echo 'call command is required after --' >&2; exit 1; } +[[ "$interface" =~ ^[A-Za-z0-9_.:-]+$ ]] || { echo 'invalid capture interface' >&2; exit 1; } +[[ "$sip_port" =~ ^[0-9]+$ && "$rtp_start" =~ ^[0-9]+$ && "$rtp_end" =~ ^[0-9]+$ ]] || { echo 'invalid port' >&2; exit 1; } +if [[ "$interface" == any ]]; then + default_interface="$(ip route show default 2>/dev/null | awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i == "dev") {print $(i + 1); exit}}')" + [[ -n "$default_interface" ]] && interface="$default_interface" +fi + +if [[ -z "$evidence_dir" ]]; then + evidence_dir="/var/lib/sip-go-agent/evidence/$call_id" +fi +install -d -m 0700 "$evidence_dir" +umask 077 +exec > >(tee "$evidence_dir/entrypoint.log") 2>&1 +install -d -o "$run_as" -g "$run_as" -m 0700 "$recording_dir" +touch "$evidence_dir/recording-start.marker" + +asterisk_bin="${ASTERISK_BIN:-/usr/sbin/asterisk}" +tcpdump_bin="${TCPDUMP_BIN:-$(command -v tcpdump || true)}" +[[ -x "$asterisk_bin" ]] || { echo 'Asterisk CLI unavailable; fail-closed'; exit 1; } +[[ -n "$tcpdump_bin" && -x "$tcpdump_bin" ]] || { echo 'tcpdump unavailable; fail-closed'; exit 1; } +command -v runuser >/dev/null || { echo 'runuser unavailable; fail-closed'; exit 1; } +command -v flock >/dev/null || { echo 'flock unavailable for daily attempt gate; fail-closed'; exit 1; } +command -v python3 >/dev/null || { echo 'python3 unavailable for SIP evidence summary; fail-closed'; exit 1; } + +# A successful one-packet probe or a timeout after opening the capture proves +# that the binary can open a raw capture socket; permission errors fail closed. +probe_status=0 +timeout 2s "$tcpdump_bin" -i "$interface" -nn -c 1 -w /dev/null >/dev/null 2>"$evidence_dir/tcpdump-preflight.log" || probe_status=$? +if [[ "$probe_status" != 0 && "$probe_status" != 124 ]]; then + echo "tcpdump CAP_NET_RAW preflight failed: status=$probe_status" >&2 + exit 1 +fi + +started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \ + "$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json" + +redact() { + sed -E 's/(password|secret|token|authorization|api[_-]?key)[^[:space:]]*/\1=/Ig' +} + +systemctl is-enabled asterisk.service >"$evidence_dir/asterisk-enabled.txt" 2>&1 || true +systemctl is-active asterisk.service >"$evidence_dir/asterisk-active.txt" 2>&1 || true +uname -a >"$evidence_dir/uname.txt" +cat /etc/os-release >"$evidence_dir/os-release.txt" +ip -brief address >"$evidence_dir/ip-address.txt" +ss -lunp >"$evidence_dir/udp-listeners.txt" 2>&1 || ss -lun >"$evidence_dir/udp-listeners.txt" +"$asterisk_bin" -rx "pjsip show endpoint $trunk" 2>&1 | redact >"$evidence_dir/pjsip-endpoint.txt" +"$asterisk_bin" -rx "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-before.txt" +"$asterisk_bin" -rx "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-before.txt" +sha256sum /opt/sip-go-agent/current/sip-go-agent /etc/sip-go-agent/artifacts/*.json /etc/sip-go-agent/ai/*.json >"$evidence_dir/installed-sha256.txt" 2>&1 || true + +logger_enabled=0 +capture_pid="" +write_sip_summary() { + python3 - "$evidence_dir/asterisk-journal.txt" "$evidence_dir/call-output.private" >"$evidence_dir/sip-summary.json" <<'PY' +import json +import re +import sys +from pathlib import Path + +journal = Path(sys.argv[1]).read_text(errors="replace") if Path(sys.argv[1]).exists() else "" +call_output = Path(sys.argv[2]).read_text(errors="replace") if Path(sys.argv[2]).exists() else "" +responses = [] +methods = [] +reason_headers = [] +timeline = [] +direction = None +current_response = None +sdp = {"present": False, "audio_ports": [], "codecs": [], "payload_types": [], "ptime": [], "directions": []} + +def timestamp(line): + return line.split(" asterisk", 1)[0].strip() if " asterisk" in line else None + +for line in journal.splitlines(): + ts = timestamp(line) + if "Transmitting SIP request" in line: + direction = "outbound" + elif "Received SIP request" in line or "Received SIP response" in line: + direction = "inbound" + status = re.search(r"SIP/2\.0\s+(\d{3})(?:\s+(.+?))?\s*$", line) + if status: + item = {"timestamp": ts, "direction": direction, "code": int(status.group(1)), "reason": (status.group(2) or "").strip(), "cseq_method": None} + responses.append(item) + current_response = item + timeline.append({"timestamp": ts, "direction": direction, "event": f"{item['code']} {item['reason']}".strip()}) + method = re.search(r"\b(INVITE|ACK|BYE|CANCEL)\s+(sip:\S+)\s+SIP/2\.0", line) + if method: + current_response = None + item = {"timestamp": ts, "direction": direction, "method": method.group(1), "request_uri": method.group(2)} + methods.append(item) + timeline.append({"timestamp": ts, "direction": direction, "event": method.group(1), "request_uri": method.group(2)}) + cseq = re.search(r"\bCSeq:\s*\d+\s+([A-Za-z]+)", line) + if cseq and current_response is not None and current_response["cseq_method"] is None: + current_response["cseq_method"] = cseq.group(1).upper() + reason = re.search(r"\bReason:\s*(.+)$", line) + if reason: + value = reason.group(1).strip() + reason_headers.append(value) + q850 = re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I) + else: + q850 = None + if "v=0" in line: + sdp["present"] = True + audio = re.search(r"m=audio\s+(\d+)", line) + if audio: + sdp["audio_ports"].append(int(audio.group(1))) + rtpmap = re.search(r"a=rtpmap:(\d+)\s+([^\s]+)", line) + if rtpmap: + sdp["payload_types"].append(int(rtpmap.group(1))) + sdp["codecs"].append(rtpmap.group(2)) + ptime = re.search(r"a=ptime:\s*(\d+)", line) + if ptime: + sdp["ptime"].append(int(ptime.group(1))) + media_direction = re.search(r"a=(sendrecv|sendonly|recvonly|inactive)\s*$", line) + if media_direction: + sdp["directions"].append(media_direction.group(1)) + +hangup = re.search(r"cause=(\d+)", call_output) +invite_responses = [item for item in responses if item.get("cseq_method") in (None, "INVITE")] +final_response = next((item for item in reversed(invite_responses) if item["code"] >= 200), None) +summary = { + "responses": responses, + "invite_responses": invite_responses, + "final_response": final_response, + "methods": methods, + "bye": [item for item in methods if item["method"] == "BYE"], + "cancel": [item for item in methods if item["method"] == "CANCEL"], + "reason_headers": reason_headers, + "q850": next((re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I).group(1) for value in reason_headers if re.search(r"Q\.850\s*;\s*cause\s*=\s*(\d+)", value, re.I)), None), + "asterisk_hangup_cause": int(hangup.group(1)) if hangup else None, + "sdp": sdp, + "timeline": timeline[:200], + "stasis_start_seen": "StasisStart" in journal, + "stasis_end_seen": "StasisEnd" in journal, +} +print(json.dumps(summary, ensure_ascii=False, sort_keys=True, indent=2)) +PY +} +stop_capture() { + if [[ -n "$capture_pid" ]]; then + # Let libpcap drain packets already accepted by the kernel before SIGINT; + # short INVITE/404 calls otherwise can leave a header-only pcap. + sleep 2 + fi + if [[ -n "$capture_pid" ]] && kill -0 "$capture_pid" 2>/dev/null; then + kill -INT "$capture_pid" 2>/dev/null || true + for _ in 1 2 3 4 5; do + kill -0 "$capture_pid" 2>/dev/null || break + sleep 1 + done + kill -TERM "$capture_pid" 2>/dev/null || true + wait "$capture_pid" 2>/dev/null || true + fi + capture_pid="" +} +cleanup() { + set +e + stop_capture + if ((logger_enabled)); then + "$asterisk_bin" -rx "pjsip set logger off" >"$evidence_dir/pjsip-logger-off.txt" 2>&1 || true + fi + date -u +%Y-%m-%dT%H:%M:%SZ >"$evidence_dir/ended-at.txt" + if [[ -f "$evidence_dir/capture.pcap" ]]; then + sha256sum "$evidence_dir/capture.pcap" >"$evidence_dir/capture.pcap.sha256" || true + fi + find "$recording_dir" -maxdepth 1 -type f -newer "$evidence_dir/recording-start.marker" -print0 | xargs -0r sha256sum >"$evidence_dir/recordings.sha256" || true + journalctl -u asterisk.service --since "$started_at" --no-pager 2>/dev/null | redact >"$evidence_dir/asterisk-journal.txt" || true + write_sip_summary || printf '{"error":"sip summary unavailable"}\n' >"$evidence_dir/sip-summary.json" + chown -R "$run_as:$run_as" "$evidence_dir" 2>/dev/null || true +} +trap cleanup EXIT +reserve_attempt() { + if ((preflight_only)); then + return + fi + local today count legacy_count metadata + today="$(date -u +%F)" + install -d -m 0700 "$(dirname "$attempt_ledger")" + touch "$attempt_ledger" + exec 9>>"$attempt_ledger.lock" + flock -x 9 + count="$(awk -F '\t' -v d="$today" -v t="$trunk" -v n="$target" '$1 == d && $2 == t && $3 == n {count++} END {print count + 0}' "$attempt_ledger")" + legacy_count=0 + while IFS= read -r metadata; do + if grep -q '"environment":"development"' "$metadata" \ + && grep -q '"call_id":"real-' "$metadata" \ + && grep -q "\\\"trunk\\\":\\\"$trunk\\\"" "$metadata" \ + && grep -q "\\\"target\\\":\\\"$target\\\"" "$metadata" \ + && grep -q "\\\"started_at\\\":\\\"$today" "$metadata"; then + legacy_count=$((legacy_count + 1)) + fi + done < <(find /var/lib/sip-go-agent/evidence -mindepth 2 -maxdepth 2 -type f -name metadata.json -print 2>/dev/null) + if ((legacy_count > count)); then + count=$legacy_count + fi + if ((count >= 3)); then + printf 'attempt_rejected=quota\ndate=%s\ntrunk=%s\ntarget=%s\nknown_attempts=%s\nmax_attempts=3\n' \ + "$today" "$trunk" "$target" "$count" >"$evidence_dir/attempt-rejected.txt" + flock -u 9 + exec 9>&- + echo "daily SIP/number attempt limit reached: $trunk/$target has $count attempts on $today" >&2 + exit 1 + fi + attempt_number=$((count + 1)) + printf '%s\t%s\t%s\t%s\t%s\n' "$today" "$trunk" "$target" "$call_id" "$started_at" >>"$attempt_ledger" + flock -u 9 + exec 9>&- + printf '{"environment":"%s","call_id":"%s","trunk":"%s","target":"%s","interface":"%s","attempt_ledger":"%s","attempt_number":%s,"sip_port":%s,"rtp_start":%s,"rtp_end":%s,"started_at":"%s"}\n' \ + "$environment" "$call_id" "$trunk" "$target" "$interface" "$attempt_ledger" "$attempt_number" "$sip_port" "$rtp_start" "$rtp_end" "$started_at" >"$evidence_dir/metadata.json" +} + +reserve_attempt +"$asterisk_bin" -rx "pjsip set logger on" >"$evidence_dir/pjsip-logger-on.txt" 2>&1 || { echo 'cannot enable PJSIP logger; fail-closed' >&2; exit 1; } +logger_enabled=1 + +"$tcpdump_bin" -i "$interface" -nn -s0 -U -w "$evidence_dir/capture.pcap" \ + "udp port $sip_port or (udp portrange $rtp_start-$rtp_end)" >"$evidence_dir/tcpdump.log" 2>&1 & +capture_pid=$! +sleep 1 +kill -0 "$capture_pid" 2>/dev/null || { echo 'tcpdump exited before call; fail-closed' >&2; exit 1; } +printf '%s\n' "capture_started=$evidence_dir/capture.pcap" +if ((preflight_only)); then + sleep 1 + stop_capture + printf 'call_exit=0\ncapture_packets=0\ncapture_status=0\npreflight_only=1\n' >"$evidence_dir/result.txt" + exit 0 +fi + +call_status=0 +set +e +runuser -u "$run_as" -- "${call_command[@]}" >"$evidence_dir/call-output.private" 2>&1 +call_status=$? +set -e +printf '%s\n' "call_exit=$call_status" +stop_capture +"$asterisk_bin" -rx "pjsip show contacts" 2>&1 | redact >"$evidence_dir/pjsip-contacts-after.txt" +"$asterisk_bin" -rx "core show channels verbose" 2>&1 | redact >"$evidence_dir/channels-after.txt" +capture_packets="$(awk '/ packets captured/{print $1; exit}' "$evidence_dir/tcpdump.log" 2>/dev/null || true)" +[[ "$capture_packets" =~ ^[0-9]+$ ]] || capture_packets=0 +capture_status=0 +if ((capture_packets == 0)); then capture_status=2; fi +printf 'call_exit=%s\ncapture_packets=%s\ncapture_status=%s\nattempt_number=%s\n' "$call_status" "$capture_packets" "$capture_status" "$attempt_number" >"$evidence_dir/result.txt" +if ((call_status != 0)); then exit "$call_status"; fi +exit "$capture_status" diff --git a/deploys/config/agent-endpoints.example.json b/deploys/config/agent-endpoints.example.json new file mode 100644 index 0000000..371879a --- /dev/null +++ b/deploys/config/agent-endpoints.example.json @@ -0,0 +1,14 @@ +[ + { + "agent_id": "agent-cell-a", + "cell_id": "cell-a", + "address": "agent-cell-a.internal:19090", + "server_name": "agent-cell-a.internal" + }, + { + "agent_id": "agent-cell-b", + "cell_id": "cell-b", + "address": "agent-cell-b.internal:19090", + "server_name": "agent-cell-b.internal" + } +] diff --git a/deploys/config/ai-dental-meiba-v1.json b/deploys/config/ai-dental-meiba-v1.json new file mode 100644 index 0000000..2973d75 --- /dev/null +++ b/deploys/config/ai-dental-meiba-v1.json @@ -0,0 +1,54 @@ +{ + "agent_version_id": "agent_dental_meiba_v1", + "immutable": true, + "mode": "full_ai", + "llm": { + "provider_ref": "bailian-openai-compatible", + "model": "qwen-plus", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000, + "credential_ref": "bailian-default" + }, + "prompt": { + "text": "你是美吧口腔的电话咨询助手。你必须保持简洁、礼貌、自然,每次只回复一到两句。通话开始时先明确说明‘您好,这里是美吧口腔’,并询问用户想咨询或考虑做什么口腔项目;随后只围绕用户的项目、症状、预约意向进行追问,不进行诊断,不承诺价格或疗效。若用户明确表示打错、不需要、不考虑、没兴趣、不方便、要求停止,或接通的是空号、机器人、自动语音、语音信箱,必须只输出 [INVALID_CALL],不得附加任何文字。有效对话中要给出下一步简短建议,并在用户回答后继续询问,直到完成最多三轮有效问答。", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "bailian-openai-compatible", + "model": "qwen3-tts-flash", + "voice": "Cherry", + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1 + }, + "timeout_ms": 5000, + "credential_ref": "bailian-default" + }, + "asr": { + "provider_ref": "volcengine", + "language": "zh-CN", + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2 + }, + "interim": true, + "timeout_ms": 5000, + "credential_ref": "volcengine-default", + "model": "volc.bigasr.sauc.duration" + }, + "conversation": { + "opening": "您好,这里是美吧口腔,请问您想咨询或考虑做什么口腔项目?", + "allow_interrupt": true, + "silence_timeout_ms": 900, + "max_duration_ms": 120000, + "max_turns": 3, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32 + } +} diff --git a/deploys/config/static-cell-artifact-v1.json b/deploys/config/static-cell-artifact-v1.json new file mode 100644 index 0000000..6e2c7bb --- /dev/null +++ b/deploys/config/static-cell-artifact-v1.json @@ -0,0 +1,95 @@ +{ + "artifact_id": "cell-single-real-v1", + "source_release": "asterisk-22.10.1-native-v1", + "source_digest": "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + "approval_reference": "project-auto-approved:2026-09-19:single-node-v1", + "cell_id": "cell-single", + "revision": 1, + "config_sha256": "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", + "mode": "real", + "trunks": [ + { + "trunk_id": "provider-primary", + "provider_id": "provider-primary", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-primary" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "provider-primary", + "credential_ref": "sip-provider-primary", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-second", + "provider_id": "provider-second", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-second" + ], + "dial_prefix": "", + "enabled": true, + "sip_endpoint_ref": "provider-second", + "credential_ref": "sip-provider-second", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-third", + "provider_id": "provider-third", + "egress_pool_id": "egress-single", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-third" + ], + "dial_prefix": "mka755", + "enabled": true, + "sip_endpoint_ref": "provider-third", + "credential_ref": "sip-provider-third", + "media_profile_id": "pcma-8k-pt8" + } + ], + "ari": { + "base_url": "https://127.0.0.1:8088/ari", + "websocket_url": "wss://127.0.0.1:8088/ari/events", + "application": "agent-call", + "credential_ref": "ari-single" + }, + "media": { + "bind_address": "127.0.0.1", + "port": 12000, + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "recording": { + "enabled": true, + "format": "wav", + "directory": "/var/lib/sip-go-agent/recordings", + "max_bytes": 67108864 + }, + "load_evidence": { + "status": "not-yet-loaded" + }, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/deploys/config/static-cell-runtime-v1.json b/deploys/config/static-cell-runtime-v1.json new file mode 100644 index 0000000..57f01bb --- /dev/null +++ b/deploys/config/static-cell-runtime-v1.json @@ -0,0 +1,64 @@ +{ + "cell_id": "cell-single", + "asterisk": "22.10.1", + "source_commit": "f0e408a7b0d829c85bf15fa4b487870a50cb3000", + "trunk_id": "provider-second", + "ari_application": "agent-call", + "media": { + "bind_address": "127.0.0.1", + "port": 12000, + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "recording": { + "directory": "/var/lib/sip-go-agent/recordings", + "format": "wav", + "max_bytes": 67108864 + }, + "trunks": [ + { + "trunk_id": "provider-primary", + "provider_id": "provider-primary", + "codec": "PCMA", + "dial_prefix": "7089", + "caller_profile_id": "caller-primary", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-second", + "provider_id": "provider-second", + "codec": "PCMA", + "dial_prefix": "", + "caller_profile_id": "caller-second", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-third", + "provider_id": "provider-third", + "codec": "PCMA", + "dial_prefix": "mka755", + "caller_profile_id": "caller-third", + "media_profile_id": "pcma-8k-pt8" + } + ], + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/deploys/env/agent.env.example b/deploys/env/agent.env.example new file mode 100644 index 0000000..ac311bc --- /dev/null +++ b/deploys/env/agent.env.example @@ -0,0 +1,33 @@ +# Production physical-host example. Inject secrets and approved paths out of band. +SIP_GO_AGENT_MODE=real +AGENT_ID=agent-cell-a +AGENT_VERSION=0.1.0-p1.20260919 +CELL_ID=cell-a +AGENT_SPOOL=/var/lib/sip-go-agent/agent/spool +AGENT_GRPC_LISTEN=0.0.0.0:19090 +MTLS_CA_FILE=/etc/sip-go-agent/pki/ca.pem +MTLS_CERT_FILE=/etc/sip-go-agent/pki/agent.pem +MTLS_KEY_FILE=/etc/sip-go-agent/pki/agent.key +MTLS_SERVER_NAME=agent-cell-a.internal +# Approved Dispatcher management address; upload data goes Agent -> OSS directly. +# DISPATCHER_GRPC_ENDPOINT=:19443 +# DISPATCHER_GRPC_SERVER_NAME=dispatcher.internal +DISPATCHER_AGENT_ENDPOINTS_FILE=/etc/sip-go-agent/agent-endpoints.json +AGENT_STATIC_ARTIFACT=/etc/sip-go-agent/artifacts/cell-a.json +AGENT_CALL_BUSINESS_LOG=/var/lib/sip-go-agent/agent/call-business.jsonl +# AGENT_CALL_PHONE_LOG_KEY= +# Optional one-shot flow inputs; mode selects adapters, not a separate business path. +# AGENT_CALL_TARGET= +# AGENT_CALL_TRUNK_ID= +# AGENT_CALL_CALLER_ID= +# AGENT_CALL_MEDIA_BIND=127.0.0.1 +# AGENT_CALL_MEDIA_PORT=12000 +# AGENT_CALL_RECORDING_DIR=/var/lib/sip-go-agent/recordings +# AGENT_CALL_AI_SNAPSHOT=/etc/sip-go-agent/ai/full-ai-v1.json +# Required only when DISPATCHER_GRPC_ENDPOINT is enabled for --call-once evidence: +# AGENT_CALL_TENANT_ID= +# AGENT_CALL_TENANT_KEY= +# AGENT_CALL_TASK_ID= +# AGENT_CALL_TASK_ITEM_ID= +RABBITMQ_EXCHANGE=agent-call.commands.v1 +# RABBITMQ_URL= diff --git a/deploys/env/dispatcher.env.example b/deploys/env/dispatcher.env.example new file mode 100644 index 0000000..18a6ee1 --- /dev/null +++ b/deploys/env/dispatcher.env.example @@ -0,0 +1,24 @@ +# Production physical-host example. Inject secrets and approved paths out of band. +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 +# 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= diff --git a/deploys/env/dispatcher.offline-oss.env.example b/deploys/env/dispatcher.offline-oss.env.example new file mode 100644 index 0000000..804bfc7 --- /dev/null +++ b/deploys/env/dispatcher.offline-oss.env.example @@ -0,0 +1,12 @@ +# Offline/non-ECS OSS integration profile. +# Do not use this profile as the production ECS profile. +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 diff --git a/deploys/install.sh b/deploys/install.sh new file mode 100755 index 0000000..cf25483 --- /dev/null +++ b/deploys/install.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo 'install.sh must run as root' >&2 + exit 1 +fi + +ALLOW_NONPRODUCTION=false +START=false +for arg in "$@"; do + case "$arg" in + --allow-nonproduction) ALLOW_NONPRODUCTION=true ;; + --start) START=true ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd -- "$PACKAGE_DIR" +[[ -f package.SHA256SUMS ]] || { echo 'package.SHA256SUMS is missing' >&2; exit 1; } +sha256sum -c package.SHA256SUMS + +. /etc/os-release +[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || { + echo 'Debian 13 is required' >&2 + exit 1 +} +[[ $(uname -m) == x86_64 ]] || { echo 'amd64 host is required' >&2; exit 1; } + +if ! grep -q '"source_dirty": false' manifest.json || + ! grep -q '"production_approval": true' manifest.json; then + if [[ "$ALLOW_NONPRODUCTION" != true ]]; then + echo 'release is dirty or not production-approved; use an approved package or --allow-nonproduction for smoke only' >&2 + exit 1 + fi +fi + +VERSION=$(awk -F'"' '/"version"[[:space:]]*:/ {print $4; exit}' manifest.json) +LOCK_VERSION=$(awk -F'"' '/"version"[[:space:]]*:/ {print $4; exit}' versions.lock.json) +[[ "$VERSION" =~ ^[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]] || { echo 'invalid release version' >&2; exit 1; } +[[ "$VERSION" == "$LOCK_VERSION" ]] || { echo 'release/version lock mismatch' >&2; exit 1; } + +id -u rogee >/dev/null 2>&1 || useradd --create-home --shell /bin/bash rogee +install -d -m 0755 /opt/sip-go-agent/releases \ + /etc/sip-go-agent/pki /etc/sip-go-agent/artifacts +install -d -m 0700 -o rogee -g rogee \ + /var/lib/sip-go-agent/agent /var/lib/sip-go-agent/agent/spool \ + /var/lib/sip-go-agent/dispatcher +install -m 0755 cell/nonprod-call-evidence.sh /usr/local/sbin/agent-call-nonprod-evidence +command -v visudo >/dev/null || { echo 'visudo is required for the non-production capture gate' >&2; exit 1; } +cat >/etc/sudoers.d/agent-call-nonprod-evidence <<'EOF' +rogee ALL=(root) NOPASSWD: /usr/local/sbin/agent-call-nonprod-evidence +EOF +chmod 0440 /etc/sudoers.d/agent-call-nonprod-evidence +visudo -cf /etc/sudoers.d/agent-call-nonprod-evidence >/dev/null + +RELEASE_DIR=/opt/sip-go-agent/releases/$VERSION +install -d -m 0755 "$RELEASE_DIR" +install -m 0755 sip-go-agent "$RELEASE_DIR/sip-go-agent" +install -m 0644 go.mod go.sum manifest.json versions.lock.json "$RELEASE_DIR/" +ln -sfn "$RELEASE_DIR" /opt/sip-go-agent/current +chown -R root:root /opt/sip-go-agent/releases "$RELEASE_DIR" + +install -m 0644 systemd/sip-go-agent-agent.service /etc/systemd/system/sip-go-agent-agent.service +install -m 0644 systemd/sip-go-agent-dispatcher.service /etc/systemd/system/sip-go-agent-dispatcher.service +if [[ ! -e /etc/sip-go-agent/agent.env ]]; then + install -m 0600 env/agent.env.example /etc/sip-go-agent/agent.env +fi +if [[ ! -e /etc/sip-go-agent/dispatcher.env ]]; then + install -m 0600 env/dispatcher.env.example /etc/sip-go-agent/dispatcher.env +fi +if [[ ! -e /etc/sip-go-agent/agent-endpoints.json ]]; then + install -m 0644 config/agent-endpoints.example.json /etc/sip-go-agent/agent-endpoints.json +fi +install -m 0644 versions.lock.json /etc/sip-go-agent/versions.lock.json +systemctl daemon-reload +systemctl enable sip-go-agent-agent.service sip-go-agent-dispatcher.service + +if [[ "$START" == true ]]; then + systemctl restart sip-go-agent-dispatcher.service sip-go-agent-agent.service +fi +printf 'installed version=%s mode=physical-systemd start=%s\n' "$VERSION" "$START" diff --git a/deploys/packages/README.md b/deploys/packages/README.md new file mode 100644 index 0000000..1504b11 --- /dev/null +++ b/deploys/packages/README.md @@ -0,0 +1,23 @@ +# Release packages + +`../build-package.sh` writes the self-contained Linux package and checksum here: + +```text +sip-go-agent--linux-amd64.tar.gz +sip-go-agent--linux-amd64.tar.gz.sha256 +``` + +The package contains only the SIP Agent/Dispatcher business binary, module +checksums, manifest, systemd units, safe environment templates, endpoint +template, version lock and `install.sh`. It contains no credentials, +certificates, phone log key, audio, provider configuration, MQ/OSS/AI runtime or +SaaS infrastructure. + +Asterisk 22.10.1 source, its third-party dependency cache and a validated +native stage are staged separately under `asterisk-22.10.1-*` and +`asterisk-22.10.1-native/`, with SHA-256 files and a locked Git commit. A management-approved physical Cell release can +use `deploys/cell/build-asterisk-native.sh` or the native stage, then must +own its systemd unit/config; +this Go package does not rewrite Asterisk. RabbitMQ, OSS and AI are SaaS +infrastructure endpoints rather than packages here; this directory does not +turn any of them into Docker services. diff --git a/deploys/packages/asterisk-22.10.1-deps/MD5SUMS b/deploys/packages/asterisk-22.10.1-deps/MD5SUMS new file mode 100644 index 0000000..41c0313 --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-deps/MD5SUMS @@ -0,0 +1,2 @@ +6077c52677206a84304979b226322283 jansson-2.15.0.tar.bz2 +baacc87418a95107657039269bc71538 pjproject-2.17.tar.bz2 diff --git a/deploys/packages/asterisk-22.10.1-deps/SHA256SUMS b/deploys/packages/asterisk-22.10.1-deps/SHA256SUMS new file mode 100644 index 0000000..3170d8f --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-deps/SHA256SUMS @@ -0,0 +1,2 @@ +a7eac7765000373165f9373eb748be039c10b2efc00be9af3467ec92357d8954 jansson-2.15.0.tar.bz2 +04b2eb1f0f01aa0ad1945b167171843448a51aa6b7c3e806496d434f13a112b7 pjproject-2.17.tar.bz2 diff --git a/deploys/packages/asterisk-22.10.1-native/asterisk-22.10.1-native-stage.tar.sha256 b/deploys/packages/asterisk-22.10.1-native/asterisk-22.10.1-native-stage.tar.sha256 new file mode 100644 index 0000000..e7b23aa --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-native/asterisk-22.10.1-native-stage.tar.sha256 @@ -0,0 +1 @@ +68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d asterisk-22.10.1-native-stage.tar diff --git a/deploys/packages/asterisk-22.10.1-native/asterisk.service b/deploys/packages/asterisk-22.10.1-native/asterisk.service new file mode 100644 index 0000000..1101f5b --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-native/asterisk.service @@ -0,0 +1,22 @@ +[Unit] +Description=Asterisk SIP Cell 22.10.1 (physical host) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=asterisk +Group=asterisk +WorkingDirectory=/var/lib/asterisk +ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk -vvvg +ExecStop=/usr/sbin/asterisk -rx "core stop now" +Restart=on-failure +RestartSec=5s +UMask=0077 +LimitNOFILE=65536 +PrivateTmp=yes +ProtectHome=yes +ReadWritePaths=/etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + +[Install] +WantedBy=multi-user.target diff --git a/deploys/packages/asterisk-22.10.1-native/install-asterisk-native.sh b/deploys/packages/asterisk-22.10.1-native/install-asterisk-native.sh new file mode 100755 index 0000000..fb4c404 --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-native/install-asterisk-native.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ ${EUID} -eq 0 ]] || { echo 'install-asterisk-native.sh must run as root' >&2; exit 1; } +START=false +for arg in "$@"; do + case "$arg" in + --start) START=true ;; + *) echo "unknown option: $arg" >&2; exit 2 ;; + esac +done + +PACKAGE_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd -- "$PACKAGE_DIR" +. /etc/os-release +[[ ${ID:-} == debian && ${VERSION_ID:-} == 13 ]] || { echo 'Debian 13 is required' >&2; exit 1; } +[[ $(uname -m) == x86_64 ]] || { echo 'amd64 host is required' >&2; exit 1; } +STAGE=asterisk-22.10.1-native-stage.tar +sha256sum -c "$STAGE.sha256" +[[ -f asterisk.service ]] || { echo 'asterisk.service is missing' >&2; exit 1; } +getent group asterisk >/dev/null || groupadd --system asterisk +id -u asterisk >/dev/null 2>&1 || useradd --system --home-dir /var/lib/asterisk --shell /usr/sbin/nologin --gid asterisk asterisk +# Keep management-owned /etc/asterisk configuration intact. +tar --exclude='etc/asterisk/*' -xpf "$STAGE" -C / +ldconfig +install -d -o asterisk -g asterisk -m 0750 /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk +install -o root -g root -m 0644 asterisk.service /etc/systemd/system/asterisk.service +systemctl daemon-reload +systemctl enable asterisk.service +if [[ "$START" == true ]]; then + systemctl restart asterisk.service +fi +/usr/sbin/asterisk -V +printf 'installed asterisk=22.10.1 start=%s config_preserved=true\n' "$START" diff --git a/deploys/packages/asterisk-22.10.1-source.sha256 b/deploys/packages/asterisk-22.10.1-source.sha256 new file mode 100644 index 0000000..8c3c44e --- /dev/null +++ b/deploys/packages/asterisk-22.10.1-source.sha256 @@ -0,0 +1 @@ +373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663 asterisk-22.10.1-source.tar.gz diff --git a/deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz.sha256 b/deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz.sha256 new file mode 100644 index 0000000..a6b7d31 --- /dev/null +++ b/deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz.sha256 @@ -0,0 +1 @@ +fd38deece346f1f8ef820a465d41046ccf04ffa2a67c0791dba452a6234b00e5 deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz diff --git a/deploys/physical-deployment.md b/deploys/physical-deployment.md new file mode 100644 index 0000000..8e5d218 --- /dev/null +++ b/deploys/physical-deployment.md @@ -0,0 +1,16 @@ +# Production physical deployment contract + +This project installs the Go Agent and single-active Dispatcher as ordinary +Debian 13 systemd services. The production path is not a Docker Compose stack. + +- Version and host pins: `versions.lock.json`. +- Uploadable package: `packages/` after `build-package.sh`. +- Service units: `systemd/`. +- Safe configuration templates: `env/` and `config/`. +- Secrets/PKI/static Cell artifacts: injected by deployment, never packaged. +- Asterisk 22.10.1: separate approved physical Cell installation; this project + does not rewrite or containerize it. + +The package installer deliberately refuses dirty or non-approved manifests for +production. `--allow-nonproduction` exists only for an explicitly labelled +smoke installation and does not change the manifest or claim P1 acceptance. diff --git a/deploys/systemd/sip-go-agent-agent.service b/deploys/systemd/sip-go-agent-agent.service new file mode 100644 index 0000000..1c19ea0 --- /dev/null +++ b/deploys/systemd/sip-go-agent-agent.service @@ -0,0 +1,24 @@ +[Unit] +Description=SIP Go Agent (physical host) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rogee +Group=rogee +WorkingDirectory=/opt/sip-go-agent/current +EnvironmentFile=/etc/sip-go-agent/agent.env +ExecStart=/opt/sip-go-agent/current/sip-go-agent agent +Restart=on-failure +RestartSec=5s +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +UMask=0077 +LimitNOFILE=65536 +ReadWritePaths=/var/lib/sip-go-agent/agent + +[Install] +WantedBy=multi-user.target diff --git a/deploys/systemd/sip-go-agent-dispatcher.service b/deploys/systemd/sip-go-agent-dispatcher.service new file mode 100644 index 0000000..5b33719 --- /dev/null +++ b/deploys/systemd/sip-go-agent-dispatcher.service @@ -0,0 +1,24 @@ +[Unit] +Description=SIP Go Dispatcher (physical host) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +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 +Restart=on-failure +RestartSec=5s +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +UMask=0077 +LimitNOFILE=65536 +ReadWritePaths=/var/lib/sip-go-agent/dispatcher + +[Install] +WantedBy=multi-user.target diff --git a/deploys/versions.lock.json b/deploys/versions.lock.json new file mode 100644 index 0000000..b8faa73 --- /dev/null +++ b/deploys/versions.lock.json @@ -0,0 +1,45 @@ +{ + "schema_version": 1, + "release": { + "name": "sip-go-agent", + "version": "0.1.0-p1.20260919", + "target": "linux/amd64", + "go": "1.27.1", + "source_policy": "clean-source-required", + "production_approval_required": true + }, + "host": { + "distribution": "debian", + "version": "13", + "codename": "trixie", + "architecture": "amd64", + "system_image": "debian_13_6_x64_20G_alibase_20260828.vhd", + "init": "systemd", + "container_runtime": "not-used" + }, + "cell": { + "asterisk": "22.10.1", + "source_commit": "f0e408a7b0d829c85bf15fa4b487870a50cb3000", + "source_archive": "deploys/packages/asterisk-22.10.1-source.tar.gz", + "source_sha256": "373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663", + "native_stage_archive": "deploys/packages/asterisk-22.10.1-native/asterisk-22.10.1-native-stage.tar", + "native_stage_sha256": "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + "third_party": { + "jansson": { + "version": "2.15.0", + "sha256": "a7eac7765000373165f9373eb748be039c10b2efc00be9af3467ec92357d8954" + }, + "pjproject": { + "version": "2.17", + "sha256": "04b2eb1f0f01aa0ad1945b167171843448a51aa6b7c3e806496d434f13a112b7" + } + }, + "deployment": "physical-host", + "configuration_owner": "management-approved-static-cell-artifact" + }, + "paths": { + "application": "/opt/sip-go-agent", + "configuration": "/etc/sip-go-agent", + "state": "/var/lib/sip-go-agent" + } +} diff --git a/docs/G0开发准备与契约冻结提案_v0.1.md b/docs/G0开发准备与契约冻结提案_v0.1.md new file mode 100644 index 0000000..878b288 --- /dev/null +++ b/docs/G0开发准备与契约冻结提案_v0.1.md @@ -0,0 +1,248 @@ +# G0 开发准备与契约冻结提案 v0.1 + +## 1. 状态、权限与使用方式 + +**状态:D01–D10 方案已由用户确认;项目内 W01 契约基线和 W02 Proto 已建立,但外部权威发布、D10 PoC/运行验收和真实预算仍待完成,G0 尚未通过。** 确认依据为用户本轮指令:“除 OSS 上传Agent会直连上传,从Dispatcher拿相关配置;其它按建议更新文档。” OSS 文件由 Agent 从 Dispatcher 获取受限配置后直接上传,Dispatcher 不中转文件内容;其余按已列建议执行。 + +本项目已创建并验证自己的 Go module、W01 bundle、W02 Proto/stubs、RPC/mTLS 和本地 Mock 测试;未修改父项目权威来源、字段索引或生成产物,未访问真实供应商或创建云资源。文件名保留“提案”以保持链接稳定,不代表还需重复审批已确认方向。 + +当前结论:可以继续开展 P0 契约补充、隔离 PoC 和项目内实现;不得宣称 G0 通过或把项目内基线冒充外部权威,也不得启动未获授权的真实供应商/云/拨号。架构、首发范围和供应商方向沿用已有决定,不重新选型。 + +- 外部字段/路径/状态仍以带来源哈希的上游主契约为准。本文件记录已确认的变更方向和验收要求,**不能被代码当 Schema 使用**。后续由上游维护者修改唯一来源并发布,再导入生成;本文件保留为决策历史,不继续手工维护字段副本。 +- 内部机制、字段方向及兼容策略已确认,正式 Proto 的类型/编号/错误映射等仍需定稿与校验。本轮不生成 `.proto`,也不以临时 JSON 绕过契约;未提供的字段细节、实际预算和技术验证不能视为一并通过。 +- 角色简称:S=SaaS/上游契约维护方,M=管理平台维护方,D/A=Dispatcher/Agent 实现方,O=部署/安全/测试负责人。角色不代表已指定人员或已签收;实际姓名、版本、日期和证据须在批准记录补全。 +- 外部交互仍保留现有 7 条 SaaS 业务路径及 8 种 MQ 事件;已有 AI 版本 GET 的部署归属由 §4 确认,不新增 HTTP 拨号/业务结果回调、MQ 模式字段或内部 MQ。 +- 依据:[重写方案](Go重写方案_v0.3.md)、[通信设计](通信与事件数据交互_v0.1.md)、[验收方案](验证与切换验收_v0.3.md)、[只读字段索引](OpenAPI与MQ字段索引_v0.1.md)、[组件清单](开源组件选型与复用清单_v0.2.md)。索引不是独立契约包。 + +## 2. 方案确认记录与开发边界 + +D01–D10 的共同状态为 **用户已确认方案/源发布与验证未完成**,D07 明确为 Agent 从 Dispatcher 取配置后直连 OSS 上传。确认人及依据为 §1 的本轮用户指令;实现/安全签收责任人和产物版本仍待实际登记。不得再次把已确认方向标成“待用户审批”,也不得因此关闭 GAP。编号保留用于跟踪交付证据,不增加原 88 项运行验收数量。 + +| 确认项 | 对应缺口/交付内容 | 提交与签收角色 | 最小退出证据 | 未满足时阻塞 | +| --- | --- | --- | --- | --- | +| D01 | §3 八种事件的 payload、条件约束与同域版本 | S 提交,D/A 联合验证 | 上游发布版本、Schema、每种事件正反例、跨语言校验/摘要适用规则 | 业务结果序列化与消费闭环冻结;不阻塞只读工具 PoC | +| D02 | §4.1 双 AI 模式及旧版本兼容(GAP-08) | S 提交,D/A 签收 | 两分支 Schema、旧完整模式兼容样本、模式资源/行为断言 | 两模式执行配置实现与 P1 双模式签收 | +| D03 | §4.2 SaaS 配置授权、参数、受控引用(GAP-09) | S/O 提交,D/A 签收 | 部署归属、租户授权/撤销合同、摘要金样、参数到 SDK 映射 PoC | 配置缓存/下发和对应 AI 参数能力承诺 | +| 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 Agent 取配置后直连 OSS 上传(GAP-02 P1) | S/O 提交,D/A 签收 | 受限配置、Agent→OSS 直传及 D 不转发文件证据;既有接口作用域、15分钟token、显式重新申请/完成幂等与 verified 证据 | 录音交接闭环;文本归档仍延后 | +| D08 | §6.3 profile、单活恢复、保留(GAP-06/07 P1) | O 提交,D/A 签收 | 明确数值/来源/预算、备份与恢复演练设计、唯一所有权方案 | 对应运行参数冻结与真实验收,不阻塞离线原理 PoC | +| D09 | §7 独立契约包和重建链 | S/M 提供版本,D/A 负责导入 | 只读包、清单、可重复生成/校验、项目外隔离构建记录 | 独立可交付构建,不得临时读取父目录运行 | +| D10 | §8 依赖和关键 SDK PoC | D/A 提交,O 审核许可/安全 | 精确版本、许可/NOTICE、漏洞处置及对应 PoC 原始记录 | 未验证组件进入正式实现/发布依赖 | + +### 2.1 允许先做、不能先做 + +允许先做:在另行安排开发时,用成熟库开展 SQLite 事务/恢复、AMQP 确认、ARI/媒体、Schema 工具链、SDK 参数映射和 mTLS 原理 PoC;本地资源必须隔离真实外网,不使用生产凭据。PoC 不依赖猜测的业务字段,也不以临时协议投入业务运行。 + +不能先做:实现者自行给 MQ 补字段、用宽松 object 校验生产事件、凭本地 CLI/env 选择 AI 模式、先写临时许可协议后让上游兼容、把供应商 SDK 示例成功当作参数/取消/收费行为验收。 + +可按依赖逐项解锁开发,不要求所有真实供应商测试先完成;但完整 G0 须有 P1 合同和本地关键 PoC 签收,P1 交付仍须独立完成真实验证。真实凭据、外呼、云资源或费用均另行授权。 + +### 2.2 P1/P2 配额澄清(既有要求,不是新提案) + +P1 必须实施当前真实租户的限额,并在单活 D 的同一 SQLite 事务域汇总两个 Cell 的租户/全局/供应商/Cell/按模式 AI 并发及 CPS;保留租户复合键、有界窗口、未知占用、停止屏障。不能把“只有一个真实租户”解释为免除租户隔离或额度执行。 + +P2 增加多个同时活跃租户的等权轮询、额度不足跳过、公平恢复、全链路背压和公平性验收;不是首次实现原子配额。P1 可用两个模拟租户测隔离,但不因此开放第二真实租户。 + +## 3. D01:外部事件 Schema 补齐方案(已确认方向) + +### 3.1 上游修改方法 + +由上游将现有 `event_type` 分支与专属 `payload` 约束关联,保留原信封和严格未知字段策略。复用上游已有 Call、Attempt、TranscriptSegment、Recording、CommandResult 等实际组件;组件名称以源发布物为准,不在本项目重复定义。现有字段可复用不等于可以省略事件条件约束。 + +源发布前须给每种事件补齐必填/可选、类型/枚举、字节限制、可空规则、时间单位和条件组合。下表是**已确认的约束及复用方向**,不是已发布完整字段表;缺少源字段的地方由 S 明确扩展细节,不能凭表生成未经权威源定义的 JSON。 + +| 事件 | payload 方向与必须冻结的条件 | 最少反例 | +| --- | --- | --- | +| `command.result` | 复用原命令结果快照;按 command_type/status 限定 execution/call/control revision 及 reason 的适用性;发起前拒绝/超期没有 call_id;executing 不代表已发 SIP,控制 applied 必须有全目标屏障事实 | 无 call 拒绝却造 call_id;仅 accepted 就标控制 applied;CAS 失败返回成功 | +| `call.status` | 复用同 call 下的通话/attempt 状态快照,关联租户、原命令/执行及当前 attempt;线路/Cell/出口来自实际获批路由;阶段时间依据观测而非纯本地计时 | answered 无接通事实;换租户/attempt;用整体版本丢掉独立域事件 | +| `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.failed` | 复用原录音身份、失败原因与可恢复性;明确上传超时、校验失败和永久丢失的区分;仍保存后续对账/补传所需关联 | 失败产生新 recording_id 逃避幂等;永久丢失报 ready | +| `contact.opt_out` | 复用原租户/task/member/call 关联与拒绝时间/证据引用;最小必要信息;ASR-only 同样具备经批准的判定与通知流程 | 未授权模型判定;跨成员关联;等待录音上传才发 opt-out | + +### 3.2 顺序、重复与测试 + +- D 对同一稳定事实和内容摘要幂等;同事实 ID 异内容必须冲突。MQ event_id/aggregate 字段继续由 D 的权威事务生成,不能由 Agent 自报最大版本。 +- 版本按源规定的聚合实体和状态域处理,独立文字段/录音不被其它域的大版本覆盖;重放使用原事件身份,publish confirm 不等于 SaaS 应用收讫。 +- 每种事件至少保留一个合法样本和一个非法样本;另测缺必填、未知字段、错误事件分支、跨租户/归属、空值、边界尺寸、乱序与重复。Schema 能验证形状,数据库归属、控制屏障和 verified 来源还必须运行时验证。 +- 上游发布包含上述样本与预期结果的版本;本项目导入原样例运行 Go 校验,不手写一组与上游互不校验的“同名 Schema”。 + +## 4. D02/D03:双模式与 AI 配置方案 + +### 4.1 两模式与兼容(用户已确认,待 S 发布) + +在**不可变 AI 版本对象**新增 `mode`,枚举为 `asr_only` / `asr_llm_tts`;不放入 `call.execute`。模式随 agent_version_id 固定,不能通过运行时参数覆盖;当前上游 Schema 尚未更新。 + +| 情况 | 已确认解释与条件 | +| --- | --- | +| 已有发布版本未带 mode | 仅在符合原完整 AI Schema 时按 `asr_llm_tts` 执行;不回写原对象、不注入默认值后重算原摘要。该兼容规则须进发布版本说明 | +| 新发布版本 | 发布入口要求显式 mode;读取旧版本与创建新版本可有不同校验上下文,不能拿兼容读取规则默许新建省略 mode | +| `asr_llm_tts` | 保持原 llm/prompt/tts/asr/conversation 条件和完整对话能力,不因模式引入放宽原必填 | +| `asr_only` | 要求原身份/不可变标记、asr 和适用的 conversation 控制;llm/prompt/tts 必须缺省而非空对象或 null;不得建立 LLM/TTS 会话或占用其专属额度 | +| ASR-only 的 conversation | 仅要求 silence_timeout_ms/max_duration_ms;opening/allow_interrupt/max_turns/sentence_max_chars/max_pending_audio_chunks 禁止提供,避免对无播放/生成场景产生假控制。若业务需识别段数量上限,应另提明确字段,不套用对话轮数 | +| 缺必需能力/未知 mode | 拒绝新准入,不自动切换成另一模式,不静默关闭不支持参数 | + +ASR-only 静音以实际输入活动及获批 VAD 策略判定,最长时限从源契约约定的接通时点起算;静音到期按获批结束策略关闭,不自动播放提示。最终文字、录音、文字失败、opt-out 和控制屏障仍适用。opt-out 判定复用独立批准规则;禁止为此暗中开启 LLM,规则缺失时该能力门禁仍阻塞。 + +模式分支由上游使用源 Schema 机制生成,保持 additionalProperties 严格策略。当前 Schema 无法表达上述 ASR-only,上游发布前不能提交假的完整配置“先跑起来”。 + +### 4.2 读取、授权与快照(方向已确认,待 S/O 补齐部署与合同细节) + +1. 明确已有 `GET /internal/v1/ai/agent-versions/{agent_version_id}` 的 **SaaS 部署入口**、服务身份与可信租户绑定;租户不从响应任意覆盖任务归属。无权、找不到、不可用分别处理,不猜测第二个 task-config 路径。 +2. 在首次成功准备执行配置时,将 `(原值 tenant_key, agent_version_id, 源 Schema 版本, 原内容摘要)` 与执行绑定并持久化。尚未取得有效配置的排队项不能准入;已有有效绑定的排队/在途项不因“最新版本”改变配置。新任务须显式引用新 agent_version_id。 +3. 摘要采用上游已有算法及其精确输入边界,导入跨语言金样:键顺序、Unicode、数值、缺省/null/0/false 均须验证;本项目不另选 canonical 算法。若上游未发布明确算法,保持该项阻塞,不把普通 json.Marshal 字节当权威摘要。 +4. 模式/参数归一化结果作为独立有效快照,关联原摘要和适配器版本,不修改原不可变内容。日志仅留版本、摘要及允许的脱敏参数,不留 prompt/variables/密钥。 +5. P1 **不凭离线缓存授予新的发起许可**:D 可缓存不可变内容,但最终许可前须有本次执行的有效授权检查;SaaS 不可达且无上游已批准有效期的授权记录则拒新准入。已确认撤销阻止新许可,已发许可按 §5 有界收敛,在途终止仍走批准控制,不擅自挂断;撤销传播时效等源细节仍需冻结。 +6. 如需要离线准入,上游须另外发布租户绑定、有效期、撤销传播和缓存可用性规则;未批准前不从本地 TTL 或 ETag 推导业务授权。每次权威检查使用已有读取合同,不新增自造授权接口。 +7. provider_ref/credential_ref 由 O 管理的受控注册表解析,包含供应商、允许 Endpoint、API/资源版本、凭据种类、租户作用域及出口/能力限制;AI 业务参数不写入该注册表。Agent 不直连 SaaS,也不接收全局凭据库。 +8. D 向被绑定 Agent 交付本执行最小权限配置/凭据;会话元数据、令牌和敏感值不进日志。配置哈希与原 JSON 校验、有效参数映射和权限检查都成功才准入。并发 SDK 客户端不得共享可变的 per-call 参数。 + +### 4.3 参数差异表(方向已确认,待进入上游,不是本地 Schema) + +现有 model/prompt/voice/speed、ASR 输入/语言/interim、temperature/max_tokens/timeout_ms 和 conversation 字段必须原义传入 SDK/控制器;不能因 SDK 有默认值就省略映射。已有 speed 范围等不在本文件重定义。 + +| 待源发布的扩展名 | 已确认语义/单位/缺省方向 | 能力与边界要求 | +| --- | --- | --- | +| `asr.hotwords` | 有界非空词字符串列表;未提供表示不启用用户热词 | S 冻结项数/UTF-8 字节上限和必要的词权重模型;仅适配 SDK 真正支持的表达,不能塞 raw_request | +| `asr.vad` | enabled(bool)及获批的语音开始/结束静音阈值(ms);false 与未提供不同 | 明确供应商 VAD 或本地控制器职责,不能双重处理互相截断;阈值范围需供应商证据 | +| `llm.top_p` | number,0 < top_p ≤ 1;未提供按版本化能力表明确解释 | 与 temperature 的同时使用/互斥规则逐供应商批准,不擅自丢弃一个参数 | +| `tts.volume` | 对外统一 0–1 线性音量;显式 0 表示静音,不是默认音量 | 须验证 SDK 原生单位及转换是否保真;只能离散或无等价语义时先提交变更确认,不能先四舍五入上线 | +| 阶段时限 | 分 connect/first_output/idle/total,均整数 ms;与现有 timeout_ms 的优先级须源合同定义 | 网络连接由适配器、流空闲/总时限由控制器共同执行;缺省使用已批准模式 profile 的明确值,不静默沿用 SDK 默认 | + +批准参数必须附:必填条件、合法范围、缺省来源、显式零/false 处理、SDK 精确字段/转换、供应商不支持时的拒绝行为和正反例。上表未给齐的供应商范围属于待取证项,不允许本地臆定;metadata 不承担执行参数透传。OpenAI 兼容 SDK 自动重试显式关闭;取消不能触发旧音频重播或二次收费请求。 + +## 5. D04/D05:首发 Unary 与最后许可方案 + +### 5.1 公共关联和幂等(语义已确认,待正式 Proto 定稿) + +保持通信设计 R01–R03/R05/R07–R13 职责,不增加在线发布 R04/R06 空壳。公共字段按已确认方向分层,精确 wire 定义随 Proto 定稿: + +| 层 | 字段方向/规则 | +| --- | --- | +| 传输/诊断 | protocol_version、request_id、trace_id;RPC deadline 只限制一次等待,不是业务授权/通话期限;重试可换 request_id,不能换业务幂等键 | +| 目标/授权 | dispatcher_epoch、agent_id、cell_id、boot_id、session_generation;节点会话凭据仅安全元数据传输;目标均来自 D 的受控绑定 | +| 持久幂等 | operation_id + operation_type + target;内容摘要不可变,同键异内容冲突;业务归属键始终包含原值 tenant_key 与源 execution/call/attempt 标识 | +| 配置/控制 | AI 原版本/摘要、SIP applied revision/hash、task_revision、admission_generation 与 resource_reservation_id;缺失/过期/不匹配拒绝 | +| 事实提交 | fact_id、fact_digest、observed_at、发生时 boot/source_sequence;当前认证会话与历史事实 boot 分离,D 事务持久接收后才确认 | + +错误映射沿用通信设计 §7.1。accepted 仅表示持久接收,applied 需真实屏障/加载/控制证据;NOT_FOUND、超时、CANCELLED、Asterisk 当前通道列表为空均不是“从未拨过”证明。 + +### 5.2 共用证书下的激活、代次和撤销 + +- D 从预配置 Endpoint 列表发 R01 受限探测,校验 TLS SAN/SNI、Agent 群组证书及部署期预期目标,再发 R02 绑定 agent/cell/boot。A 只接受独立 Dispatcher 身份;不能执行 A 自报 URL。 +- D 每次取得唯一运行所有权后建立新的 dispatcher_epoch;A 每次启动产生新的 boot_id,旧 boot 的执行许可一律无效,但旧执行/资产占用不清零。generation 在所属 epoch 内单调;epoch 不能按 UUID 大小排序。 +- R02 采用持久 activation operation_id:D 先保存 pending 绑定,再由 A 持久接受,D 收到/查询到相同绑定后标 active;回复丢失重查原操作。active 前 A 不获得执行/敏感配置权限。会话令牌用成熟安全随机源,D 仅保存必要校验材料,禁止自研密码协议。 +- 激活冲突、旧 epoch、重放、错误 boot、未完成绑定都 fail closed。新 epoch 激活必须在旧 D 已停用/网络隔离/身份撤销可证明后进行;备份恢复不能仅修改一个 generation 数字声称隔离旧进程。 +- 共用私钥泄露可能冒充整组 Agent,Endpoint 和会话只能降低误绑定/重放风险,不形成节点级密码学隔离。撤销/轮换针对全组;先关准入、收敛许可并处理在途,再替换证书和会话。不能关闭主机名验证或以风险声明替代演练。 + +### 5.3 最后许可的持久顺序(已确认安全边界) + +内部阶段为 prepared → permit_granted → dispatching → observed/unknown → terminal;这些不是新增 MQ status。资源预留、文件记录、D 账本和 Asterisk 事实分别承担职责,不宣称跨 SQLite/文件/ARI 原子事务。 + +1. D 事务建立原执行/attempt 的发起意图及完整资源预留、唯一 Agent/Cell 路由和配置绑定,持久后才 R07。A 校验后先持久执行文件,R07 重复只能返回原决定,不能发起通话。 +2. A 准备发起时,以原 attempt 和唯一 permit 请求 R08。D 在一个短 SQLite 事务内校验当前任务控制、准入代次、会话/健康/版本、授权时段/截止、供应商矩阵及全部额度,再记录唯一 permit_id 和有效期;不跨网络持有 SQLite 写事务。 +3. 同 permit 操作重试返回原许可/原期限,不延长授权、不另计一份并发。CPS 在最后许可发放时不可重复扣减地记入限速窗口;许可未使用也不在窗口内提前退 CPS。但许可速率不能直接当真实发起速率:不同许可可能因传输/调度延迟集中提交。P1对每个共享资源采用保守窗口,在任意“源CPS统计窗口+最大许可TTL+时钟安全余量”内发放的许可不超过原窗口预算,同时保留原突发上限;不得各Cell自行发满一份。该办法降低可用吞吐,须由O按供应商实际CPS口径批准,并用两Cell延迟/集中提交负例验证,不能擅自缩短窗口换吞吐。并发释放另依实际可证明终态判断,不能将两者混为退款。 +4. A 在统一的**本地发起/控制串行区**检查 boot/session、控制/准入代次、实际加载快照和许可期限,持久记录 dispatching 及确定的 ARI 通道关联后,才允许至多一次 originate 提交。R05/R09 关闭准入也进入该串行区;检查与提交之间不能让控制悄悄穿过。 +5. 串行区不能无限等 ARI:网络等待有界,超时/进程中断即保留 unknown;已经提交或可能提交的请求不以同 ID 再次 originate。ARI 客户端对该操作禁止透明自动重试;确定通道 ID 用于关联对账,不单独构成 exactly-once 证明。 +6. A 用本机单调时钟对一次 R08 请求建立有效期上界:计时从请求发出起,不从响应收到起;同请求重试不重置起点。D 返回剩余有效期不得超过原许可期限/业务截止。绝对授权截止还须检查批准的时钟偏差安全余量;RPC 晚到、时钟异常、失联或本地代次变化拒绝发起。 +7. boot 重启不恢复旧单调计时或旧许可;dispatching/unknown 先查本地文件、D 意图和 Asterisk/其它获批证据,不自行重新拨号。prepared 未越过发起点也必须得到 D 对旧许可收敛的明确决定,不能自动续期。 +8. R11 提交稳定事实:D 将去重、状态/配额和 MQ outbox 放同一事务,commit 后才成功响应。成功包丢失 A 重报原 fact;A 不得因缺确认删除唯一事实,也不得换 fact_id 制造新结果。 + +这套顺序选择“宁可保留不确定占用,也不重拨”,不是承诺网络分区下无需对账的 exactly-once。许可控制的本地界线是首次提交 ARI,不是对 Asterisk 发出线上 INVITE 的跨进程原子提交;已经在途的 ARI 请求必须计入未知占用和控制屏障,不能仅等 TTL 就报告无发起可能。许可过期只禁止新的 ARI 提交,不证明过去没有提交,不能自动释放未知占用。CPS还须观测 Asterisk 实际发起时间;若其排队能使供应商实际口径超额,须先用适用的 Asterisk 原生限速/准入能力及成熟 SDK 验证解决,否则阻塞 D04,不以许可窗口通过冒充实际CPS通过。 + +### 5.4 pause/stop/维护屏障 + +D 先 CAS 持久化控制及关闭新许可,再向所有相关 Cell 下发同一控制操作和目标 revision。A 持久关闭门闩、使未使用旧许可失效,报告已提交/可能提交/振铃/接通/未知占用。串行区内抢先提交的发起属于屏障前执行,必须计入控制范围,不能漏掉。 + +- pause/drain:不新增发起,屏障前已拨出/振铃/接通沿原生命周期继续;暂停后的恢复须新的合法控制/授权,不复活旧许可。 +- stop/drain:同样阻止新发起,已提交执行按源 drain 规则结束;stopped 不可恢复。stop/hangup 必须逐项执行获授权挂断并核对终态,超时不当成功。 +- 维护/SIP 静态改配:关闭受影响资源,旧许可失效且未知占用完成对账,按维护要求排空后才加载。不能拿任务 pause 的完成条件代替配置安全排空。 +- applied 只有在所有必需目标的对应语义均有证据时成立。失联 A/仍可能在途的 ARI 请求保持 applying/reconciling;D 自己等到 TTL 不替代 A 的收敛证据。 + +### 5.5 恢复/故障矩阵与内部 PoC 初始 profile + +| 注入位置 | 必须保留与允许的动作 | 禁止 | +| --- | --- | --- | +| D commit 前后崩溃、R07 回复丢失 | 原执行/预留/操作恢复并查询原结果 | 改 execution_id 再投;重新发一份额度 | +| R08 已提交但回包丢失 | 查/重取原许可和原期限;过期进入收敛 | 新 permit 无限延寿;先退并发再重拨 | +| A 写 dispatching 后、ARI 提交前后崩溃 | 按原通道/意图对账;不能证明则 unknown | “通道没找到”即重发 originate | +| 控制与最终检查/ARI 提交交错 | 同串行区判定先后,D 等所有目标屏障事实 | 仅 CAS 成功就发 applied | +| A 重启/D epoch 更新/时钟跳变 | 废弃旧发起权限,保留旧通话/资产归属 | 新 boot 清空占用;旧事实按旧 boot 丢弃 | +| R11 commit 后回复丢失 | 重报同 fact 同摘要,返回原持久结果 | 二次 outbox 事件、跨状态域版本覆盖 | +| SQLite 备份恢复、旧 D 仍可能存在 | 隔离旧 D 后受控激活;无证据停止新准入 | 两份 SQLite 并行发许可;NFS 共享文件充当 HA | + +以下为**用户已确认、尚未实测的内部隔离 PoC 初始值**,不覆盖验收 §9 的上游基线或真实供应商预算:最终许可 TTL 上限 1000ms、R08 单次 deadline 500ms;一般查询/事实提交单次 deadline 3s;接收控制单次 deadline 1s(不是 applied 完成时限)。重试不得越原业务/许可截止,退避继承已批准 profile;不能证明 TTL/网络/时钟余量时拒绝发起。 + +每条内部 Unary 编码后上限 512KiB,许可/控制请求上限 64KiB;不传音频/录音字节,不无限批量事实。外部 MQ 256KiB、HTTP JSON 64KiB 等源限制仍独立有效,内部较大上限不能绕过外部上限;超限拒绝/告警而非截断。协议初版仅支持同一批准版本和显式能力集,未知枚举/关键字段拒绝准入;后续兼容范围须有升级测试再扩展,不承诺自动滚动升级。 + +## 6. D06–D08:静态交接、录音与运行恢复 + +### 6.1 P1 静态制品交接(流程已确认,待正式制品合同) + +M 是唯一编辑/审批面;制品外层记录 source_release、source_digest、approval_reference、精确 cell/trunk 目标、源 Publication 的 revision/config_sha256 和协议兼容范围。SIP 配置体复用源结构,外层包装不得冒充已有 API 已支持的新字段。 + +交接顺序:M 批准 → O 核验来源和两个 Cell×至少三供应商授权矩阵 → D 持久关闭资源准入/R05 收敛 → O 经唯一受控入口原子部署/加载 → A/R01 报实际快照与脱敏 Asterisk 加载证据 → D 核验后逐资源开放。审批记录必须证明旧直写入口已关闭。 + +加载证据至少关联制品版本/哈希、目标、Agent boot、Asterisk 镜像/配置版本、实际加载时间及 SDK/CLI 观测摘要;文件写入成功或 reload 返回 0 不足以证明加载。失败保留关闭准入,人工恢复旧制品同样重新核验;不自动拨电话验证,也不自动启用未获白名单授权的另一出口。 + +### 6.2 P1 录音配置与 Agent 直连 OSS 上传(用户已确认) + +**上传数据面固定为 Agent → OSS;配置/授权控制面为 SaaS → Dispatcher → Agent。Dispatcher 不接收、缓存或代理转发录音文件内容,也不替 Agent 执行 OSS 文件上传。** Agent 从 Dispatcher 取得相关受限配置后,使用官方 OSS SDK/成熟上传客户端直接连接获准 OSS 目标;“Agent 不直连 SaaS”不禁止其直连 OSS。 + +OSS 配置源仍是 SaaS,D 通过原 recording-uploads/upload_id/complete 流程管理每个录音的上传会话。下发配置包含契约允许的 HTTPS 上传目标、必要 headers/受限上传凭证、有效期及对象/内容绑定;不下发全局长期 AK,不维护 Agent/CLI 的第二套 bucket/AK 配置。S 仍须补齐作用域、size/checksum、续期是否保持原会话及 complete 幂等返回的源细节。 + +1. A 封口文件并持久元信息,通过 R12 向 D 取得该原录音身份的受限上传配置;D 负责与 SaaS 申请/续期。 +2. A 直接把文件内容上传至指定 OSS,不把文件发给 D,也不通过内部 gRPC 传录音字节。D 失联时已有仍有效授权可继续上传;授权过期或缺失则保留原文件,等待 D 恢复后续原会话,不改用本地长期密钥。 +3. A 通过 R13 向 D 报告原资产/会话及上传元信息;D 调用 SaaS complete,由 SaaS 独立校验对象。上传成功但 D/complete 不可达时只保留待完成状态,不能发 ready。 +4. verified 后 D 事务记录资产状态和 recording.ready outbox,再通过 MQ 回传 OSS ID。PUT/complete 超时分别按原对象/会话幂等对账,恢复不能新建资产或重拨。未定义的续期/查询语义仍阻塞相应恢复分支,不猜测接口。 + +本地删除条件继承验收 profile:verified、ready 已得到要求的发布确认、无恢复任务且满足保留;MQ confirm 不等于 SaaS 应用收讫,不额外等待不存在的应用 ACK。文本 OSS 归档继续受 GAP-02 延后约束,实时 transcript.updated/opt-out 不等待 OSS。 + +### 6.3 运行 profile 与单活恢复登记 + +审批登记须包含:profile_id/版本/源哈希/覆盖理由、1 D+2 A 的精确资源绑定、租户/供应商/Cell/模式额度、队列与待发起窗口、心跳/许可/时钟余量、磁盘/文件阈值、保留/备份、真实测试号码/线路/费用上限、维护窗口、RPO/RTO 和签收人。 + +沿用验收 §9/§9.1 的适用基线(例如心跳 2s、租约 10s、失联停新发起、磁盘 70%告警/80%停新准入/60%恢复),拓扑差异只用有批准记录的覆盖;不把 DEV 全局 6 或后续 1000 路当 P1 真实授权。未取得实际预算/供应商上限时填写 blocked,不填虚构数字。 + +单活采用本机部署监督+成熟 OS 排他锁防止同机双启动,SQLite 保持本地盘,在线备份走成熟驱动/SQLite 支持的备份能力,包含 WAL 一致性;这些不构成跨机隔离。人工迁移/恢复先证明旧 D 停止与发起权限隔离,再恢复库、激活新 epoch、核对两 A 的文件/Asterisk/未决资产和额度,最后开准入。卷丢失时按实际事实损失签收,永久未上传录音不得承诺 RPO=0。 + +## 7. D09:独立只读契约包与可重复生成方案 + +后续进入开发时,在本项目导入一个只读版本目录 `contracts/upstream//`(此目录本轮未创建),完整包含需要的 Schema/OpenAPI/示例及其引用闭包;禁止外部 `$ref` 在构建时联网或越出包目录。本项目自己的批准 Proto 来源单独维护并记录版本,不手写外部业务结构替代源类型。 + +清单记录:发布 ID、上游仓库/不可变 commit 或发布地址、文件路径与 SHA-256、批准引用、生成器版本/校验参数、源 Schema 方言和许可证。发布方的来源校验与内容哈希都要验证;自报哈希不等于可信来源。 + +已确认的后续流水线(本轮未执行): + +1. 验证来源和全文件哈希,拒绝缺失/额外未声明/路径越界/引用不闭合的文件;导入过程不能覆盖旧已发布版本。 +2. 用锁定的成熟生成器从源生成 Go 类型和只读字段索引,并使用锁定的成熟 Schema 校验器显式执行运行时校验;类型生成不等于校验。生成器不支持的关键约束应报阻塞,不手改生成代码绕过。 +3. 对上游合法/非法样例执行一致性测试;同输入重建结果必须确定,生成结果差异须在 CI 失败。源升级经过兼容测试而非静默替换。 +4. 将项目复制到无父目录、无外部网络依赖的隔离工作区,用已批准依赖缓存完成生成/构建/测试;普通运行不读取父文档目录。维护者导入可以显式取上游发布物,普通构建不可以偷偷读父目录。 +5. 字段索引同步由同一源版本生成,本文件提案不进入生成输入。本轮保留现有索引,避免手工改成第二套 Schema。 + +## 8. D10:最小 PoC 顺序与证据 + +复用[组件清单](开源组件选型与复用清单_v0.2.md)现有候选,不新增协议栈。本轮不声称已下载或验证任一精确版本。每个采用项记录 module/tag/commit、源码/依赖哈希、Go 1.27.1 构建、许可证/NOTICE、传递依赖/漏洞处置和适用协议版本。 + +| 顺序/已有验收域 | 本地最小检查与通过条件 | 失败处理 | +| --- | --- | --- | +| 1.Schema/生成工具 | 真实上游 3.1/2020-12 特性、严格对象、条件分支、缺省/0/false、引用闭包和中文原值在 Go 中与金样一致;不只编译生成类型 | 换成熟生成/校验工具或修上游;不放宽 Schema | +| 2.SQLite+Agent 文件/恢复 | 单 Cell 额度事务、inbox/outbox crash 点、WAL 备份还原;文件使用成熟 FS 原语验证落盘/原子替换/损坏隔离和重启重放 | 驱动不满足先替代;不改成内存字典或 Agent 业务数据库;跨 Cell 额度延期第二阶段 | +| 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 的 confirm/重投/限流、SQLite outbox、受限上传及 complete 超时恢复;供应商 API 用协议 Mock | 本地通过不替代真实 MQ/OSS/云身份验证 | + +每份证据记录环境/mock-mixed-real、版本、输入边界、预期/实际、脱敏日志、失败注入点和残余风险;失败/blocked 不删样本。SDK 本地 Mock 只验证客户端映射,不证明供应商服务端支持、费用或双模式真实可用。真实 SIP/AI/OSS 必须另行授权并分别验收。 + +## 9. 确认结果、后续交付与完成判定 + +D01–D10 的建议已按 §1 用户指令确认,D07 明确为 Agent 取 Dispatcher 配置后直连 OSS 上传。不再重复要求用户审批相同方向。后续优先完成 D02 的上游双模式 Schema 和 D04/D05 的正式 Proto/会话合同,同时由 S 补 D01/D03、M/O 补静态交接、上传会话细节和实际 profile;选型 PoC 与只读包工具验证可以并行准备。 + +逐项跟踪格式:`确认项 → 用户已确认语义/例外 → 待交付字段/产物 → 上游发布版本/哈希或内部协议版本 → 实施/签收责任人及日期 → PoC/验收证据 → 未决风险`。缺产物/责任人/证据时标为“方案已确认,交付/验证未完成”,不能退回“待用户审批”或冒充“已冻结/通过”;对未给出的字段细节、真实额度/费用以及偏离已确认方案的变更另行确认。 + +**本轮完成标准:**确认状态、Agent→OSS直传及相关验收要求在文档间一致,父项目权威源未改;不是十项门禁全部通过。后续获得修改上游/创建代码的执行授权再导入发布物、生成 Proto/类型和执行隔离 PoC;本轮不扩大到这些操作。真实云、供应商消费和拨号仍另获明确授权,P1/P2/后续功能分别验收。 diff --git a/docs/Go重写方案_v0.3.md b/docs/Go重写方案_v0.3.md new file mode 100644 index 0000000..16f7208 --- /dev/null +++ b/docs/Go重写方案_v0.3.md @@ -0,0 +1,431 @@ +# SIP Go Agent 重写方案 v0.3 + +本版按本次讨论收敛为**稳定、快速单节点内测上线**:1个Agent/1套Asterisk/1个单活Dispatcher、单 Cell、单租户、静态发布、ASR-only与ASR+LLM+TTS双模式。至少3家SIP供应商保留为 trunk 配置与协议 fixture 覆盖;双节点、第二 Cell、双租户、真实 SaaS/MQ 联调和生产 ECS 延期第二阶段。保留Cobra双命令、Dispatcher SQLite、Agent文件、Unary gRPC及关键可靠性;动态治理和规模化另立项。文件名保留,当前仍只有文档,不代表实现或验收通过。 + +## 1. 状态、范围与决策 + +**状态:设计草案,不代表实现、切换或生产验收完成。** + +用户已确认: + +1. 使用 Go 1.27.1,完整重写 Agent,但分阶段替换。 +2. 范围包含调度、Cell 执行、ARI/RTP/录音、AI 流式适配、Cell 配置接收;不是只做一层 Go 代理。 +3. `go-sip/` 是独立 Git 仓库,自己的文档也全部放在其 `docs/` 中。 +4. 不重写 Asterisk,不合并独立管理平台,不改变 SaaS 的 MQ 业务边界。 +5. SIP 和其它组件有适配需求的现成开源库/官方 SDK 时必须复用,绝不从零手写替代协议栈或客户端;库不满足时先证明缺口、选择替代或向上游修复,不能自行转为手写实现。 + +6. 同一Go module、同一版本/二进制,使用Cobra的 **`agent`、`dispatcher` 两个业务子命令**,分进程部署,方便统一更新而不混用权限。 +7. 不接入PostgreSQL;独立Dispatcher统一全局任务/配额,使用本地持久SQLite。Agent不设SQLite业务库,文字/录音及必要执行/上传恢复信息流式落文件。 +8. 内部采用 **Unary gRPC**;Dispatcher预配置Agent Endpoint列表,Agent业务启动参数尽量只有Dispatcher Endpoint,不增加内部MQ或双向流。 +9. 所有Agent共用一套mTLS证书;这只认证Agent群组,单节点授权另由受控Endpoint/自动会话绑定落实。Dispatcher身份独立,不能伪称具备独立节点证书隔离。 +10. OSS配置来源于SaaS,Dispatcher向Agent提供受限上传信息并统一回传SaaS。文本保留实时MQ事件,OSS用于归档;录音按OSS ID查看。 +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 联调延期第二阶段。 + +以上范围不再列为待定。G0只冻结P1实际使用的Proto、事件payload、双AI模式及SaaS配置/调参、静态配置交接、录音授权、共享证书授权和运行阈值;后续功能有明确阶段,不再要求所有未来缺口同时关闭。本轮不创建Go骨架、数据库、broker或真实呼叫。[G0开发准备与契约冻结方案](G0开发准备与契约冻结提案_v0.1.md) D01–D10的方向、模式/许可/恢复机制及内部PoC初始profile已获用户确认;权威源发布、正式Proto及验证仍未完成,不能视为G0通过。本轮只同步文档,不修改上游Schema或创建代码。 + +OSS数据面已明确为**Agent→OSS直连上传**:Agent从Dispatcher获取本录音的受限目标/凭证/headers等配置后自行上传文件;Dispatcher不接收或转发录音字节,负责SaaS上传会话/complete协调及verified后的MQ结果回传。每次下发token有效15分钟;过期或失败时保留源文件,由调用方显式重新申请,不自动续期或重试。此直传不改变Agent不直连SaaS的边界。 + +### 1.1 本次P1目标与完成标准 + +| 目标 | 完成判定 | +| --- | --- | +| 单节点/单 Cell/单租户 | 1个Agent/1套Asterisk/1个单活Dispatcher;单 Cell 静态制品、单租户准入和本地持久恢复闭环;不要求第二节点或第二 Cell | +| 至少3家SIP供应商 | 每家独立trunk/主叫/前缀/codec/额度的契约、路由和协议 fixture 校验;真实供应商成功外呼延期第二阶段 | +| 双AI模式 | 两种模式均在本地/隔离环境从命令到通话模拟、实时文字、录音和结果闭环;ASR-only不调用/占用LLM/TTS,真实新供应商联调延期 | +| 单租户、静态配置 | 只启用一个可信租户;使用管理批准的不可变单 Cell 快照,初装及维护变更均核验实际加载,错版本/哈希不接单 | +| 可靠性基础 | 同执行不重复拨号、未知不重拨、控制屏障/持久恢复/录音交接安全;租户独立队列、复合幂等键和单 Cell 调度边界留在本轮 | +| 契约与独立交付 | SaaS/MQ 交互按版本化契约、Schema、fixture 和本地状态机验收;可独立检出、构建、测试、打包,无父仓库/Python运行依赖 | + +验收按[验收方案§1.1–§1.2](验证与切换验收_v0.3.md)执行。P1通过只表示当前单节点/单 Cell/单租户本地范围通过;不代表真实 SaaS/MQ receipt、真实供应商/ECS、双节点、第二 Cell、双租户、1000路/N+1或生产切换通过。 + +### 1.2 明确不做 + +- 不自研 SIP 协议栈,不逐通电话重写 `pjsip.conf` 或重载 Asterisk。 +- 不建设第二套 SIP 管理后台,不共享管理平台或 SaaS 的业务数据库。 +- 不引入自研调度框架、插件系统、服务网格、事件溯源平台或默认 Kubernetes。 +- 不复用 `voice_test` 的 LLM/TTS;验证前仍明确Mock/未启用,但真实双模式是P1退出门禁,不是可删减项。 +- P1不做在线动态配置编排、自动跨供应商FALLBACK、活动通话迁移、多Dispatcher HA、权重借用、AI插件/多供应商编排、文本OSS归档、1000路/N+1验收。 +- 本轮不开发或验收双租户、公平调度和第二 Cell 汇总配额;保留 tenant_key、独立队列、复合幂等、单租户配额和控制屏障。多租户/跨 Cell 能力作为后续阶段,不能在本轮状态中标为已通过。 +- 不把整份 Python 源码机械翻译为 Go,也不为使用新语言特性而增加抽象。 + +## 2. 已有实现与迁移依据 + +以下路径仅记录现有实现的来源,**不是新项目的运行依赖**: + +| 现有模块 | 已观察到的职责 | 新项目处理方式 | +| --- | --- | --- | +| `agent_call/core.py` | 持久状态、租约、租户轮询、控制、MQ inbox/outbox、上传与补传恢复 | 拆出调度控制面,重建自有持久化边界 | +| `agent_call/real_cell.py` | ARI、RTP、通话资源、路由验证、执行账本、未知执行对账 | 迁移语义到Agent文件恢复与Dispatcher权威账本;不照搬Agent SQLite,不确定不重拨 | +| `agent_call/cell_agent.py` | 配置版本/哈希校验、落盘、Asterisk 应用和状态回执 | 迁移为 Cell 的受控配置接收能力 | +| `agent_call/ai_runtime.py` | ASR/LLM/TTS 流式编排及取消 | 按新项目的音频与供应商契约重新划界 | +| `services/asr-web` | 已有 Go ASR 协议实现 | 复用其已验证行为/测试;先选匹配供应商协议的现成 SDK,不照搬或重写二进制协议,不依赖原服务目录 | +| `management/` | 独立 Go 管理 API 和前端 | 保持外部系统,只通过批准的版本化接口集成 | + +当前 Python 调度每轮涉及租约、指令接收、控制、调度、outbox 等多个职责;真实 Cell 已有本地执行账本。**行为和测试可以复用,进程内锁、线程组织以及调用方/执行方混在一个服务中的边界不能照搬。** 尤其不得让生产 Agent 同时扮演 SaaS 事件消费者或写 SaaS 数据库;这些能力只能存在于独立测试端。 + +## 3. 独立项目与契约管理 + +### 3.1 目录规划 + +下面是逐阶段建立的目标布局,不是本轮已经生成的文件列表: + +```text +go-sip/ +├── AGENTS.md +├── README.md +├── go.mod / go.sum # 独立模块;P1 再创建 +├── cmd/sip-go-agent/ # 唯一main,Cobra根命令 +├── internal/ +│ ├── cli/ # agent / dispatcher 两个子命令 +│ ├── contract/ # 从批准契约生成的类型/校验入口 +│ ├── rpc/ # 生成的Unary gRPC桩与薄适配 +│ ├── dispatcher/ # MQ、租户感知调度/配额、SQLite/outbox;P2补公平 +│ ├── agent/ # 通话执行、文件恢复、配置加载、状态采集 +│ ├── media/ # 现成RTP/音频能力、录音与打断 +│ ├── ai/ # 现成供应商SDK薄适配 +│ └── store/ # 仅Dispatcher的SQLite事务/迁移 +├── migrations/ # 仅Dispatcher业务数据库迁移 +├── config/ # 无密钥的配置样例 +├── contracts/ # 带来源与校验和的只读契约发布包 +├── tests/ # 项目内夹具、协议 Mock、集成与故障注入 +├── deploy/ # 独立镜像、Compose/systemd 与运维入口 +└── docs/ # 本项目方案、运行、验收和发布文档 +``` + +采用单Go module、同一发布版本/镜像、少量具体类型。使用`github.com/spf13/cobra`,不手写命令解析或引入第二个业务main。显式业务子命令只有`agent`、`dispatcher`;help/version是框架能力,不再提供scheduler/cell两套别名以免运维混淆。只在真实边界建立小接口,不建通用调度/插件框架。 + +规划用法(当前本地制品已提供 `agent`/`dispatcher`;生产参数仍须受控注入): + +```text +sip-go-agent dispatcher --config <受控配置文件> +sip-go-agent agent --dispatcher-endpoint +``` + +Dispatcher配置包含MQ/SaaS受控引用、SQLite路径、两个Agent Endpoint→agent_id/cell_id绑定及准入策略;Agent证书/信任根、监听地址、ARI访问、持久目录及批准的静态快照由部署提供。**只需Endpoint指业务启动不再手填SIP/OSS配置,不代表无需凭据、持久盘、Asterisk或静态制品。** 静态制品不是允许运维另写业务Schema;不在CLI传密钥,不让agent初始化MQ/业务DB或dispatcher打开RTP端口。 + +两角色同一制品便于更新,不等于必须同时重启;P1采用受控维护窗口,不承诺混合版本无损滚动更新,见§5.7。 + +### 3.2 权威来源与独立拆仓 + +现有上游权威是《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》(正文 v1.0)及经核验的发布产物。现有产物包括 `mq.schema.json`、`executor.openapi.yaml`、`cell-agent.openapi.yaml`、AI 配置及 SIP 管理相关契约。文件存在不代表完整覆盖:P0 必须逐条核对正文、Schema、状态语义和实现差异。 + +- 将经批准的协议作为**版本化、只读发布包**导入本项目 `contracts/`,记录源仓库、源版本/提交、文件 SHA-256 和生成器版本;工作区未提交修改不能冒充某个提交的发布产物。 +- 类型和校验代码从该发布包生成;禁止手写另一套字段、枚举或业务 Schema。 +- 同步工具在开发阶段获取发布包;普通构建、测试和发行包运行不得读取父目录。 +- 新增调度器—Cell 的消息、fencing、回执或对账字段,先形成契约变更提案并获批;本方案不擅自定字段或 MQ 路由。 +- 本项目自己的设计、运行、验收文档只在此处维护;上游共享契约仍只有一个编辑源。 +- 本项目已独立初始化 Git,并按 `git.ipao.vip/rogee/go-sip` 维护;不创建 submodule 或改变父仓库跟踪关系。项目许可证和第三方 NOTICE 在对外发布前确认。 +- [通信与事件数据交互](通信与事件数据交互_v0.1.md)覆盖1类外部执行命令、8类业务事件、现有HTTP归属及13个内部Unary职责草案;[字段索引](OpenAPI与MQ字段索引_v0.1.md)完整提取42个HTTP操作/115个命名组件。 +- 已逐字段/哈希确认MQ信封command_type/command_id、event_type/aggregate_*及task_revision与正文一致;但事件payload目前仅通用object,专属约束仍需上游补齐(GAP-01)。不能以旧测试或通用object校验冒充完整验收。文字事件准确名称为transcript.updated。 +- 42操作/115组件是上游目录,不是本项目首发工作量。P1保留既有7条SaaS交互路径/8种事件,并消费实际所需AI配置和静态制品;不实现管理平台30个API或复制其业务后端。生成/校验仅覆盖使用入口及其引用闭包,来源包与只读字段索引保持完整。 +- 当前AI Schema强制要求llm/prompt/tts/asr/conversation,缺少明确的ASR-only表达。GAP-08须先在上游批准模式/缺省/文字与错误语义;不在MQ添加临时mode字段或绕过required校验。 + +## 4. Go 1.27.1 的采用策略 + +已用本机 `GOTOOLCHAIN=local go version` 核验 `go1.27.1 linux/amd64`,并核对官方 Go 1.27 发布说明与本地 `go doc`。以下区分“新版本能力”和“已有成熟能力”,不把所有 API 都说成 1.27 新增。 + +| 能力 | 使用位置与收益 | 边界 | +| --- | --- | --- | +| Go 1.27 `encoding/json/v2` | 新 JSON 协议边界的候选实现,减少兼容包依赖 | 先过黄金报文与哈希兼容测试;不直接替换全部旧编码行为 | +| Go 1.27 `uuid` | 新项目自生成且契约允许的 UUID | 不改既有命令/事件/执行标识格式,更不改原值 `tenant_key` | +| Go 1.27 goroutine leak profile | 长时间通话、取消/断线循环后的泄漏诊断 | 仅管理网/本地诊断,不公开 pprof | +| Go 1.27 `testing/synctest.Sleep`、`httptest.NewTestServer` | 测试定时、取消、重试与 HTTP 协作 | 真实 socket、broker、DB 的故障仍用真实集成测试,不能只靠虚拟时钟 | +| 已有 `context`、`sync.WaitGroup.Go`、`log/slog`、`net/http`、`pprof` | 生命周期、结构化日志、内部 HTTP 和诊断 | `WaitGroup.Go` 不负责错误传播;任务必须返回/上报错误并正确退出,不把它当 panic 隔离 | +| 泛型与语言新能力 | 简单类型复用 | 没有实际重复就不用,不建立通用调度 DSL | +| Go 1.27 实验性 SIMD | 不采用 | 当前重点是 I/O 编排,不增加实验开关或平台约束 | + +特别注意:JSON 新版本在重复键、大小写、空集合、数字、字段省略和对象输出顺序等方面可能与旧行为不同。`content_sha256`、签名或配置摘要必须遵守上游的**确切规范化规则**,不能把某次 `Marshal` 输出或“排序一下”当成通用 canonical JSON;Python/Go 黄金样本不一致就阻塞发布。非法/歧义 JSON 应按获批契约拒绝,不为兼容而接受歧义。 + +P1 建模块时拟使用 `go 1.27.1` 并锁定 CI/构建镜像及 digest;`go` 指令是最低工具链要求,不是精确锁定。CI 校验实际版本并使用 `GOTOOLCHAIN=local`,不靠运行时自动下载工具链。未来补丁升级走独立验证,不连带修改管理平台或其它服务的版本基线。 + +### 4.1 开源库优先是实施硬约束 + +标准库/现有服务原生能力能覆盖的先用它们;其余使用经评估的现成开源库。自有代码只承担契约绑定、状态机、权限、配额、事务和必要的薄适配,不能以“薄适配”为名重写 SIP/ARI、RTP 编解包、G.711、WebSocket、AMQP、数据库驱动、OSS 签名、供应商 SDK 或 Schema 解析器。 + +候选、许可证、上游证据与风险集中维护在 [开源组件清单](开源组件选型与复用清单_v0.2.md)。使用Cobra、官方grpc-go/protobuf、gopsutil、Asterisk/ARI、Pion、amqp091-go;modernc SQLite用于Dispatcher,pgx退出本项目选型,Agent不引数据库驱动业务能力。ASR/LLM/TTS按批准供应商选SDK。不将所有候选一并引入,不用sipgo/diago另起SIP栈。 + +P0 必须以锁定 tag/commit 的 PoC 验证 Go 1.27.1、Asterisk 镜像、API/音频格式和现有契约,检查许可证全文、传递依赖、安全通告及 SDK 自动重试;失败则阻塞该组件,不默许改为手写协议。只有业务接口确有必要才抽象,不额外引入 Web 框架、ORM、Redis 锁或认证服务。 + +当前契约为 OpenAPI 3.1 / JSON Schema 2020-12。生成/校验库必须实测支持这一方言及原契约;不能把开发分支能力当已发布能力,不能为了生成器方便手工降级或复制 Schema。依赖锁版、许可清单、漏洞处置与复用审查是 P1/发布门禁,验收见 L01–L08。 + +## 5. 目标运行架构 + +```text +SaaS ── RabbitMQ命令 ──> dispatcher(单活SQLite) +SaaS <── RabbitMQ事件 ── dispatcher outbox +SaaS <── 既有HTTP控制/查询/录音握手 ──> dispatcher +管理平台 ── 批准静态快照 ──> 受控部署入口(维护窗口) + │ + dispatcher ── Unary gRPC ──────┼─ agent-1 ↔ Asterisk-1 ↔ 获授权的SIP trunk + (mTLS) └─ agent-2 ↔ Asterisk-2 ↔ 获授权的SIP trunk + ├─ 两种AI模式、文字/录音/恢复文件 + └─ Dispatcher授权 → OSS直传 +``` + +内部不再增加MQ或双向流。Agent不拿broker/SaaS管理凭据,业务事件统一由Dispatcher发布;内部RPC不是新的SaaS拨号HTTP入口。两个Cell分别使用获授权固定出口,多EIP直连;至少3家供应商按矩阵预配置独立trunk,不承诺每家已同时授权两个出口。Dispatcher可与一个Cell同机但权限/目录/资源隔离,该节点故障会同时影响中央调度,不能宣传两节点即自动HA。 + +### 5.1 Dispatcher:唯一业务状态与调度权威 + +持久化命令、执行归属、租户/供应商/Cell/按模式AI全局配额、控制CAS、许可、静态目标版本/加载事实、事实去重、资产交接和outbox。单个活动Dispatcher负责决策;同机竞争实例可做所有权/恢复测试,但不以两台各有SQLite的Dispatcher各自发额度。 + +SQLite业务状态留在Dispatcher,管理平台数据库保持独立。首版不承诺跨机自动热备:备份/恢复时必须停止旧所有者、对账,再开放新准入,禁止NFS共享DB、双活复制或把MQ当数据库。Dispatcher故障暂停新拨,已授权通话在Agent继续,结果落文件待补。 + +每天2万次:24小时平均约0.23次/s、8小时约0.69次/s;假设每通100条业务写入,8小时平均约69条/s,只是量级估算不是基准测试。若是每Agent2万,中央总量按Agent数汇总;验收要测峰值CPS、并发、写放大、保留和故障恢复,不据日均承诺1000路容量。 + +### 5.2 Agent:无业务数据库的执行端 + +不自行分配全局额度、不消费SaaS MQ、不承担另一份任务主库。通过Unary执行授权、控制和配置,在本Cell维护ARI/RTP/AI会话,流式落文字/录音及必要manifest,回报真实事实/上传结果。 + +每通话固定在同一Cell/出口;不因Dispatcher短暂不可达自动挂断,也不把Agent重启说成通话无损续接。每通话一个生命周期所有者,媒体/AI工作有界可取消,不创建每包goroutine;共享ARI事件按通话分派,控制不能静默丢失。 + +### 5.3 持久化与恢复(SQLite/文件方向已确认) + +| 数据 | 落点 | 边界 | +| --- | --- | --- | +| 全局任务/配额/归属/控制、事件/静态配置事实/资产、inbox/outbox | Dispatcher独立SQLite | WAL、可靠Sync策略、短事务/有界写队列、唯一约束与条件更新;提交后MQ ACK,不跨网络持事务 | +| 活跃通话上下文 | Agent内存+Asterisk真实通道 | 不是任意实例可替换的无状态HTTP请求;未知执行须对账 | +| 执行关联/配置加载/待回报事实/上传进度 | Agent受控文件/manifest | 原子替换、关键事实同步落盘、单写者与尾部截断识别;不用Agent SQLite,也不自造通用数据库 | +| 文字/录音 | Agent持久spool;P1录音→OSS,文字OSS归档后续 | 实时文字先回报;文件封口、摘要校验、恢复与保留条件完整 | + +推荐SQLite WAL+synchronous=FULL作为关键事实持久性基线,具体驱动/文件系统/锁/忙等待在P0验证;记录不能按RTP包或每token逐条写中央库。单文件仍只有一个writer,长期读事务/checkpoint/磁盘空间必须监控。备份用SQLite一致性备份能力,不在运行中只拷db忽略WAL。 + +恢复旧备份可能丢失已ACK的事实/许可/版本;先关准入,对比Agent文件、Asterisk、MQ与资产水位,不恢复过期许可或盲重拨。去重/stop墓碑不套普通日志TTL。持久卷完好崩溃、旧备份、满盘/只读、永久丢盘分开验收;永久丢失未上传资产不能承诺RPO=0。 + +### 5.4 Unary通信与最小启动 + +Dispatcher受控配置列出Agent Endpoint及agent_id/cell_id绑定,监听地址必须管理网可达;不信任Agent提交的任意回拨地址。Agent先加载受控证书、本地监听/ARI及部署交付的静态制品,以Dispatcher Endpoint请求引导;不由用户再手填SIP/OSS/AI业务配置。 + +初次调用若未绑定只返回pending/非敏感能力;Dispatcher主动探测**预配置Endpoint**,核对boot/协议,再向该端点激活受限节点会话。Agent得到会话后获取自身运行策略、SIP/AI版本索引和OSS策略引用,加载并报告ready。任何身份/版本/凭据缺失都只允许诊断,不接真实任务。 + +普通Unary复用HTTP/2连接;Execute只返回accepted/rejected,不等待整通话;状态/最终文字/上传结果独立回报。SDK重连不证明业务未执行,回应丢失只按原标识查询/重报,不盲目再originate。Proto方法/字段号、截止与大小见交互草案,P0冻结后生成。 + +### 5.5 共享mTLS的权限边界 + +用户选择所有Agent共用证书,便于扩展;证书/私钥通过Secret/受控文件挂载,不写进镜像/仓库/参数。Dispatcher使用独立受信身份,Agent管理RPC不能接受仅有Agent群组身份的对端。 + +共享证书只认证群组:禁止凭自报agent_id/源IP直接领取另一节点的配置或执行。采用预配置Endpoint主动激活和自动签发的受限节点会话,将agent/cell/boot/代次/操作范围绑定;无需用户逐节点填写运行token,但具体认证中间件、期限、重放/撤销机制须PoC,不能自创密码算法。 + +必须验证SAN/SNI/信任链,不设InsecureSkipVerify;共享证书要覆盖合法管理域名/受控SAN,扩容不能靠关闭主机名校验。管理网隔离/DNS控制/最小端口仍必要。任何Agent私钥泄露影响整组,需全组轮换/撤销;会话授权只能减少误绑定,不等价独立证书的节点隔离。把这一已知风险留在发布签收中。 + +### 5.6 健康准入与静态候选选择 + +Dispatcher定期调用GetAgentStatus,复用既有2s心跳/10s失联基线;以本机接收时间判新鲜,保留Agent采样窗口/序列/boot,过期/缺失为unknown而非0。gRPC SERVING只说明能应答,不说明可拨号。 + +P1状态包括软件/构建/Proto/Asterisk版本,准入必需的CPU/内存/FD/磁盘/spool、媒体端口、ARI及已知/未知占用,本模式AI健康,静态授权trunk的codec/注册适用性/desired与applied版本/哈希。完整load/IO历史、staged发布态和高级评分后续再做。用gopsutil/ARI SDK采集,区分host/container/process,不自写/proc解析。 + +P1选择顺序为:可信绑定与协议兼容→静态route_policy/caller_profile及授权矩阵匹配→版本/出口/新鲜健康/未排空→租户/供应商/Cell/本模式AI配额→在可行Agent内按固定轮询分配新执行。已建立执行意图后固定归属,不因健康变化改投另一Cell;未发起的新执行可选同策略下其它获授权健康Cell。CPU低不能越额度,新boot/0活动不能清中央未知占用。不做价格/权重优化、自动跨供应商FALLBACK或活动通话迁移。 + +CPU/内存/FD等首发必要保护阈值和恢复条件在profile登记;spool沿用70/80/60%规则。供应商“支持能力”“已配置”“已加载”“真实验证”分别展示,未验证线路不报ready。P1只传有界静态清单及必要状态,不先建全量遥测/供应商分页平台。 + +### 5.7 一个制品、维护窗口更新 + +Cobra两命令共用release版本、依赖锁、镜像和协议包;每个进程只能启动所选角色,凭据/目录/监听权限分离。更新前检查Dispatcher/Agent协议兼容范围与配置Schema,不兼容的节点拒绝新准入,不尝试猜测字段。 + +P1允许维护窗口暂停新任务:关闭受影响资源准入、排空/对账、备份、更新、重新激活、核验实际配置和资源后恢复。当前只验收单 Cell;第二 Cell、多节点并行和在线滚动编排属于后续阶段。Dispatcher保持唯一所有者,迁移/恢复先验证;Agent不执行远程任意shell或自下载二进制。 + +## 6. 核心可靠性设计 + +### 6.1 消息接入与 ACK + +以下MQ消费/发布和业务数据库事务仅由Dispatcher承担;Agent事实经gRPC进入中央事务。沿用 `agent-call` 资源命名空间,不因项目名变化而重命名协议资源:命令 exchange 为 direct,routing key 为 `agent-call.tenant.{tenant_key}.call.execute`,事件 routing key 为 `agent-call.{event_type}`;不把 SaaS 业务结果队列改成每租户一条。 + +1. 以可信队列绑定校验 `tenant_key`、正文身份和版本;原值一对一,不清洗、编码或截断。超过 224 个 UTF-8 字节的既有路由预算时保留原任务并停发,不能换一个 key。 +2. 先验证认证、租户归属和基本 Schema,再识别已持久化的历史幂等事实,最后才对新执行检查当前控制状态、不可变 agent 版本和资源准入。重投不能因当前配置/任务状态变化而改写原决定;权限检查不能因命中历史而绕过。外部命令不能注入 SIP 地址、主叫、密钥或任意 AI URL。 +3. inbox 与可恢复待执行状态在同一事务持久化后,才 ACK broker;无效消息按既有拒绝/死信契约可靠处理,不能先 ACK 后记原因。临时额度不足进入受限持久等待,不丢弃合法任务。 +4. 传输消息 ID 与业务标识的映射遵守契约;命令、执行、事件的唯一键分别为 `(tenant_id, command_id)`、`(tenant_id, execution_id)`、`(tenant_id, event_id)`,禁止只按 execution ID 跨租户去重。同键异载荷按契约冲突处理;换 command ID 不能绕过同一执行去重。 +5. 业务 outbox 与产生它的状态变更同事务提交;MQ confirm 后只标记运输层发布成功。confirm 丢失允许以原 `event_id` 重发,不创造第二个业务结果。 +6. broker 持久化、publisher confirm、mandatory/不可路由处理、手动 ACK 和重试退避均必须真实验证。事件 confirm 不是 SaaS 业务应用收讫,不增加应用 ACK。 + +启动须核验 broker 的 exchange 类型、精确绑定、持久化、ACL、队列上限和拒绝发布策略;配置漂移即不 ready。处理 mandatory return、confirm 丢失、blocked 和全局水位;满队列拒绝新发布,不丢队头,SaaS 保留原任务。死信/重试恢复回原租户配额,租户停用和清理先完成未决任务/消息/资产对账。现成 AMQP 库不等于已实现这些业务保证;连接恢复封装也需过 S04/S05/S20–S22。 + +MQ 的 exactly-once 拨号不是可承诺特性。采取持久幂等、授权屏障和**不确定就不重拨**的策略,并把未知执行放入可对账状态。 + +### 6.2 多租户分期与资源预留 + +**P1现在做骨架和安全,不开放多租户运营:** + +- tenant_key原值贯穿租户独立队列、持久记录、事件与受控诊断;保留tenant_id复合幂等键,不用default tenant或共享总FIFO。 +- 调度按可信的活跃租户集合组织,当前启用列表只含一个租户;其它租户不能因发来消息就自动启用。每租户/全局prefetch、未ACK、持久待执行及内存窗口有界,额度不足时不绕过限制。 +- 一次预留原子校验当前租户并发/CPS、供应商并发/CPS、单 Cell/媒体端口、出口及本模式AI额度:ASR-only只需ASR;完整模式必须同时具备ASR/LLM/TTS,不以缺少LLM/TTS阻断合法ASR-only,也不静默降级完整模式。 +- 单 Cell 额度在单活Dispatcher SQLite短事务中校验/更新,唯一约束/CAS防竞争;并发覆盖预留/拨号/振铃/接通/未知,未知是否发起不错误退还CPS或释放并发占用。 +- 所有权代次/fencing、时效和最后发起许可在本轮实现并故障注入;Cell每次originate前必须核验,pause/stop/维护屏障覆盖已发许可。 +- 本轮只验证一个可信租户;不开发双租户公平、第二 Cell 汇总或多 Dispatcher 协调。未来阶段再增加多活跃租户轮询、背压、轮转恢复和跨 Cell 汇总配额。 + +P2/后续多租户能力不作为本轮退出门禁。未来若启用获批FALLBACK,仍计原执行attempt/CPS,不能获得免费额度。 + +### 6.3 执行、结果与不确定窗口 + +```text +Dispatcher事务:控制/归属校验 → 预留资源 → 持久执行意图 + → Unary Execute → Agent保存原执行/通道关联文件并返回accepted + → Unary最后许可(含 Asia/Shanghai 09:00–20:00 时间门禁) → ARI originate → 跟踪实际通话事实 + → Agent文件持久待回报 → Unary Report → Dispatcher事务状态/outbox + → SaaS MQ事件 + 全局对账/资源释放 +``` + +SIP 外呼时间窗口为 Asia/Shanghai 每日 `[09:00, 20:00)`;窗口外在最后许可和 Agent 执行入口均 fail-closed,不等待、自动延迟、重试或换线。上图是内部处理顺序,不新增对外状态枚举。对外状态必须从批准契约生成。 + +- 中央幂等/固定归属、Agent恢复文件和Asterisk关联共同防重;任何SQLite/文件提交都无法与外部originate做原子事务,Agent无SQLite不等于可跳过对账。 +- 在 originate 超时、进程退出或网络中断时,利用稳定执行/通道标识、ARI 状态和账本对账;无法证明未拨或已结束则保持未知占用,不自动另起一通。 +- Caller/callee 由 Cell 受信配置映射;切换线路必须从原始被叫重新构造,禁止串用上一线路前缀。 +- 用户业务重试仍由SaaS决定。P1静态策略不启用自动跨供应商FALLBACK;新执行的选路从原始号码开始,失败如实返回。若输入要求未支持策略,按批准的能力/拒绝语义处理,不能静默改写;未来扩展仍不得重拨已接通或未知执行。 +- 控制、记录、录音、结果分别持久化重试;OSS 或 MQ 故障不能导致已完成通话再次执行。 + +### 6.4 控制屏障 + +沿用主契约的租户作用域 `command_id` 幂等、`expected_task_revision` CAS;区分 `requested_task_revision` 与 `applied_task_revision`,同 command 重试不能再次递增版本。 + +- HTTP 202 仅表示 accepted;相关 Cell、在途许可和要求的挂断均确认后,才能通过 MQ 报 applied。失联/结果未知继续 applying/reconciling 并告警,不伪造成功。 +- `pause` 停止新发起,已拨出(包括拨号/振铃)和已接通电话继续原生命周期;收敛的是尚可发起的在途许可,不以暂停为由取消已拨出通道。`stop` 必须显式指定 `active_call_policy=drain|hangup`:drain 让已有电话自然结束;hangup 还需 `outbound.hangup` 权限并等待挂断确认。 +- 撤销号码先由 SaaS 禁发并对相关旧任务建立整体 pause 屏障,不能用临时删 broker 消息代替。SaaS 过滤撤销对象、对账旧执行后,paused 任务可以按当前已生效 revision 恢复;剩余对象用新 command/execution 授权,旧命令重投不能复活。 +- 只有 stopped 任务不可 resume。是否改用新任务由 SaaS 决定,Agent 不能强制把 pause 改成不可恢复的 stop。 + +### 6.5 整体补传 + +HTTP 仅按一个 `call_id` 或 `source_command_id` 发起整体补传,不增加 task/execution 范围或事件/turn/资产筛选。受理时固定截止点,使用原 event ID/内容/版本,分批、限速并优先实时结果;source command 尚无 call 也应能恢复其结果。区分不存在与保留过期(404/410),补传自身 command.result 不得循环纳入;completed 只表示该固定集合发布完成,不代表 SaaS 已应用。 + +### 6.6 最终文字、事件与 DNC + +Dispatcher对Agent稳定事实去重后,按 command/call/transcript_segment/recording 的实体与状态域事务持久化聚合版本,payload 是该域的快照;测试中的独立 SaaS 按同域合并。不能用全局最大版本丢掉低版本但独立的录音/attempt,也不能用 call.finished 覆盖 recording.ready。最终文字不会被迟到中间稿覆盖;当前基线同段 final 同内容幂等、异内容冲突,未来允许修订须经 G0 更新契约。文字失败必须出 transcript.failed,通话结束不等待所有后处理。 + +客户实际说话与 AI 生成/发送/播放证据严格区分,旧轮次片段取消后不能冒充已播放。contact.opt_out 按获批业务判定及时发布,不等挂断;SaaS 持久禁发并枚举相关任务完成屏障。Agent 不能用未经确认的关键词替代业务判定。 + +## 7. 媒体、录音与 AI + +### 7.1 ARI/RTP + +- Asterisk 继续实现 SIP、注册和编解码协商;Go 使用现成 ARI SDK 管理通话/桥/ExternalMedia/录音/事件,不手写 ARI 客户端。SDK 缺口优先用已有 API 组合或修复上游,不另起 SIP 栈。 +- 固定通话到 Cell/出口,验证受信路由和配置版本。白名单原始号码仅为 `15003164745`、`15830461047`;写入配置不代表授权发起测试。 +- 已知供应商 PCMA 作为待真机复验的基线,不把一个供应商的前缀/主叫或媒体源带到其它线路。 +- RTP/RTCP 编解包使用 Pion 等现成库;在 SDK 边界校验长度、扩展、padding、SSRC、序号/时间戳回绕、源地址及负载类型。抖动/乱序/节拍也先评估现成组件,不复制协议解析器。 +- 转码/重采样优先使用 Asterisk 现有能力;确需 Go 侧 G.711 时评估 `zaf/g711`,不手写 A-law/μ-law 算法或线性插值重采样。明确 PCM 端序、采样率、声道、帧时长和缓冲策略,不能硬编码“所有链路都是 20ms”。 +- 每 Cell 独立分配媒体端口;端口池、FD、包率与带宽均计入容量,不能用现有 `10000–10800` 范围宣称保证 1000 路。 +- early media 与接通分开统计;按最终计划从接通开始记录双向音频,不能把 183 媒体到达当成 answered。 +- 固化既有问题的等价回归:ExternalMedia 就绪/本地端口、桥成员未齐、RTP 源/SSRC、双向标识音、末帧与录音封口。沿用 MixMonitor 时停止并确认封口后才读文件;替代实现须证明相同行为,不接受空 WAV 或合成音冒充录音。 + +### 7.2 AI 流与打断 + +本次必须支持两种模式,均由批准的不可变 `agent_version_id` 配置选择,任务不携带供应商密钥或临时模式字段: + +| 模式(概念名,非新协议枚举) | 执行与准入 | 验收边界 | +| --- | --- | --- | +| ASR-only | 采集来电音频、识别、实时文字、录音及结果;不建立LLM/TTS会话、不预留其额度、不生成虚假播放事实 | 即使LLM/TTS未配置或不可用也可独立运行;仍受ASR/媒体/控制/录音约束 | +| ASR + LLM + TTS | 完整对话、生成与播放、取消/打断;三类AI配额同时满足 | 使用新批准SDK/模型,真实闭环;依赖不足拒新任务,不静默退成ASR-only | + +当前AI Schema强制要求llm/prompt/tts/asr/conversation,没有明确的ASR-only模式/禁用规则。GAP-08须由上游唯一源补齐模式选择、缺省与模式相关的字段/超时/事件语义,包含ASR-only无agent播放片段时的合法表示;禁止伪造LLM/TTS配置或放宽源Schema。本轮用户已确认百炼/火山两种ASR、OpenAI兼容LLM、火山TTS;模型/voice/API协议仍按SaaS配置和能力PoC冻结。每个适配只选必要的一套SDK,不建插件、自动AI切换或多供应商编排。 + +- ASR 先用真实匹配的现成 SDK:DashScope 的 Paraformer/FunASR 与阿里 NLS 不能混同,火山 SAUC 与经典接口须分别核对。现有 Go ASR 的协议行为/测试是兼容性基线,不因已有手写代码就跳过 SDK 调研。选库、访问控制、生命周期与重连验收归本项目。 +- LLM/TTS新规范、供应商、模型、音频、取消/打断、限额及费用核验属于P0/P1必需门禁。使用官方/适用开源SDK薄适配,禁止自写已有客户端或复用旧实现;此前仅Mock/未启用且P1保持blocked,不能将完整模式移到未来。SDK重试不得重复收费生成、播放或拨号。 +- 通话 context 管全生命周期,每轮回答使用子 context 和代次;用户打断时取消生成并清理待播音频,过期分片不能继续播放。 +- 区分生成、排队、发送和实际播放,不能把 TTS 已产生字节当作用户已听到。 +- 媒体/识别/生成队列按音频时长和字节数限额,溢出策略显式验收;不得无限积压或静默丢弃关键控制。 +- 持续采集 ASR 首包/最终结果、LLM 首 token、TTS 首音频、端到端响应和打断清空延迟;换 Go 不会消除供应商网络与推理延迟。 + +### 7.2.1 AI配置来源与调参边界(本次必需) + +- 配置流固定为:MQ任务引用agent_version_id → **Dispatcher调用SaaS已有AI版本GET** → 校验租户/源Schema/摘要及供应商能力 → R07的执行快照/获批缓存引用 → Agent会话局部SDK请求与控制器。Agent不直接查询SaaS,不从CLI/env另填业务参数,不新增HTTP拨号或结果回调。 +- 初选组件为百炼devinyf/dashscopego、火山ASR/TTS共用GizClaw/doubao-speech-go、LLM官方openai-go;均需实际协议与参数PoC,尤其火山TTS不能忽略语速/音量等字段。基础库及未完成的锁版门禁见 [组件清单§1.3/§4.3](开源组件选型与复用清单_v0.2.md)。 +- 模型、提示词/变量、语言/中间稿、音色/语速、音频格式、temperature/Token上限、超时、开场白、打断、静音/轮数/时长、分句/缓存等已有参数必须从SaaS贯穿到执行;热词/VAD/top_p/音量/阶段超时等实际所需扩展先由上游补GAP-09。精确字段及缺省/单位/能力规则仅在源契约维护,需求索引见 [交互§6.1–§6.3](通信与事件数据交互_v0.1.md)。 +- 在已支持并获授权的能力范围内,SaaS发布新不可变版本,新任务显式引用即可调参,无需改代码/重建/重启;在途与原排队任务固定原快照,未知执行不因版本变化重拨。SIP静态发布不限制这条AI配置读取链路,也不需要建设R04通用热更新平台。 +- 无有效授权缓存、版本冲突、未支持参数、模式/SDK能力不匹配均在准入前明确失败;不改用SDK默认值或伪造配置。显式0/false/未提供必须区分,并发两Agent不能共享可变参数。凭据/端点来自受控引用,业务参数不能突破安全硬限额或启用不安全SDK重试。 +- 本轮确认配置来源/需求,不代表SaaS已有全部参数或SDK已暴露全部控制。GAP-08/09批准前不手改只读字段索引、不添加MQ临时字段/metadata后门;首发参数必须以SDK请求/本地控制器实测证明有效。 + +### 7.3 录音交接 + +OSS配置来源于SaaS,不在Agent启动参数中维护bucket/AK/SaaS地址。Dispatcher统一解析获授权的配置/版本,承接现有上传申请和complete;Agent通过gRPC获取仅本执行/对象所需的HTTPS目标、headers和有效期,直传OSS并回报元信息。verified后由Dispatcher写outbox发布recording.ready,不把PUT200/ETag当验证成功。SaaS全局配置拉取接口未在现有OpenAPI定义,列为GAP-02而非虚构已可调用路径。 + +spool 按继承的测试 profile 在 70% 告警、80% 停止新接单,降至 60% 且依赖恢复后才恢复;为活动通话预留剩余录音空间。已 verified、ready 持久并确认发布、无已知恢复任务的本地已交接录音,测试至少保留 24h;未确认/失败文件不自动删。生产保留另行批准,不能在 confirm 后无条件删原始资产。 + +`CALL_NOT_REGISTERED` 保留文件并等待;签名失效续用原 recording/upload 会话,不制造新资产。仅接受受控 HTTPS 目标与约定 headers,禁止任意重定向/跨对象写入;verified 对象须防止旧签名覆盖。SaaS/对象侧独立读真实字节验证,不能信自报摘要或把 ETag 当 SHA-256。 + +Agent对已签名PUT使用标准库HTTP/约定headers,需要OSS API才用官方SDK,不自写签名或拿不必要长期凭据。文字也流式落文件,但实时transcript.updated/contact.opt_out仍经gRPC→Dispatcher→MQ及时回SaaS。文本OSS归档的授权/完成/引用尚缺契约,未冻结前明确未启用,不伪装录音;保留文字MQ链路不受此影响。补传按§6.5整体call/command范围。 + +## 8. 静态配置发布与授权 + +P1不实现在线动态发布平台。管理平台仍是唯一编辑/审批面,输出带来源/版本/哈希、目标Cell/trunk和credential_ref的不可变快照;由项目自有受控部署入口交付。GAP-03先批准最小静态交接合同,不另建CRUD后台、不手写第二套Schema、不依赖管理源码运行。Dispatcher记录期望版本/准入,Agent验证并报告Asterisk实际加载;旧直写通道不得并行。 + +- 维护流程:关闭受影响资源新准入→收敛最后许可与预留→排空/对账拨号、振铃、已接通及未知占用→备份/原子替换批准制品→加载或受控重启→核验实际版本/哈希/线路能力→恢复准入。初装同样必须先验证才ready。 +- 固定两个Cell及供应商授权矩阵;同版本同哈希幂等、异哈希/旧版本冲突按合同拒绝。失败保持not-ready/待对账;人工恢复旧制品也须完成加载确认,不靠切active指针假成功。 +- 部分节点加载成功时只允许已满足完整准入条件的资源接新执行;存在共享冲突则一并阻塞。不能以“静态配置”为由跳过租户/供应商总额度或旧许可屏障。 +- 只接受获批快照和凭据引用,不执行任意脚本/路径,不从MQ覆写SIP地址、主叫或供应商凭据;不逐呼reload,不虚构备用地址。 +- 运行态验证Endpoint/boot/授权代次及采样新鲜度;实时结果保留实际attempt、线路/Cell/出口历史事实。管理聚合统计与在线发布回滚属于后续集成,不影响事实留存。 + +未来需要在线发布时,再冻结完整目标集合/发布意图/CAS/回执及R04/R06,验收新增/移除节点、在线停用和回滚编排。P1不为这些功能建立空框架;安全排空与人工恢复纳入本次交付,当前尚未实现。 + +## 9. 运行、监控与安全 + +### 9.1 生命周期 + +SIGTERM 先撤销 ready/停止新预留和新拨号,再有界排空、提交状态并关闭连接。长通话的维护排空与截止行为需配置并验收;到时仍有通话不得默默丢状态或由新实例重拨。 + +调度器失去租约即停止新决策;Cell 失去授权/心跳时停止接新任务,已有通话按本地策略继续并保留结果。就绪、存活与外部依赖状态分开:数据库不可用不能报告可调度,第三方缺失可以启动诊断/草稿能力,但不能宣称真实可用。 + +### 9.2 可观测性 + +- 使用 `slog`;允许记录受控的 trace/command/execution 关联信息,但不输出密钥、ARI 密码、完整音频、完整对话或用户号码。 +- P1指标覆盖等待/额度不足、inbox/outbox年龄、confirm延迟、未知执行、两个Cell心跳/配置、媒体/AI延迟、spool/OSS积压与恢复告警;公平份额/租户饥饿指标在P2补齐,不先建复杂监控平台。 +- 不把任意 `tenant_key`、execution ID、手机号作为无限基数指标标签;租户详细诊断通过受控查询提供。 +- pprof/泄漏诊断只在受控管理网暴露。先 profile,再考虑调 GC、PGO、对象池或零拷贝;不用未经测量的“高性能优化”。 + +### 9.3 部署边界 + +独立构建非 root 镜像和部署入口,初期用 Compose/受控 systemd 即可。是否采用 CGO/SQLite 驱动会影响静态链接和交叉编译,必须按实际构建验证,不能因为用了 Go 就承诺完全静态二进制。 + +镜像/依赖锁定版本及digest;Dispatcher才持MQ/SaaS/中央库权限,Agent才持本地ARI/必要供应商运行权限。两角色不共享工作目录或全部Secret,共用二进制不等于共用运行权限。mock/mixed/real 可识别:Mock 默认限制真实外网,real 拒绝 Mock 端点、测试凭据和未授权路由,不静默回退。 + +独立开发与 CI 不依赖云资源、真实电话或其它业务测试栈。外部 Mock 服务按需使用固定镜像与版本化协议,不读取父目录源码。生产仍使用多机器、多 EIP 直连;不引入单 EIP+NAT 方案。沿用阿里云北京、指定 IP、竞价与清理授权约束,方案不产生任何云消费权限。 + +## 10. 分阶段目标与交付顺序 + +本节替代旧P0–P5全量重写计划;**P1现在表示本次单节点/单 Cell/单租户本地首发,第二阶段再做真实 SaaS/MQ 联调及多节点/多租户扩展**,不是完整生产控制面。旧12–22人周估算不再用于本次排期;不承诺未经SDK/契约核验的缩减百分比或工期。 + +| 阶段 | 交付范围 | 退出门禁 | +| --- | --- | --- | +| P0 首发契约/PoC | 8种事件、双AI模式与SaaS配置/参数GAP-08/09、首发Unary/最后许可、单 Cell 静态交接、录音授权、共享证书、至少3家trunk的契约/协议 fixture | 契约正反例、模式/参数/权限和本地隔离 PoC 通过;不以宽松Schema、假配置或手写协议绕过 | +| P1a 独立基础与调度骨架 | Cobra/单module、SQLite/文件、MQ inbox/outbox、单租户独立队列/有界窗口、单 Cell 原子配额、Unary身份/健康 | 独立构建、契约/权限/存储/恢复和单租户隔离通过 | +| P1b 单 Cell 执行 | 1 Agent+1 Asterisk、至少3独立trunk配置、静态快照、原始号码选路、最后许可/控制/未知对账 | 协议 Mock/mixed、重复拨号防护、维护时停止新准入通过;真实供应商/ECS 延期 | +| P1c 双AI与资产闭环 | 百炼/火山ASR、OpenAI兼容LLM、火山TTS,双模式/取消/实时文字/录音;D读取契约 fixture 配置并逐执行传参 | 两ASR适配及两模式通过本地/协议 Mock,调参无需重启且参数实际生效;真实供应商联调延期 | +| P1d 本地内测 | 受限隔离负载、故障注入、短期稳定性、维护恢复、告警与运维步骤 | 当前单节点门禁通过;不要求真实 ECS、双节点、第二 Cell、双租户或1000路/N+1 | +| 第二阶段 | 真实 SaaS/MQ、真实供应商/ECS、双节点/第二 Cell、第二租户公平及生产切换 | 另行授权、另行验收,不阻塞本轮本地 P1 | +| 后续按需立项 | 动态发布/回滚、自动SIP FALLBACK、AI多供应商编排、文本OSS归档、多D协调/HA、权重借用、1000路/N+1 | 各自批准契约、资源/预算与专项验收;不阻塞已满足范围的P1/P2 | + +P1a–P1d为同一首发的有序工作包,不是各自可替代完整首发的产品。可并行协调供应商/契约,但真实双模式未通过就不能宣布P1完成。P1只要求登记负载内的稳定性和性能,无须先完成Python/Go性能对照项目;规模化收益对比留待后续相同环境专项测试。 + +## 11. 迁移与切换原则 + +1. 先做离线黄金样本和非执行影子调度;影子输入来自显式复制/回放,不能竞争消费旧生产队列、预留生产额度或真正拨号。 +2. P1不以旧Python Cell作首发运行依赖;若迁移验证确需临时对接,必须有批准协议并完整支持新许可/控制/幂等,否则停留在Mock,不写HTTP拨号代理凑过渡。 +3. 优先使用空闲/专用 Cell 做小范围验证。只有能证明全局共享额度和控制屏障协调正确时,才允许 Python/Go 在互斥资源分区中同时运行;否则采用维护窗口完整排空后切换。 +4. 切换单元包含租户调度归属、Cell/路由、共享配额及未决任务,不是改一个进程地址。任何相同资源不得由两套 Agent 同时写入/发放许可。 +5. 排空旧端、处理 broker 未 ACK、保存控制版本/幂等 ID/待执行/未知执行/outbox/录音进度,再切换所有权。数据库迁移显式、可审计,不长期双写同一业务库。 +6. Go 接手新执行后,不能让 Python 忘记这些执行直接恢复消费。回滚先停止新准入、排空并对账;状态无法兼容回迁就暂停并前向修复,不能靠退镜像冒险重拨。 + +详细演练、量化profile及新增架构门禁见 [验证与切换验收](验证与切换验收_v0.3.md)。 + +## 12. G0 待冻结清单 + +| 项目 | 本方案建议 | 未确认时的边界 | +| --- | --- | --- | +| 持久化/恢复细则 | 已确定Dispatcher SQLite、Agent文件;冻结驱动/Sync/checkpoint/备份、保留和RPO/RTO | 不引PG/Agent DB/NFS共享,不拿普通文件复制冒充一致性备份 | +| 调度可用性 | 首版唯一活动Dispatcher,登记维护/故障恢复窗口与fencing | 不宣称跨机自动热备、active-active或零切换延迟 | +| 内部Unary合同 | P1冻结R01–R03、R05、R07–R13必需职责;方法可经批准合并,R04/R06在线改配后续冻结 | 最后许可/查询/事实/上传不裁掉,不手写另一套Schema或新增SaaS入口 | +| 启动/安全/版本 | 共享Agent证书、独立D身份;细化自动节点会话/轮换/重放、监听与兼容矩阵 | 不把共享证书当节点身份,不无凭据下发SIP/OSS配置 | +| 上游缺口 | P1关闭GAP-01、GAP-02录音、GAP-03静态交接、GAP-08双模式、GAP-09 SaaS任务AI配置/有效参数;文本归档/在线发布后续 | 按唯一源修复;明确阻塞阶段,不要求未来接口先落地 | +| 健康准入 | 单 Cell 样本口径/新鲜度、基本资源保护、固定候选选择、供应商和已加载版本 | 不建复杂评分,不用低CPU越额度或新boot清旧未知通话 | +| 运行参数 | 登记P1单租户单 Cell 受限profile及供应商授权 fixture;继承安全/恢复阈值,第二阶段再验公平与SCALE | 拓扑覆盖需版本化批准,不把测试基线当生产SLA或消费授权 | +| 开源依赖 | 锁定实际可用 module/tag/commit、许可证、漏洞处置及兼容 PoC | 无现成库匹配时先报告阻塞,不私自手写协议替代 | +| AI/音频能力 | 已选百炼/火山ASR、OpenAI兼容LLM、火山TTS;D从SaaS读取任务版本,冻结参数/默认/能力/实际SDK映射 | 未核验仍未启用/阻塞P1;无参数的SDK不能靠硬编码或静默忽略凑过 | +| 发布和停用 | P1静态制品版本/哈希/目标、维护排空和加载确认,批准唯一写入路径 | 无在线编排不等于无控制屏障,未知阻塞、不假报applied | +| 成本与保留 | OSS/录音/日志/审计/RPO/RTO、供应商额度 | 不承诺无限保留和自动灾难接管 | +| 真实验收 | 供应商能力、白名单、出口、时间窗口、费用授权 | 不自动拨号、开云主机或扩大网络放行 | + +## 13. 参考与证据边界 + +- 已核验本地工具链:`go1.27.1 linux/amd64`;本地 API:`go doc uuid`、`go doc encoding/json/v2`、`go doc testing/synctest.Sleep`、`go doc net/http/httptest.NewTestServer`、`go doc sync.WaitGroup.Go`、`go doc runtime/pprof`。 +- [Go 1.27 官方发布说明](https://go.dev/doc/go1.27)。Go 特性的可用性不等于已验证其在本项目中的收益。 +- 上游实施依据:《最终开发部署监控与验收计划_v1.0.md》《SaaS对接与任务调度专项验收计划_v1.0.md》《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》及对应机器可读契约。这里只记录来源名称,独立项目运行不回读这些父目录文件。 +- 本文没有新的压测数据、生产容量证明或供应商验收结论;本轮文档检查不能替代实现验收。 diff --git a/docs/OpenAPI与MQ字段索引_v0.1.md b/docs/OpenAPI与MQ字段索引_v0.1.md new file mode 100644 index 0000000..dad2e5d --- /dev/null +++ b/docs/OpenAPI与MQ字段索引_v0.1.md @@ -0,0 +1,1655 @@ +# OpenAPI 与 MQ 字段索引 v0.1 + +> 本文件为本轮从现有上游文件一次性生成的只读索引,不是第二份可手工维护的 Schema。原始引用和约束原样保留;它描述“文件现在是什么”,不代表已经与正文权威契约一致。 +> 已逐字段和哈希确认:当前MQ的command_type/command_id与event_type/aggregate_*信封已对齐正文。仍待补齐8种事件的payload专属Schema/条件规则;详见《通信与事件数据交互_v0.1.md》。源文件更新须重新导入生成,不得只改索引。 + +普通构建/运行不读取父目录;P0须建立版本化契约包和可重复生成入口。本轮没有添加Python运行依赖或Go源码。 + +## 1. 来源与内容指纹 + +| 源文件 | 版本/方言 | SHA-256 | +| --- | --- | --- | +| `ai-config.openapi.yaml` | OpenAPI 3.1.0 / info 1.0.0 | `d2f75d8fd2bf76ceb4eef869a838f08dca156938e1f3025d126ce81e436f624a` | +| `cell-agent.openapi.yaml` | OpenAPI 3.1.0 / info 1.0.0 | `79dc697d7ce16e2a6aa7e350f30dd006dd847afe2cdc0ea29841f9c6d4310c41` | +| `executor.openapi.yaml` | OpenAPI 3.1.0 / info 1.0.0 | `b24703783df63e044fc0151c5e215430d2294e13937d2a5ceee3c6fee99b0329` | +| `saas.openapi.yaml` | OpenAPI 3.1.0 / info 1.0.0 | `368c3a7d75ecc74771b88f9bd9fb7131697c69ce695475fc5aaae6099ff889eb` | +| `sip-management.openapi.yaml` | OpenAPI 3.1.0 / info 1.0.0 | `5006bbb1fb69f7b4a05cbaa5a43f8944e8522172910a41aba61897c9d5e00281` | +| `mq.schema.json` | https://json-schema.org/draft/2020-12/schema | `4fbfc39d46fb55ca48b71bc11cafce60e0814182ba4f898973c7c4d7657f912a` | +| `ai-config.schema.json` | https://json-schema.org/draft/2020-12/schema | `62d9090adcca619e2bb3a6c5d6aa249c92601fe47b3278e24df82c640987c5db` | +| `SaaS交互_OpenAPI与MQ契约规划_v0.1.md` | 正文 v1.0;接口/状态权威来源 | `fb6d218fd0c5a7860083806b26757bec56d42a6829ea5b8257878573ed28e957` | + +## 2. HTTP 操作全集 + +下列为现有全部5份OpenAPI、42个操作;并非42个操作都由Dispatcher实现。Executor的5个操作映射Dispatcher,SaaS的2个操作由Dispatcher调用,管理平台30个操作仍属于独立管理平台,AI配置2个操作保持独立配置认证域,Cell Agent的3个操作是待适配的既有管理协议,不能静默同时保留两个写入面。 + +### ai-config.openapi.yaml + +- 标题:agent-call immutable AI configuration API +- 服务器:`[{"url":"/"}]` +- 默认安全声明:`[]` + +| 方法与路径 | operationId / 用途 | 参数 | 请求 JSON Schema | 响应 | +| --- | --- | --- | --- | --- | +| `POST /internal/v1/ai/agent-versions` | publishAgentVersion | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersionPublishRequest"}}}}` | `{"200":{"description":"Identical immutable content already exists","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersionReceipt"}}}},"201":{"description":"Immutable version published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersionReceipt"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Existing version has different content"}}` | +| `GET /internal/v1/ai/agent-versions/{agent_version_id}` | getAgentVersion | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"name":"agent_version_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}}]` | `null` | `{"200":{"description":"Trusted immutable snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"description":"Agent version not found"}}` | + +操作级安全覆盖: +```json +[ + [ + "/internal/v1/ai/agent-versions", + "post", + [ + { + "aiConfigPublish": [] + } + ] + ], + [ + "/internal/v1/ai/agent-versions/{agent_version_id}", + "get", + [ + { + "aiConfigRead": [] + } + ] + ] +] +``` + +### cell-agent.openapi.yaml + +- 标题:agent-call Cell Agent API +- 服务器:`[{"url":"https://cell.internal:9443","description":"Cell management network only"}]` +- 默认安全声明:`[]` + +| 方法与路径 | operationId / 用途 | 参数 | 请求 JSON Schema | 响应 | +| --- | --- | --- | --- | --- | +| `GET /healthz/live` | live | `[]` | `null` | `{"200":{"description":"Cell Agent is alive","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}` | +| `POST /v1/sip/trunks/{trunk_id}/apply` | applyTrunk | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Publication"}}}}` | `{"200":{"description":"Asterisk has loaded the exact snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Acknowledgement"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"description":"Revision or expected local snapshot is stale; the Cell never guesses over an unknown baseline","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"502":{"description":"Asterisk rejected the apply or reload","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}` | +| `GET /v1/sip/trunks/{trunk_id}/state` | getTrunkState | `[{"$ref":"#/components/parameters/TrunkId"}]` | `null` | `{"200":{"description":"Durable Cell apply state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/State"}}}},"404":{"$ref":"#/components/responses/NotFound"}}` | + +操作级安全覆盖: +```json +[ + [ + "/v1/sip/trunks/{trunk_id}/apply", + "post", + [ + { + "CellManagementMtls": [] + } + ] + ], + [ + "/v1/sip/trunks/{trunk_id}/state", + "get", + [ + { + "CellManagementMtls": [] + } + ] + ] +] +``` + +### executor.openapi.yaml + +- 标题:agent-call Executor Control API +- 服务器:`[{"url":"https://executor.internal"}]` +- 默认安全声明:`[{"bearerAuth":[]}]` + +| 方法与路径 | operationId / 用途 | 参数 | 请求 JSON Schema | 响应 | +| --- | --- | --- | --- | --- | +| `POST /internal/v1/outbound/tasks/{task_id}/controls` | controlTask Persist a pause, resume, or stop barrier | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/IdempotencyKey"},{"$ref":"#/components/parameters/TaskId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlRequest"}}}}` | `{"202":{"description":"Reliably persisted, not yet necessarily applied","headers":{"Location":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ControlAccepted"}}}},"409":{"$ref":"#/components/responses/Conflict"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /internal/v1/outbound/commands/{command_id}` | getCommand | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/CommandId"}]` | `null` | `{"200":{"description":"Command snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Command"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /internal/v1/outbound/calls/{call_id}` | getCall | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/CallId"}]` | `null` | `{"200":{"description":"Call snapshot","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Call"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `POST /internal/v1/outbound/calls/{call_id}/replays` | replayCall | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/IdempotencyKey"},{"$ref":"#/components/parameters/CallId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayRequest"}}}}` | `{"202":{"description":"Replay persisted for bounded broker delivery","headers":{"Location":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayAccepted"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"410":{"$ref":"#/components/responses/ReplayExpired"}}` | +| `POST /internal/v1/outbound/commands/{source_command_id}/replays` | replayCommand | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/IdempotencyKey"},{"$ref":"#/components/parameters/SourceCommandId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayRequest"}}}}` | `{"202":{"description":"Replay persisted for bounded broker delivery","headers":{"Location":{"schema":{"type":"string"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplayAccepted"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"410":{"$ref":"#/components/responses/ReplayExpired"}}` | + +### saas.openapi.yaml + +- 标题:agent-call SaaS Recording Handoff API +- 服务器:`[{"url":"https://saas.internal"}]` +- 默认安全声明:`[{"bearerAuth":[]}]` + +| 方法与路径 | operationId / 用途 | 参数 | 请求 JSON Schema | 响应 | +| --- | --- | --- | --- | --- | +| `POST /internal/v1/outbound/recording-uploads` | createRecordingUpload | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/IdempotencyKey"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadRequest"}}}}` | `{"201":{"description":"Upload session created or existing session returned","headers":{"Cache-Control":{"schema":{"const":"no-store"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSession"}}}},"200":{"description":"Existing upload session","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadSession"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /internal/v1/outbound/recording-uploads/{upload_id}/complete` | completeRecordingUpload | `[{"$ref":"#/components/parameters/TenantId"},{"$ref":"#/components/parameters/RequestId"},{"$ref":"#/components/parameters/IdempotencyKey"},{"name":"upload_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Id"}}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompleteRequest"}}}}` | `{"200":{"description":"Object independently verified","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifiedUpload"}}}},"409":{"$ref":"#/components/responses/Conflict"},"410":{"$ref":"#/components/responses/Expired"},"422":{"$ref":"#/components/responses/Unprocessable"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}` | + +### sip-management.openapi.yaml + +- 标题:agent-call Asterisk/SIP Management API +- 服务器:`[{"url":"https://sip-admin.internal","description":"Restricted operator management network"},{"url":"https://sip-read.internal","description":"SaaS read-only service network"}]` +- 默认安全声明:`[]` + +| 方法与路径 | operationId / 用途 | 参数 | 请求 JSON Schema | 响应 | +| --- | --- | --- | --- | --- | +| `GET /healthz/live` | live | `[]` | `null` | `{"200":{"description":"Service is alive","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}` | +| `GET /admin/v1/providers` | listProviders | `[]` | `null` | `{"200":{"description":"Provider directory","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/providers/{provider_id}` | getProvider | `[{"$ref":"#/components/parameters/ProviderId"}]` | `null` | `{"200":{"description":"Provider","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `PUT /admin/v1/providers/{provider_id}` | upsertProvider | `[{"$ref":"#/components/parameters/ProviderId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderInput"}}}}` | `{"200":{"description":"Updated provider","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderResponse"}}}},"201":{"description":"Created provider","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProviderResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `GET /admin/v1/trunks` | listAdminTrunks | `[{"$ref":"#/components/parameters/ProviderFilter"},{"$ref":"#/components/parameters/CellIdFilter"},{"$ref":"#/components/parameters/TrunkStatusFilter"}]` | `null` | `{"200":{"description":"All Trunks, including unpublished revisions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunkList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/Forbidden"}}` | +| `GET /admin/v1/trunks/{trunk_id}` | getAdminTrunk | `[{"$ref":"#/components/parameters/TrunkId"}]` | `null` | `{"200":{"description":"Trunk configuration and revisions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `PUT /admin/v1/trunks/{trunk_id}` | createTrunkRevision | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrunkConfig"}}}}` | `{"200":{"description":"New draft revision for an existing Trunk","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"201":{"description":"New Trunk with its first draft revision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /admin/v1/trunks/{trunk_id}/validate` | validateTrunk | `[{"$ref":"#/components/parameters/TrunkId"}]` | `{"required":false,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"properties":{"revision":{"type":"integer","minimum":1}}}}}}` | `{"200":{"description":"Validation issues, compatible Cells, and impact preview","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `POST /admin/v1/trunks/{trunk_id}/verifications` | addTrunkVerification | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerificationRequest"}}}}` | `{"200":{"description":"Versioned verification record","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /admin/v1/trunks/{trunk_id}/publish` | publishTrunk | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `null` | `{"200":{"description":"Published revision after all selected Cell acknowledgements","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /admin/v1/trunks/{trunk_id}/disable` | disableTrunk | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `null` | `{"200":{"description":"Trunk disabled after selected Cell acknowledgements","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /admin/v1/trunks/{trunk_id}/rollback` | rollbackTrunk | `[{"$ref":"#/components/parameters/TrunkId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["target_revision"],"properties":{"target_revision":{"type":"integer","minimum":1}}}}}}` | `{"200":{"description":"New revision copied from the target after Cell acknowledgements","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminTrunk"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `GET /admin/v1/trunks/{trunk_id}/publications` | listTrunkPublications | `[{"$ref":"#/components/parameters/TrunkId"}]` | `null` | `{"200":{"description":"Per-Cell publication intents","content":{"application/json":{"schema":{"type":"object","required":["mode","publications"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"publications":{"type":"array","items":{"$ref":"#/components/schemas/Publication"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /admin/v1/trunks/{trunk_id}/audit` | listTrunkAudit | `[{"$ref":"#/components/parameters/TrunkId"}]` | `null` | `{"200":{"description":"Immutable management audit entries","content":{"application/json":{"schema":{"type":"object","required":["mode","audit"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"audit":{"type":"array","items":{"$ref":"#/components/schemas/AuditEntry"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /admin/v1/cells` | listCells | `[]` | `null` | `{"200":{"description":"Registered multi-machine voice Cells","content":{"application/json":{"schema":{"type":"object","required":["mode","cells"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"cells":{"type":"array","items":{"$ref":"#/components/schemas/Cell"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/cells/{cell_id}` | getCell | `[{"$ref":"#/components/parameters/CellId"}]` | `null` | `{"200":{"description":"Registered Cell","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Cell"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `PUT /admin/v1/cells/{cell_id}` | registerCell | `[{"$ref":"#/components/parameters/CellId"},{"$ref":"#/components/parameters/IfMatch"},{"$ref":"#/components/parameters/RequestId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CellConfig"}}}}` | `{"200":{"description":"Updated Cell revision","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Cell"}}}},"201":{"description":"Registered Cell","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Cell"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `POST /admin/v1/cells/{cell_id}/observations` | ingestCellObservation | `[{"$ref":"#/components/parameters/CellId"}]` | `{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ObservationInput"}}}}` | `{"201":{"description":"Observation accepted for the current boot and monotonic sequence","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"$ref":"#/components/responses/Conflict"}}` | +| `GET /admin/v1/egress-pools` | listEgressPools | `[]` | `null` | `{"200":{"description":"Fixed egress pool directory","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/sip-status` | getSipStatus | `[{"$ref":"#/components/parameters/CellIdsFilter"},{"$ref":"#/components/parameters/CellIdFilter"},{"$ref":"#/components/parameters/ProviderFilter"}]` | `null` | `{"200":{"description":"Cell/trunk status matrix with freshness and missing sources","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/cells/{cell_id}/sip-status` | getCellSipStatus | `[{"$ref":"#/components/parameters/CellId"},{"$ref":"#/components/parameters/TrunkFilter"}]` | `null` | `{"200":{"description":"One Cell status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusItem"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /admin/v1/providers/{provider_id}/status` | getProviderStatus | `[{"$ref":"#/components/parameters/ProviderId"}]` | `null` | `{"200":{"description":"Provider status matrix","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /admin/v1/statistics/outbound/summary` | getOutboundSummary | `[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/StatsMode"},{"$ref":"#/components/parameters/ProviderFilter"},{"$ref":"#/components/parameters/TrunkFilter"},{"$ref":"#/components/parameters/CellIdFilter"},{"$ref":"#/components/parameters/EgressFilter"}]` | `null` | `{"200":{"description":"Cohort and interval metrics with completeness metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatisticsSummary"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/BadRequest"}}` | +| `GET /admin/v1/statistics/outbound/timeseries` | getOutboundTimeseries | `[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/StatsMode"},{"$ref":"#/components/parameters/Granularity"},{"$ref":"#/components/parameters/ProviderFilter"},{"$ref":"#/components/parameters/TrunkFilter"},{"$ref":"#/components/parameters/CellIdFilter"},{"$ref":"#/components/parameters/EgressFilter"}]` | `null` | `{"200":{"description":"Bounded UTC time series","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TimeseriesResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"422":{"$ref":"#/components/responses/BadRequest"}}` | +| `GET /admin/v1/call-attempts` | listCallAttempts | `[{"$ref":"#/components/parameters/From"},{"$ref":"#/components/parameters/To"},{"$ref":"#/components/parameters/StatsMode"},{"$ref":"#/components/parameters/ProviderFilter"},{"$ref":"#/components/parameters/TrunkFilter"},{"$ref":"#/components/parameters/CellIdFilter"},{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/Cursor"},{"$ref":"#/components/parameters/EgressFilter"}]` | `null` | `{"200":{"description":"Redacted raw attempt facts","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/call-attempts/{attempt_id}` | getCallAttempt | `[{"name":"attempt_id","in":"path","required":true,"schema":{"type":"string"}}]` | `null` | `{"200":{"description":"One redacted attempt fact","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /admin/v1/audit` | listAudit | `[{"$ref":"#/components/parameters/Limit"},{"$ref":"#/components/parameters/ResourceFilter"},{"$ref":"#/components/parameters/RequestFilter"},{"$ref":"#/components/parameters/ActorFilter"}]` | `null` | `{"200":{"description":"Immutable audit entries","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /admin/v1/operations/{operation_id}` | getOperation | `[{"name":"operation_id","in":"path","required":true,"schema":{"type":"string"}}]` | `null` | `{"200":{"description":"Durable operation state for retry/recovery","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | +| `GET /readonly/v1/sip/trunks` | listAuthorizedTrunks | `[]` | `null` | `{"200":{"description":"Published Trunks authorized for this SaaS principal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadonlyTrunkList"}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}` | +| `GET /readonly/v1/sip/trunks/{trunk_id}` | getAuthorizedTrunk | `[{"$ref":"#/components/parameters/TrunkId"}]` | `null` | `{"200":{"description":"Published, sanitized Trunk metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadonlyTrunk"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}` | + +操作级安全覆盖: +```json +[ + [ + "/admin/v1/providers", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/providers/{provider_id}", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/providers/{provider_id}", + "put", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}", + "put", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/validate", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/verifications", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/publish", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/disable", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/rollback", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/publications", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/trunks/{trunk_id}/audit", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/cells", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/cells/{cell_id}", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/cells/{cell_id}", + "put", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/cells/{cell_id}/observations", + "post", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/egress-pools", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/sip-status", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/cells/{cell_id}/sip-status", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/providers/{provider_id}/status", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/statistics/outbound/summary", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/statistics/outbound/timeseries", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/call-attempts", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/call-attempts/{attempt_id}", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/audit", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/admin/v1/operations/{operation_id}", + "get", + [ + { + "SipAdminBearer": [] + } + ] + ], + [ + "/readonly/v1/sip/trunks", + "get", + [ + { + "SaasTrunkReadBearer": [] + } + ] + ], + [ + "/readonly/v1/sip/trunks/{trunk_id}", + "get", + [ + { + "SaasTrunkReadBearer": [] + } + ] + ] +] +``` + +## 3. OpenAPI 组件字段全集 + +组件按源文件分域,`#/components/...` 只指其所属OpenAPI,不能跨文件按同名合并。以下JSON为源YAML组件的等价只读呈现:required、additionalProperties、enum/const、格式、范围、引用及返回错误均保留。未定义的属性约束不能从示例擅自推成必填。 + +### ai-config.openapi.yaml 的 components + +#### parameters + +##### TenantId + +```json +{"name":"X-Tenant-Id","in":"header","required":true,"schema":{"type":"string","minLength":1}} +``` + +##### RequestId + +```json +{"name":"X-Request-Id","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":128}} +``` + +#### securitySchemes + +##### aiConfigPublish + +```json +{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Requires scope ai.config.publish and the AI-config issuer/audience."} +``` + +##### aiConfigRead + +```json +{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Requires scope ai.config.read and the AI-config issuer/audience."} +``` + +#### schemas + +##### AgentVersionPublishRequest + +```json +{"type":"object","additionalProperties":false,"required":["agent_version_id","config"],"properties":{"agent_version_id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"},"config":{"$ref":"ai-config.schema.json"}}} +``` + +##### AgentVersionReceipt + +```json +{"type":"object","required":["tenant_id","agent_version_id","status","immutable","content_sha256"],"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}$"}}} +``` + +##### AgentVersion + +```json +{"allOf":[{"$ref":"#/components/schemas/AgentVersionReceipt"},{"type":"object","required":["config"],"properties":{"config":{"$ref":"ai-config.schema.json"},"created_at":{"type":"string","format":"date-time"},"published_at":{"type":"string","format":"date-time"},"created_by":{"type":"string"}}}]} +``` + +##### Error + +```json +{"type":"object","required":["error"],"properties":{"error":{"type":"string"},"message":{"type":"string"}}} +``` + +#### responses + +##### BadRequest + +```json +{"description":"Invalid configuration","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}} +``` + +##### Unauthorized + +```json +{"description":"Missing or invalid AI-config token"} +``` + +##### Forbidden + +```json +{"description":"Token lacks the AI-config permission"} +``` + +### cell-agent.openapi.yaml 的 components + +#### securitySchemes + +##### CellManagementMtls + +```json +{"type":"mutualTLS","description":"Management backend client certificate signed by the Cell CA."} +``` + +#### parameters + +##### TrunkId + +```json +{"name":"trunk_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}} +``` + +##### RequestId + +```json +{"name":"X-Request-ID","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":128}} +``` + +#### schemas + +##### Health + +```json +{"type":"object","additionalProperties":false,"required":["status","mode","cell_id"],"properties":{"status":{"type":"string","const":"ok"},"mode":{"type":"string","const":"real"},"cell_id":{"type":"string"}}} +``` + +##### CodecProfile + +```json +{"type":"object","additionalProperties":false,"required":["allowed","preferred"],"properties":{"allowed":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","enum":["PCMA","PCMU"]}},"preferred":{"type":"string","enum":["PCMA","PCMU"]}}} +``` + +##### SipConfig + +```json +{"type":"object","additionalProperties":false,"required":["host","port","transport","auth_mode","register","credential_ref"],"properties":{"host":{"type":"string","minLength":1,"maxLength":253},"port":{"type":"integer","minimum":1,"maximum":65535},"transport":{"type":"string","enum":["udp","tcp","tls"]},"auth_mode":{"type":"string","enum":["ip","digest"]},"register":{"type":"boolean"},"credential_ref":{"type":["string","null"],"description":"Secret-store reference only; plaintext is forbidden."}}} +``` + +##### TrunkConfig + +```json +{"type":"object","additionalProperties":false,"required":["provider_id","display_name","enabled","sip","codec_profile","caller_ids","dial_prefix","egress_pool_id","max_concurrency","max_cps"],"properties":{"provider_id":{"type":"string"},"display_name":{"type":"string","minLength":1,"maxLength":256},"enabled":{"type":"boolean"},"sip":{"$ref":"#/components/schemas/SipConfig"},"codec_profile":{"$ref":"#/components/schemas/CodecProfile"},"caller_ids":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","minLength":1,"maxLength":128}},"dial_prefix":{"type":"string","maxLength":32},"egress_pool_id":{"type":"string"},"max_concurrency":{"type":"integer","minimum":1},"max_cps":{"type":"integer","minimum":1}}} +``` + +##### Publication + +```json +{"type":"object","additionalProperties":false,"required":["mode","cell_id","trunk_id","revision","expected_local_revision","config","config_sha256"],"properties":{"mode":{"type":"string","const":"real"},"cell_id":{"type":"string"},"trunk_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"expected_local_revision":{"type":"integer","minimum":0},"config":{"$ref":"#/components/schemas/TrunkConfig"},"config_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"}}} +``` + +##### Acknowledgement + +```json +{"type":"object","additionalProperties":false,"required":["mode","cell_id","trunk_id","revision","config_sha256","status","idempotent"],"properties":{"mode":{"type":"string","const":"real"},"cell_id":{"type":"string"},"trunk_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"config_sha256":{"type":"string","pattern":"^[0-9a-f]{64}$"},"status":{"type":"string","const":"applied"},"idempotent":{"type":"boolean"}}} +``` + +##### State + +```json +{"type":"object","additionalProperties":false,"required":["mode","cell_id","trunk_id","desired_revision","applied_revision","status","updated_at"],"properties":{"mode":{"type":"string","const":"real"},"cell_id":{"type":"string"},"trunk_id":{"type":"string"},"desired_revision":{"type":"integer","minimum":1},"applied_revision":{"type":"integer","minimum":0},"status":{"type":"string","enum":["applying","applied","failed"]},"last_error":{"type":["string","null"]},"updated_at":{"type":"string","format":"date-time"}}} +``` + +##### ErrorResponse + +```json +{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"}}}}} +``` + +#### responses + +##### BadRequest + +```json +{"description":"Invalid publication or hash","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### Forbidden + +```json +{"description":"Certificate or Cell identity is not authorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### NotFound + +```json +{"description":"State does not exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +### executor.openapi.yaml 的 components + +#### securitySchemes + +##### bearerAuth + +```json +{"type":"http","scheme":"bearer"} +``` + +#### parameters + +##### TenantId + +```json +{"name":"X-Tenant-ID","in":"header","required":true,"schema":{"type":"string"}} +``` + +##### RequestId + +```json +{"name":"X-Request-ID","in":"header","required":true,"schema":{"type":"string"}} +``` + +##### IdempotencyKey + +```json +{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}} +``` + +##### TaskId + +```json +{"name":"task_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Id"}} +``` + +##### CommandId + +```json +{"name":"command_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Id"}} +``` + +##### SourceCommandId + +```json +{"name":"source_command_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Id"}} +``` + +##### CallId + +```json +{"name":"call_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Id"}} +``` + +#### schemas + +##### Id + +```json +{"type":"string","minLength":1,"maxLength":128,"pattern":"^[^\\s/\\\\]+$"} +``` + +##### ControlRequest + +```json +{"type":"object","additionalProperties":false,"required":["command_id","action","expected_task_revision","reason"],"properties":{"command_id":{"$ref":"#/components/schemas/Id"},"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}}} +``` + +##### ControlAccepted + +```json +{"type":"object","required":["command_id","tenant_id","tenant_key","task_id","status","requested_task_revision","accepted_at"],"properties":{"command_id":{"$ref":"#/components/schemas/Id"},"tenant_id":{"type":"string"},"tenant_key":{"type":"string"},"task_id":{"$ref":"#/components/schemas/Id"},"status":{"const":"accepted"},"requested_task_revision":{"type":"integer"},"accepted_at":{"type":"string","format":"date-time"}}} +``` + +##### ReplayRequest + +```json +{"type":"object","additionalProperties":false,"required":["command_id","reason"],"properties":{"command_id":{"$ref":"#/components/schemas/Id"},"reason":{"type":"string","minLength":1,"maxLength":512}}} +``` + +##### ReplayAccepted + +```json +{"type":"object","required":["command_id","status","snapshot_cutoff"],"properties":{"command_id":{"$ref":"#/components/schemas/Id"},"status":{"const":"accepted"},"snapshot_cutoff":{"type":"string","format":"date-time"}}} +``` + +##### Command + +```json +{"type":"object","required":["command_id","command_type","tenant_id","tenant_key","status","aggregate_version"],"properties":{"command_id":{"$ref":"#/components/schemas/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}}} +``` + +##### Call + +```json +{"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":{"type":"object"}},"transcript":{"type":"object"},"recordings":{"type":"array","items":{"type":"object"}},"delivery":{"type":"object"},"snapshot_at":{"type":"string","format":"date-time"}}} +``` + +##### Problem + +```json +{"type":"object","additionalProperties":false,"required":["type","title","status","code","detail","request_id","retryable"],"properties":{"type":{"type":"string","format":"uri-reference"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"},"detail":{"type":"string"},"request_id":{"type":"string"},"retryable":{"type":"boolean"}}} +``` + +#### responses + +##### Unauthorized + +```json +{"description":"Unauthorized","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}} +``` + +##### Forbidden + +```json +{"description":"Forbidden","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}} +``` + +##### NotFound + +```json +{"description":"Not found without cross-tenant enumeration","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}} +``` + +##### Conflict + +```json +{"description":"Idempotency or revision conflict","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}} +``` + +##### ReplayExpired + +```json +{"description":"Retention window expired","content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/Problem"}}}} +``` + +### saas.openapi.yaml 的 components + +#### securitySchemes + +##### bearerAuth + +```json +{"type":"http","scheme":"bearer"} +``` + +#### parameters + +##### TenantId + +```json +{"name":"X-Tenant-ID","in":"header","required":true,"schema":{"type":"string"}} +``` + +##### RequestId + +```json +{"name":"X-Request-ID","in":"header","required":true,"schema":{"type":"string"}} +``` + +##### IdempotencyKey + +```json +{"name":"Idempotency-Key","in":"header","required":true,"schema":{"type":"string"}} +``` + +#### schemas + +##### Id + +```json +{"type":"string","minLength":1,"maxLength":128,"pattern":"^[^\\s/\\\\]+$"} +``` + +##### UploadRequest + +```json +{"type":"object","additionalProperties":false,"required":["recording_id","call_id","content_type","size_bytes","checksum_algorithm","checksum","channels","sample_rate_hz","duration_ms"],"properties":{"recording_id":{"$ref":"#/components/schemas/Id"},"call_id":{"$ref":"#/components/schemas/Id"},"content_type":{"type":"string","const":"audio/wav"},"size_bytes":{"type":"integer","minimum":1},"checksum_algorithm":{"type":"string","const":"SHA-256"},"checksum":{"type":"string","pattern":"^[0-9a-f]{64}$"},"channels":{"type":"integer","const":1},"sample_rate_hz":{"type":"integer","minimum":8000},"duration_ms":{"type":"integer","minimum":1}}} +``` + +##### UploadSession + +```json +{"type":"object","required":["upload_id","recording_id","expires_at","upload_method","upload_url","required_headers","constraints"],"properties":{"upload_id":{"$ref":"#/components/schemas/Id"},"recording_id":{"$ref":"#/components/schemas/Id"},"expires_at":{"type":"string","format":"date-time"},"upload_method":{"const":"PUT"},"upload_url":{"type":"string","format":"uri"},"required_headers":{"type":"object"},"constraints":{"type":"object"},"oss_id":{"type":["string","null"]}}} +``` + +##### CompleteRequest + +```json +{"type":"object","additionalProperties":false,"required":["recording_id","size_bytes","checksum_algorithm","checksum"],"properties":{"recording_id":{"$ref":"#/components/schemas/Id"},"size_bytes":{"type":"integer","minimum":1},"checksum_algorithm":{"const":"SHA-256"},"checksum":{"type":"string","pattern":"^[0-9a-f]{64}$"},"etag":{"type":["string","null"]}}} +``` + +##### VerifiedUpload + +```json +{"type":"object","required":["upload_id","recording_id","status","oss_id","verified_at"],"properties":{"upload_id":{"$ref":"#/components/schemas/Id"},"recording_id":{"$ref":"#/components/schemas/Id"},"status":{"const":"verified"},"oss_id":{"type":"string"},"verified_at":{"type":"string","format":"date-time"}}} +``` + +##### Problem + +```json +{"type":"object","required":["type","title","status","code","detail","request_id","retryable"],"properties":{"type":{"type":"string"},"title":{"type":"string"},"status":{"type":"integer"},"code":{"type":"string"},"detail":{"type":"string"},"request_id":{"type":"string"},"retryable":{"type":"boolean"}}} +``` + +#### responses + +##### Unauthorized + +```json +{"description":"Unauthorized"} +``` + +##### Forbidden + +```json +{"description":"Forbidden"} +``` + +##### Conflict + +```json +{"description":"Idempotency conflict"} +``` + +##### Expired + +```json +{"description":"Upload expired"} +``` + +##### Unprocessable + +```json +{"description":"Object failed independent verification"} +``` + +### sip-management.openapi.yaml 的 components + +#### securitySchemes + +##### SipAdminBearer + +```json +{"type":"http","scheme":"bearer","bearerFormat":"opaque","description":"Dedicated operator/backend credential for SIP management writes. It is not accepted by the SaaS read-only API or ordinary scheduling API."} +``` + +##### SaasTrunkReadBearer + +```json +{"type":"http","scheme":"bearer","bearerFormat":"opaque","description":"Dedicated SaaS read-only credential. It cannot publish, modify, disable, rollback, or access Cell management."} +``` + +#### parameters + +##### ProviderId + +```json +{"name":"provider_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}} +``` + +##### TrunkId + +```json +{"name":"trunk_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}} +``` + +##### CellId + +```json +{"name":"cell_id","in":"path","required":true,"schema":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}} +``` + +##### IfMatch + +```json +{"name":"If-Match","in":"header","required":true,"description":"Exact latest revision required for CAS; quotes are accepted.","schema":{"type":"integer","minimum":0}} +``` + +##### CellIdsFilter + +```json +{"name":"cell_ids","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### CellIdFilter + +```json +{"name":"cell_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### ProviderFilter + +```json +{"name":"provider_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### TrunkFilter + +```json +{"name":"trunk_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### TrunkStatusFilter + +```json +{"name":"status","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### ResourceFilter + +```json +{"name":"resource_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### ActorFilter + +```json +{"name":"actor","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### From + +```json +{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}} +``` + +##### To + +```json +{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}} +``` + +##### StatsMode + +```json +{"name":"mode","in":"query","required":false,"schema":{"type":"string","enum":["mock","mixed","real"]}} +``` + +##### EgressFilter + +```json +{"name":"egress_pool_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### Granularity + +```json +{"name":"granularity","in":"query","required":false,"schema":{"type":"string","enum":["minute","hour","day"]}} +``` + +##### Limit + +```json +{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200}} +``` + +##### Cursor + +```json +{"name":"cursor","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### RequestFilter + +```json +{"name":"request_id","in":"query","required":false,"schema":{"type":"string"}} +``` + +##### RequestId + +```json +{"name":"X-Request-ID","in":"header","required":true,"schema":{"type":"string","minLength":1,"maxLength":128}} +``` + +#### responses + +##### BadRequest + +```json +{"description":"Invalid configuration or request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### Unauthorized + +```json +{"description":"Missing or wrong authentication domain","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### Forbidden + +```json +{"description":"Credential lacks the required scope","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### Conflict + +```json +{"description":"CAS conflict or no compatible Cell","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +##### NotFound + +```json +{"description":"Resource is not visible or does not exist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}} +``` + +#### schemas + +##### Mode + +```json +{"type":"string","enum":["mock","real"]} +``` + +##### StatsMode + +```json +{"type":"string","enum":["mock","mixed","real"]} +``` + +##### Health + +```json +{"type":"object","additionalProperties":false,"required":["status","mode"],"properties":{"status":{"type":"string","const":"ok"},"mode":{"$ref":"#/components/schemas/Mode"}}} +``` + +##### CodecProfile + +```json +{"type":"object","additionalProperties":false,"required":["allowed","preferred"],"properties":{"allowed":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","enum":["PCMA","PCMU"]}},"preferred":{"type":"string","enum":["PCMA","PCMU"]}}} +``` + +##### SipConfig + +```json +{"type":"object","additionalProperties":false,"required":["host","port","transport","auth_mode","register"],"properties":{"host":{"type":"string","minLength":1,"maxLength":253},"port":{"type":"integer","minimum":1,"maximum":65535},"transport":{"type":"string","enum":["udp","tcp","tls"]},"auth_mode":{"type":"string","enum":["ip","digest"]},"register":{"type":"boolean"},"credential_ref":{"type":"string","writeOnly":true,"description":"Secret-store reference only; plaintext credentials are forbidden."}}} +``` + +##### VerificationRequest + +```json +{"type":"object","additionalProperties":false,"required":["revision","check_name","result"],"properties":{"revision":{"type":"integer","minimum":1},"check_name":{"type":"string","enum":["transport","registration_auth","caller_id_rules","codec","capacity","whitelist"]},"result":{"type":"string","enum":["confirmed","failed","unknown","not_applicable"]},"evidence_ref":{"type":"string","maxLength":512},"checked_by":{"type":"string","maxLength":128}}} +``` + +##### TrunkConfig + +```json +{"type":"object","additionalProperties":false,"required":["provider_id","display_name","enabled","sip","codec_profile","caller_ids","dial_prefix","egress_pool_id","max_concurrency","max_cps"],"properties":{"provider_id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},"display_name":{"type":"string","minLength":1,"maxLength":256},"enabled":{"type":"boolean"},"sip":{"$ref":"#/components/schemas/SipConfig"},"codec_profile":{"$ref":"#/components/schemas/CodecProfile"},"caller_ids":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","minLength":1,"maxLength":128}},"dial_prefix":{"type":"string","maxLength":32},"egress_pool_id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},"max_concurrency":{"type":"integer","minimum":1},"max_cps":{"type":"integer","minimum":1}}} +``` + +##### CellConfig + +```json +{"type":"object","additionalProperties":false,"required":["egress_pool_id","codec_capabilities","status","max_concurrency"],"properties":{"egress_pool_id":{"type":"string","pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},"codec_capabilities":{"type":"array","minItems":1,"uniqueItems":true,"items":{"type":"string","enum":["PCMA","PCMU"]}},"status":{"type":"string","enum":["healthy","draining","disabled"]},"max_concurrency":{"type":"integer","minimum":1},"management_url":{"type":"string","format":"uri","pattern":"^https://","description":"mTLS Cell Agent endpoint. Required when mode=real; credentials and query strings are not allowed."}}} +``` + +##### RevisionInfo + +```json +{"type":"object","additionalProperties":false,"required":["revision","state","created_at","created_by"],"properties":{"revision":{"type":"integer","minimum":1},"state":{"type":"string","enum":["draft","publishing","published","superseded"]},"config_sha256":{"type":"string","pattern":"^[a-f0-9]{64}$"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string"}}} +``` + +##### AdminTrunk + +```json +{"type":"object","required":["mode","trunk_id","provider_id","latest_revision","active_revision","active_status","status","compatible_cell_ids","latest","active","versions"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"trunk_id":{"type":"string"},"provider_id":{"type":"string"},"latest_revision":{"type":"integer","minimum":1},"active_revision":{"type":"integer","minimum":0},"active_status":{"type":"string","enum":["draft","published","disabled"]},"status":{"type":"string","enum":["draft","published","disabled"]},"updated_at":{"type":"string","format":"date-time"},"compatible_cell_ids":{"type":"array","items":{"type":"string"}},"latest":{"$ref":"#/components/schemas/TrunkView"},"active":{"$ref":"#/components/schemas/TrunkView"},"versions":{"type":"array","items":{"$ref":"#/components/schemas/RevisionInfo"}}}} +``` + +##### TrunkView + +```json +{"allOf":[{"$ref":"#/components/schemas/TrunkConfig"},{"type":"object","properties":{"trunk_id":{"type":"string"},"credential_configured":{"type":"boolean"},"asterisk_allow":{"type":"array","items":{"type":"string","enum":["alaw","ulaw"]}},"config_sha256":{"type":"string","pattern":"^[a-f0-9]{64}$"}}}]} +``` + +##### ReadonlyTrunk + +```json +{"type":"object","required":["mode","trunk_id","provider_id","revision","status","config"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"trunk_id":{"type":"string"},"provider_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"status":{"type":"string","const":"published"},"updated_at":{"type":"string","format":"date-time"},"config":{"$ref":"#/components/schemas/TrunkView"}}} +``` + +##### AdminTrunkList + +```json +{"type":"object","required":["mode","trunks"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"trunks":{"type":"array","items":{"$ref":"#/components/schemas/AdminTrunk"}}}} +``` + +##### ReadonlyTrunkList + +```json +{"type":"object","required":["mode","trunks"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"trunks":{"type":"array","items":{"$ref":"#/components/schemas/ReadonlyTrunk"}}}} +``` + +##### Cell + +```json +{"type":"object","required":["mode","cell_id","revision","config","updated_at","updated_by"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"cell_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"config":{"$ref":"#/components/schemas/CellConfig"},"cloud_instance_id":{"type":"string"},"instance_name":{"type":"string"},"region":{"type":"string"},"boot_id":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"updated_by":{"type":"string"}}} +``` + +##### Publication + +```json +{"type":"object","required":["trunk_id","revision","cell_id","status","updated_at"],"properties":{"trunk_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"cell_id":{"type":"string"},"status":{"type":"string","enum":["pending","applied","failed"]},"error_code":{"type":["string","null"]},"applied_at":{"type":["string","null"],"format":"date-time"},"target_digest":{"type":"string"},"local_revision":{"type":"integer","minimum":0},"local_digest":{"type":"string"},"operation_id":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}} +``` + +##### AuditEntry + +```json +{"type":"object","required":["audit_id","resource_type","resource_id","action","revision","actor","details_json","created_at"],"properties":{"audit_id":{"type":"string"},"resource_type":{"type":"string","const":"trunk"},"resource_id":{"type":"string"},"action":{"type":"string","enum":["upsert","publish","publish_failed","disable","disable_failed","rollback","rollback_failed"]},"revision":{"type":"integer","minimum":0},"actor":{"type":"string"},"request_id":{"type":["string","null"]},"details_json":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}} +``` + +##### ProviderInput + +```json +{"type":"object","additionalProperties":false,"required":["display_name","lifecycle"],"properties":{"display_name":{"type":"string","minLength":1,"maxLength":256},"notes":{"type":"string","maxLength":2000},"lifecycle":{"type":"string","enum":["active","archived"]}}} +``` + +##### Provider + +```json +{"allOf":[{"$ref":"#/components/schemas/ProviderInput"},{"type":"object","required":["provider_id","revision","trunk_count","created_at","updated_at"],"properties":{"provider_id":{"type":"string"},"revision":{"type":"integer","minimum":1},"trunk_count":{"type":"integer","minimum":0},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"},"updated_by":{"type":"string"}}}]} +``` + +##### ProviderList + +```json +{"type":"object","required":["mode","providers"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"providers":{"type":"array","items":{"$ref":"#/components/schemas/Provider"}}}} +``` + +##### ProviderResponse + +```json +{"type":"object","required":["mode","provider"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"provider":{"$ref":"#/components/schemas/Provider"}}} +``` + +##### ObservationInput + +```json +{"type":"object","additionalProperties":false,"required":["cell_id","boot_id","sequence","observed_at","source","states"],"properties":{"observation_id":{"type":"string"},"cell_id":{"type":"string"},"trunk_id":{"type":"string"},"boot_id":{"type":"string","minLength":1},"sequence":{"type":"integer","minimum":1},"observed_at":{"type":"string","format":"date-time"},"source":{"type":"string"},"config_revision":{"type":"integer","minimum":0},"states":{"type":"object"},"occupancy":{"type":"object"},"sample_id":{"type":"string"}}} +``` + +##### StatusItem + +```json +{"type":"object","required":["mode","cell_id","availability","complete","missing_sources"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"cell_id":{"type":"string"},"availability":{"type":"string","enum":["healthy","stale","unknown","disabled"]},"complete":{"type":"boolean"},"observed_at":{"type":["string","null"],"format":"date-time"},"received_at":{"type":["string","null"],"format":"date-time"},"observation_age_seconds":{"type":["integer","null"],"minimum":0},"clock_skew":{"type":"boolean"},"reason":{"type":"string"},"boot_id":{"type":"string"},"sequence":{"type":"integer","minimum":1},"config_revision":{"type":"integer","minimum":1},"config_status":{"type":"string"},"egress_pool_id":{"type":"string"},"eligibility":{"type":"string"},"missing_sources":{"type":"array","items":{"type":"string"}},"states":{"type":"object"},"occupancy":{"type":"object"},"trunks":{"type":"array","items":{"type":"object"}},"publication":{"type":"object"}}} +``` + +##### StatusResponse + +```json +{"type":"object","required":["mode","complete","cells"],"properties":{"mode":{"$ref":"#/components/schemas/Mode"},"complete":{"type":"boolean"},"generated_at":{"type":"string","format":"date-time"},"data_as_of":{"type":["string","null"],"format":"date-time"},"coverage":{"type":"object"},"cells":{"type":"array","items":{"$ref":"#/components/schemas/StatusItem"}}}} +``` + +##### StatisticsSummary + +```json +{"type":"object","required":["mode","from","to","complete","metrics"],"properties":{"mode":{"$ref":"#/components/schemas/StatsMode"},"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"},"timezone":{"type":"string"},"definition_version":{"type":"string"},"filters":{"type":"object"},"complete":{"type":"boolean"},"missing_sources":{"type":"array","items":{"type":"string"}},"unresolved_count":{"type":"integer","minimum":0},"data_as_of":{"type":["string","null"],"format":"date-time"},"metrics":{"type":"object"},"failure_reasons":{"type":"array","items":{"type":"object"}},"realtime":{"type":"object"}}} +``` + +##### TimeseriesResponse + +```json +{"type":"object","required":["mode","from","to","granularity","complete","series"],"properties":{"mode":{"$ref":"#/components/schemas/StatsMode"},"from":{"type":"string","format":"date-time"},"to":{"type":"string","format":"date-time"},"timezone":{"type":"string"},"definition_version":{"type":"string"},"filters":{"type":"object"},"granularity":{"type":"string","enum":["minute","hour","day"]},"complete":{"type":"boolean"},"series":{"type":"array","items":{"type":"object"}}}} +``` + +##### ErrorResponse + +```json +{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code","message"],"properties":{"code":{"type":"string"},"message":{"type":"string"},"fields":{"type":"object"},"request_id":{"type":"string"}}}}} +``` + +## 4. 现有 MQ Schema 快照(信封已对齐,事件payload覆盖待补) + +以下原件已使用command_type/command_id、event_type与aggregate_*,call.execute required包含task_revision。事件payload仍为通用object,不足以证明每种事件的条件必填/状态语义;需在上游唯一生成源补齐并验收,再冻结发布包。 +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.invalid/contracts/mq.schema.json", + "title": "agent-call MQ command and event envelope", + "oneOf": [ + { + "$ref": "#/$defs/executeCommand" + }, + { + "$ref": "#/$defs/event" + } + ], + "$defs": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[^\\s/\\\\]+$" + }, + "tenantKey": { + "type": "string", + "minLength": 1 + }, + "time": { + "type": "string", + "format": "date-time" + }, + "executeCommand": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "command_type", + "command_id", + "tenant_id", + "tenant_key", + "trace_id", + "issued_at", + "not_after", + "payload" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "command_type": { + "const": "call.execute" + }, + "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": { + "$ref": "#/$defs/executePayload" + } + } + }, + "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 + } + } + }, + "event": { + "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" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "event_id": { + "$ref": "#/$defs/id" + }, + "event_type": { + "enum": [ + "command.result", + "call.status", + "transcript.updated", + "call.finished", + "recording.ready", + "recording.failed", + "transcript.failed", + "contact.opt_out" + ] + }, + "tenant_id": { + "$ref": "#/$defs/id" + }, + "tenant_key": { + "$ref": "#/$defs/tenantKey" + }, + "trace_id": { + "$ref": "#/$defs/id" + }, + "occurred_at": { + "$ref": "#/$defs/time" + }, + "aggregate_type": { + "enum": [ + "command", + "call", + "transcript_segment", + "recording" + ] + }, + "aggregate_id": { + "$ref": "#/$defs/id" + }, + "aggregate_version": { + "type": "integer", + "minimum": 1 + }, + "payload": { + "type": "object" + } + } + } + } +} +``` + +## 5. AI 配置 JSON Schema 快照 + +本对象仍属于不可变配置面,不可直接放入call.execute。 +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": [ + "agent_version_id", + "immutable", + "llm", + "prompt", + "tts", + "asr", + "conversation" + ], + "properties": { + "agent_version_id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + }, + "immutable": { + "const": true + }, + "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": [ + "opening", + "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 + } + } +} +``` + +## 6. 生成与差异处理 + +- 本次索引覆盖42个HTTP操作及上述组件原始约束;没有新增、修复或覆盖父项目契约文件。 +- 文本归档授权、SaaS全局OSS配置同步、管理平台到Dispatcher发布适配及内部gRPC均不能假称已由这些OpenAPI定义;详见交互文档的GAP清单。 +- 对齐后更新源版本/哈希及索引,校验所有引用和黄金请求/事件;任何残留漂移阻塞类型生成和P0冻结。 +- 以下文档才给出跨角色交互与事件语义:[通信与事件数据交互](通信与事件数据交互_v0.1.md)。 diff --git a/docs/decisions/20260918-w02-unary-proto-baseline.md b/docs/decisions/20260918-w02-unary-proto-baseline.md new file mode 100644 index 0000000..b7e74c9 --- /dev/null +++ b/docs/decisions/20260918-w02-unary-proto-baseline.md @@ -0,0 +1,37 @@ +# W02 Unary Proto baseline decision + +## Decision + +Create the first project-owned `agent.v1.AgentControlService` Unary gRPC contract from +the already-confirmed R01–R03/R05/R07–R13 responsibilities. The contract is +kept in `proto/agent/v1/agent.proto` and generated Go output is produced by +Buf using the official Go protobuf and grpc plugins. + +## Included responsibilities + +`GetAgentStatus`, `ActivateAgent`, `GetBootstrap`, `SetAdmissionState`, +`Execute`, `GetExecutionPermit`, `ApplyTaskControl`, `QueryExecution`, +`ReportExecutionEvent`, `RequestUpload`, and `CompleteUpload`. + +R04/R06 online runtime publication remains out of this P1 baseline. Static SIP +publication is represented by config references and applied snapshots, not a +new online management API. + +## Safety rules encoded by the baseline + +- Every request has request/trace/operation identity and a deadline. +- Target identity includes agent/cell/boot/session generation; the Dispatcher + owns the binding and the Agent cannot self-select a target. +- Execute carries the approved external command bytes plus an execution binding; + it does not accept arbitrary SIP endpoints, credentials or AI URLs. +- Permit and control operations carry revision/epoch/reservation information; + accepted is not applied, and unknown results are reconciled under the original + identity rather than retried as a new call. +- Facts carry a stable ID, content digest, observed time, boot and source + sequence. Recording upload messages carry metadata/grants only, never audio. +- Failure codes align with the documented gRPC classes; transport deadlines do + not change business deadlines. + +This is a project implementation baseline, not a modification of the parent +SaaS contract. Any later field-number or semantic change requires a versioned +Proto review and compatibility check. diff --git a/docs/evidence/20260918-acceptance-matrix.md b/docs/evidence/20260918-acceptance-matrix.md new file mode 100644 index 0000000..9744664 --- /dev/null +++ b/docs/evidence/20260918-acceptance-matrix.md @@ -0,0 +1,27 @@ +# 2026-09-18 本地开发与验收矩阵(更新 2026-09-19) + +范围:`go-sip` 子项目本地工程检查及本轮真实云控制面/主机 smoke。该矩阵不是生产签收;本轮创建并加固了一台测试 ECS,从固定 EIP 对三条登记 SIP endpoint 做了 OPTIONS 200 reachability probe,并按用户选择各发送一条真实 INVITE,后续又在用户即时确认后对 provider-second 做了一次临时抓包的 direct-PJSIP 探针;没有形成 `200 OK` 已接通电话、RTP/录音或可确认的供应商拨号消费,且没有把外部系统凭据写入项目。 + +> **范围修订(2026-09-20)**:本历史矩阵的双节点/第二 Cell/生产联调门禁已被 `docs/evidence/20260920-scope-amendment.md` supersede。本阶段按单节点、单 Cell、单租户和契约/fixture/隔离环境签收;真实 ECS、真实供应商、生产 SaaS/MQ receipt、双节点/第二 Cell/双租户和生产切换均延期第二阶段。历史数据仍按事实保留,不代表当前门禁。 + +| 门禁 | 本地结果 | 证据/未决项 | +| --- | --- | --- | +| G0 / W01 契约发布 | **本地通过 / 外部阻塞** | 项目内 `contracts/upstream/2026-09-18-p1-baseline` 已完成严格事件/双模式 AI/授权/OSS/静态制品/profile Schema、正反例与哈希;明确继承 dirty source 且非外部权威,真实预算/授权仍未签收。 | +| W02 Unary Proto/会话 | **本地通过 / 集成待验证** | `agent.v1.AgentControlService` Proto、生成 stubs、`proto/ERRORS.md`、Buf manifest 和生成/handler 测试通过;Dispatcher/Agent部署级运行会话仍需联测。 | +| W03-a 工具链/依赖 | **本地通过** | `go.mod` Go 1.27.1、Cobra 双子命令、`go mod verify`、依赖许可证清单和 `govulncheck@v1.7.0` 无漏洞扫描;ARI/LLM/ASR/TTS 隔离 API/协议 Mock PoC 已记录,正式锁版和真实供应商仍未验收。 | +| W03-c SQLite/MQ PoC | **本地通过 / 外部未验收** | inbox、幂等、outbox、publisher-confirm 边界、租户队列/版本化 exchange、lease、配额和控制 CAS 有 race 单测;`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;当前候选二进制另在 owner-authorized ECS 通过隔离 RabbitMQ 完成 `routed=true`、`inbox=1/task=1/outbox=1`,再由候选 Dispatcher flush 为 `task_status=accepted/outbox_status=published`(证据:`docs/evidence/20260918-rabbitmq-integration.md`);SaaS 侧使用版本化 contract/fixture Mock,边界记录在 `docs/evidence/20260919-local-saas-contract-mock.md`;没有批准 broker 版本/ACL/崩溃注入/SaaS application receipt。 | +| W03-e AI Schema PoC | **本地通过 / 供应商未验收** | W01 项目内 Schema 通过 full-AI/ASR-only 正负例、不可变摘要和模式匹配;真实供应商参数/能力/凭据仍未验收。 | +| W05 Dispatcher | **本地部分通过** | SQLite 任务/配额/控制/replay/outbox 及内部 HTTP handler 有本地测试,且覆盖 SQLite 文件 close/reopen 后 dispatching outbox 恢复为 retry,以及 `TestOutboxProcessCrashRecovery` 覆盖测试子进程在 durable claim 后退出、父进程恢复并重新发布(证据:`docs/evidence/20260918-w05-restart.md`);contract-backed local flow 串联命令/配额/Agent 执行/event 校验;`ExecuteReserved` 将已持有 reservation 与 Agent permit/Execute 联通,并以事务方式区分提交前回队列与未知占用;RabbitMQ adapter 默认 prefetch=1、per-tenant DLQ、publisher confirm 和 manual ACK/NACK,以及 Dispatcher tenant consume→SQLite inbox/task/outbox→event publish,已通过 disposable RabbitMQ 4.1.8 集成;当前候选 ECS `--consume` 自动 flush 回执也已通过,`task_status=accepted`、`outbox_status=published`,临时 SaaS-events queue 收到 `agent-call.command.result`(证据:`docs/evidence/20260918-rabbitmq-integration.md`)。批准 broker/ACL、真实进程/commit 故障、SaaS application receipt 或跨 Cell 真实验证仍未完成。 | +| W06 Agent | **本地部分通过 / 云端 loopback mTLS smoke 通过** | 文件 state/transcript/assets、原子封口、损坏隔离、boot→unknown、TLS1.3 mTLS config、静态 Cell 制品启动路径/激活校验、gopsutil 主机/进程资源采样(媒体/AI维度显式 unknown)、Unary R01 pre-activation probe/R02 activation/session/fencing/CAS/permit/fact/upload boundary handlers 和 CLI listener 已通过本地测试,包含旧 client CA 被替换后拒绝的本地 trust-root rotation 单测;W06 定向本地证据见 `docs/evidence/20260918-w06-w12-local.md`;已部署 release 在 owner-authorized Debian ECS loopback listener 完成 `ActivateAgent` + `GetAgentStatus`,并拒绝无客户端证书和 rogue-CA 证书;项目 `internal/rpc.DialFromFiles` + `dispatcher.AgentCoordinator`、当前工作树实际 `dispatcher` Cobra endpoint-inventory startup、同一主机两 loopback Agent/Cell session smoke、服务端错误 Agent/Cell metadata 拒绝测试、错误 inventory misbinding 的远程 `PermissionDenied`,以及重启式 CA 根替换(旧 client 拒绝、新 client 成功)、同一 Agent boot 下 Dispatcher 重启 generation 1→2、Agent-side leaf fingerprint allowlist(同 CA 未授权 client 拒绝)均通过(`docs/evidence/20260918-w06-mtls-cloud.md`);仍未完成物理两 Cell/跨主机、fleet-wide endpoint-role rotation/revocation distribution 和真实健康采样。 +| W08 Permit/Execute | **隔离 mock mTLS 通过 / 生产阻塞** | 当前工作树项目 `internal/rpc.DialFromFiles` + `dispatcher.AgentCoordinator.ExecuteRaw` 已在 owner-authorized ECS mock Agent 上完成 R01/R02、执行许可、Execute 和 accepted receipt;同一工作树 `Dispatcher.ExecuteReserved` 通过 SQLite quota/command/reservation 串联 mTLS Agent,结果为 accepted receipt、task_status=running(`docs/evidence/20260918-w06-mtls-cloud.md`);没有跨 Cell 最后发起屏障、Agent/Asterisk 业务路径的 SIP originate、真实 MQ application receipt 或故障注入验收;三条直连 SIPp INVITE 仅是独立线路诊断。 | +| W07/W10 双 AI | **本地 Mock/授权/SDK API PoC 通过 / 真实参数门禁阻塞** | W01 双模式 Schema、不可变摘要、Agent RPC 对 permit/Execute 的授权绑定校验、bounded/cancellable ASR-only/full-AI mock pipeline 和阶段调用测试通过;隔离 `openai-go/v3.62.0` streaming 参数/禁重试、`dashscopego/v0.1.2` Paraformer surface、`doubao-speech-go` ASR/TTS surface 均编译通过;Volcengine TTS 当前缺 speed/volume/pitch 可达性,DashScope endpoint injection/两家真实供应商的取消/背压/能力验收仍未完成,禁止复用旧 LLM/TTS。 | +| W09 SIP/ARI/RTP | **隔离 Asterisk/ARI runtime PoC 通过 / 业务媒体与真实阻塞** | 已锁定并引入 Pion RTP `v1.10.5`,`internal/media` 只做 bounded policy adapter,库解析和 payload/SSRC/包长边界测试通过;新增 `TestPacketGuardPreservesPCMAPayloadByteForByte` 通过 160-byte PT=8 fixture 的精确 payload/header 保真;`internal/contract.ValidateStaticArtifact` 覆盖 Schema、Cell/source/digest/revision、egress allowlist、必需 trunk 和重复 trunk 拒绝;临时 module `ari/v5.3.1` 已在固定 Asterisk `22.10.1` 隔离容器运行:内部 Stasis channel、mixing bridge、RTP/UDP PCMA ExternalMedia、`UNICASTRTP_LOCAL_ADDRESS/PORT`、`StasisEnd` 均通过;双 ExternalMedia 合成 RTP 经过 bridge 转发并验证 RTP v2/PT=8(证据:`docs/evidence/20260918-w09-ari-runtime.md`)。隔离 SIPp-to-PJSIP/ARI signaling 已通过(证据:`docs/evidence/20260918-w09-sip-ari.md`);进一步的 PJSIP/PJSUA2 mock leg 已通过真实 SIP RTP 双向 PCMA、U1/U2/U3 播放、端点 WAV 封口和 ARI 清理(证据:`docs/evidence/20260918-w09-sip-rtp-ari.md`)。另在 owner-authorized Debian ECS 通过现有 SIPp image 从固定 EIP 对 `61.132.228.221:5060`、`60.171.24.90:5060`、`160.202.254.79:5060` 各发一条 OPTIONS,均收到 `SIP/2.0 200 OK`(证据:`docs/evidence/20260918-ecs-sip-deployment.md`);这不证明 provider authorization、registration、caller-ID/dial-prefix 或真实媒体。另在 owner-authorized ECS 按用户选择对三条登记线路各发一条真实 INVITE:数企 `480 Temporarily Unavailable`、中鼎 `404 Not Found`、百应含 PCMA SDP 的 `183 Session Progress` 后无 `200 OK`,均未形成已接通对话(证据:`docs/evidence/20260918-real-sip-provider-calls.md`、`docs/evidence/20260919-real-sip-provider-calls-retry.md`)。后续允许窗口重试仍无最终 `200 OK`;2026-09-19 新 ECS 对两个白名单目标直连真实供应商,数企/百应仍无最终 `200`,中鼎第二目标一次信令达到 `200`,但 media probe 未干净完成,未验收 RTP/录音;用户即时确认后又对 provider-second 做了 1 次真实 direct-PJSIP 外呼,临时 PCAP 观察 8 个 SIP 包、0 个媒体包和 `100/200/404` token,原始 PCAP 已删除且未自动重试;证据:`docs/evidence/20260919-real-provider-ecs-direct.md`。这仍未证明供应商真实媒体、端到端生产 PCMA sample preservation、录音 retention/OSS handoff、重连、Agent 集成,`docs/evidence/20260919-sip-routing-implementation-comparison.md` 进一步记录父目录线路配置与 Go Agent 的注册/认证、From/PAI、前缀和选路边界;ARI 也未锁入生产 module。 | +| W11 OSS/录音 | **Alibaba OSS 本地/ECS通过 / 生产闭环阻塞** | W01 OSS control-plane Schema、受限 HTTPS grant 的 host/size/checksum/expiry/object-key/redirect 校验、Agent 直接 PUT client 和 upload metadata RPC handlers 通过;Dispatcher 使用 Alibaba 官方 OSS Go SDK v2,15分钟单次 grant,显式重新申请,源资产保留;本地及新 ECS mTLS gRPC→OSS 内网 endpoint 已完成 PUT/HEAD、SQLite completion、`recording.ready` outbox 和幂等验证(证据:`docs/evidence/20260920-local-oss-mq-integration.md`、`docs/evidence/20260920-new-ecs-preflight.md`);没有生产 SaaS application receipt、生产 broker ACL/TLS 和真实 provider-third 新录音闭环。 | +| W12 事件/观测/故障 | **本地资源样本/事件部分通过** | command.result outbox/replay、8 种 strict event Schema/fixtures、`transcript.updated` archive/realtime builder、gopsutil 资源样本 freshness/unknown、contract-backed local flow、状态和本地检查有证据;新增 `internal/calllog` 的 HMAC `phone_ref`、掩码手机号和 allow-list JSONL 业务日志,覆盖执行/呼叫状态/终态/attempt/录音事实,验证原始号码不落盘,证据见 `docs/evidence/20260919-phone-call-business-log.md`;W12 定向 replay/outbox/no-originate-retry 证据见 `docs/evidence/20260918-w06-w12-local.md`;最新 race/vet/module/契约/Proto 回归通过;指标、应用收讫、整体补传和故障注入未闭合。 +| W13-a 本地构建 | **本地通过 / 生产候选未冻结** | `make acceptance-local`:契约哈希、`go mod verify`、`go test -race ./...`、`go vet ./...`、`go build`、mock smoke 和 real-mode fail-closed 均通过;`make release` 生成带源脏状态、模块/二进制 SHA-256、Go版本和无凭据声明的本地 manifest;该 release 已复制到 ECS hash-named releases 目录并按 manifest 校验 binary/go.mod/go.sum,仍明确 `production_approval=false`;新增物理 Debian 13 systemd 上传包 `0.1.0-p1.20260919`,`deploys/install.sh` 在 owner-authorized ECS 完成 checksum/OS/权限和 `systemd-analyze verify` smoke,详见 `docs/evidence/20260919-physical-systemd-deployment.md`。 | +| W13-b/P1 两 Cell | **物理单机安装 smoke / 两 Cell 真实阻塞** | 已有两套隔离 mock Cell 的 Agent session/permit/execute 测试;一台 Debian ECS 已创建、加固并完成当前候选二进制 Agent/Dispatcher mock 启动、隔离 RabbitMQ tenant consume→SQLite→outbox flush、Asterisk mock、三条 SIP endpoint OPTIONS 200 smoke,以及三条线路真实 INVITE 的非接通结果及后续允许窗口重试,证据:`docs/evidence/20260918-ecs-sip-deployment.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/20260918-cloud-host-bootstrap.md`。仍没有物理两 Agent/两 Asterisk/三供应商真实媒体/真实双 AI/录音验收、生产镜像 digest、容量或 N+1 证据;初始账户失败保留在 `docs/evidence/20260918-aliyun-provisioning-blocker.md`。 | +| W14/W15/W16 | **阻塞/延期** | 真实 P1、旧新切换、第二租户公平/真实 broker 背压/DLQ 未执行;本地 round-robin 加 SQLite scheduler_state 文件重开恢复、prefetch=1/DLQ 拓扑代码不等于 P2 通过。单主机 smoke 不等于 W14;当前仍缺 Asterisk/ARI/SIP/AI/OSS/MQ 真实链路和两 Cell。 | + +## 结论 + +本轮可以签收的是本地 M/PoC、真实云盘点及可重复检查入口,不可以签收 G0、W04、真实依赖、P1、切换或生产容量。解除顺序:项目内 W01 → W02 已完成;仍须完成 W03 正式 PoC/依赖门禁 → W04,再按 W05–W13 集成和授权逐项验收。 diff --git a/docs/evidence/20260918-ai-sdk-poc.md b/docs/evidence/20260918-ai-sdk-poc.md new file mode 100644 index 0000000..02ab43b --- /dev/null +++ b/docs/evidence/20260918-ai-sdk-poc.md @@ -0,0 +1,67 @@ +# AI SDK PoC evidence — 2026-09-18 + +## Scope + +This record covers an isolated compile/protocol-mock PoC only. It does not +provide supplier credentials, call a paid endpoint, or unlock W10/W14. + +## OpenAI-compatible streaming LLM + +A temporary module outside `sip-go-agent` was built with Go 1.27.1: + +- module: `github.com/openai/openai-go/v3` +- version: `v3.62.0` +- module sum: `h1:P3G1Ip9eOwCJxg2q1lPBFg7iuyDEJDuoqQinzZDaxJM=` +- `LICENSE` SHA-256: `636eb7d79da9bb6d515a4b3fd417aa26679eb3cf16396ddab4bc55fa74e616e4` +- compile surface: Chat Completions streaming, context cancellation surface, + configured base URL, and `option.WithMaxRetries(0)` +- protocol mock: local `httptest` server verified the authorized model, + `stream`, explicit `temperature: 0`, and explicit `max_tokens: 37`; exactly + one request was observed +- `go test ./...`: passed +- `govulncheck ./...`: no vulnerabilities found + +The SDK is not added to the production module. The PoC does not prove that a +supplier's endpoint, model capability matrix, billing behavior, or streaming +error semantics match the approved SaaS configuration. + +## DashScope Paraformer ASR + +A temporary module outside `sip-go-agent` compiled `github.com/devinyf/dashscopego` +`v0.1.2`: + +- module sum: `h1:pBqXY4+9LnCE2886hX9bRXw4WjxRpJ6Rl6Kb5XiYbcQ=` +- `LICENSE` SHA-256: `0ac223aad8ac9f5331b7a6c7161b7011fd42907940964489985274ecda0e5dcf` +- compile surface: Paraformer `run-task` request, PCM/16 kHz parameters, + language hints, binary audio send, result callback, and close +- `go test ./...`: passed +- `govulncheck ./...`: no reachable vulnerabilities; one unused required-module + finding was reported as unreachable + +This candidate hard-codes the service WebSocket endpoint in its public +connection helper, so the PoC did not make a local protocol mock request. It +is not added to the production module until endpoint injection, cancellation, +backpressure, and selected-model parameter behavior are accepted. + +## Volcengine ASR/TTS + +A temporary module compiled +`github.com/GizClaw/doubao-speech-go` +`v0.0.0-20260915022405-e38c14802696`: + +- module sum: `h1:FL/2Z4hIT4gaVWz0kCVFSZP1O4RrvpWTZWwLnuVmNO0=` +- `LICENSE` SHA-256: `04df76b166686a49f7f51f129aeab846b1b65dfa2de29bd38c2a39e433dd0fe5` +- compile surface: ASR V2 session/open, typed model/audio parameters, + `SendAudio`, `Recv`, TTS V2 session/open, `SendText`, and `CancelSession` +- `go test ./...`: passed +- `govulncheck ./...`: no vulnerabilities found + +The source is a pseudo-version with no release tag in the local module query. +More importantly, `TTSV2WSConfig` exposes speaker/resource/format/sample-rate +but not the required speed/volume/pitch controls. This is a parameter +reachability failure for the approved snapshot and keeps the Volcengine SDK +unlocked; no real endpoint was called. + +The existing local AI pipeline remains a bounded mock and must not be reported +as real ASR/LLM/TTS. Supplier authorization is still required for final +protocol, cancellation, backpressure, billing, and capability acceptance. diff --git a/docs/evidence/20260918-aliyun-provisioning-blocker.md b/docs/evidence/20260918-aliyun-provisioning-blocker.md new file mode 100644 index 0000000..2c3f1dc --- /dev/null +++ b/docs/evidence/20260918-aliyun-provisioning-blocker.md @@ -0,0 +1,71 @@ +# 2026-09-18 Alibaba Cloud provisioning evidence — initial attempt (historical) + +## Scope + +This record covers the owner-authorized **initial** real-cloud provisioning +attempt for the SIP Go Agent test host. The initial attempt failed for account +balance; a later resumed create/bootstrap is recorded separately in +`docs/evidence/20260918-cloud-host-bootstrap.md`. This remains real +control-plane evidence, not SIP, AI, P1, or production acceptance. + +- Region: `cn-beijing` +- Required fixed EIP: `123.56.71.98` +- Intended project tag/name: `agent-call` +- No credentials, private keys, account balances, or phone audio are recorded here. + +## Read-only inventory + +The authenticated Alibaba CLI inventory was queried before any mutation: + +- The fixed EIP exists as allocation `eip-2zeevfsaxzwuue2szy7xb` and was + `Available` with no attached instance. +- No instance tagged `project=agent-call` existed. An unrelated running + prepaid Rocky Linux instance in another VPC/security-group context was not + adopted or modified. +- Available image: `debian_13_6_x64_20G_alibase_20260828.vhd` (Debian 13.6, + x86_64, `Available`). +- Selected VSwitch: `vsw-2zejwbni9h4ct51j1afry` in zone `cn-beijing-g`, with + available addresses; selected persistent SIP security group: + `sg-2zed6d5vmcvojt7zek4r`. +- `ecs.e-c1m1.large` reported stock in the selected zone. +- The existing persistent key pair did not match the required public key and + was not used. The existing `mac_ed25519` key pair fingerprint matched the + required `rogee` public key fingerprint; no key import or key replacement was + performed. + +## Authorized attempt + +The owner selected `SpotAsPriceGo` for this test host. The prepared request +used the Debian 13.6 image, 40 GiB ESSD PL1 system disk, selected VSwitch and +security group, `mac_ed25519`, `PostPaid`, `InternetMaxBandwidthOut=0`, the +`project=agent-call` tag, and a persisted ClientToken. The provisioning helper +persisted the request state before the billable `RunInstances` call. + +The create call failed with: + +```text +InvalidAccountStatus.NotEnoughBalance +Your account does not have enough balance to order postpaid product. +``` + +A matching `DryRun=true` request returned the same account-status error. A +later recheck using the same persisted ClientToken and the same request shape +again returned `InvalidAccountStatus.NotEnoughBalance`; no new ClientToken was +created. A follow-up inventory confirmed: + +- zero `project=agent-call` instances; +- fixed EIP still `Available` and unattached; +- no security-group, VSwitch, or unrelated-instance mutation. + +## Initial gate impact (superseded) + +At the time of this initial attempt, account funding/eligibility blocked W13-b / +W14. The owner later resolved the account condition; the same persisted request +then created and bootstrapped one Debian test host. The resumed host evidence is +`docs/evidence/20260918-cloud-host-bootstrap.md`. + +This historical failure does not change the G0/P1 status, does not authorize a +new public IP, and does not justify adopting the unrelated instance. The +created host still has no real Asterisk/SIP/AI/OSS/MQ or phone-call acceptance. +No cleanup was performed because the current host/EIP remains reserved for the +next explicitly authorized step. diff --git a/docs/evidence/20260918-cloud-host-bootstrap.md b/docs/evidence/20260918-cloud-host-bootstrap.md new file mode 100644 index 0000000..4b3d273 --- /dev/null +++ b/docs/evidence/20260918-cloud-host-bootstrap.md @@ -0,0 +1,81 @@ +# 2026-09-18 Alibaba Cloud host bootstrap evidence + +## Scope + +This is real cloud deployment evidence for the owner-authorized first test Cell. +It is **not** W13-b/P1 or production acceptance: the Go binary still has no +production ARI/SIP/ExternalMedia/call-execution path, and no real provider call +was initiated from this host. The host later ran a separately isolated, +mock-only Asterisk/PJSIP/PJSUA2/ARI compatibility stack; those results are +recorded in the W09 evidence documents below. + +- Region: `cn-beijing` +- Instance: `i-2zeb69p1fo75r92wplbz` +- Status: `Running` +- Image: `debian_13_6_x64_20G_alibase_20260828.vhd` (Debian 13) +- Type/charge: `ecs.e-c1m1.large`, `PostPaid`, owner-selected `SpotAsPriceGo` +- Zone: `cn-beijing-g` +- EIP: `123.56.71.98`, allocation `eip-2zeevfsaxzwuue2szy7xb`, `InUse` by the + instance +- Security group: `sg-2zed6d5vmcvojt7zek4r` +- SSH key: existing `mac_ed25519`, matching the required rogee public key +- Release binary SHA-256: `6047a9a6a1f52db52e326167f1f50a17d8c46c34b34327294657d35c87d10cf9` + +The initial `RunInstances` request was rejected for insufficient balance. After +account eligibility was restored, the same persisted request/client token +passed `DryRun` and was applied. No second EIP, unrelated instance, or new key +pair was used. + +## Host hardening + +- Host ED25519 key was scanned twice and was stable at + `SHA256:k1OBPlQcjYo5DqO2sbvcJEpdoqKNuWZHBreHSVB24pQ`; the stale EIP entry was + replaced only after confirming the EIP was now attached to this new instance. +- User `rogee` (UID 1000) was created with only the required public key. +- `PasswordAuthentication no`, `KbdInteractiveAuthentication no`, + `PermitRootLogin no`, public-key authentication, and `AllowUsers rogee` were + applied and `sshd -t` passed. +- Root SSH login was rejected after the hardening reload. +- Subsequent host commands used SSH as `rogee` and the restricted sudoers file; + no root command was used after final hardening. +- Existing security-group rules were inspected. TCP 22 is open for the + required administration path; UDP/SIP/RTP rules are limited to the recorded + provider/diagnostic source IPs and no `0.0.0.0/0` UDP rule was added. + +## Release smoke + +The release binary was copied over SSH to `/opt/sip-go-agent/sip-go-agent` and +local/remote SHA-256 matched. On the Debian host, both commands completed in +explicit `mock` mode and returned valid JSON: + +```text +SIP_GO_AGENT_MODE=mock ... /opt/sip-go-agent/sip-go-agent agent +SIP_GO_AGENT_MODE=mock ... /opt/sip-go-agent/sip-go-agent dispatcher +``` + +The smoke only proves the binary, directories, permissions, SQLite/file spool +startup, and non-production mode on the real host. It does not prove mTLS +Dispatcher/Agent interop, a management-approved static artifact, production +Asterisk loading, Agent ARI/RTP/PCMA integration, AI suppliers, OSS/MQ, or a +phone call. + +## Isolated mock compatibility follow-up + +Docker was installed on this host for an isolated mock-only Asterisk container. +The pinned Asterisk 22.10.1 image was transferred from the local cache because +Docker Hub was unreachable from the host. The container was loopback-restricted +for ARI/SIP publication and used no provider credentials. Runtime results: + +- `docs/evidence/20260918-w09-ari-runtime.md` +- `docs/evidence/20260918-w09-sip-ari.md` +- `docs/evidence/20260918-w09-sip-rtp-ari.md` + +These documents prove only the disposable mock compatibility layers and do not +change the host's production or real-provider status. + +## Resource state and cleanup + +The instance and EIP remain running/attached for the next explicitly authorized +step. Spot interruption can terminate the host and does not migrate calls. +Do not stop/delete the instance or release/unbind the fixed EIP without a +separate user cleanup instruction. diff --git a/docs/evidence/20260918-contract-input-blocker.md b/docs/evidence/20260918-contract-input-blocker.md new file mode 100644 index 0000000..e5f9935 --- /dev/null +++ b/docs/evidence/20260918-contract-input-blocker.md @@ -0,0 +1,27 @@ +# W01/W02 input handoff blocker + +The user selected **provide formal W01/W02 artifacts and continue**. The only +new candidate found in the parent worktree was copied read-only into +`contracts/upstream/2026-09-17-snapshot/` and verified against its recorded +file hashes. + +It is not yet a formal release: + +- `SNAPSHOT.json` records `source_worktree: dirty` and a dirty-patch hash. +- Its `snapshot_id`/`source` fields are not populated as a clean release ID. +- `integration-manifest.json` still identifies runtime/RTP/worker integration + artifacts as missing. +- Repository-wide search found no `.proto`, generated stubs, or W02 Unary gRPC + error/idempotency/activation-session contract. + +Required handoff before W02/W04 work can continue: + +1. Clean W01 contract directory with release ID, source commit, file hashes, + event-specific payload schemas, AI mode/parameter closure (GAP-08/GAP-09), + OSS upload/session contract, and positive/negative fixtures. +2. W02 Proto package with exact service/method/message definitions, mTLS + activation/session identity, error codes, idempotency and fencing semantics, + plus generation/version instructions. + +The current bundle remains useful for local schema/routing tests only. It must +not be treated as an authorization to invent a Proto or as G0/W04/P1 evidence. diff --git a/docs/evidence/20260918-dependencies.md b/docs/evidence/20260918-dependencies.md new file mode 100644 index 0000000..ad70f98 --- /dev/null +++ b/docs/evidence/20260918-dependencies.md @@ -0,0 +1,63 @@ +# Local dependency inventory (2026-09-18) + +This is a development inventory, not a production approval. Versions are from +`go.mod`/`go.sum`; license filenames were present in the local module cache. +`govulncheck` was rebuilt with Go 1.27.1 and reported no vulnerabilities for +this module on 2026-09-18. This is a point-in-time dependency scan, not a +production security approval; protocol PoC gates remain open. + +| Module | Version | Local license file | +| --- | --- | --- | +| `github.com/rabbitmq/amqp091-go` | `v1.15.0` | `LICENSE` | +| `github.com/santhosh-tekuri/jsonschema/v6` | `v6.0.3` | `LICENSE` | +| `github.com/spf13/cobra` | `v1.10.1` | `LICENSE.txt` | +| `google.golang.org/grpc` | `v1.83.2` | `LICENSE` | +| `google.golang.org/protobuf` | `v1.36.12` | `LICENSE` | +| `modernc.org/sqlite` | `v1.59.0` | `LICENSE`, `LICENSE-SQLITE`, `LICENSE-SQLITE_VEC` | +| `github.com/pion/rtp` | `v1.10.5` | `LICENSE` | +| `github.com/zaf/g711` | `v1.4.0` | `LICENSE` | +| `github.com/shirou/gopsutil/v4` | `v4.26.8` | `LICENSE` | +| `github.com/aliyun/alibabacloud-oss-go-sdk-v2` | `v1.6.0` | `LICENSE` | + +`github.com/shirou/gopsutil/v4 v4.26.8` is used for host/process resource +sampling; its local `LICENSE` SHA-256 is +`ad1e64b82c04fb2ee6bfe521bff01266971ffaa70500024d4ac767c6033aafb9`. The +sampler reports media-port and AI-provider quota dimensions as unknown until +those authoritative sources are connected. + +The project now uses Pion RTP only through the thin bounded `internal/media` +policy adapter and `github.com/zaf/g711 v1.4.0` for A-law conversion; RTP wire +parsing and G.711 codec logic are not reimplemented. SIP/ARI, Asterisk +ExternalMedia, recording and the real media PoC remain blocked until the +approved media contract and compatibility gate are completed. No custom SIP, +ARI, RTP or RTCP protocol stack is used as a substitute. + +The cached `github.com/zaf/g711 v1.4.0` license SHA-256 is +`2539ec80c8dd46ce74ba1fb145f270ce5c6f3df1fbe6132eed06e23e22edef48`. + +The security scan used `govulncheck@v1.7.0` built with Go 1.27.1. Before +upgrading, it found reachable gRPC advisories in v1.79.3; upgrading to +`google.golang.org/grpc v1.83.2` and its compatible `golang.org/x/net v0.58.0` +/ `golang.org/x/text v0.41.0` closure produced a clean scan. The old scanner +binary had been built with Go 1.25 and was rebuilt rather than treating its +failure as a clean result. + +Alibaba OSS is accessed through the official Go SDK and is used only by the +Dispatcher; Agents receive short-lived presigned grants and never receive AK/SK. +The project-owned static Cell artifact boundary is validated by +`internal/contract.ValidateStaticArtifact`: the imported schema is checked +first, then deployment-local Cell/source/digest/revision/egress/trunk bindings +are checked. This is a local contract guard only; it does not prove that +Asterisk loaded the artifact or that any SIP provider is reachable. + +## 2026-09-19 physical Cell addendum + +The physical deployment lock now includes Asterisk `22.10.1` source commit +`f0e408a7b0d829c85bf15fa4b487870a50cb3000`, source SHA-256 +`373c98f4d4a1b923b42def0aee03f4e36aca9d1c244a8eeda646da8a97f89663`, bundled +Jansson `2.15.0`, bundled PJPROJECT `2.17`, and a native stage SHA-256 +`68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d`. The +source/dependency archives and native stage are under `deploys/packages/`; +Asterisk was compiled directly on Debian 13 and started by physical systemd. +This locks the build input and install path, but does not replace management +approval of the static Cell configuration or supplier/media acceptance. diff --git a/docs/evidence/20260918-ecs-sip-deployment.md b/docs/evidence/20260918-ecs-sip-deployment.md new file mode 100644 index 0000000..f98490f --- /dev/null +++ b/docs/evidence/20260918-ecs-sip-deployment.md @@ -0,0 +1,105 @@ +# 2026-09-18 ECS deployment and SIP endpoint verification + +## Scope and safety boundary + +This is an owner-authorized first-version deployment/environment check on the +existing Debian 13 ECS. No ECS, EIP, security group, Docker container, or +unrelated instance was created, detached, stopped, or rebound. The fixed +whitelist EIP `123.56.71.98` was confirmed by Alibaba Cloud as `InUse` on +instance `i-2zeb69p1fo75r92wplbz` in `cn-beijing`. + +This evidence is not P1 or production acceptance. It records deployment of the +current dirty-tree candidate and a SIP `OPTIONS` reachability probe only. The +probe sent no `INVITE`, did not select a telephone number, and did not place a +telephone call or claim caller-ID/dial-prefix acceptance. + +## Candidate deployment + +- Source tree: current working tree at source commit `758c0a2` with documented + dirty state; local `make build` passed. +- Candidate SHA-256: + `269a0d2ad350544597a71d3b0869ceb57d57f86defb3fe560e64e75bc052edb6`. +- The candidate was copied as the non-destructive hash-named file + `/opt/sip-go-agent/sip-go-agent.269a0d2ad350544597a71d3b0869ceb57d57f86defb3fe560e64e75bc052edb6` + and the remote hash matched. +- `--help` executed on the ECS and exposed the explicit `agent` and + `dispatcher` subcommands. +- The pre-existing `/opt/sip-go-agent/sip-go-agent` was not overwritten. No + Agent or Dispatcher systemd unit exists on this host, so the candidate was + not silently activated as a production service. + +The final `make release` artifact was also transferred to +`/opt/sip-go-agent/releases/local-development-14d61e617df1c00a9a0ab372f8baf0b8a67e40cff75fcfb36fb1f9cfb27a0725/`. +The release manifest and portable relative `SHA256SUMS` verified the +binary/module hashes, Go 1.27.1, `source_dirty=true`, and +`credentials_embedded=false`; the remote release `--help` exposed both +`agent` and `dispatcher`. The manifest explicitly retains +`production_approval=false`, so this is a deployment candidate, not a release +approval. + +## Deployed process startup + +- The candidate Agent was launched on the ECS in explicit `--mode mock` with + an isolated temporary spool and returned successfully after startup. +- The candidate Dispatcher was launched in explicit `--mode mock` with an + isolated temporary SQLite database and started successfully under a bounded + timeout; the database was created at 122,880 bytes. +- `dispatcher --once` without `RABBITMQ_URL` failed closed with the expected + missing-publisher configuration error. It did not silently run without the + required MQ path. +- These are binary/startup checks only. No production Agent service was + activated, and no real MQ command or business event was consumed. + +## Follow-up Dispatcher outbox validation + +The candidate was rebuilt after the first ECS receipt smoke exposed that the +long-running `dispatcher --consume` path did not flush its own outbox. Candidate +SHA-256: +`14d61e617df1c00a9a0ab372f8baf0b8a67e40cff75fcfb36fb1f9cfb27a0725`. +The current candidate was copied to the ECS and run with only `--consume`. +Publishing the contract fixture to the isolated tenant queue resulted within +one second in `tasks=1`, `inbox=1`, `outbox=1`, and `outbox_status=published`; +no separate `--once` process was used. A temporary event queue bound to +`agent-call.events.v1` received one `agent-call.command.result` message. The +regression test is in `cmd/sip-go-agent/main_test.go`. + +## Existing media environment + +- Container: `agent-call-asterisk-poc`. +- Image: `andrius/asterisk:22.10.1-pinned`; container healthy. +- Asterisk: `22.10.1`; UDP transport `0.0.0.0:5060` inside the container. +- Current endpoints are only `anonymous` and `mock-callee`; `pjsip show + registrations` reports `No objects found`. +- Therefore this host currently has no configured provider trunk, provider + registration, provider credentials, or production Agent/ARI execution path. + +## Provider signaling probe + +Using the existing `sipmock/sipp:0.1.0` SIPp image and a temporary +OPTIONS-only scenario, one UDP `OPTIONS` was sent to each registered provider +from the ECS private source `172.16.0.123`. Each response carried +`received=123.56.71.98` and returned `SIP/2.0 200 OK`: + +| Provider entry | Endpoint | Result | +| --- | --- | --- | +| 数企 | `61.132.228.221:5060` | `200 OK` | +| 中鼎 | `60.171.24.90:5060` | `200 OK` | +| 百应 | `160.202.254.79:5060` | `200 OK` | + +The temporary scenario and logs were removed after the probe. No `INVITE`, +RTP media, registration, Digest exchange, caller-ID assertion, prefix rewrite, +or telephone charge was generated. + +## Result and remaining gate + +**Passed:** current candidate artifact was copied and executed for CLI +verification on the authorized ECS; the fixed EIP was confirmed; all three +registered SIP endpoints answered an OPTIONS probe through the fixed EIP. + +**Not proven:** a `200 OK` to OPTIONS is not provider authorization for an +outbound call. The remote Asterisk still lacks provider trunk configuration. +The provider-specific transport/registration/authentication mode and the +mapping of the preserved caller IDs (`BD93205882`, `mbkq`, `KQ91526`) to +`From`/`PAI`/Digest fields remain unconfirmed. The approved dial prefixes, +number selection, real call duration/recording, AI/MQ/OSS path, and two-Cell +P1 acceptance therefore remain open. diff --git a/docs/evidence/20260918-g0-status.md b/docs/evidence/20260918-g0-status.md new file mode 100644 index 0000000..b6163e8 --- /dev/null +++ b/docs/evidence/20260918-g0-status.md @@ -0,0 +1,76 @@ +# G0 status ledger — 2026-09-18 (updated 2026-09-19) + +## Decision + +**G0 is not passed.** The project-owned W01/W02 and local implementation +artifacts are sufficient to continue isolated development, but they are not an +external contract publication, role sign-off, supplier acceptance, or +production release authorization. + +Environment: local development plus one owner-authorized real Debian ECS test +host that was created, hardened, and used for mock-mode binary smoke, an +isolated disposable RabbitMQ candidate receipt/event smoke, three bounded +provider INVITE signaling attempts plus a later allowed-window retry, and one +freshly user-confirmed physical provider-second outbound probe with temporary +packet capture. No approved production RabbitMQ, SaaS, OSS, AI supplier +acceptance, completed phone call, or connected RTP/media session was run; the +latest probe observed SIP signaling only and its raw capture was deleted after +sanitized analysis. The initial `RunInstances` attempt (and matching dry-run) was +rejected with `InvalidAccountStatus.NotEnoughBalance`; after account recovery +the same persisted request passed dry-run and created instance +`i-2zeb69p1fo75r92wplbz` with the fixed EIP. The parent source worktree was dirty; at the time of this evidence, +`sip-go-agent` was not an independent Git repository. The current independent +repository is `go-sip`; local release manifests preserve the historical fact. + +## Current blocker register + +| Blocker | Affected W/Q/D | Responsible role | Parallel work | Release evidence required | +| --- | --- | --- | --- | --- | +| External authoritative contract publication and role sign-off | W04, W05, W07, W08, W12, W14, D01–D04, D09 | S / 未指派 | Keep local contract-backed tests and generated-artifact checks green | Signed source commit/release, schema hash, role acceptance, approved broker/application-receipt semantics | +| Approved production static Cell artifact and management handoff | W06, W09, W13, W14, D06 | M / 未指派 | Maintain schema-first validation and native Asterisk package locks | Approved real-mode artifact, unique deployment writer, applied digest/revision, Asterisk load evidence and maintenance-window record | +| Production PKI, broker ACL/TLS, SaaS/OSS/AI credentials and budgets | W05–W14, D01–D08 | O + S / 未指派 | Continue isolated MQ/OSS/AI/MQ/PKI mocks and local fault tests | Expiring credentials/ACL records, approved endpoints and budgets, application receipt/verified OSS evidence, revoke/rotate evidence | +| Real SIP/media/recording/AI/OSS and two-Cell environment | W06, W09–W14, D05–D08 | O + M + S / 未指派 | Keep non-dialing Asterisk/ARI/RTP/SDK PoCs and deployment smoke isolated | Two physical Cells, approved trunks, final From/PAI/prefix proof, PCMA/RTP/recording/AI/OSS chain, failure/capacity evidence | +| Clean reproducible release and controlled cutover ownership | W13–W15 | O + 集成负责人 / 未指派 | Preserve dirty/non-production manifest and do not start production services | Clean source/ref and digest, approval matrix, old/new ownership proof, unknown-execution reconciliation and signed cutover record | + +## D01–D10 evidence + +| Item | Local evidence completed | Remaining G0/P1 gate | +| --- | --- | --- | +| D01 events | Strict project-owned schemas, eight event fixtures, rejection of `call.transcript`, contract hash checks, disposable RabbitMQ confirm/ACK/DLQ integration, Dispatcher tenant consume→SQLite inbox/task/outbox→event publish integration, and owner-authorized ECS candidate consume-only automatic outbox flush with temporary `agent-call.command.result` queue receipt | External authoritative publication, role sign-off, approved broker ACL/version, crash ordering, and SaaS application receipt evidence | +| D02 dual AI modes | Full-AI/ASR-only schema branches, immutable snapshot digest/mode tests, bounded mock pipelines, and local contract/fixture SaaS Mock checks (`docs/evidence/20260919-local-saas-contract-mock.md`) | Upstream source publication and real ASR-only/full-AI supplier acceptance | +| D03 authorization/parameters | Tenant/version/digest/time/revocation/egress authorization; optional Agent RPC permit/Execute guard; isolated OpenAI/DashScope/Volcengine API PoCs | SaaS AI-version GET/credential registry/validity contract, actual parameter capability, cancellation/backpressure, and supplier authorization | +| D04 Unary/last permit | Generated `agent.v1` Unary service, TLS 1.3 mTLS/SAN checks, session fencing, CAS, SQLite reservation-to-Agent execution seam, unknown-result preservation, contract-backed local flow joining command/quota/Agent/event boundaries, owner-authorized ECS mock mTLS R01/R02→permit→Execute→accepted receipt smoke, and full `Dispatcher.ExecuteReserved` quota/command/reservation→mTLS Agent result with `task_status=running` | Approved business semantics/sign-off, cross-Cell final barrier, ARI submission boundary, real MQ application receipt, and fault-injection evidence | +| D05 sessions/certificates | Peer/SAN validation, durable session-generation journal, old-generation fencing, boot/epoch tests, local trust-root rotation rejection; owner-authorized Debian ECS loopback-only smoke with disposable TLS1.3 CA/client/server certificates, pre-activation R01 `GetAgentStatus` + R02 session status, negative rejection of no-client/rogue-CA clients, project `internal/rpc.DialFromFiles` + `dispatcher.AgentCoordinator`, actual Dispatcher Cobra endpoint-inventory startup, same-host two-Agent/two-Cell session binding, configured Agent/Cell identity rejection, wrong-inventory `PermissionDenied`, restart-based CA root replacement with old-client rejection, same-boot Dispatcher generation 1→2 recovery, and Agent-side leaf fingerprint allowlist with same-CA unauthorized-client rejection | Physical two-Cell/cross-host process interop, fleet-wide endpoint-role rotation/revocation distribution, cross-host deployment certificate interop, and production health evidence; smoke record: `docs/evidence/20260918-w06-mtls-cloud.md` | +| D06 static Cell artifact | Schema-first artifact validation, source/Cell/digest/revision/egress/trunk checks, real-mode startup path, activation rejection tests | Management approval matrix, unique deployment writer, actual Asterisk load evidence, and maintenance-window proof | +| D07 OSS handoff | HTTPS/host/redirect/size/checksum/expiry/object-key grant guards, 15-minute single-use grant policy with explicit re-request, upload binding/expiry completion checks, direct PUT and metadata RPC tests, source-asset retention after direct upload until verified handoff, Alibaba OSS SDK PUT/HEAD, durable completion, outbox and `recording.ready` closure (`docs/evidence/20260920-local-oss-mq-integration.md`, `docs/evidence/20260920-new-ecs-preflight.md`) | Production SaaS upload-session/complete/application receipt, production broker ACL/TLS, approved retention cleanup and current provider-third recording evidence | +| D08 profile/recovery | SQLite quotas, leases, reservation finalization, file recovery/quarantine, gopsutil host/process sampling with media/AI dimensions explicit unknown, two mock Cell isolation, one hardened Debian ECS host smoke | Approved numeric budget/profile, backup/RPO/RTO and disk/clock/lease fault evidence; no two-Cell or production capacity evidence | +| D09 independent contract package | Self-contained project-owned baseline, manifests, hashes, contract/proto checks, local release manifest | External source commit/release and reproducible clean isolated import/build sign-off | +| D10 reuse/PoCs | Go module licenses (including gopsutil), `go mod verify`, clean `govulncheck`; isolated ARI, OpenAI, DashScope and Volcengine compile/protocol-mock records; fixed Asterisk 22.10.1 + temporary `ari/v5.3.1` runtime probes created Stasis channels, mixing bridges, PCMA ExternalMedia address/port/lifecycle, synthetic bridge forwarding, and a PJSIP/PJSUA2 mock leg with bidirectional PCMA and closed endpoint WAVs; owner-authorized ECS also recorded three non-connected provider INVITE outcomes | Lockable production versions, Asterisk/provider/OSS/broker compatibility, successful supplier/real media, recording retention/OSS/reconnect evidence, DashScope endpoint injection, Volcengine TTS speed/volume/pitch reachability, and O review/sign-off | + +## References + +- Project baseline: `contracts/upstream/2026-09-18-p1-baseline/` +- Local evidence: `docs/evidence/20260918-local-development.json` +- W05 SQLite restart evidence: `docs/evidence/20260918-w05-restart.md` +- W06/W12 targeted local evidence: `docs/evidence/20260918-w06-w12-local.md` +- W06 cloud mTLS smoke: `docs/evidence/20260918-w06-mtls-cloud.md` +- Acceptance matrix: `docs/evidence/20260918-acceptance-matrix.md` +- Dependencies: `docs/evidence/20260918-dependencies.md` +- Media PoC boundary: `docs/evidence/20260918-media-poc-blocker.md` +- W09 isolated ARI runtime: `docs/evidence/20260918-w09-ari-runtime.md` +- W09 isolated SIP/ARI signaling: `docs/evidence/20260918-w09-sip-ari.md` +- W09 isolated PJSIP/RTP/ARI media: `docs/evidence/20260918-w09-sip-rtp-ari.md` +- AI SDK PoC: `docs/evidence/20260918-ai-sdk-poc.md` +- RabbitMQ integration: `docs/evidence/20260918-rabbitmq-integration.md` +- Local release entry: `scripts/build-release.sh` +- Initial real cloud provisioning failure: `docs/evidence/20260918-aliyun-provisioning-blocker.md` +- Successful cloud host bootstrap: `docs/evidence/20260918-cloud-host-bootstrap.md` +- Owner-authorized real SIP signaling attempts: `docs/evidence/20260918-real-sip-provider-calls.md` +- Later allowed-window retry: `docs/evidence/20260919-real-sip-provider-calls-retry.md` +- Latest confirmation-gated physical provider probe: `docs/evidence/20260919-real-provider-ecs-direct.md` +- ECS deployment/SIP boundary: `docs/evidence/20260918-ecs-sip-deployment.md` +- SIP routing implementation boundary: `docs/evidence/20260919-sip-routing-implementation-comparison.md` + +The next G0 decision requires the missing external source/role/authorization +records and the corresponding isolated or real evidence; local green tests +must not be relabeled as G0, P1, or production acceptance. diff --git a/docs/evidence/20260918-local-development.json b/docs/evidence/20260918-local-development.json new file mode 100644 index 0000000..017cb8a --- /dev/null +++ b/docs/evidence/20260918-local-development.json @@ -0,0 +1,110 @@ +{ + "date": "2026-09-18", + "scope": "go-sip local development only", + "working_tree": "dirty-project-worktree", + "source_contract": { + "active_bundle": "contracts/upstream/2026-09-18-p1-baseline", + "release_kind": "project-owned-development-baseline", + "external_authority": false, + "source_bundle": "2026-09-17-snapshot", + "source_head": "fa6925010ba47976d2e99893eac92140b3cb0d09", + "source_worktree": "dirty-inherited-only", + "manifest": "contracts/upstream/manifest.txt" + }, + "implemented_local_scope": [ + "Go 1.27.1 single module with Cobra agent and dispatcher subcommands", + "project-owned W01 contract release with strict envelope/event/AI/authorization/OSS/static-artifact/profile schemas and positive/negative fixtures", + "project-owned W02 agent.v1 Unary gRPC Proto, generated Go stubs, error/idempotency/fencing contract and deterministic Buf generation", + "SQLite inbox/tasks/quotas/reservations/controls/replays/outbox and durable scheduler_state persistence with transactional reservation finalization", + "RabbitMQ adapter bounded prefetch=1 and per-tenant dead-letter topology", + "tenant-key-preserving routing and durable command idempotency", + "file-backed Agent execution state, transcript/assets, crash-to-unknown recovery and corruption quarantine", + "immutable full-AI and ASR-only snapshot validation/cache with explicit mode matching", + "RabbitMQ transport adapter with versioned command/event exchanges, tenant queue declaration, publisher confirms, ACK/requeue/permanent-reject handling, and internal control/query/replay HTTP handler", + "single-active Dispatcher lease with renewal/expiry and dispatching-outbox restart recovery", + "Unary gRPC session generation fencing, admission/task CAS, execution permit/fact deduplication, upload metadata boundary, optional immutable AI authorization enforcement, static Cell artifact activation validation, and local RPC handler tests", + "TLS 1.3 mTLS server/client configuration with CA verification, SAN check and deployment-file loading", + "Agent CLI optional mTLS gRPC listener with graceful shutdown and mock-only upload policy", + "Agent direct grant-bound upload client with HTTPS/host/size/checksum/redirect guards", + "strict transcript.updated realtime/archive event writer and recording.ready verification guard", + "bounded/cancellable local full-AI and ASR-only mock pipeline", + "Pion RTP v1.10.5 bounded packet-policy adapter without a custom RTP parser", + "static Cell artifact schema/deployment-binding validator and Agent startup path for Cell/source/digest/revision/egress/trunk checks", + "Agent direct OSS upload grant expiry/object-key/HTTPS/host/size/checksum/redirect boundary", + "Rust loopback filesystem OSS mock with bounded object keys, optional upload token, atomic writes and SHA-256 ETag for local acceptance", + "SaaS contract/fixture Mock using versioned schemas, negative fixtures, immutable AI snapshots and RabbitMQ fixture flow; no live SaaS HTTP", + "gopsutil host/process resource sampling with media/AI dimensions explicit unknown", + "hardened non-root systemd templates and local acceptance script" + ], + "checks": [ + {"command": "buf lint && buf build && buf generate", "status": "passed"}, + {"command": "go test ./...", "status": "passed"}, + {"command": "go test -race ./...", "status": "passed"}, + {"command": "go vet ./...", "status": "passed"}, + {"command": "go build ./...", "status": "passed"}, + {"command": "go mod verify", "status": "passed"}, + {"command": "govulncheck@v1.7.0 ./... (built with Go 1.27.1)", "status": "passed", "result": "no vulnerabilities found"}, + {"command": "make check", "status": "passed"}, + {"command": "make release + SHA256SUMS verification", "status": "passed", "scope": "local-development manifest; source_dirty preserved"}, + {"command": "make acceptance-local", "status": "passed"}, + {"command": "mock Agent + Dispatcher binary smoke run", "status": "passed"}, + {"command": "Dispatcher Cobra strict endpoint inventory + mTLS R01 Probe/R02 Activate loopback smoke", "status": "passed", "scope": "owner-authorized Debian ECS; current-tree mock binaries; not production"}, + {"command": "Dispatcher Cobra two-Agent/two-Cell loopback inventory + session smoke", "status": "passed", "scope": "owner-authorized Debian ECS; same-host two loopback processes; not physical Cells or production"}, + {"command": "RPC configured Agent/Cell identity binding rejects wrong endpoint metadata", "status": "passed", "scope": "local unit test plus current-tree same-host smoke"}, + {"command": "Dispatcher restart-based CA root replacement rejects old client and accepts replacement client", "status": "passed", "scope": "owner-authorized Debian ECS; mock loopback; no live rotation claim"}, + {"command": "Dispatcher wrong-inventory Agent/Cell misbinding is rejected with PermissionDenied", "status": "passed", "scope": "owner-authorized Debian ECS; mock loopback"}, + {"command": "Dispatcher same-Agent-boot restart advances session generation 1 to 2", "status": "passed", "scope": "owner-authorized Debian ECS; mock loopback; no active-call migration claim"}, + {"command": "Agent leaf certificate fingerprint allowlist accepts configured Dispatcher and rejects same-CA unauthorized client", "status": "passed", "scope": "owner-authorized Debian ECS; mock loopback"}, + {"command": "Project AgentCoordinator ExecuteRaw over mTLS returns permit and accepted receipt in mock mode", "status": "passed", "scope": "owner-authorized Debian ECS; no SIP originate or real call"}, + {"command": "Dispatcher ExecuteReserved SQLite quota/command/reservation over mTLS returns accepted receipt and task_status=running", "status": "passed", "scope": "owner-authorized Debian ECS; mock Agent; no SIP originate or MQ application receipt"}, + {"command": "internal/rpc TLS/session/CAS/idempotency/persisted-generation tests", "status": "passed"}, + {"command": "internal/rpc immutable AI authorization and static Cell artifact activation tests", "status": "passed"}, + {"command": "real Agent configuration requires AGENT_STATIC_ARTIFACT", "status": "passed"}, + {"command": "internal/health gopsutil sample freshness/unknown tests", "status": "passed"}, + {"command": "internal/dispatcher AgentCoordinator, reservation-to-Agent integration, durable fair-cursor restart, two mock Cell tests, and contract-backed local flow", "status": "passed"}, + {"command": "internal/store SQLite outbox dispatching close/reopen recovery and Dispatcher claimed-outbox replay", "status": "passed"}, + {"command": "internal/dispatcher TestOutboxProcessCrashRecovery", "status": "passed", "scope": "isolated test subprocess exits after durable outbox claim; parent reopens and publishes recovered row"}, + {"command": "targeted W06 RPC mTLS/session/activation and Agent spool/event recovery tests", "status": "passed", "scope": "isolated local"}, + {"command": "R01 pre-activation GetAgentStatus probe, Dispatcher AgentCoordinator boot binding, and R02 activation tests", "status": "passed", "scope": "isolated local"}, + {"command": "TLS trust-root rotation rejects the previous client CA and accepts the replacement", "status": "passed", "scope": "isolated local; full shared-certificate rotation/revocation remains open"}, + {"command": "targeted W12 tenant replay/outbox retry/no-originate-retry tests", "status": "passed", "scope": "isolated local"}, + {"command": "internal/mq and tenant bounded-prefetch/DLQ topology unit tests", "status": "passed", "scope": "adapter-only"}, + {"command": "make mq-integration-local", "status": "passed", "scope": "disposable RabbitMQ 4.1.8 Docker broker; adapter plus Dispatcher consume/inbox/task/outbox/event flow; no production broker"}, + {"command": "internal/agent direct upload and event-writer tests; successful upload retains source asset until verified handoff", "status": "passed"}, + {"command": "AGENT_CALL_OSS_INTEGRATION=1 go test ./internal/oss ./internal/rpc -run 'TestAlibabaOSS(GrantPutHead|DispatcherUploadDurable)Integration' -count=1", "status": "passed", "scope": "authorized Alibaba OSS presigned PUT/HEAD plus Dispatcher durable completion and recording.ready outbox; no SaaS application receipt"}, + {"command": "local SaaS contract/fixture Mock checks", "status": "passed", "scope": "check-contracts, internal contract/AI fixture tests, disposable MQ fixture flow; no live SaaS HTTP"}, + {"command": "internal/agent expired/object-bound upload grant tests", "status": "passed"}, + {"command": "Alibaba OSS source asset policy", "status": "passed", "scope": "upload-only test; source remains local until verified handoff; no OSS delete attempted"}, + {"command": "internal/ai mock pipeline tests", "status": "passed"}, + {"command": "internal/media Pion RTP packet-policy tests, including exact PCMA PT=8 payload preservation", "status": "passed"}, + {"command": "isolated ari/v5.3.1 ExternalMedia/GetVariable API compile PoC", "status": "passed", "scope": "temporary module outside project"}, + {"command": "isolated openai-go/v3.62.0 streaming parameter/retry protocol-mock PoC", "status": "passed", "scope": "temporary module outside project"}, + {"command": "isolated dashscopego/v0.1.2 Paraformer API compile PoC", "status": "passed", "scope": "temporary module outside project", "note": "endpoint injection/real provider still blocked"}, + {"command": "isolated doubao-speech-go pseudo-version ASR/TTS API compile PoC", "status": "passed", "scope": "temporary module outside project", "note": "TTS speed/volume/pitch reachability gate remains blocked"}, + {"command": "owner-authorized ECS SIPp one-shot INVITE signaling to all three registered providers", "status": "passed", "scope": "15003164745 only; first window 数企=480, 中鼎=404, 百应=183 then timeout; later allowed window 数企=100/183, 中鼎=100, 百应=100/183/180, all without 200/connected call/media"}, + {"command": "internal/contract static Cell artifact schema/binding tests", "status": "passed"} + ], + "not_claimed": [ + "G0/W04 is not passed", + "The W01 bundle is project-owned and explicitly not externally authoritative", + "No approved production RabbitMQ, OSS, SaaS, AI supplier, completed provider SIP call, Dispatcher/Agent production mTLS deployment, two-Cell, or P1 production acceptance was run; owner-authorized ECS did perform three one-shot provider INVITE signaling attempts plus one later user-confirmed provider-second direct-PJSIP probe, all without a verified connected call, while its Asterisk/PJSIP/ARI/PJSUA2 work remained isolated compatibility evidence; the latest temporary PCAP was deleted after sanitized analysis", + "No completed phone call, RTP/media, paid AI/OSS handoff, or provider-side billing result was verified; SIP credentials/From-PAI mapping remain unconfirmed. The first and later non-200/non-final-200 INVITE outcomes are recorded in docs/evidence/20260918-real-sip-provider-calls.md and docs/evidence/20260919-real-sip-provider-calls-retry.md, while the ECS test host and fixed EIP remain recorded in docs/evidence/20260918-cloud-host-bootstrap.md", + "RPC mock grants and mock:// upload IDs are not verified OSS assets or recording.ready events", + "The full W05-W16 integration, capacity, N+1, cutover and second-tenant acceptance remains incomplete" + ], + "acceptance_matrix": "docs/evidence/20260918-acceptance-matrix.md", + "cloud_host_bootstrap": "docs/evidence/20260918-cloud-host-bootstrap.md", + "w06_mtls_cloud": "docs/evidence/20260918-w06-mtls-cloud.md", + "w09_ari_runtime": "docs/evidence/20260918-w09-ari-runtime.md", + "w09_sip_ari": "docs/evidence/20260918-w09-sip-ari.md", + "w09_sip_rtp_ari": "docs/evidence/20260918-w09-sip-rtp-ari.md", + "g0_status": "docs/evidence/20260918-g0-status.md", + "rabbitmq_integration": "docs/evidence/20260918-rabbitmq-integration.md", + "next_release_blockers": [ + "External authority/role/real-budget sign-off for the project-owned W01 baseline", + "D10 remaining endpoint-injection, Asterisk/SIP/recording, Volcengine TTS parameter, OSS and broker integration evidence", + "Cross-Cell final permit/barrier, two-Cell/cross-host endpoint-role authorization, certificate lifecycle, health sampling, and fault-injection integration beyond the local Dispatcher-to-Agent seam", + "Real or separately authorized mock-broker/AI/media/OSS integration and production P1 two-Cell evidence", + "Management-approved static artifact, production Agent ARI/SIP/ExternalMedia/media implementation, and real integration credentials/authorization" + ] +} diff --git a/docs/evidence/20260918-media-poc-blocker.md b/docs/evidence/20260918-media-poc-blocker.md new file mode 100644 index 0000000..85cd6d3 --- /dev/null +++ b/docs/evidence/20260918-media-poc-blocker.md @@ -0,0 +1,67 @@ +# Media/ARI PoC boundary (2026-09-18) + +## Local result + +- `github.com/pion/rtp v1.10.5` is in `go.mod` with Go module checksum + `h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE=`. +- `internal/media/PacketGuard` delegates RTP wire parsing to Pion and only + applies local packet-size, payload-type and SSRC policy. It has positive and + negative tests. No RTP/RTCP parser was written in this project. +- `go test ./internal/media`, `go test -race ./...`, `go vet ./...`, `go build + ./...` and `go mod verify` pass. + +## Isolated ARI surface PoC + +A temporary module outside this project compiled the smallest selected ARI +surface with Go 1.27.1: + +- module: `github.com/CyCoreSystems/ari/v5` +- version: `v5.3.1` +- module sum: `h1:S+NHG1+uMwoAIl0hMBnRUGNZsQKQQQFq7XCRSE2c2mg=` +- license file SHA-256: `b309e1d2561537de51c4adf9872be73e0ad8ccdee9dc2aba8a59a4ae04e0e7e1` +- compiled API surface: `ExternalMedia`, `StageExternalMedia`, + `GetVariable`, and channel `Subscribe` +- `go test ./...`: passed +- `govulncheck ./...`: no reachable vulnerabilities; one required-module + finding was reported as not reachable by the temporary program + +This is only an API/compile PoC. The module is not added to this project: its +historical dependency graph and the repository's v5/v6 module/tag ambiguity +still require review before locking it for production. + +## Isolated runtime follow-up + +The separately authorized runtime probe now exercises the same temporary ARI +module against the pinned Asterisk 22.10.1 image on the isolated ECS Docker +network. It successfully originated an internal Stasis channel, created a +mixing bridge, created an RTP/UDP PCMA ExternalMedia channel, waited for its +`StasisStart` before adding it to the bridge, read the Asterisk-local RTP +address/port, and observed `StasisEnd`. Evidence: +`docs/evidence/20260918-w09-ari-runtime.md`. + +The initial topology-only probe received zero RTP packets because no active +media source was attached. A follow-up with two ExternalMedia channels injected +synthetic PCMA RTP into one channel and observed an RTP/PCMA packet forwarded +through the Asterisk mixing bridge to the second channel. A further isolated +PJSIP/PJSUA2 run originated a real mock SIP leg, injected FFmpeg-produced PCMA +through ARI ExternalMedia, observed PCMA RTP in both directions, exercised the +callee U1/U2/U3 fixtures, and closed endpoint WAV recordings; evidence: +`docs/evidence/20260918-w09-sip-rtp-ari.md`. These are mock compatibility +results, not provider/Agent acceptance. + +## Deliberate gate + +ARI remains unselected in the production module. The existing component record +shows a major/module and tag mismatch risk (`/v6` module declarations under +historical v5 paths), and the isolated runtime does not constitute an approved +production media contract or static Cell artifact. The project therefore does +not add `sipgo`, `diago`, a custom ARI client, a SIP stack, G.711 decoder, WAV +writer, or an RTP fallback. + +W09 remains open for provider/real-media validation, reconnect/fencing tests, +recording retention/OSS handoff, formal library/license/security review, +approved static artifact loading, and then the separately authorized +SIP/provider media validation. Isolated SIPp-to-PJSIP/ARI signaling is recorded +in `docs/evidence/20260918-w09-sip-ari.md`; the full mock PJSIP/RTP/ARI leg is +recorded in `docs/evidence/20260918-w09-sip-rtp-ari.md`. These results are not +supplier-line, telephone, P1, or production capacity acceptance. diff --git a/docs/evidence/20260918-rabbitmq-integration.md b/docs/evidence/20260918-rabbitmq-integration.md new file mode 100644 index 0000000..f64248a --- /dev/null +++ b/docs/evidence/20260918-rabbitmq-integration.md @@ -0,0 +1,84 @@ +# RabbitMQ local integration evidence — 2026-09-18 + +## Scope + +This is an isolated local Docker broker test only. It is not a SaaS broker, +production acceptance, or an authorization to connect to an external broker. +The container was removed by the test script on exit; credentials were +randomized and not recorded. + +## Run + +- command: `make mq-integration-local` +- image: `rabbitmq:4.1-management-alpine` +- local image ID: `sha256:fcc273cebb0880ec25845c9bfd97687122ac9cc391538f053cb5873f4181f35f` +- adapter: `OpenWithPrefetch(..., 1)` +- tests: `go test -tags=integration ./internal/mq ./internal/dispatcher` +- result: passed + +The tests exercised a fresh tenant queue and its durable DLQ, publisher +confirm, routing-key preservation, manual ACK, permanent rejection/dead-letter +routing, and a second consumer after cancellation. The Dispatcher integration +case additionally delivered the contract fixture through the tenant queue, +persisted inbox/task/outbox state in SQLite, published the resulting event, +and verified `task_status=accepted` and `outbox_status=published`. Consumer +tags are unique and are explicitly cancelled so a stopped consumer cannot +retain later deliveries. +The broker readiness gate uses RabbitMQ's `check_running`; a ping alone is not +sufficient for application readiness. + +## ECS candidate connection smoke + +After the local test, the same pinned image digest +`sha256:fcc273cebb0880ec25845c9bfd97687122ac9cc391538f053cb5873f4181f35f` +was transferred to the owner-authorized Debian ECS because the host could not +pull Docker Hub directly. It was run as an isolated loopback-only disposable +broker with a randomized non-recorded credential and removed after the test. +The current candidate binary at +`/opt/sip-go-agent/sip-go-agent.269a0d2ad350544597a71d3b0869ceb57d57f86defb3fe560e64e75bc052edb6` +connected successfully: + +- `dispatcher --mode mock --once` returned `0` and reported `published: 0`. +- `dispatcher --mode mock --consume --tenant-key deploy-smoke` connected and + remained waiting for deliveries until the bounded 8-second smoke timeout + (`124`); it emitted no connection or configuration error. +- A second bounded run published the approved contract fixture + `examples/call.execute.json` through the broker management API. The broker + returned `routed: true`; the deployed candidate consumed it with + `tasks=1`, `inbox=1`, and `outbox=1`, then a separate deployed candidate + `dispatcher --mode mock --once` returned `0` with `published: 1`. +- Final isolated SQLite state was `task_status=accepted` and + `outbox_status=published` for one row. The disposable broker container and + fixture were removed after the check; the randomized password was not + recorded. + +This proves candidate-to-broker TCP/AMQP authentication, tenant routing, +SQLite inbox/task/outbox handling, and candidate outbox publication on the ECS +against a disposable broker. It does not prove SaaS application receipt or an +approved production broker. + +## Follow-up candidate: consume now flushes outbox + +The first ECS receipt smoke exposed that the deployed `--consume` path only +accepted commands and required a separate `--once` process to flush outbox. +The command was corrected to run a bounded outbox flush loop while consuming, +with a regression test in `cmd/sip-go-agent/main_test.go`. + +Candidate SHA-256: +`14d61e617df1c00a9a0ab372f8baf0b8a67e40cff75fcfb36fb1f9cfb27a0725`. +With only the candidate `dispatcher --consume` process running, the same +fixture was published to the isolated tenant queue. The broker returned +`routed: true`; within one second the candidate persisted `tasks=1`, +`inbox=1`, `outbox=1`, and `outbox_status=published`. No separate `--once` +flush process was run. A temporary SaaS-events queue bound to +`agent-call.events.v1` then received one `agent-call.command.result` message +(`payload_bytes=472`, `delivery_mode=2`, `content_type=application/json`). The +temporary broker, queue, and fixture were removed after the check. + +## Remaining boundary + +This proves only the local adapter/topology and an isolated ECS candidate +connection against a disposable RabbitMQ container. It does not prove the +approved broker version, production ACL/vhost, TLS, network policy, queue +limits, crash/commit recovery, multi-Dispatcher coordination, SaaS +application receipt, or P1 capacity/backpressure. diff --git a/docs/evidence/20260918-real-sip-provider-calls.md b/docs/evidence/20260918-real-sip-provider-calls.md new file mode 100644 index 0000000..a533bf1 --- /dev/null +++ b/docs/evidence/20260918-real-sip-provider-calls.md @@ -0,0 +1,44 @@ +# 2026-09-18 authorized SIP provider call attempts + +## Authorization and boundary + +The owner explicitly selected all three registered SIP lines and the approved +number `15003164745` for a direct first-version verification. The attempts ran +from the existing Debian 13 ECS and fixed outbound whitelist EIP +`123.56.71.98`; no new cloud resource or Asterisk configuration was created. + +This was a direct SIPp signaling probe using the existing +`sipmock/sipp:0.1.0` image, not the production Agent/Dispatcher call path. +Each line received one `INVITE` only. The scenarios preserved the supplied +caller identifier in both candidate `From` and `P-Asserted-Identity` values, +used the registered line prefix, advertised PCMA/8000 SDP, and stopped on +failure or after a bounded 3-second connected hold. No RTP audio was sent, so +this does not prove media, recording, AI, billing, or production acceptance. + +## Results + +| Line | Request-URI user | Candidate caller identifier | Result | +| --- | --- | --- | --- | +| 数企 `61.132.228.221:5060` | `708915003164745` | `BD93205882` | `100 Trying`, then `480 Temporarily Unavailable`; no `200 OK` | +| 中鼎 `60.171.24.90:5060` | `15003164745` | `mbkq` | `100 Trying`, then `404 Not Found`; no `200 OK` | +| 百应 `160.202.254.79:5060` | `mka75515003164745` | `KQ91526` | `100 Trying`, `183 Session Progress` with PCMA SDP from `160.202.254.79:12786`, but no final `200 OK` within 25 seconds; bounded attempt aborted/cancelled | + +For the 百应 response, the provider identified itself as `VOS3000 V2.1.8.05` +and advertised `PCMA/8000` plus telephone-event. It did not include a final +answer during the bounded attempt. No line reached an answered INVITE dialog. +Provider-side billing/charge status is not inferable from SIP responses and was +not claimed. + +## Cleanup and conclusion + +After the attempts, no SIPp process or temporary scenario/log remained on the +ECS; the existing Asterisk mock container remained healthy and untouched. + +**Proven:** the fixed EIP can send authorized one-shot INVITEs to all three +registered endpoints and receives line-specific SIP responses; 百应 reaches +session progress and advertises PCMA. + +**Not proven:** provider registration/authentication, canonical `From`/`PAI` +mapping, successful caller-ID authorization, completed call, RTP/media, +recording, AI, OSS handoff, MQ business result, or P1/W14 acceptance. The +three outcomes are line diagnostics, not successful real-call acceptance. diff --git a/docs/evidence/20260918-w05-restart.md b/docs/evidence/20260918-w05-restart.md new file mode 100644 index 0000000..2f1e055 --- /dev/null +++ b/docs/evidence/20260918-w05-restart.md @@ -0,0 +1,62 @@ +# W05 local SQLite restart evidence — 2026-09-18 + +## Scope + +This is isolated local evidence for the Dispatcher outbox recovery seam. It is +not a broker crash, commit-failure, cross-Cell, or production acceptance. + +- Mode: `mock` / local development +- Go: `go1.27.1 linux/amd64` +- Test: `internal/store/TestOutboxRecoverySurvivesSQLiteReopen` + +## Procedure and result + +The test opens a real SQLite file, inserts one `dispatching` outbox row, closes +the store, reopens the same file, and verifies startup recovery changes the row +to `retry` with `last_error=recovered_after_restart`. + +```text +$ go test ./internal/store -run '^TestOutboxRecoverySurvivesSQLiteReopen$' -count=1 -v +go version go1.27.1 linux/amd64 +=== RUN TestOutboxRecoverySurvivesSQLiteReopen +--- PASS: TestOutboxRecoverySurvivesSQLiteReopen (0.01s) +PASS +ok git.ipao.vip/rogee/go-sip/internal/store 0.016s +``` + +## Dispatcher claim/restart replay + +A second test exercises the Dispatcher path rather than inserting the row +manually. It ingests the contract-backed command, claims the pending outbox +row (simulated crash after claim), closes the SQLite store, reopens it, and +flushes the recovered row through a publisher: + +```text +$ go test ./internal/dispatcher -run '^TestOutboxClaimRecoveryPublishesAfterRestart$' -count=1 -v +go version go1.27.1 linux/amd64 +=== RUN TestOutboxClaimRecoveryPublishesAfterRestart +--- PASS: TestOutboxClaimRecoveryPublishesAfterRestart (0.02s) +PASS +ok git.ipao.vip/rogee/go-sip/internal/dispatcher 0.033s +``` + +## Process crash injection + +`TestOutboxProcessCrashRecovery` starts the same test binary as a helper, +ingests and durably claims an outbox row, exits with a dedicated status before +publish, then reopens the SQLite file in the parent process and flushes the +recovered row. This covers an actual process-death boundary rather than only a +same-process close/reopen simulation: + +```text +$ go test -race ./internal/dispatcher -run TestOutboxProcessCrashRecovery -count=1 +ok git.ipao.vip/rogee/go-sip/internal/dispatcher +``` + +## Limits + +This proves persistence across a close/reopen boundary and one deterministic +process-crash-after-claim window. It does not prove broker commit ordering, +process kill at every W05 crash window, application receipt, approved broker +ACL/topology, cross-Cell final permit barriers, or real P1 behavior. Those +remain separately blocked or pending in the acceptance matrix. diff --git a/docs/evidence/20260918-w06-mtls-cloud.md b/docs/evidence/20260918-w06-mtls-cloud.md new file mode 100644 index 0000000..0ac8aeb --- /dev/null +++ b/docs/evidence/20260918-w06-mtls-cloud.md @@ -0,0 +1,373 @@ +# W06 cloud mTLS Agent RPC smoke (2026-09-18) + +## Scope and boundary + +This is a disposable, mock-mode deployment check on the owner-authorized Debian +13 ECS host. It does not represent W06 completion, G0, P1, or production +mTLS authorization. The listener was bound to host loopback +`127.0.0.1:19090`; no public port or security-group rule was opened. + +The test used: + +- the deployed Agent release binary SHA-256 + `6047a9a6a1f52db52e326167f1f50a17d8c46c34b34327294657d35c87d10cf9`; +- `SIP_GO_AGENT_MODE=mock`, Agent ID `agent-mtls`, Cell ID `cell-mtls`; +- TLS 1.3 with a disposable one-day CA, server certificate SAN + `agent.test`/`127.0.0.1`, and client certificate SAN `dispatcher.test`; +- generated Go stubs plus a temporary client outside the project production + binary; the client verified the server name and presented the client cert. + +The temporary private keys were stored only under the remote disposable test +folder and removed after the run. No key or certificate contents are included +here. + +## Procedure and output + +The Agent was launched with the deployment TLS files and +`AGENT_GRPC_LISTEN=127.0.0.1:19090`. The temporary client connected with +TLS 1.3, called `ActivateAgent`, then called `GetAgentStatus` with the returned +session generation and binding tuple. + +```text +agent_ready=1 +activation_state=ACTIVATION_STATE_ACTIVE session_generation=1 +status_agent=agent-mtls status_cell=cell-mtls mtls_authenticated=true session_active=true admission=ADMISSION_STATE_CLOSED +client_rc=0 +``` + +Public test-artifact hashes, retained only to identify this run: + +```text +ca.pem cdca06a27b8338a533b603fc6f27f4877973d9c87cb317052e1dac9839725536 +server.pem a0eeaa5590e2e27657098a972bf563c694065ceae36313e7fe598e6c0e06cc79 +client.pem 1ee5cdbfb9a347593319508f368fd9ff922a98be61fdd68f86fa155f65d98fce +client d0c567076b4e4d2c25979955e9698bb74546545eff15d0b00c627a1614b615d1 +``` + +A follow-up negative transport check used the same server CA plus a client +certificate signed by a separate disposable rogue CA. The temporary client +was extended only to omit the certificate for this check; its hash was +`34e0b36417064be68410bbf6dbe5dbafe69f6a7471c6f80e2e959f1dab22a34a`. + +```text +no client certificate: gRPC ActivateAgent failed with TLS alert "certificate required"; client_rc=2 +rogue client certificate: gRPC ActivateAgent failed with a broken pipe after server certificate rejection; client_rc=2 +``` + +The negative checks were run against the same loopback listener and the +remote disposable test directory was removed afterward. The rogue CA public +certificate hash was `9012fd9b725f0561161a1bcfd1ef1ea0f2c27c2f36ab3ab04beaa7cdbe1d4ce3`. + +## Project RPC client boundary + +A follow-up run used the project's production `internal/rpc.DialFromFiles` +wrapper (temporary command source removed after build), rather than a +standalone TLS client. It connected to the deployed Agent binary, completed +`ActivateAgent` and `GetAgentStatus`, and returned: + +```text +agent_ready=1 +client=project_rpc_dial activation=ACTIVATION_STATE_ACTIVE session_generation=1 mtls_authenticated=true session_active=true +client_rc=0 +``` + +The temporary client binary SHA-256 was +`4a20234806b6df43aaf9a6397c9be2011fd82fa185bc25dc784517d5e73db19f`. +This validates the project client/TLS boundary against the deployed Agent; it +is not a Dispatcher process or cross-host integration test. + +## Pre-activation status and Coordinator binding + +The pre-activation R01 path was then exercised against a temporary binary built +from the current working tree (not installed over the release at `/opt`). The +binary SHA-256 was +`03d3f3d3acdab39c6f068f38586876b5f00c3845ed0dc29c6482cabbec1566ba`. +A temporary command using the project's `dispatcher.AgentCoordinator` and +`internal/rpc.DialFromFiles` performed `Probe`, used the returned boot ID for +`Activate`, and read the session-authorized status: + +```text +agent_ready=1 +probe_boot=agent-coordinator-client-1789728978523557105 probe_session_active=false activation_state=ACTIVATION_STATE_ACTIVE session_generation=1 active_session=true mtls_authenticated=true +client_rc=0 +``` + +The temporary Coordinator client SHA-256 was +`b6f3f6aaf3808e72c3a7d2b8448bf74b2b513d89be8c084a3941ca5853212917`. +This proves the project R01 pre-activation probe and R02 session binding +against the current Agent implementation in mock mode. It does not prove the +Dispatcher Cobra process, cross-host endpoint inventory, or production +release deployment; the temporary binary, client, certificates, and spool +were removed afterward. + +## Dispatcher Cobra endpoint-inventory smoke + +The current working-tree binary was then run as both the mock Agent and the +actual `dispatcher` Cobra command. The Dispatcher used a strict, +deployment-owned endpoint inventory and its mTLS client certificate; no tenant +or command payload selected the endpoint. The temporary Agent/Dispatcher +binary SHA-256 was +`35510789e6797267010a57b365198b086ff794fed4615bddfe045d8229a492e6`, and the +inventory SHA-256 was +`d344dc4db123ea2583805bb7d20fa27cc0da98b7f99ce6f00334ce7cf88edd70`. + +```text +agent_ready=1 +dispatcher_rc=0 +dispatcher_output: +{"agent_sessions":[{"agent_id":"agent-dispatcher-poc","boot_id":"agent-dispatcher-poc-1789731033229003007","cell_id":"cell-dispatcher-poc","session_generation":1}],"db":"/home/rogee/dispatcher-poc/dispatcher.db","mode":"mock","role":"dispatcher"} +``` + +The run used one loopback endpoint (`127.0.0.1:19090`) and was cleaned up +along with the temporary mTLS files and SQLite database. This validates the +first-version Dispatcher startup path: inventory load, mTLS dial, R01 probe, +boot binding, R02 activation, and JSON session reporting. It does not prove +two-Cell/cross-host operation, endpoint-role authorization matrix, task +execution through the scheduler, RabbitMQ application receipt, health/version +fault handling, or production release deployment. + +## Two-Agent/two-Cell same-host inventory smoke + +Using the same current-tree binaries and disposable mTLS files, the endpoint +inventory was expanded to two Agent/Cell bindings on two loopback listeners +(`127.0.0.1:19090` and `127.0.0.1:19091`). The binary hash remained +`35510789e6797267010a57b365198b086ff794fed4615bddfe045d8229a492e6`; the +two-entry inventory hash was +`917241cb50685002b4ba9666ea9a0096d733357b52cbb929f7e74980761a0803`. + +```text +agents_ready=2 +dispatcher_rc=0 +dispatcher_output: +{"agent_sessions":[{"agent_id":"agent-cell-a","boot_id":"agent-cell-a-1789731290744754658","cell_id":"cell-a","session_generation":1},{"agent_id":"agent-cell-b","boot_id":"agent-cell-b-1789731290740322704","cell_id":"cell-b","session_generation":1}],"db":"/home/rogee/dispatcher-two-poc/dispatcher.db","mode":"mock","role":"dispatcher"} +``` + +Both Agent processes, the Dispatcher process, the temporary SQLite database, +and mTLS files were removed after the run. This is a same-host/two-loopback +smoke only; it does not prove two physical Cells, cross-host networking, +separate egress, endpoint-role authorization, task execution, health/failover, +or P1 capacity. + +## Post-bind-authorization repeat + +After adding server-side binding of activation/status metadata to the Agent's +configured `AgentStatus.AgentId` and `CellId`, the same two-loopback inventory +was repeated. The current-tree binary SHA-256 was +`bd74ade951eb542ec07ec488e1517530c4179f2bd81513d920ff7a81eeafbfbc` and the +inventory remained +`917241cb50685002b4ba9666ea9a0096d733357b52cbb929f7e74980761a0803`. + +```text +agents_ready=2 +dispatcher_rc=0 +dispatcher_output: +{"agent_sessions":[{"agent_id":"agent-cell-a","boot_id":"agent-cell-a-1789731637923625989","cell_id":"cell-a","session_generation":1},{"agent_id":"agent-cell-b","boot_id":"agent-cell-b-1789731637925117185","cell_id":"cell-b","session_generation":1}],"db":"/home/rogee/dispatcher-two-poc/dispatcher.db","mode":"mock","role":"dispatcher"} +``` + +This repeat plus local `TestAgentIdentityIsBoundToConfiguredEndpoint` proves +that a configured Agent rejects an activation/status request carrying another +Agent/Cell identity. It is still not an endpoint-role certificate allowlist, +cross-host test, or production authorization matrix. + +## Endpoint identity misbinding rejection + +A negative endpoint-inventory run pointed `agent-cell-b`/`cell-b` at the +listener configured as `agent-cell-a`/`cell-a`. The current Dispatcher and +Agent binaries rejected the R01 probe before activation: + +```text +agent_ready=1 +dispatcher_rc=1 +dispatcher_output: +2026/09/18 19:46:57 ERROR command failed error="probe Agent \"agent-cell-b\": rpc error: code = PermissionDenied desc = request Agent identity is not bound to this endpoint" +``` + +The wrong-inventory SHA-256 was +`267f25e990673066dd2f383f407a564c6558135be7d8eaf0081711469c68d330`. This +confirms that a valid mTLS peer cannot select another configured Agent/Cell by +changing deployment metadata. It does not replace a full certificate-role +allowlist or cross-host authorization matrix. + +## Restart-based trust-root rotation smoke + +A restart-based certificate rotation was exercised with the current-tree +binary. Phase one used CA1/server1/client1 and completed Dispatcher startup; +phase two restarted the Agent with CA2/server2. The old client certificate +was presented with a CA1+CA2 trust bundle so the new server certificate was +trusted while the old client chain remained testable. The new client used +CA2/client2. + +Public artifact hashes: + +```text +agent binary bd74ade951eb542ec07ec488e1517530c4179f2bd81513d920ff7a81eeafbfbc +ca2.pem e8b86883a446f8dce32080244c9f587f70618385516e6db7aee19a157fd91160 +server2.pem a777f1c1131bd9292e12869b0b2e76529eb0c11295b386daa368308ebd994dba +client2.pem 1bd0df7e79b91504bcf1e17f03c10a4f785d9b82a584c7c1e7bb40bd2b6acbcf +old-client-bundle 7e5df4438028307e44ed0a497e438754a7c179b6dce85938994c22d9f56dd209 +``` + +```text +phase_one_rc=0 +old_client_rc=1 +old_client_output: rpc error: code = Unavailable ... write: broken pipe +new_client_rc=0 +new_client_output: {"agent_sessions":[{"agent_id":"agent-dispatcher-poc","cell_id":"cell-dispatcher-poc","session_generation":1}]...} +``` + +The old client was rejected after the Agent restarted with the replacement +trust root, while the replacement client completed R01/R02 successfully. +Temporary processes, keys, endpoint inventory, databases, and spool data were +removed. This proves restart-based trust-root replacement and old-client +rejection in mock mode; it does not prove live rotation without restart, +revocation distribution, shared-certificate fleet rotation, or production +health/failover. + +## Dispatcher restart session-generation smoke + +With one Agent left running, the current Dispatcher Cobra process was started +once with a fresh SQLite database and then started again with a different +Dispatcher ID/database. The startup path used generation `0`, allowing the +Agent's durable session journal to allocate the next generation rather than +self-fencing a restarted Dispatcher. + +The current-tree binary SHA-256 was +`3db047d71e2d87cacde8fb645de9476b92662abcf093092150a4a71f747373ad`, and the +one-entry inventory remained +`d344dc4db123ea2583805bb7d20fa27cc0da98b7f99ce6f00334ce7cf88edd70`. + +```text +agent_ready=1 +first_rc=0 +first_output: +{"agent_sessions":[{"agent_id":"agent-dispatcher-poc","boot_id":"agent-dispatcher-poc-1789732334849315059","cell_id":"cell-dispatcher-poc","session_generation":1}],"db":"/home/rogee/dispatcher-restart-poc/dispatcher-one.db","mode":"mock","role":"dispatcher"} +second_rc=0 +second_output: +{"agent_sessions":[{"agent_id":"agent-dispatcher-poc","boot_id":"agent-dispatcher-poc-1789732334849315059","cell_id":"cell-dispatcher-poc","session_generation":2}],"db":"/home/rogee/dispatcher-restart-poc/dispatcher-two.db","mode":"mock","role":"dispatcher"} +``` + +This proves same-boot Dispatcher restart fencing/recovery in mock mode. It +does not prove crash-safe Dispatcher session ownership across machines, active +call migration, or production HA. + +## Project Coordinator Execute over mTLS + +A temporary project command using `internal/rpc.DialFromFiles` and +`dispatcher.AgentCoordinator.ExecuteRaw` exercised the full mock execution +boundary against the current Agent binary: R01 probe, R02 activation, +execution-permit issuance, Execute, and durable receipt. It used the approved +contract fixture only; no SIP leg or telephone call was attempted. + +Artifact hashes: + +```text +Agent binary 3db047d71e2d87cacde8fb645de9476b92662abcf093092150a4a71f747373ad +Coordinator client 2afa146c15cfe2fa792550e0a21a1eaba73e32cad5504e5442fa389c202140ed +call.execute fixture 0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742 +``` + +```text +probe_boot=agent-execute-1789732783527268138 session_generation=1 permit=true receipt=true receipt_result=RESULT_CODE_ACCEPTED unknown=false state=EXECUTION_STATE_PERMIT_GRANTED +agent_ready=1 +client_rc=0 +``` + +This validates the project Dispatcher/Agent execution seam over loopback mTLS +in `mock` mode. It does not prove SIP originate, real AI/media, RabbitMQ +application receipt, or production call execution. + +## Dispatcher SQLite reservation to Execute over mTLS + +A second temporary project command exercised the full Dispatcher seam: it +opened SQLite, installed tenant/global/Cell quotas, ingested the contract +fixture, reserved the task, called `dispatcher.ExecuteReserved` through the +mTLS Agent Coordinator, and checked the persisted task status. + +```text +Dispatcher client f3e540b0aea15596d621455f1df51a824509a983dca399e56b31972d71b199ad +Agent binary 3db047d71e2d87cacde8fb645de9476b92662abcf093092150a4a71f747373ad +call.execute fixture 0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742 + +probe_boot=agent-execute-1789733076935907928 permit=true receipt=true receipt_result=RESULT_CODE_ACCEPTED unknown=false task_status=running +agent_ready=1 +client_rc=0 +``` + +This is a mock loopback reservation-to-Agent execution smoke. It does not +prove RabbitMQ application receipt, SIP originate, real AI/media, call +completion, or production quota/fault-injection acceptance. + +## Two-Agent SQLite reservation to Execute over mTLS + +The two-loopback Agent inventory was also exercised through a temporary project +Dispatcher client that created two SQLite reservations with separate Cell +scopes and executed them against the two registered Agents. Both permits and +receipts were accepted and both task rows reached `running`. + +```text +Dispatcher client 0d280c80880d1dafa46bd401704aea5c854a439d61e777f8e1b3cce039ac023d +Agent binary 3db047d71e2d87cacde8fb645de9476b92662abcf093092150a4a71f747373ad +call.execute fixture 0164664fd3503d72668b24bdecb623aa0414ce58191960b868abe8454988c742 + +session_a=1 session_b=1 permit_a=true receipt_a=true permit_b=true receipt_b=true result_a=RESULT_CODE_ACCEPTED result_b=RESULT_CODE_ACCEPTED task_a=running task_b=running +agents_ready=2 +client_rc=0 +``` + +This is same-host/two-loopback mock evidence for separate Cell reservations +and Agent execution. It does not prove physical Cell separation, final +cross-Cell last-originate barriers, RabbitMQ application receipt, SIP/media, +failover, or capacity. + +## Dispatcher certificate fingerprint allowlist smoke + +The Agent was restarted with `MTLS_PEER_CERT_FINGERPRINTS` containing only the +SHA-256 leaf fingerprint of the approved Dispatcher client certificate. A +second client certificate was signed by the same trusted CA but was not in the +allowlist. + +```text +Agent binary 5abc8bb43076927c4eb1e04e2baf0e305715eaf019a7d2e87ccaa86ebaf6f6a5 +allowed client leaf d7965d2e345d7f7496c2913f5a2b673c84cbddc72d93df47abb87c29d5f87b0c +rejected client leaf 6339698cf4979b26424203ac689ef4da9279aed2dd3222036b06f5a42ab0db4a + +agent_ready=1 +allowed_rc=0 +rejected_rc=1 +rejected_output: rpc error: code = PermissionDenied desc = mTLS certificate is not in the endpoint allowlist +``` + +Both certificates chained to the same CA, so the negative result exercises the +leaf allowlist rather than CA trust failure. This proves a deployment-owned +Dispatcher certificate allowlist in mock mode; it does not prove fleet-wide +role rotation, revocation distribution, or cross-host authorization. + +## Draft service-unit verification + +The two draft units under `deploy/systemd/` were copied to the same host's +`/tmp` directory and checked with: + +```text +systemd-analyze verify /tmp/sip-go-agent-agent.service /tmp/sip-go-agent-dispatcher.service +exit=0 +``` + +The command emitted only pre-existing warnings for the unrelated host +`cloudmonitor.service`; it emitted no warning or error for either project unit. +The units were not installed, enabled, or started by this check. This is a +syntax/executable-path check, not systemd runtime, restart, or production +hardening acceptance. + +## Result + +**Passed in isolation:** the deployed Agent accepted a verified client +certificate, completed the activation/session-generation exchange, and returned +`mtls_authenticated=true` and `session_active=true` over a TLS 1.3 Unary gRPC +call. The test also exercised the Agent's actual file-backed spool startup on +the cloud host. + +**Not proven:** Dispatcher-to-Agent process interoperability, endpoint/role +allow-list authorization, certificate rotation/revocation, static artifact +activation, real/mixed mode, cross-host networking, health/version fault +handling, call execution, or two-Cell/P1 acceptance. The result is a cloud +mTLS smoke only. diff --git a/docs/evidence/20260918-w06-w12-local.md b/docs/evidence/20260918-w06-w12-local.md new file mode 100644 index 0000000..fd1d568 --- /dev/null +++ b/docs/evidence/20260918-w06-w12-local.md @@ -0,0 +1,58 @@ +# W06/W12 local evidence — 2026-09-18 + +## Scope + +This record covers isolated local tests for Agent recovery/session safety and +Dispatcher event/replay behavior. It is `mock`/local evidence only; it does +not prove deployment certificate interop, real health reporting, approved +broker application receipt, cross-Cell barriers, or production behavior. + +- Go: `go1.27.1 linux/amd64` +- W06: mTLS/SAN, session generation fencing, static artifact activation, file + recovery/quarantine, transcript/event guards +- W12: tenant-scoped durable replay, outbox publish/retry, Agent execution + idempotency/no originate retry, and reservation-to-execution seam + +## Results + +```text +== W06 RPC session/TLS/activation == +TestSessionGenerationFencesOlderRequests PASS +TestSessionRegistryPersistsGenerationAcrossRestart PASS +TestActivationValidatesStaticCellArtifact PASS +TestActivationRejectsInvalidStaticCellArtifact PASS +TestTLSConfigsRequireVerifiedSANPeer PASS +TestTLSConfigsCompleteMutualHandshake PASS +TestTLSConfigsRejectMissingOrUntrustedClient PASS +TestTLSRotationRejectsPreviousClientCA PASS +TestPeerCertificateAllowlist PASS +TestGetAgentStatusSupportsPreActivationProbe PASS + +== W06 Agent durable recovery/events == +TestEventWriterBuildsApprovedRealtimeTranscript PASS +TestEventWriterRejectsUnverifiedRecordingAndWrongArchiveEvent PASS +TestSpoolAtomicStateAndBootUnknown PASS +TestSpoolQuarantinesCorruptStateAndNeverDeletesIt PASS +TestSpoolTranscriptAndAssetAreDurableFiles PASS + +== W12 replay/outbox/unknown execution == +TestCommandQueryAndReplayAreTenantScopedAndDurable PASS +TestAgentCoordinatorProbesBeforeActivation PASS (generation 1→2 restart reactivation) +TestAgentCoordinatorActivatesExecutesAndControlsWithoutRetryingOriginate PASS +TestFlushOutboxPublishesAfterDurableIngest PASS +TestFlushOutboxMarksRetryOnPublishFailure PASS +TestExecuteReservedBindsQuotaAndAgentExecution PASS +``` + +The exact commands were run with `go test ./internal/rpc`, +`go test ./internal/agent`, `go test ./internal/control`, and +`go test ./internal/dispatcher`, each with `-count=1 -v` and the named test +regexes; all four commands exited 0. + +## Limits + +The tests do not close the external gates for approved broker ACL/topology, +application receipt versus publisher confirm, independent process/network +fault injection, real Dispatcher/Agent deployment, Asterisk/ARI/SIP/RTP, +real AI/OSS/SaaS, or W13-b/W14 P1 acceptance. Those remain explicitly +pending or blocked in the acceptance matrix. diff --git a/docs/evidence/20260918-w09-ari-runtime.md b/docs/evidence/20260918-w09-ari-runtime.md new file mode 100644 index 0000000..b61c4fb --- /dev/null +++ b/docs/evidence/20260918-w09-ari-runtime.md @@ -0,0 +1,161 @@ +# W09 isolated Asterisk/ARI runtime PoC (2026-09-18) + +## Scope and boundary + +This is an owner-authorized, mock-only compatibility probe on the existing +Debian 13 ECS test host. It does not use a SIP provider, a real phone number, +SaaS, RabbitMQ, OSS, AI credentials, or the production Agent binary. It is not +W09, W13, W14, or P1 acceptance. + +The probe was deliberately kept outside the project module at `/tmp/ari-poc`. +It uses the already reviewed temporary module: + +- `github.com/CyCoreSystems/ari/v5` `v5.3.1` +- module checksum: + `h1:S+NHG1+uMwoAIl0hMBnRUGNZsQKQQQFq7XCRSE2c2mg=` +- Asterisk runtime: `22.10.1` +- source image reference: `andrius/asterisk@sha256:1fde2a38e17c42c4f8999bb80d2ac52a01543bc696a98e02faee1cf6f14d4d64` +- transferred image ID on the host: + `sha256:08c732ed191c02ad54c019c4e6b46b641bb3ebbe42fcd5b525eb41c6373f6128` + +The image was transferred as an archive because Docker Hub was unreachable from +the host. The host-local tag is only a transport/runtime tag; it is not a new +upstream digest claim. The container is on the isolated Docker network +`agent-call-asterisk-poc`, with ARI published only on host loopback +`127.0.0.1:18088`. + +## Runtime procedure + +The temporary Go probe: + +1. listened on host UDP `0.0.0.0:19000` as the external-media sink; +2. connected to ARI with the mock `sipmock` application and mock credentials; +3. originated an internal `Local/900001@ari-gate` channel, with no provider leg; +4. waited for `StasisStart`; +5. created a `mixing` bridge and added the internal channel; +6. created an `ExternalMedia` channel using RTP/UDP, PCMA (`alaw`), direction + `both`, and external host `172.18.0.1:19000`; +7. waited for the ExternalMedia `StasisStart`, added it to the bridge, read + `UNICASTRTP_LOCAL_ADDRESS` and `UNICASTRTP_LOCAL_PORT`, and then hung it up; +8. observed `StasisEnd` for the ExternalMedia channel. + +The build and execution used the host-installed Go toolchain with +`GOTOOLCHAIN=local`; the resulting probe binary was copied to the ECS host and +run as `rogee`. + +## Observed output + +```text +local_channel=ari-poc-local +stasis_start channel=ari-poc-local args=[] +bridge=ari-poc-bridge data=&{Key:ari-poc-bridge ID:ari-poc-bridge Class:stasis Type:mixing ChannelIDs:[01m2syek1a93x6jkz567r1m3j8-ch ari-poc-local] Creator:Stasis Name:ari-poc-bridge Technology:simple_bridge} +external_channel=01m2syek1a93x6jkz567r1m3j8-ch state=Up name=UnicastRTP/172.18.0.1:19000-0x7f51780063b0 +local_rtp=172.18.0.2:10748 +probe_packets=0 peer= read_error=read udp4 0.0.0.0:19000: i/o timeout +stasis_end channel=01m2syek1a93x6jkz567r1m3j8-ch +``` + +The first run exposed a real ordering requirement: adding ExternalMedia to the +bridge before its `StasisStart` was observable returned HTTP `422`. Waiting for +that event made the bridge add succeed. This is useful compatibility evidence, +not a production implementation. + +## Controlled RTP forwarding follow-up + +A second temporary probe used two independent ExternalMedia channels in the +same mixing bridge. Both were configured as RTP/UDP, PCMA (`alaw`), direction +`both`. The host listened on `172.18.0.1:19000` and `172.18.0.1:19001`, sent 20 +synthetic RTP packets to the first channel's Asterisk-local port, and captured +the bridge output at the second host port. + +The successful run, after restarting only the isolated PoC container to clear +connections left by a timed-out disposable probe, returned: + +```text +connected +bridge_created +first_created=01m2szdw9r2s4erk251qpm4pme-ch +first_stasis +first_added +second_created=01m2szdwa1p6a16jtqsyphcefj-ch +second_stasis +second_added +bridge=ari-poc-media first=172.18.0.2:10724 second=172.18.0.2:10256 +forwarded_rtp_bytes=172 peer=172.18.0.2:10256 version=2 payload_type=8 sequence=63988 timestamp=800 ssrc=1b5ec593 +stasis_end channel=01m2szdwa1p6a16jtqsyphcefj-ch +stasis_end channel=01m2szdw9r2s4erk251qpm4pme-ch +remote_rc=0 +channels: [] +bridges: [] +``` + +This proves a controlled host-to-Asterisk-to-host RTP path through an Asterisk +mixing bridge and confirms RTP version 2, PCMA payload type 8, and lifecycle +cleanup. Asterisk rewrote the forwarded sequence/timestamp/SSRC; payload +identity and production jitter/clock behavior were not assessed. + +## Recording close follow-up + +A third temporary probe created the same two-channel synthetic bridge, started +an ARI bridge recording with format `wav`, injected 40 PCMA packets, stopped +the recording explicitly, and read the stored-recording metadata. The isolated +container first received the missing `/var/spool/asterisk/recording` directory +with `asterisk:asterisk` ownership; this was a disposable mock-container setup +change, not a production artifact. + +The run returned: + +```text +connected +recording_started name=ari-poc-record-20260918 format=wav state=recording +forwarded_rtp_bytes=172 peer=172.18.0.2:10250 payload_type=8 +recording_stopped name=ari-poc-record-20260918 format=wav +stasis_end channel=01m2szqryme5sm3w89866j2key-ch +stasis_end channel=01m2szqryxqpz6xgakr28bgdj0-ch +recording_name=ari-poc-record-20260918 +``` + +The closed file was inspected before cleanup: + +```text +size=13804 mode=644 owner=asterisk:asterisk path=/var/spool/asterisk/recording/ari-poc-record-20260918.wav +sha256=c62bede7c200c99a9fb9d73eee11ebefe71397b71aeff4dbfe0a05dc7d112e82 +header=RIFF/WAVE, PCM mono, 8000 Hz, 16-bit +channels: [] +bridges: [] +recording_cleanup=ok +``` + +The synthetic recording file was then removed from the disposable container. +This proves ARI recording start/stop, a closed WAV header, and cleanup in the +isolated environment; it does not prove business recording retention or OSS +upload semantics. + +## Result + +**Passed in isolation:** Asterisk 22.10.1 accepted the selected ARI client at +runtime; internal Stasis channels and mixing bridges were created; RTP/UDP +PCMA ExternalMedia channels reached `Up`; Asterisk-local RTP addresses and +ports were retrievable; bridge insertion required and honored `StasisStart`; +a controlled RTP packet stream traversed the bridge; an ARI bridge recording +started and stopped with a valid closed WAV file; the artifact was cleaned; and +both channels produced clean `StasisEnd` lifecycle events. + +**Not proven:** this remains synthetic ExternalMedia-to-ExternalMedia traffic, +not a SIP/RTP provider leg or an Agent media session. The separate isolated +SIPp-to-PJSIP/ARI signaling result is recorded in +`docs/evidence/20260918-w09-sip-ari.md`. Full same-channel bidirectional +semantics, exact PCMA sample preservation, reconnect/fencing behavior, provider +lines, static artifact loading, Agent integration, retention/OSS upload, and +capacity were not tested. The result +therefore only advances the compatibility PoC and does not unlock W09's full +M/G gate or any P1/production claim. + +## Follow-up gate + +Before selecting ARI in the production module, retain this runtime result and +complete the separate library/license/security review, full media-session and +reconnect/fencing tests, approved static Asterisk/Cell artifact loading, +retention/OSS handoff, and the remaining SIP/provider validation. Real provider +and telephone tests remain under W14 authorization and are not implied by this +document. diff --git a/docs/evidence/20260918-w09-sip-ari.md b/docs/evidence/20260918-w09-sip-ari.md new file mode 100644 index 0000000..df2026b --- /dev/null +++ b/docs/evidence/20260918-w09-sip-ari.md @@ -0,0 +1,91 @@ +# W09 isolated SIP/PJSIP-to-ARI lifecycle probe (2026-09-18) + +## Scope and boundary + +This is an owner-authorized mock-only test against the isolated ECS Asterisk +container. It used no provider trunk, real telephone number, SaaS, RabbitMQ, +OSS, AI credential, or production Agent binary. It is not W14, P1, or real +supplier acceptance. + +Runtime inputs were: + +- Asterisk `22.10.1`, source image + `andrius/asterisk@sha256:1fde2a38e17c42c4f8999bb80d2ac52a01543bc696a98e02faee1cf6f14d4d64`; +- PJSIP UDP listener published only on host loopback `127.0.0.1:15060`; +- anonymous mock PJSIP endpoint, PCMA/alaw only; +- SIPp `v3.6.1`, source commit `eef7746fd28cd44b4d1a6a5b2e3b8be0af69d2a4`; +- transferred image `sipmock/sipp:0.1.0`, runtime image ID + `sha256:fca49c1abbcf2ae63292efc93165b82537e7d309d86accc50609ae09d3bf0c0d`; +- temporary Go ARI observer using `github.com/CyCoreSystems/ari/v5` `v5.3.1`. + +SIPp was run with host networking and capability drop; the scenario was a +throwaway UAC script targeting the fictional extension `900001`. It declared +PCMA/8000 SDP but intentionally did not send RTP. The Asterisk dialplan enters +`Stasis(sipmock)`, and the observer waits for the SIP channel's ARI lifecycle. + +## Command and result + +The remote test command was equivalent to: + +```text +/home/rogee/sip +sudo docker run --rm --network host --cap-drop=ALL \ + -v /home/rogee/sipp-uac-pcma.xml:/scenario.xml:ro \ + -v /home/rogee/sipp-evidence:/evidence \ + sipmock/sipp:0.1.0 127.0.0.1:15060 \ + -sf /scenario.xml -i 127.0.0.1 -p 15070 \ + -mi 127.0.0.1 -mp 16000 -m 1 \ + -trace_msg -trace_err -trace_logs +``` + +Observed exit status and ARI output: + +```text +sipp_rc=0 observer_rc=0 +connected +stasis_start channel=1789726495.0 name=PJSIP/anonymous-00000000 args=[] +stasis_end channel=1789726495.0 +sip_ari_lifecycle=ok +channels: [] +bridges: [] +``` + +SIPp's successful-call screen recorded one outgoing call, one successful +call, and the expected transaction sequence: + +```text +INVITE -> 100 Trying -> 200 OK (PCMA/8000 SDP) -> ACK + -> BYE -> 200 OK +``` + +The Asterisk `200 OK` advertised `m=audio 10738 RTP/AVP 8` and +`a=rtpmap:8 PCMA/8000`. The captured message trace was downloaded from the +remote disposable evidence directory and hashed as: + +```text +scenario_1_messages.log b038ab9dbef020eca25a1fb4bbc4fbd26a97846e170784cad15f6cd4724f1bdd +scenario_1_errors.log 752afaef019844b1f0f2ffea4d6878625a906d306cd12a2113067114f8ea910a +``` + +The error trace contains SIPp's non-fatal cleanup warning +`Failed to delete FD from epoll, errno = 1 (Operation not permitted)` under +capability drop. The call still returned `sipp_rc=0`, completed all SIP +messages, and the ARI observer returned `observer_rc=0`; the warning is retained +rather than treated as a clean-runtime claim. + +## Result + +**Passed in isolation:** the pinned SIPp engine sent a PCMA/8000 INVITE to the +isolated Asterisk PJSIP listener; Asterisk returned valid 100/200 SIP +responses and PCMA SDP; ACK/BYE completed; the SIP channel entered and left the +ARI Stasis application; and no channels or bridges remained afterward. + +**Not proven:** this run sent no RTP packets, so it does not prove SIP media +flow, PCMA sample correctness, recording/OSS handoff, reconnect behavior, +provider authentication, caller-ID/dial-prefix rules, real supplier lines, +Agent integration, or capacity. The result advances only the mock SIP/ARI +signaling gate. + +The remote SIPp trace directory was disposable and may be removed after this +evidence is retained; no production host, EIP, provider, or telephone resource +was changed. diff --git a/docs/evidence/20260918-w09-sip-rtp-ari.md b/docs/evidence/20260918-w09-sip-rtp-ari.md new file mode 100644 index 0000000..6ae862e --- /dev/null +++ b/docs/evidence/20260918-w09-sip-rtp-ari.md @@ -0,0 +1,116 @@ +# W09 isolated PJSIP + RTP + ARI full mock media probe (2026-09-18) + +## Scope and boundary + +This is an owner-authorized, mock-only full media probe on the existing Debian +13 ECS test host. It uses a fictional in-network PJSUA2 callee and no provider +trunk, real telephone number, SaaS, RabbitMQ, OSS, AI credential, or production +Agent binary. It is not W14, P1, or supplier acceptance. + +The isolated topology was: + +```text +Go ARI driver -> Asterisk 22.10.1/PJSIP -> PJSUA2 2.15.1 mock callee + ^ | + | +-- ARI ExternalMedia RTP/UDP PCMA bridge leg + +-------------------- synthetic PCMA fixture source/sink +``` + +Inputs and provenance: + +- Asterisk `22.10.1`, source image + `andrius/asterisk@sha256:1fde2a38e17c42c4f8999bb80d2ac52a01543bc696a98e02faee1cf6f14d4d64`; +- PJSUA2/PJSIP mock image `sipmock/pjsua2:0.1.0`, transferred image ID + `sha256:70c3ac43a99d5ede0f4df8f7cfa973c5d6f15dee4a490cf5b98f68339e260545`; +- PJSIP library `2.15.1`, running as the non-root `sipmock` callee; +- Go ARI client `github.com/CyCoreSystems/ari/v5` `v5.3.1` in a temporary + module; Pion RTP `v1.10.5` was used for test-packet framing; +- Docker network `agent-call-asterisk-poc` (`172.18.0.0/16`), with Asterisk at + `172.18.0.2` and the callee at `172.18.0.3`; +- mock Asterisk endpoint `mock-callee` statically targeted + `sip:callee:5070` and allowed only PCMA/alaw; +- source fixture `fixtures/audio/A0.wav`, SHA-256 + `f9cd8c60d1d3fcc95e538572a6c9396947f7fe8fc93b6634fd14e1ff7038a071`, + converted by FFmpeg 7.1.3 to PCMA/8000 raw audio, + SHA-256 `6f59765f4faa509a89f9a7d5b4e383bfa00ddcf55193f5a83e08c52797498f7a`. + +The driver originated `PJSIP/mock-callee` into the `sipmock` ARI application, +waited for `StasisStart`, created a mixing bridge, created an RTP/UDP PCMA +ExternalMedia channel, waited for its `StasisStart`, and added both channels to +the bridge. It injected three 30-packet PCMA bursts from the converted fixture +with four-second turn gaps. The PJSUA2 callee's existing controller then +recorded the received leg and played its approved mock fixtures `U1`, `U2`, +and `U3` back through the SIP leg. + +## Observed driver output + +```text +connected +sip_stasis_start channel=ari-poc-fullmedia +external_channel=01m2t0pt8v612tw3j94y367tyq-ch local_rtp=172.18.0.2:10430 +inject_burst=1 packets=30 +inject_burst=2 packets=30 +inject_burst=3 packets=30 +received_packets=282 pcma_packets=282 bytes=48504 peer=172.18.0.2:10430 +stasis_end channel=01m2t0pt8v612tw3j94y367tyq-ch +stasis_end channel=ari-poc-fullmedia +full_sip_rtp_ari=ok +driver_rc=0 +``` + +The callee event journal recorded: + +```text +{"type":"media_active"} +{"type":"user_playback_started","label":"U1"} +{"type":"user_playback_finished","label":"U1"} +{"type":"user_playback_started","label":"U2"} +{"type":"user_playback_finished","label":"U2"} +{"type":"user_playback_started","label":"U3"} +{"type":"user_playback_finished","label":"U3"} +{"type":"call_disconnected"} +``` + +PJSUA2 media statistics recorded PCMA payload type 8 in both directions: + +```text +RX pt=8 total 86pkt 13.7KB +TX pt=8 total 282pkt 45.1KB +``` + +The endpoint recording artifacts were closed WAV files and were inspected after +the run: + +```text +callee_rx.wav 272364 bytes +sha256 5f93d938ffdb91005f6242b26f58016fa8845760b244a535feba67b497a60b4a + +callee_tx.wav 53164 bytes +sha256 7b129b83fccbe6fd3cae15f1ed59d34fca159500e67cd92cef9f3d21f26b3b31 + +RIFF/WAVE, PCM mono, 8000 Hz, 16-bit +``` + +The final ARI cleanup query returned empty channel and bridge arrays. The +callee container and its synthetic evidence directory were then removed; no +host instance, EIP, provider, or telephone resource was changed. + +## Result + +**Passed in isolation:** an actual PJSIP call from Asterisk to the PJSUA2 mock +callee negotiated PCMA/8000; the channel entered ARI Stasis; synthetic PCMA +input crossed the ARI ExternalMedia bridge into the SIP media leg; PJSUA2 +received RTP and executed U1/U2/U3 playback; RTP returned from the SIP leg to +the ExternalMedia sink; endpoint RX/TX WAV recordings closed; Stasis lifecycle +ended cleanly; and ARI channels/bridges were cleaned up. + +A separate local `internal/media` regression test, +`TestPacketGuardPreservesPCMAPayloadByteForByte`, passed with RTP payload type +8, a 160-byte fixture, and exact byte-for-byte payload/header preservation +through the Pion parser. This closes only the bounded policy-adapter regression; +it does not replace the end-to-end runtime or provider media evidence above. + +**Not proven:** this remains a single isolated mock Cell. It does not prove +production Agent integration, OSS upload/retention, reconnect/fencing, static +artifact rollout, provider authentication or caller-ID/dial-prefix policy, +real supplier media, multi-Cell behavior, capacity, or P1 acceptance. diff --git a/docs/evidence/20260919-local-saas-contract-mock.md b/docs/evidence/20260919-local-saas-contract-mock.md new file mode 100644 index 0000000..e4016ab --- /dev/null +++ b/docs/evidence/20260919-local-saas-contract-mock.md @@ -0,0 +1,21 @@ +# 2026-09-19 local SaaS contract mock boundary + +Per the local-first acceptance decision, SaaS is not contacted as a production +service. The project-owned versioned contract bundle and fixtures are the mock +boundary: + +- `scripts/check-contracts.sh` validates the self-contained OpenAPI/JSON + schemas, examples, negative fixtures, mock profile and source hashes. +- `internal/contract` tests validate `call.execute`, event payloads, static + Cell artifacts and strict rejection cases. +- `internal/ai` tests use immutable ASR-only/full-AI fixture snapshots and the + bounded mock pipeline; no old LLM/TTS implementation is selected. +- `make mq-integration-local` publishes the contract `call.execute` fixture to + an isolated tenant queue and verifies Dispatcher inbox/task/outbox/event + behavior. The ECS candidate receipt is separately recorded in + `docs/evidence/20260918-rabbitmq-integration.md`. + +This is a contract/fixture mock, not a claim that SaaS HTTP APIs, credentials, +production AI-version reads, upload-session/complete, or SaaS application +receipts are live. Business results continue to use the RabbitMQ contract; no +HTTP business callback was added. diff --git a/docs/evidence/20260919-mixed-ari-callflow.json b/docs/evidence/20260919-mixed-ari-callflow.json new file mode 100644 index 0000000..fa710ee --- /dev/null +++ b/docs/evidence/20260919-mixed-ari-callflow.json @@ -0,0 +1,30 @@ +{ + "date": "2026-09-19", + "status": "passed", + "scope": "isolated sip_mock_server Asterisk/ARI/callee; no real provider or external network", + "mode": "mixed", + "flow": "shared internal/callflow with MockPipeline and ARI/RTP transport", + "artifact": "temporary project-owned mixed artifact; mock-callee trunk", + "ari": { + "channel_id": "01m2wh00p61v4jjd9rygtecv3e-ch", + "bridge": "channel-specific mixing bridge", + "external_media": "UnicastRTP ExternalMedia" + }, + "facts": { + "rtp_received_packets": 31, + "rtp_received_bytes": 19840, + "rtp_sent_packets": 2, + "rtp_sent_bytes": 640, + "transcript_chars": 24, + "reply_chars": 39, + "inbound_recording_bytes": 19840, + "outbound_recording_bytes": 320, + "inbound_recording_sha256": "cc05279bbb947627677cc4dbfe00448388fbd9910f16e373a3c69908596c3d1b", + "outbound_recording_sha256": "e7531525a8818a3b06dc5c4e09269c351688352352117dc6caa3bba02d876112" + }, + "limitations": [ + "MockPipeline supplies the AI adapter; this does not prove real ASR/LLM/TTS provider behavior.", + "The callee-side evidence WAV path was not writable in the container; Agent-side inbound/outbound recordings were closed and hashed.", + "This evidence does not sign real SIP provider answered-dialog, phone media, OSS/MQ, production cutover, capacity, or P1 acceptance." + ] +} diff --git a/docs/evidence/20260919-phone-call-business-log.md b/docs/evidence/20260919-phone-call-business-log.md new file mode 100644 index 0000000..611c029 --- /dev/null +++ b/docs/evidence/20260919-phone-call-business-log.md @@ -0,0 +1,52 @@ +# 按手机号关联的外呼业务日志(2026-09-19) + +## 实现 + +已在 Agent RPC 执行/事实回报边界增加文件型结构化业务日志: + +- `internal/calllog/log.go`:JSONL 追加器;每条记录 `0600`,父目录 `0700`,写入后 `Sync`。 +- `internal/rpc/server.go`:`Execute` 记录 `execution.prepared`;`ReportExecutionEvent` 记录 call status/finished、录音事实和 attempt summary。 +- `cmd/sip-go-agent/main.go`:Agent 配置 `CallLogger`;未指定路径时写入 Agent spool 的 `call-business.jsonl`。 +- `internal/config/config.go`:支持 `AGENT_CALL_BUSINESS_LOG` 与 `AGENT_CALL_PHONE_LOG_KEY`。 + +## 脱敏边界 + +- 原始 `callee` 只在进程内用于生成身份,永不序列化到日志。 +- `phone_ref` 是使用受控注入密钥计算的 HMAC-SHA256,可跨任务关联同一号码;`phone_mask` 只保留末四位(短号码全部掩码)。 +- 不写入凭据、SIP URI/服务端地址、音频、转写文本、prompt、变量或 OSS 本地路径。 +- 日志 key 不得写入代码、配置样例、日志或证据;建议通过受控环境/secret 文件注入,长度至少 16 字节。key 轮换会产生新的手机号引用,轮换应作为单独运维动作登记。 + +## 记录字段 + +每条记录包含 `schema_version`、`event_id`、`occurred_at`、`event_type`、`phone_ref`、`phone_mask`,并按实际事实填充: + +- 业务关联:`tenant_id`、`trace_id`、`task_id`、`task_item_id`、`task_revision`、`execution_id`、`attempt_id`、`call_id`。 +- 线路和节点:`route_policy_id`、`caller_profile_id`、`trunk_id`、`cell_id`、`agent_id`。 +- SIP/状态:`sip_stage`、`sip_status_code`、`sip_reason`、`call_state`、`attempt_state`、`status`、`result`、`reason_code`。 +- 录音/结果:`recording_id`、`recording_state`、`recording_size_bytes`、`recording_duration_ms`、`recording_sha256`、`duration_ms`。 + +`ReportExecutionEvent` 只从 allow-list 字段解析事实 payload,不把任意 payload 或 transcript 文本复制到业务日志;`call.finished` 的 `attempt_summary` 会拆成独立 `call.attempt` 记录。 + +## 配置 + +```dotenv +# 可选路径;只配置 key 时默认落到 $AGENT_SPOOL_ROOT/call-business.jsonl +AGENT_CALL_BUSINESS_LOG=/var/lib/sip-go-agent/call-business.jsonl +AGENT_CALL_PHONE_LOG_KEY=<受控注入的至少16字节密钥> +``` + +设置路径时必须同时设置 key;只设置 key 时使用 spool fallback。未设置 key 时不会创建日志文件,避免退回明文手机号。 + +## 本地验证 + +已通过: + +```text +go test -race ./internal/calllog ./internal/rpc +go test -race ./... +go vet ./... +``` + +覆盖场景:同一手机号跨事件稳定关联、原始号码不出现在 JSONL、SIP 阶段/状态码/线路/执行与呼叫 ID/录音/结果字段、attempt summary 拆分、文件权限、非法身份拒绝和并发追加。 + +该日志是 Agent 本地业务审计材料,不替代 SaaS 的最终业务事件、MQ 应用收讫、OSS verified→MQ 回执或生产观测系统;本地测试通过也不代表真实 SIP/AI/OSS/P1 验收通过。 diff --git a/docs/evidence/20260919-physical-systemd-deployment.md b/docs/evidence/20260919-physical-systemd-deployment.md new file mode 100644 index 0000000..4f73c12 --- /dev/null +++ b/docs/evidence/20260919-physical-systemd-deployment.md @@ -0,0 +1,73 @@ +# 2026-09-19 physical-host systemd deployment smoke + +## Scope + +This evidence validates the new upload/install path for the Go Agent and +Dispatcher on a Debian 13 host. It does **not** approve G0/P1, start a +production service, or prove SIP/AI/OSS/MQ acceptance. + +Production deployment is physical-host systemd. Docker was not used for this +installation path; the earlier Docker/SIPp activity is a disposable ECS smoke +and remains outside the production package. + +## Locked release and package + +- Release: `0.1.0-p1.20260919` +- Target: `linux/amd64` +- Go: `1.27.1` +- OS: Debian 13 (Trixie) amd64 +- Init: systemd +- Cell boundary: Asterisk `22.10.1`, separately installed/owned by the + management-approved physical Cell artifact +- Package: + `deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz` +- SHA-256: + `4567afe7ea2155549c278b23f4456bab88cd18beea157772d76079d5f322960e` + +The package contains only the SIP Agent/Dispatcher business binary, release +manifest, module checksums, locked versions, hardened systemd units, safe +environment templates, endpoint template and installer. It contains no +credentials, certificates, broker URL, SIP credentials, phone-log key, audio, +provider payload or SaaS infrastructure. + +## Owner-authorized ECS smoke + +- Host: Debian 13 ECS `i-2zegf59q5yqbrp3igj3m` +- Fixed EIP: `123.56.71.98` +- Installation command: `install.sh --allow-nonproduction` +- Checks passed: package `sha256sum -c`, Debian/amd64 gate, binary install, + systemd unit install/enable, `systemd-analyze verify`, and state permissions. +- State paths were verified as `rogee:rogee`, mode `0700`: + `/var/lib/sip-go-agent/agent`, its spool, and + `/var/lib/sip-go-agent/dispatcher`. +- Runtime services were deliberately **not started** because the package + manifest records `source_dirty=true` and `production_approval=false`, and + the host still requires approved PKI, broker ACL, and static Cell artifact. +- The temporary sudo grant used only to validate the installer was removed; + the persistent sudo allowlist remained restricted to deployment/diagnostic + commands. + +## Native Asterisk validation + +The pinned Asterisk `22.10.1` source and third-party cache were built directly +on the same Debian 13 host with one compiler job, staged without Docker, and +installed as `/usr/sbin/asterisk` plus the physical systemd unit +`asterisk.service`. The service was active and `provider-second` reported an +available contact before a bounded real-provider probe. That probe produced 13 +SIP packets and 1 non-SIP UDP packet but a 0-byte fixed-name recording, so it +remains signaling/installation evidence rather than RTP/recording acceptance. +The native stage is also retained locally under +`deploys/packages/asterisk-22.10.1-native/`; the versioned +`deploys/cell/install-asterisk-native.sh` was executed on the same host with +`start=false`, verified the stage checksum, preserved `/etc/asterisk`, and +left `asterisk.service` active. Management-owned SIP/ARI/RTP configuration +remains outside the Go package. + +## Remaining gates + +The Asterisk source input is also staged locally and hash-verified; the +management-owned native Cell build/install and systemd unit remain a separate +release step. A clean reproducible release and external production approval are still +required. The management-owned physical Asterisk/ARI/SIP media, SaaS-provided +AI/MQ/OSS application receipt and verified handoff, two Cells, capacity/N+1 and +cutover remain separate acceptance gates; no MQ/OSS/AI service is packaged here. diff --git a/docs/evidence/20260919-real-provider-callflow-attempts.json b/docs/evidence/20260919-real-provider-callflow-attempts.json new file mode 100644 index 0000000..0e8661c --- /dev/null +++ b/docs/evidence/20260919-real-provider-callflow-attempts.json @@ -0,0 +1,22 @@ +{ + "date": "2026-09-19", + "status": "blocked", + "scope": "single ECS physical Asterisk plus project-owned v1 Agent candidate", + "candidate_package": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "candidate_sha256": "bc106f733e26354221c18c7bb593eca84a4267dec3156f80311eb30f630e7b84", + "latest_media_profile_candidate_sha256": "bc106f733e26354221c18c7bb593eca84a4267dec3156f80311eb30f630e7b84", + "call_policy": { + "target": "allowlisted target; raw value intentionally not repeated", + "automatic_retry": false, + "additional_targets": false, + "capture": "ARI events, RTP counters, Agent-side WAV facts; no raw PCAP retained" + }, + "attempts": [ + {"trunk":"provider-second","sip_endpoint":"60.171.24.90:5060","dial_prefix":"","cause":1,"stasis_start":false,"rtp":false,"recording":false}, + {"trunk":"provider-third","sip_endpoint":"160.202.254.79:5060","dial_prefix":"mka755","cause":19,"stasis_start":false,"rtp":false,"recording":false}, + {"trunk":"provider-primary","sip_endpoint":"61.132.228.221:5060","dial_prefix":"7089","cause":19,"stasis_start":false,"rtp":false,"recording":false} + ], + "conclusion": "All three registered endpoints were online during checks, but no attempt formed an answered dialog or entered StasisStart. Therefore real phone RTP, recording, ASR, LLM, TTS playback, business facts, OSS verification and MQ result closure remain unverified.", + "blocker": "provider/SIP answer and media authorization/behavior; no further call was automatically attempted", + "deployment_gate": "The later per-trunk PCMA/A-law candidate was not called because SSH timed out and the fixed EIP 123.56.71.98 remains Available/unassociated. A read-only 2026-09-20 inventory found only an untagged Rocky Linux instance with a different public IP; it was not adopted. See docs/evidence/20260920-real-cloud-inventory.md. No rebinding, replacement ECS, or additional call was authorized." +} diff --git a/docs/evidence/20260919-real-provider-ecs-direct.md b/docs/evidence/20260919-real-provider-ecs-direct.md new file mode 100644 index 0000000..0a4b527 --- /dev/null +++ b/docs/evidence/20260919-real-provider-ecs-direct.md @@ -0,0 +1,95 @@ +# 2026-09-19 direct real-provider outbound probe + +## Scope + +This was a bounded, owner-authorized direct SIP probe from the newly provisioned +Debian ECS `i-2zegf59q5yqbrp3igj3m` through fixed EIP `123.56.71.98`. +`sipmock/sipp:0.1.0` was used only as the SIP UAC test client; no mock SIP +supplier, mock provider response or local SIP server was used. The Go Agent +still does not own SIP registration/originate; the probe checks the approved +Cell/Asterisk boundary and provider response only. + +Two targets were selected from the already authorized outbound whitelist. They +are referred to below as `whitelist-1` and `whitelist-2` to keep the evidence +from persisting raw phone numbers. + +Each line used its approved route policy, original caller profile and PCMA/8000 +SDP. No retry was issued after an answered call, and no provider credential was +written to the repository or probe output. + +## Results + +| Provider line | whitelist-1 | whitelist-2 | Boundary | +| --- | --- | --- | --- | +| 数企 `61.132.228.221:5060` | `100/183`, no final `200 OK` | `100/183`, no final `200 OK` | No answered dialog | +| 中鼎 `60.171.24.90:5060` | `404 Not Found` | One signaling run reached `200 OK`; SIPp ended with its normal stop code `97` | Answer signaling observed once; media/recording not accepted | +| 百应 `160.202.254.79:5060` | `100/183`, no final `200 OK` | `100/183`, no final `200 OK` | No answered dialog | + +A follow-up bounded media probe for 中鼎/`whitelist-2` captured five SIP +packets and one non-SIP UDP packet, but did not complete cleanly (`SIPp rc=1`). +It therefore does not prove bidirectional RTP, PCMA sample preservation, +recording, AI, OSS or billing. + +## Physical Asterisk attempt + +The same host also ran the pinned native Asterisk `22.10.1` build as a physical +systemd service, with the `provider-second` endpoint reporting `Avail` before +the probe. A bounded call through the physical Asterisk dialplan used the +`whitelist-2` target and the approved `provider-second`/`mbkq` route. The +origin command returned `0`, the service remained active, and the packet +counters were 13 SIP packets and 1 non-SIP UDP packet; the fixed-name +`MixMonitor` file was 0 bytes. A follow-up direct-PJSIP originate after the +validation `from_domain`/`send_pai` adjustment produced 11 SIP packets and 1 +non-SIP UDP packet, also without a clean media/recording result. These attempts +did not establish a clean answer/RTP or recording result, and temporary +recordings/captures were removed. Additional physical direct-PJSIP probes using +the route-correct prefixed URIs for provider-primary and provider-third each +returned `origin_rc=0`, 10 SIP packets and 1 non-SIP UDP packet, without a +recording or clean media result. + +## Confirmation-gated latest probe + +Immediately before this latest attempt, the user confirmed one real outbound +call and requested the channel/target be shown and packets be captured. The +call used channel `provider-second` (中鼎) and the `whitelist-2` target. The +temporary PCAP was 4,108 bytes with SHA-256 +`b7178008dcb25addfcc898d8e067d730863fec276d74ab30ad73c0b99a66cd54`; analysis +observed 8 SIP packets, 0 non-SIP/media packets and status tokens `100, 200, +404`. The `200` may be the existing OPTIONS/keepalive response rather than the +INVITE transaction; the raw PCAP was deleted after analysis, so this is not a +clean answered-call or RTP result. No automatic retry was made. After analysis, the temporary capture file, helper +outputs, and capture-specific sudoers drop-in were cleared; the host retained +no matching PCAP or running tcpdump process. Asterisk remained active while +Docker, docker.socket and containerd remained inactive. + +## 2026-09-19 single-node AI runtime attempt + +The project-owned v1 real-mode artifact and the new Agent one-shot ARI runtime +were staged on the same physical ECS. The Bailian/Volcengine provider smoke +passed independently through real ASR, LLM and TTS: the test synthesized a +spoken input, recognized it, generated an LLM reply, and synthesized reply WAV +PCM. The TTS contract uses the licensed Bailian `Cherry` voice because the +pre-existing environment voice reference was rejected by the provider. + +Several bounded, explicitly authorized attempts then used `provider-second` and +the `whitelist-2` target through ARI. The provider endpoint was `Avail`, but +Asterisk emitted `ChannelHangupRequest` before `StasisStart`, finally reporting +`cause=1`, `soft=false`, `state=Down`. Therefore the runtime did not receive +RTP, did not invoke the turn pipeline for phone audio, and produced no call +recording. Application-layer RTP counters were added to the runtime; they +remain zero for this boundary. Raw PCAP could not be enabled because the SSH +operator lacked `CAP_NET_RAW`/controlled sudo; no raw capture was retained. +Transient provider credentials were removed from the ECS after the attempts. + +This is a provider/SIP acceptance blocker, not a provider-SDK smoke failure: +full phone ASR/LLM/TTS and playback remain unaccepted until the authorized +trunk returns an answered INVITE. + +## Acceptance boundary + +The result is direct supplier reachability/signaling evidence, not production +P1 acceptance. Provider registration/authentication ownership, final From/PAI +mapping, carrier answer semantics, RTP/recording retention, real AI, production +MQ/SaaS receipt, OSS verified handoff, two-Cell capacity and cutover remain +open gates. The physical systemd package smoke is recorded separately in +`docs/evidence/20260919-physical-systemd-deployment.md`. diff --git a/docs/evidence/20260919-real-sip-provider-calls-retry.md b/docs/evidence/20260919-real-sip-provider-calls-retry.md new file mode 100644 index 0000000..d7cbad3 --- /dev/null +++ b/docs/evidence/20260919-real-sip-provider-calls-retry.md @@ -0,0 +1,34 @@ +# 2026-09-19 repeated authorized SIP outbound verification + +## Scope + +The owner kept the ECS online and authorized another bounded outbound +verification window. Alibaba Cloud state was rechecked immediately before the +test: instance `i-2zeb69p1fo75r92wplbz` was `Running`, EIP +`123.56.71.98` was `InUse` and still attached to that instance, and the +Asterisk mock container was healthy. The target remained the approved +`15003164745`. + +The existing `sipmock/sipp:0.1.0` image sent one direct INVITE per registered +line using the listed prefix and candidate preserved caller ID. Each attempt +had a 35-second bound, advertised PCMA/8000 SDP, sent no RTP audio, and was +cancelled/aborted by SIPp when no final answer arrived. + +## Results + +| Line | Candidate Request-URI user / caller | Responses | Result | +| --- | --- | --- | --- | +| 数企 `61.132.228.221:5060` | `708915003164745` / `BD93205882` | `100 Trying`, `183 Session Progress` | No final `200 OK` within bound | +| 中鼎 `60.171.24.90:5060` | `15003164745` / `mbkq` | `100 Trying` | No final response/`200 OK` within bound | +| 百应 `160.202.254.79:5060` | `mka75515003164745` / `KQ91526` | `100 Trying`, `183 Session Progress`, `180 Ringing` | No final `200 OK` within bound | + +No line formed an answered INVITE dialog. No RTP/audio, recording, AI, OSS, +or billing result was claimed. The earlier attempt remains recorded in +`docs/evidence/20260918-real-sip-provider-calls.md`; this retry confirms the +same non-connected boundary in a later allowed window. + +## Cleanup + +After the retry, no SIPp process or `/tmp/real-*` scenario/log remained on the +ECS and the Asterisk container remained healthy. This is provider-line +signaling evidence only, not successful SIP media or P1 acceptance. diff --git a/docs/evidence/20260919-single-node-ai-closure.md b/docs/evidence/20260919-single-node-ai-closure.md new file mode 100644 index 0000000..4c56d55 --- /dev/null +++ b/docs/evidence/20260919-single-node-ai-closure.md @@ -0,0 +1,101 @@ +# 2026-09-19 single-node AI closure attempt + +## Scope + +This evidence covers the user-authorized scope amendment: project-owned v1 +contracts, an automatically approved single-node real-mode Cell artifact, real +provider ASR/LLM/TTS credentials, and a one-node ARI/ExternalMedia runtime. +The second Cell is intentionally out of this iteration. + +## Completed + +- Active contract bundle is `contracts/upstream/2026-09-19-p1-v1`. +- `static-cell-artifact-v1.json` validates as `mode=real` with ARI, per-trunk + media profiles, the three provider bindings selecting PCMA/A-law 8 kHz RTP + payload type 8 (plus an optional slin16/16 kHz profile), and 0600-WAV + recording requirements. +- `agent-version-full-production-v1.json` selects Volcengine ASR, Bailian + OpenAI-compatible LLM and Bailian Qwen3 TTS (`qwen3-tts-flash`, licensed + `Cherry` voice). The prior environment voice reference was rejected by the + provider and is not used by this v1 snapshot. +- `AGENT_CALL_PROVIDER_SMOKE=1 go test -v ./internal/ai -run + TestProviderFullAIChain -count=1` passed provider ASR → LLM → TTS, + including TTS WAV download, PCM16 decoding and 16 kHz resampling. +- The shared `internal/callflow` validates the immutable full-AI snapshot and + runs the same sequence for mock, mixed and real adapters: opening prompt, + bounded media capture, ASR, LLM, TTS, playback and RTP facts. The runtime now + executes up to three effective turns, enforces a 120-second call limit from + the AI snapshot, emits per-turn inbound/outbound WAV/hash facts, and ends + early for an LLM `[INVALID_CALL]` marker or explicit refusal/automation + signals. The Agent `--call-once` entry only selects transport/provider + adapters from `mode`; it is not a second real-only business flow. The mock + path completed the shared sequence with 6,400 inbound bytes, 10 received + packets and 2 sent packets. +- The isolated `sip_mock_server` Asterisk/ARI mixed run completed the same + shared flow through a real ExternalMedia bridge: 31 inbound RTP packets, + 19,840 inbound bytes, 2 outbound packets, 24 transcript characters, 39 + reply characters, and closed Agent-side inbound/outbound WAV facts. The + redacted evidence is `docs/evidence/20260919-mixed-ari-callflow.json`. +- A fresh isolated PCMA `call-once` run completed the same shared flow with the + trunk-selected `pcma-8k-pt8` profile: PCMA/8000/PT8 SDP, Asterisk + ExternalMedia `alaw`, 110 received and 20 sent RTP packets, U1 scripted + playback, non-silent callee RX/TX WAVs, and transcript/reply facts. The + structured evidence is `docs/evidence/20260920-pcma-mixed-callflow.json`. +- The runtime now passes the selected trunk media format to Asterisk + ExternalMedia instead of hard-coding `slin16`; the mock full-AI TTS fixture is + deterministic and non-silent so the strict mock callee can advance its + scripted dialogue. The unit suite asserts the fixture is non-silent. +- On 2026-09-20, one freshly confirmed real attempt used `provider-second` and + raw target `15003164745`. Asterisk answered, entered `Stasis`, joined the + PJSIP and UnicastRTP channels to the Agent bridge, and the Agent observed 220 + received and 136 sent RTP packets. The real ASR returned an empty transcript, + so LLM/TTS and final recording/OSS/MQ facts were not reached. The attempt was + not retried; details are in `docs/evidence/20260920-real-provider-second-15003164745.json`. +- The three earlier bounded real-provider CallFlow attempts and their + no-`StasisStart` causes remain recorded in + `docs/evidence/20260919-real-provider-callflow-attempts.json`. +- The new 美吧口腔 policy is in `deploys/config/ai-dental-meiba-v1.json`: it + identifies 美吧口腔, asks which dental project the user wants, limits the + call to three effective turns/120 seconds, and requires the + `[INVALID_CALL]` early-stop marker for invalid calls. +- A freshly confirmed three-turn deployment attempt was stopped before + `StasisStart` with provider-second `cause=1`, so the new three-turn, + recording and two-sided transcript behavior was not yet exercised on a real + phone. Details are in + `docs/evidence/20260920-real-provider-second-3turn-attempt.json`. +- The real transport adapter uses the Asterisk ARI SDK, a mixing bridge, Pion + RTP/UDP ExternalMedia, bounded turn capture, WAV recording and hashes. It + now derives the outbound RTP peer from Asterisk `UNICASTRTP_LOCAL_*`, sends + the opening prompt before waiting for caller audio, uses channel-specific + bridge IDs, passes ARI originate timeouts in seconds as required by the ARI + SDK, and captures PCM16 turns with bounded first-speech/max-turn/end-silence + behavior modeled on the validated Python Cell. +- ECS staging used a temporary SSH-injected credential file; it was deleted + after the attempts. Credentials are not stored in this repository. + +## Uncompleted phone acceptance + +Several explicitly authorized attempts through `provider-second` and +`provider-third` reached Asterisk originate but ended before `StasisStart` +(`cause=1` for provider-second and `cause=19` for provider-third/provider-primary). +The endpoints reported `Avail`; their local contract mappings, PCMA codec, caller +profiles, prefixes and contacts were validated. One earlier provider-second call +reached ExternalMedia and produced bidirectional RTP counters, but its ASR was +empty; the latest three-turn attempt again ended with cause=1 before +`StasisStart`. Real three-turn recording and two-sided recognition content are +still not signed as passed. + +Raw PCAP could not be collected because the SSH `rogee` account has no +`CAP_NET_RAW` and no passwordless controlled sudo. The runtime now reports +application-level RTP packet/byte counters, but they remain zero when the +provider hangs up before media setup. No raw capture was retained. + +## Decision boundary + +The remaining single-node AI closure gates are a stable real answered call, +three-turn non-empty ASR/LLM/TTS media, per-turn two-sided recording and +recognition facts, recording retention, OSS verified handoff and MQ business +result closure. The isolated mixed ARI flow and the answered-but-empty-ASR real +attempt are not substitutes for those gates. Do not claim full phone AI +acceptance, P1, production cutover or billing until a freshly confirmed run +proves all of those facts. diff --git a/docs/evidence/20260919-sip-routing-implementation-comparison.md b/docs/evidence/20260919-sip-routing-implementation-comparison.md new file mode 100644 index 0000000..8608098 --- /dev/null +++ b/docs/evidence/20260919-sip-routing-implementation-comparison.md @@ -0,0 +1,33 @@ +# SIP 注册、认证、主叫、前缀与选路对比(2026-09-19) + +## 结论 + +本记录只对比父目录既有生产验证实现与 `sip-go-agent` 当前 Go 实现,不把 Mock、SIPp 探测或收到临时 SIP 响应称为生产验收。当前 Go Agent 尚未实现 SIP 注册、SIP Digest/IP 鉴权、Asterisk/ARI 呼叫建立或 `From`/`P-Asserted-Identity`(PAI)生成;这些能力仍必须由批准的 Cell/Asterisk 运行面和版本化路由制品提供。 + +父目录实现可以证明“配置和受控调用路径如何表达线路”,不能证明供应商已授权、已接通、已收 RTP 或已生成最终录音。 + +## 对比表 + +| 项目 | 父目录生产验证实现 | 当前 `sip-go-agent` | 结论/后续门禁 | +| --- | --- | --- | --- | +| SIP 注册 | `deploy/asterisk.three-sip.json` 的三条线路均为 `register: false`;`real_cell.py`/`cell_worker_main.py` 通过已配置的 Asterisk/ARI 呼叫,不在 Python Cell 内执行注册。 | Go Agent 只有 mTLS Unary gRPC 会话;没有 SIP 注册客户端或注册状态机。 | 当前不能声称支持供应商注册。后续按供应商确认的 IP 直连或注册模式实现,不能臆造备用注册。 | +| SIP 认证 | 当前三条线路是 IP 白名单模式;生成配置保留 trunk 的服务端和 `from_user`,Digest trunk 没有受控 secret resolver 时被拒绝。认证实际由 Asterisk/PJSIP 执行。 | Go 代码没有 SIP 用户名、密码、Digest、Contact 或注册凭据字段;Dispatcher 也不会把凭据下发到任务。 | 这是正确的边界,但认证能力尚未迁移/验收。凭据仍只能由部署受控注入,不能写入任务、日志或契约。 | +| `From`/主叫 | `cell-routes.three-sip.json` 将 `caller_profile_id` 绑定到受信 `caller_id`;`real_cell.py` 只把受信 caller ID 传给 ARI `callerId`。Asterisk renderer 将 provider caller identifier 写入 trunk 的 `from_user`/callerid 相关配置。 | `call.execute` 只携带 `caller_profile_id` 和 `route_policy_id`;Go 没有 SIP URI/header 生成,也没有 caller profile 到 `From` 的映射。 | 必须以管理平台发布的 caller profile 为唯一来源;不能让任务注入任意主叫。 | +| PAI | 父目录搜索/renderer 没有独立 `send_pai` 或应用层 PAI 注入实现;探测脚本中的 `From`/PAI 候选只证明探测包内容,不能证明 Asterisk 最终 PAI 映射。 | 当前 Go 没有 PAI 代码或字段。 | 需供应商确认 `From`、`PAI`、`Remote-Party-ID` 等要求,并用抓包验证最终 INVITE;未确认前标 unknown/未启用。 | +| 被叫原值与前缀 | 线路映射为:数企 `trunk=数企`、`caller=BD93205882`、`prefix=7089`;中鼎 `mbkq`、无前缀;百应 `KQ91526`、`prefix=mka755`。`real_cell.py` 从原始业务号码构造选中线路的有效目标,不把上一线路前缀带入下一线路。 | Go 持久化原始 `callee`、`route_policy_id`、`caller_profile_id`,没有 SIP 目标构造或供应商前缀应用。 | 前缀只能由受信 route policy 应用一次;任务不得预先混入 `7089`/`mka755`,也不得跨线路复用。 | +| 线路选路 | 父目录使用受信 route map:`route_policy_id + caller_profile_id` 绑定 trunk、caller 和 prefix;payload 不能注入 SIP 地址、凭据或主叫。 | Dispatcher 只做任务、租约、配额和 Agent 选择;Go Agent 尚未加载 SIP trunk/Cell route 表,也不执行 trunk 选路。 | 选路必须在批准的静态 Cell artifact/本地 route table 中完成,并在实际呼叫前核验版本、准入和资源租约。 | +| 编解码/媒体 | 三条配置只允许 PCMA(Asterisk `allow=alaw`);父目录测试覆盖配置渲染、ARI/RTP guard 和 signaling probe。 | `internal/media` 目前只有 Pion RTP `PacketGuard`,不建立 SIP/RTP 会话。 | PacketGuard 不是 SIP/RTP 生产验收;仍需真实线路 `200 OK`、双向 RTP、录音和 AI 链路验证。 | + +## 证据边界 + +- 对照来源:`../deploy/asterisk.three-sip.json`、`../deploy/cell-routes.three-sip.json`、`../deploy/render_asterisk.py`、`../agent_call/real_cell.py`、`../agent_call/cell_worker_main.py`、`../tests/test_real_cell.py`。 +- 父目录测试主要是 Mock Asterisk/ARI、配置渲染和受控 signaling probe;已登记的真实探测没有得到最终 `200 OK`、RTP 或录音,因此不能作为供应商接通验收。 +- 当前 Go 来源:`internal/contract`、`internal/dispatcher`、`internal/rpc`、`internal/media`。其中 `internal/rpc` 的 mTLS/session fencing 与 SIP 认证无关,不能混称为 SIP 认证。 +- 父目录源和本项目均可能处于 dirty workspace;本记录是实现差异清单,不是外部契约发布、生产批准或真实拨号授权。 + +## 下一步门禁 + +1. 先冻结 Cell route artifact:trunk、IP/注册模式、codec、caller profile、`From`/PAI 规则、前缀和出口绑定。 +2. 由 Asterisk/官方或成熟 SIP/ARI 组件实现协议栈,Go 只实现授权、路由、状态、幂等和薄适配,不从零重写 SIP。 +3. 对每条线路分别验证原始被叫、前缀、主叫、最终 From/PAI、SIP 状态、RTP、录音和结果;任何线路未得到最终接通都保持未验收。 +4. 真实呼叫仍仅限已批准的白名单号码和单独授权窗口;本记录不扩大拨号授权。 diff --git a/docs/evidence/20260920-acceptance-status.md b/docs/evidence/20260920-acceptance-status.md new file mode 100644 index 0000000..e70f9ec --- /dev/null +++ b/docs/evidence/20260920-acceptance-status.md @@ -0,0 +1,29 @@ +# 2026-09-20 W00–W15 验收状态摘要 + +> 本阶段范围以 [`20260920-scope-amendment.md`](20260920-scope-amendment.md) 为准:单节点、单 Cell、单租户;生产 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。契约结构通过不等于生产联调通过。 + +## 已有可复核证据 + +- **W00–W02**:项目范围、契约包、Proto/stubs、严格校验和状态/幂等合同已通过本地检查。 +- **W03–W10 本地/隔离部分**:`go test -race ./...`、`go vet ./...`、`go mod verify`、契约/Proto 检查通过;AI mock/provider smoke、PCMA/RTP/ARI、三轮 CallFlow、无效通话早停和美吧口腔配置已有证据。 +- **SIP 时间门禁**:新增 `internal/callwindow`,按 Asia/Shanghai `[09:00,20:00)` 覆盖 08:59:59、09:00:00、19:59:59、20:00:00 及 UTC 转换;`runCallOnce` 和 real/mixed Agent RPC 的 permit/execute 入口窗口外 fail-closed。全套 race、vet、build 和本地验收通过。 +- **W11 Alibaba OSS 部分**:Dispatcher 使用官方 Alibaba OSS Go SDK v2 签发15分钟 presigned PUT,Agent 直传并保留源文件;授权真实测试已通过 PUT/HEAD 大小与 SHA-256 metadata、SQLite durable grant/completion、`recording.ready` outbox、显式过期后重新申请及重复请求/完成幂等;重建 ECS 上还完成了 mTLS gRPC→OSS 内网 endpoint 的 real-call WAV 实际上传。见 `docs/evidence/20260920-local-oss-mq-integration.md`。 +- **W12 本地/OSS outbox 部分**:RabbitMQ/Dispatcher integration、outbox/replay、事件 Schema、业务日志以及 verified recording→SQLite `recording.ready` outbox 测试通过;Dispatcher 的 `ReportExecutionEvent`、`RequestUpload`、`CompleteUpload` 已统一注册在同一个 AgentControl gRPC listener,并覆盖 durable fact、digest 幂等/冲突和 Dispatcher-owned aggregate version;见 `docs/evidence/20260920-dispatcher-unified-grpc.md`。生产 broker 的 SaaS application receipt 按本阶段契约/fixture 状态机签收,真实 receipt 延期第二阶段。 +- **W13-a**:Debian 13/systemd package、非 root 目录/权限、capture-first 入口、SIP/RTP PCAP、PJSIP logger、结构化 SIP summary 和每日 trunk×手机号 3 次 fail-closed 门禁已安装并验证;新 ECS `i-2zeac4n2cpgkqgikkqg1` 已重新完成非生产 `--preflight-only`,最终制品 `491feb9a052fdd3ad50987d64fbe59fd8e02f9c42e3e74fbe794658baff6df71` 重启后 Agent/Dispatcher 仍 active。见 `docs/evidence/20260920-sip-attempt-guard.md`、`docs/evidence/20260920-new-ecs-preflight.md`。 +- **真实 provider-third 单节点**:原始号码 `15003164745` 完成约49秒三轮 AI、PCMA/8000 RTP、3段入站+4段出站录音和双方文本事实。见 `docs/evidence/20260920-real-provider-third-capture-first.json`。 +- **真实失败证据**:provider-primary 为 `100/183/486 Busy Here`,provider-second 为 `100/404`,均有完整 capture-first PCAP;不是无证据重试。见对应 provider evidence JSON。 +- **本阶段本地签收**:`docs/evidence/20260920-local-p1-acceptance.md` 已覆盖 W13/W14 适用的单节点/单 Cell/单租户构建、故障语义、契约、AI/媒体、OSS、MQ 隔离和时间门禁检查。 + +## 第二阶段或明确不适用事项 + +- **W04/G0 外部部分**:真实外部权威、生产预算/角色签收和正式依赖属于第二阶段;项目内版本化契约、Schema、正反例 fixture 和隔离 PoC 已按本阶段签收。 +- **W11/W12 真实联调**:真实阿里 OSS handoff、SaaS verified、生产 broker ACL/TLS 和生产 application receipt 延期第二阶段;本阶段按契约/fixture、confirm/outbox 和状态机签收。 +- **W13-b/W14 扩展部分**:第二 Cell/第二 Asterisk、双节点、完整三家真实供应商、生产 MQ/OSS、容量/N+1 和双 AI 生产联调不在本阶段;本阶段只签收本地/隔离单 Cell 集成。 +- **W15**:生产切换、唯一写入权交接、生产备份/回滚和未知执行回迁延期第二阶段;本阶段只签收本地恢复/回滚规则。当前会话不再次发起真实外呼。 +- **W16**:双租户公平、第二 Cell 汇总和真实 broker 背压/DLQ不在本轮开发或验收范围,不作为 P1 阻塞。 + +## 安全与额度状态 + +- SIP 外呼仅允许 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含);窗口外 Dispatcher/Agent fail-closed,不等待、自动延迟、重试或换线。每条 SIP trunk × 每个原始手机号每天最多 3 次;失败线路可在另一条线路的独立额度内,经当前会话确认后测试。 +- provider-second/`15003164745` 已达到/超过当天额度,运行时门禁已锁定;不再拨打。 +- 任何下一次真实呼叫都必须在允许时段内重新确认精确 trunk、原始号码和 capture-first 方案;当前不执行新的真实外呼。 diff --git a/docs/evidence/20260920-dispatcher-unified-grpc.md b/docs/evidence/20260920-dispatcher-unified-grpc.md new file mode 100644 index 0000000..2575b89 --- /dev/null +++ b/docs/evidence/20260920-dispatcher-unified-grpc.md @@ -0,0 +1,27 @@ +# Dispatcher unified AgentControl gRPC listener + +- **范围**:W12 / R11–R13 本地闭环;仅 `mock`/隔离 SQLite,不代表生产 SaaS、broker 或真实外呼验收。 +- **架构决定**:不再为 OSS upload 单独开 `DispatcherUploadListen`。`DISPATCHER_GRPC_LISTEN` 是 Dispatcher-side `AgentControlService` 的统一 gRPC listener;`ReportExecutionEvent`、`RequestUpload`、`CompleteUpload` 在同一服务注册。`DISPATCHER_CONTROL_LISTEN` 仍是 HTTP 控制面,不与 gRPC 混用。 +- **安全边界**:Dispatcher listener 要求 mTLS、配置的 Agent certificate fingerprint allowlist 和 `DISPATCHER_ALLOWED_AGENT_IDS`;Agent 不获得 OSS AK/SK。 +- **R11 持久性**:Agent fact 先进入 Dispatcher SQLite `execution_facts`;有外部事件的 fact 与 authoritative MQ outbox 在同一事务写入。相同 `fact_id`/digest 幂等,不同 digest 冲突拒绝;aggregate version 由 Dispatcher 在事务内按 durable aggregate state 分配,不接受 Agent payload 中的版本。 +- **R12/R13**:上传授权、OSS PUT/HEAD 校验、completion 与 `recording.ready` outbox 继续复用同一 Dispatcher gRPC listener;文件字节仍由 Agent 直传 OSS。 + +## 验证 + +在项目根目录执行: + +```text +gofmt -w internal/rpc/dispatcher_server.go internal/rpc/dispatcher_events.go internal/rpc/dispatcher_events_test.go internal/store/facts.go +go test -race ./... +go vet ./... +go mod verify +go build ./... +bash scripts/check-contracts.sh +bash scripts/check-proto.sh +``` + +结果:全部通过。专项测试 `TestDispatcherServerReportsFactAndEmitsOneAuthoritativeEvent` 覆盖事实持久化、单次 outbox、重复事实、digest 冲突及 Dispatcher-owned aggregate version。 + +## 未完成 + +当前候选代码尚未重新部署到 ECS;生产 broker ACL/TLS/application receipt、SaaS verified receipt、双 Cell/W14 和 W15 仍按主计划保持未验收/blocked。旧服务环境需要在下一次受控部署时改用 `DISPATCHER_GRPC_LISTEN`、`DISPATCHER_GRPC_ENDPOINT` 和 `DISPATCHER_ALLOWED_AGENT_IDS`,不得在未授权时远程重启或切换。 diff --git a/docs/evidence/20260920-local-oss-mq-integration.md b/docs/evidence/20260920-local-oss-mq-integration.md new file mode 100644 index 0000000..909043a --- /dev/null +++ b/docs/evidence/20260920-local-oss-mq-integration.md @@ -0,0 +1,25 @@ +# 2026-09-20 OSS/MQ 联合验证 + +## Alibaba OSS 实际验证 + +- 运行方式:`AGENT_CALL_OSS_INTEGRATION=1 go test ./internal/oss ./internal/rpc -run 'TestAlibabaOSS(GrantPutHead|DispatcherUploadDurable)Integration' -count=1 -v`。 +- 凭据来源:本机 `aliyun-oss.env`,文件权限 `0600`;测试通过环境变量注入,未写入源码、日志或证据。 +- 数据面:Dispatcher 使用 Alibaba 官方 OSS Go SDK v2 `v1.6.0` 签发短期 presigned PUT;Agent 使用现有 `UploadClient` 直传,不持有 AK/SK。 +- 结果:`TestAlibabaOSSGrantPutHeadIntegration` 通过,实际完成 presigned PUT、对象 HEAD、大小和 `x-oss-meta-sha256` 校验。 +- 结果:`TestAlibabaOSSDispatcherUploadDurableIntegration` 通过,实际完成 `RequestUpload`、15 分钟 grant、直传、`CompleteUpload`、SQLite durable grant/completion、`recording.ready` outbox 持久化及重复请求/完成幂等。 +- 测试使用北京公网 OSS endpoint;生产示例仍配置北京内网 endpoint,需在目标 ECS/VPC 继续做网络路径验证。 +- 测试对象按集成测试前缀保留;当前 OSS 账号/接口只提供上传能力,本验证不执行删除。 +- Agent 不自动续期或重试;过期/失败时保留源文件。需要重试时由调用方显式重新 `RequestUpload` 获取新 token。 +- 另以本机既有 real-call WAV artifact(`20260918T143854Z-ai-01-three-turn-7d569c4dea`,978284 bytes,SHA-256 `f0530c65d462b830ceb654dbcba6c8797c1693e0d6ba94d2e09ec8606670e61a`)作为源文件,通过重建 ECS 上的 Dispatcher mTLS gRPC 和北京 OSS 内网 endpoint 完成实际上传;旧测试实例 `i-2zee6km2titr7ydty8a7` 的结果为 `upload-oss-v2-f0530c65d462b830ceb654db`,新当前实例 `i-2zeac4n2cpgkqgikkqg1` 的结果为 `upload-oss-new-f0530c65d462b830ceb654db`、`oss://rogee-test/agent-call/recordings/170b696e862b53cc69fa1130f59764e1ac47b273ee107204187763ae746acf48`;bytes/checksum 与源文件一致。该文件不是 2026-09-20 provider-third 远端录音,因此不冒充该次业务闭环。 + +## 本地 MQ 验证 + +- `bash scripts/mq-integration-local.sh`:通过。 +- RabbitMQ image:`rabbitmq:4.1-management-alpine`。 +- image ID:`sha256:fcc273cebb0880ec25845c9bfd97687122ac9cc391538f053cb5873f4181f35f`。 +- `go test -tags=integration ./internal/mq ./internal/dispatcher`:通过。 +- 临时 broker 使用随机端口,测试结束后由脚本清理。 + +## 边界 + +这证明了 Alibaba OSS grant、Agent 直传、HEAD 验证、SQLite durable upload state、`recording.ready` outbox 和幂等闭环。它仍不是 SaaS `verified` 应用收讫、生产 broker ACL/TLS、真实 provider-third 录音对象上传或 W14/W15 生产通过;Dispatcher 的 outbox confirm 也不等于 SaaS application receipt。 diff --git a/docs/evidence/20260920-local-p1-acceptance.md b/docs/evidence/20260920-local-p1-acceptance.md new file mode 100644 index 0000000..be82f3c --- /dev/null +++ b/docs/evidence/20260920-local-p1-acceptance.md @@ -0,0 +1,38 @@ +# 2026-09-20 单节点 P1 本地验收 + +## 适用范围 + +本证据按 `docs/evidence/20260920-scope-amendment.md` 执行:1 个 Dispatcher、1 个 Agent、1 个 Asterisk/Cell、1 个启用租户。双节点、第二 Cell、双租户、生产 SaaS/MQ 联调和生产切换不在本轮。 + +## 已执行检查 + +在 `go-sip/` 根目录执行并通过: + +```text +go test -race ./... +go vet ./... +go mod verify +go build ./... +bash scripts/check-contracts.sh +bash scripts/check-proto.sh +bash scripts/acceptance-local.sh +``` + +`acceptance-local.sh` 还验证了 mock Agent/Dispatcher 启动、real Dispatcher 缺少 broker 凭据时拒绝启动和项目内契约包完整性。 + +## 本轮覆盖 + +- 单 Cell SQLite 任务、配额、inbox/outbox、幂等、控制 CAS、未知执行保留和恢复。 +- mTLS/SAN/fingerprint/Agent allowlist、会话代次、静态制品和错误节点绑定拒绝。 +- PCMA/PCM16、RTP/ARI/录音、共享 CallFlow、ASR-only 和完整 AI 的本地/协议隔离路径;`runCallOnce` 按快照模式校验,ASR-only 的 ProviderPipeline 只调用 ASR,CallFlow 不执行开场/回复 TTS 或发送下行音频。`provider_pipeline_test.go` 验证 ProviderPipeline 构造和环境加载不要求 Bailian LLM/TTS,并在无 Bailian 配置时完成 ASR-only turn。 +- OSS grant、Agent 直传、HEAD 大小/SHA-256 校验、显式重新申请、durable completion 和 `recording.ready` outbox。 +- Dispatcher 统一 AgentControl gRPC 的事实去重、digest 冲突、权威 aggregate version 和上传 RPC。 +- SIP 外呼安全时间门禁:Asia/Shanghai `[09:00,20:00)`;覆盖 08:59:59、09:00:00、19:59:59、20:00:00 及 UTC 转换,窗口外 real/mixed permit/execute 和 `--call-once` 均 fail-closed。 +- 单 Cell 故障语义:SQLite/outbox 重启恢复、RPC unknown 不重拨、TLS 轮换/未授权证书拒绝、配额/许可屏障、OSS 失败保留源文件和本地健康 unknown。 + +## 明确未执行 + +- 真实 ECS、真实 SIP 外呼、供应商消费、生产 SaaS/MQ ACL/TLS/application receipt。 +- 双节点、第二 Cell/第二 Asterisk、第二租户公平调度、多 Dispatcher、容量/N+1 和生产切换。 + +上述未执行项是第二阶段/后续专项,不得写成生产通过;本证据只签收当前单节点本地范围。 diff --git a/docs/evidence/20260920-new-ecs-preflight.md b/docs/evidence/20260920-new-ecs-preflight.md new file mode 100644 index 0000000..d879291 --- /dev/null +++ b/docs/evidence/20260920-new-ecs-preflight.md @@ -0,0 +1,11 @@ +# 2026-09-20 新 ECS 非生产 capture-first preflight + +- ECS:`i-2zeac4n2cpgkqgikkqg1`,EIP `123.56.71.98`,Debian 13,Asterisk `22.10.1`。 +- 服务:`asterisk.service`、`sip-go-agent-dispatcher.service`、`sip-go-agent-agent.service` 均 active;Dispatcher upload gRPC 监听 `127.0.0.1:19443`,Agent gRPC 监听 `127.0.0.1:18443`。 +- 最终发布制品已重新安装并重启验证:`sip-go-agent` SHA-256 `491feb9a052fdd3ad50987d64fbe59fd8e02f9c42e3e74fbe794658baff6df71`。 +- 线路预检:`provider-third`,原始目标 `15003164745`,`eth0`,SIP `5060`,RTP `10000–10800`。 +- 执行命令使用 `--preflight-only`,未发起外呼,未消耗每日 trunk×号码尝试额度。 +- 结果:`call_exit=0`、`capture_status=0`、`capture_packets=0`、`preflight_only=1`;PJSIP logger、tcpdump、状态快照、结构化 `sip-summary.json`、录音哈希入口均完成,fail-closed 机制可用。 +- pcap SHA-256:`704e5e5b3234433c01fcfd1b20a306e77e985038120492dc53965c3edd38a4ea`。 + +这不是真实外呼通过证据;当前会话明确不执行真实外呼。真实 trunk/号码/抓包方案仍需单独重新确认。 diff --git a/docs/evidence/20260920-nonprod-capture-preflight-v6.json b/docs/evidence/20260920-nonprod-capture-preflight-v6.json new file mode 100644 index 0000000..7499a66 --- /dev/null +++ b/docs/evidence/20260920-nonprod-capture-preflight-v6.json @@ -0,0 +1,37 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T06:19:12Z", + "environment": "development", + "purpose": "No-call validation of the mandatory capture-first entrypoint after adding packet drain and structured SIP summary.", + "host": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "interface": "eth0", + "tcpdump_filter": "udp port 5060 or udp portrange 10000-10800" + }, + "entrypoint": { + "path": "deploys/cell/nonprod-call-evidence.sh", + "remote_path": "/usr/local/sbin/agent-call-nonprod-evidence", + "call_id": "preflight-20260920-v6", + "mode": "--preflight-only", + "exit_code": 0, + "pjsip_logger_on_off": true, + "asterisk_state_snapshot": true, + "structured_sip_summary": true, + "capture_drain_seconds": 2, + "fail_closed_for_zero_packets_on_real_call": true + }, + "capture": { + "pcap_bytes": 24, + "pcap_sha256": "704e5e5b3234433c01fcfd1b20a306e77e985038120492dc53965c3edd38a4ea", + "packets_captured": 0, + "note": "No call was issued, so zero SIP/RTP packets is expected in preflight-only mode; a real call with zero captured packets remains a failure." + }, + "assertions": { + "no_real_call": true, + "no_provider_consumption": true, + "entrypoint_ready_for_next_freshly_confirmed_call": true, + "real_p1_acceptance": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/preflight-20260920-v6" +} diff --git a/docs/evidence/20260920-nonprod-capture-preflight-v7-package.json b/docs/evidence/20260920-nonprod-capture-preflight-v7-package.json new file mode 100644 index 0000000..8b538d4 --- /dev/null +++ b/docs/evidence/20260920-nonprod-capture-preflight-v7-package.json @@ -0,0 +1,42 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T06:39:10Z", + "environment": "development", + "purpose": "Package-installed no-call validation of the mandatory capture-first entrypoint.", + "host": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "os": "Debian 13", + "asterisk_active": true, + "interface": "eth0" + }, + "package": { + "archive": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "sha256": "1f51c4393ea91a0495e560d2662b7c7ef5df79eaef036477ffec6acec9da85d0", + "manifest_mode": "dirty/non-production; installed with --allow-nonproduction", + "capture_script_sha256": "bd4a8d3923a420fc5044f426d8eedd99e30f2ffff7b56da014afb17420c67114" + }, + "entrypoint": { + "remote_path": "/usr/local/sbin/agent-call-nonprod-evidence", + "call_id": "preflight-20260920-v7-package", + "mode": "--preflight-only", + "exit_code": 0, + "pjsip_logger_on_off": true, + "structured_sip_summary": true, + "sudoers_rule": "rogee may run only the capture entrypoint without a password", + "agent_services_enabled": true + }, + "capture": { + "filter": "udp port 5060 or udp portrange 10000-10800", + "packets_captured": 0, + "note": "No call was issued; zero packets is expected for preflight-only and is not acceptable for a real-call result." + }, + "acceptance": { + "package_install_verified": true, + "nonproduction_gate_verified": true, + "real_sip_call": false, + "p1_w14_pass": false, + "production_release": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/preflight-20260920-v7-package" +} diff --git a/docs/evidence/20260920-nonprod-capture-preflight-v8-parser.json b/docs/evidence/20260920-nonprod-capture-preflight-v8-parser.json new file mode 100644 index 0000000..2958d7e --- /dev/null +++ b/docs/evidence/20260920-nonprod-capture-preflight-v8-parser.json @@ -0,0 +1,33 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T06:50:46Z", + "environment": "development", + "purpose": "No-call validation after correcting structured SIP summary to distinguish INVITE responses from post-call OPTIONS health checks.", + "package": { + "archive_sha256": "a83c30d861ed453ad2eee94d0d067cbae16e9354de59e7b1d473d2ae0c72b0ff", + "capture_entrypoint_sha256": "3a2c3809fafb5138a9327f4e12a52a5c89daf11cc408add16998ea599697b7e4" + }, + "host": { + "instance_id": "i-2zeew9pswry8sr33095l", + "interface": "eth0", + "asterisk_active": true + }, + "preflight": { + "call_id": "preflight-20260920-v8-parser", + "mode": "--preflight-only", + "exit_code": 0, + "pjsip_logger_on_off": true, + "sip_summary_generated": true, + "options_health_responses_tagged_as_OPTIONS": true, + "invite_responses_empty_without_call": true, + "final_invite_response": null, + "stasis_start_seen": false, + "real_call": false + }, + "assertions": { + "parser_no_longer_promotes_OPTIONS_200_to_INVITE_final": true, + "zero_packets_accepted_only_in_preflight_only": true, + "real_p1_acceptance": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/preflight-20260920-v8-parser" +} diff --git a/docs/evidence/20260920-nonprod-capture-preflight.json b/docs/evidence/20260920-nonprod-capture-preflight.json new file mode 100644 index 0000000..24ee21b --- /dev/null +++ b/docs/evidence/20260920-nonprod-capture-preflight.json @@ -0,0 +1,40 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T06:07:22Z", + "environment": "development", + "purpose": "Preflight for mandatory capture-first non-production real/mixed call entrypoint; no outbound call was made.", + "host": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "tcpdump": "/usr/bin/tcpdump", + "capture_interface": "any", + "capture_filter": "udp port 5060 or udp portrange 10000-10800", + "pcap_sha256": "e3f42e2687636327d7f18c9635173252505b4a838fa6829a755bab06c9c69749", + "pcap_bytes": 24 + }, + "preflight": { + "entrypoint": "deploys/cell/nonprod-call-evidence.sh", + "call_id": "preflight-20260920-v2", + "run_command": "/bin/true", + "entrypoint_exit": 0, + "tcpdump_raw_socket_opened": true, + "packets_captured": 0, + "packets_received_by_filter_during_preflight": 11, + "pjsip_logger_enabled_and_disabled": true, + "asterisk_active": true, + "asterisk_enabled": true, + "pjsip_endpoint_snapshot": true, + "pjsip_contacts_before_after": true, + "ari_snapshot": "available through installed Asterisk/Agent configuration; no call was issued" + }, + "assertions": { + "capture_before_call_is_ready": true, + "missing_capture_must_fail_closed": true, + "real_call_verified": false + }, + "evidence_location": "/var/lib/sip-go-agent/evidence/preflight-20260920-v2", + "limitations": [ + "This preflight intentionally used /bin/true and contains no provider SIP/RTP transaction.", + "The next real attempt still requires fresh confirmation naming trunk, raw target and capture plan." + ] +} diff --git a/docs/evidence/20260920-offline-oss-profile.md b/docs/evidence/20260920-offline-oss-profile.md new file mode 100644 index 0000000..1362975 --- /dev/null +++ b/docs/evidence/20260920-offline-oss-profile.md @@ -0,0 +1,7 @@ +# 2026-09-20 非 ECS OSS 测试配置 + +- 离线/非 ECS OSS 测试使用 `cn-beijing` 的公网 Endpoint:`oss-cn-beijing.aliyuncs.com`。 +- 可复用示例:`deploys/env/dispatcher.offline-oss.env.example`。 +- 生产 ECS 示例 `deploys/env/dispatcher.env.example` 继续使用受控内网 Endpoint,不与离线配置混用。 +- AK/SK 仅通过受控运行时文件注入;本证据不保存凭据。 +- 普通离线 OSS 测试不创建、释放或清理 ECS;ECS 仅在另行授权的真实外呼阶段使用。 diff --git a/docs/evidence/20260920-pcma-3turn-mixed-callflow.json b/docs/evidence/20260920-pcma-3turn-mixed-callflow.json new file mode 100644 index 0000000..c574afb --- /dev/null +++ b/docs/evidence/20260920-pcma-3turn-mixed-callflow.json @@ -0,0 +1,67 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T05:26:48Z", + "environment": "isolated-sip-mock", + "synthetic_only": true, + "purpose": "Verify three-turn shared CallFlow, per-turn recording, and both-side recognition/reply facts before real-line retry.", + "candidate_package": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "candidate_sha256": "b189e2cf2d51417fcb5b56a190b1f93ad8fa5e33b6e3f2eabdafb9ac66eaa10a", + "artifact": { + "artifact_id": "artifact-cell-a-pcma-3turn-local", + "mode": "mixed", + "trunk_id": "trunk-mock-pcma", + "media_profile_id": "pcma-8k-pt8", + "wire_format": "alaw", + "sample_rate_hz": 8000, + "payload_type": 8, + "canonical_ai_format": "pcm16", + "canonical_ai_sample_rate_hz": 16000 + }, + "conversation_policy": { + "hospital": "美吧口腔", + "max_turns": 3, + "max_call_duration_seconds": 120, + "invalid_call_marker": "[INVALID_CALL]" + }, + "result": { + "status": "completed", + "channel_id": "01m2ymfn30ckth48pb8h0fs0b6-ch", + "turns_completed": 3, + "rtp": { + "received_packets": 282, + "received_bytes": 45120, + "sent_packets": 40, + "sent_bytes": 6400 + }, + "turn_facts": [ + {"turn": 1, "transcript": "mock transcript e5cc7a38", "reply": "mock response: mock transcript e5cc7a38"}, + {"turn": 2, "transcript": "mock transcript d64ad05c", "reply": "mock response: mock transcript d64ad05c"}, + {"turn": 3, "transcript": "mock transcript 9f5d98c5", "reply": "mock response: mock transcript 9f5d98c5"} + ], + "recordings": { + "inbound": [ + {"segment": "inbound_turn_01", "bytes": 50560, "sha256": "ff4e585e6a232babc0f70e5a91b93eff3b0d0cfe0bf108162c8c929b787f2c82"}, + {"segment": "inbound_turn_02", "bytes": 51200, "sha256": "ebe602c892df71ae89320f4a28c0f4cec9b69608762c49239e585d86e1598711"}, + {"segment": "inbound_turn_03", "bytes": 58880, "sha256": "4dd88f06fafb68789864ea314639af0d6c19d032dfa853b2cb56cc03f1ae1d3d"} + ], + "outbound": [ + {"segment": "outbound_segment_00_opening", "bytes": 6400, "sha256": "cd8154019683c787b6adbf0c86234e9da18e610f695c1cfbc4b26e2504b00b7b"}, + {"segment": "outbound_segment_01", "bytes": 6400, "sha256": "cd8154019683c787b6adbf0c86234e9da18e610f695c1cfbc4b26e2504b00b7b"}, + {"segment": "outbound_segment_02", "bytes": 6400, "sha256": "cd8154019683c787b6adbf0c86234e9da18e610f695c1cfbc4b26e2504b00b7b"}, + {"segment": "outbound_segment_03", "bytes": 6400, "sha256": "cd8154019683c787b6adbf0c86234e9da18e610f695c1cfbc4b26e2504b00b7b"} + ] + } + }, + "assertions": { + "three_turns_completed": true, + "both_side_text_facts_present": true, + "per_turn_recordings_present": true, + "pcma_bidirectional_rtp": true, + "invalid_call_not_triggered": true, + "real_provider_verified": false + }, + "limitations": [ + "All transcripts/replies and audio are synthetic mock data.", + "This evidence does not prove three-turn real-phone ASR/LLM/TTS, OSS, MQ, or billing behavior." + ] +} diff --git a/docs/evidence/20260920-pcma-mixed-callflow.json b/docs/evidence/20260920-pcma-mixed-callflow.json new file mode 100644 index 0000000..489e3a0 --- /dev/null +++ b/docs/evidence/20260920-pcma-mixed-callflow.json @@ -0,0 +1,92 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T03:24:29Z", + "environment": "mock", + "synthetic_only": true, + "purpose": "Verify the PCMA/A-law trunk media profile through the Go call-once ARI flow.", + "contract": "contracts/upstream/2026-09-19-p1-v1", + "candidate_package": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "candidate_sha256": "bc106f733e26354221c18c7bb593eca84a4267dec3156f80311eb30f630e7b84", + "test_setup": { + "stack": "isolated sip_mock_server Docker network", + "fixtures": "temporary SIPMOCK_EVIDENCE_DIR with synthetic U1-U3 WAV fixtures", + "ari_cors_origin": "http://localhost/" + }, + "artifact": { + "artifact_id": "artifact-cell-a-pcma-local", + "cell_id": "cell-a", + "mode": "mixed", + "trunk_id": "trunk-mock-pcma", + "sip_endpoint_ref": "mock-callee", + "media_profile_id": "pcma-8k-pt8", + "wire_format": "alaw", + "codec": "PCMA", + "sample_rate_hz": 8000, + "payload_type": 8, + "canonical_ai_format": "pcm16", + "canonical_ai_sample_rate_hz": 16000 + }, + "sip_ari": { + "target": "15003164745", + "synthetic_target": true, + "answered": true, + "sdp_offer": "PCMA/8000 payload type 8", + "external_media": "Asterisk UnicastRTP format alaw", + "callee_events": [ + "media_active", + "user_playback_started:U1", + "user_playback_finished:U1", + "call_disconnected" + ] + }, + "agent_result": { + "status": "completed", + "channel_id": "01m2ydfxwtcj815mcfwh11ycr5-ch", + "transcript_chars": 24, + "reply_chars": 39, + "rtp": { + "received_packets": 110, + "received_bytes": 17600, + "sent_packets": 20, + "sent_bytes": 3200 + }, + "canonical_recordings": { + "inbound_pcm16_wav": { + "bytes": 50560, + "sha256": "ff4e585e6a232babc0f70e5a91b93eff3b0d0cfe0bf108162c8c929b787f2c82" + }, + "outbound_pcm16_wav": { + "bytes": 6400, + "sha256": "cd8154019683c787b6adbf0c86234e9da18e610f695c1cfbc4b26e2504b00b7b" + } + } + }, + "callee_recordings": { + "rx_wav_pcm16_8khz": { + "bytes": 65324, + "sha256": "2e11bb5a1b41c4a77a04bb866cafc8e0b2b2e6d0195b8388823bf1ce6a456296", + "max_abs_sample": 6016, + "rms": 1865.33 + }, + "tx_wav_pcm16_8khz": { + "bytes": 16364, + "sha256": "74c6a765bcf40cbdedb102ea9e9a4c0e53a0378e0611cece4721b70c4cb09dad", + "max_abs_sample": 6389, + "rms": 2586.77 + } + }, + "assertions": { + "ari_answered": true, + "pcma_sdp_negotiated": true, + "external_media_used_alaw": true, + "rtp_bidirectional": true, + "callee_rx_non_silent": true, + "callee_tx_non_silent": true, + "agent_callflow_completed": true, + "real_provider_validated": false + }, + "limitations": [ + "This is an isolated synthetic mock run and is not real-provider or production acceptance.", + "The Go mock pipeline emits a deterministic non-silent fixture tone so the strict mock callee can advance its scripted dialogue." + ] +} diff --git a/docs/evidence/20260920-real-cloud-inventory.md b/docs/evidence/20260920-real-cloud-inventory.md new file mode 100644 index 0000000..0141f4b --- /dev/null +++ b/docs/evidence/20260920-real-cloud-inventory.md @@ -0,0 +1,31 @@ +# 2026-09-20 Alibaba Cloud read-only inventory + +## Scope + +This is a read-only recheck before any real SIP deployment. It does not +authorize rebinding an EIP, adopting an existing instance, creating an ECS, or +placing a call. + +- Region: `cn-beijing` +- Required fixed EIP: `123.56.71.98` +- Required project tag: `project=agent-call` + +## Observed inventory + +- The fixed EIP allocation `eip-2zeevfsaxzwuue2szy7xb` exists and is + `Available` with no attached instance. +- The account currently returns one running instance, `ali.bj.01`, in + `cn-beijing-g`: Rocky Linux 9.3, `ecs.e-c1m1.large`, VPC + `vpc-2zeln7cr1x6biyr9o8l4v`, security group `sg-2ze8kw8l5egptn3vqml9`, and + public IP `39.105.111.158`. It is not Debian 13, does not use the required + fixed EIP, and has no ECS tags (`DescribeTags` returned `TotalCount: 0`). +- The instance was not adopted, modified, stopped, deleted, or used for SIP + validation. Its ownership and authorization are not established. + +## Gate result + +The fixed EIP remains unbound and the only running instance is an unrelated or +otherwise unauthorized candidate. Per the deployment rules, no rebinding, +replacement public IP, new ECS, SSH attempt, or real call was performed. A +project-tagged Debian 13 test host and an explicitly authorized EIP association +are still required before deploying the candidate package. diff --git a/docs/evidence/20260920-real-provider-primary-capture-first.json b/docs/evidence/20260920-real-provider-primary-capture-first.json new file mode 100644 index 0000000..0dbdd95 --- /dev/null +++ b/docs/evidence/20260920-real-provider-primary-capture-first.json @@ -0,0 +1,93 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T07:22:59Z", + "environment": "development", + "mode": "real", + "purpose": "One freshly confirmed provider-primary capture-first call; provider returned Busy Here before answer.", + "authorization": { + "trunk_id": "provider-primary", + "sip_server": "61.132.228.221:5060", + "route_prefix": "7089", + "caller_id": "BD93205882", + "raw_target": "15003164745", + "fresh_confirmation": true, + "automatic_retry": false, + "route_switch": false + }, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "capture_interface": "eth0", + "capture_filter": "udp port 5060 or udp portrange 10000-10800", + "package_sha256": "a83c30d861ed453ad2eee94d0d067cbae16e9354de59e7b1d473d2ae0c72b0ff", + "capture_entrypoint_sha256": "3a2c3809fafb5138a9327f4e12a52a5c89daf11cc408add16998ea599697b7e4" + }, + "call": { + "started_at": "2026-09-20T07:22:58Z", + "ended_at": "2026-09-20T07:22:59Z", + "duration_seconds": 1, + "agent_exit": 1, + "error": "channel ended before StasisStart: ChannelHangupRequest cause=17 soft=false state=Down", + "stasis_start": false, + "ai_turns": 0, + "asr": "not entered", + "llm": "not entered", + "tts": "not entered", + "rx_rtp_packets": 0, + "tx_rtp_packets": 0, + "recordings": [] + }, + "sip": { + "invite_uri": "sip:708915003164745@61.132.228.221:5060", + "response_timeline": [ + {"time": "15:22:58+08:00", "direction": "outbound", "event": "INVITE", "cseq": "5351"}, + {"time": "15:22:58+08:00", "direction": "inbound", "event": "100 trying -- your call is important to us"}, + {"time": "15:22:58+08:00", "direction": "inbound", "event": "183 Session Progress"}, + {"time": "15:22:59+08:00", "direction": "inbound", "event": "486 Busy Here"}, + {"time": "15:22:59+08:00", "direction": "outbound", "event": "ACK"} + ], + "final_invite_response": "486 Busy Here", + "reason_header": "Q.850;cause=16;text=\"NORMAL_CLEARING\"", + "q850": 16, + "asterisk_hangup_cause": 17, + "bye_seen": false, + "cancel_seen": false, + "sdp": { + "offer_present": true, + "local_audio_port": 10186, + "remote_audio_port": 16332, + "codec": "PCMA/8000", + "payload_type": 8, + "telephone_event": "101/8000", + "ptime": 20, + "direction": "sendrecv" + }, + "interpretation": "The provider accepted the INVITE transaction and returned provisional progress, then rejected the call with 486 Busy Here before answer; OPTIONS 200 OK is only reachability evidence." + }, + "capture": { + "pcap_path": "/var/lib/sip-go-agent/evidence/real-provider-primary-15003164745-capture-20260920/capture.pcap", + "pcap_bytes": 5390, + "pcap_sha256": "59583f8d39de1d7d970f4b6a9dcab08e6a365e5a87efab5c2503c6db9d2ca887", + "packets_captured": 7, + "packets_received_by_filter": 7, + "packets_dropped_by_kernel": 0 + }, + "diagnostic_hashes": { + "asterisk_journal_sha256": "cc9d283853be5556f25fca15d57dc406d7fe74f42cc1efd6c60c7e38e4fc1669", + "sip_summary_sha256": "b124f3b17cf3eccbb56892f06f97ba4e378fb903b65093861953328710b1471e", + "call_output_private_sha256": "cccf024309af473dc56c18ce83e922fe58ad893d89a314d7423010642c6817ca", + "metadata_sha256": "23d1403d2e7bc0fe4490450b0185b70bbe3dd9a83e2fc87dc51c0d7a6f844654", + "recordings_manifest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "acceptance": { + "capture_first_gate": true, + "complete_sip_failure_evidence": true, + "provider_primary_sip_answer": false, + "real_three_turn_ai_call": false, + "recording_and_two_sided_transcript": false, + "w14_complete": false, + "production_release": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/real-provider-primary-15003164745-capture-20260920", + "next_gate": "Do not retry this provider-primary/target combination without a new current-session confirmation; provider-side Busy Here/route policy must be explained before any further real call." +} diff --git a/docs/evidence/20260920-real-provider-second-15003164745.json b/docs/evidence/20260920-real-provider-second-15003164745.json new file mode 100644 index 0000000..f9c35c7 --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-15003164745.json @@ -0,0 +1,65 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T12:34:10+08:00", + "environment": "authorized-real-ecs", + "status": "answered_media_ai_incomplete", + "fresh_confirmation": true, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "package": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "package_sha256": "bc106f733e26354221c18c7bb593eca84a4267dec3156f80311eb30f630e7b84", + "agent_binary_sha256": "0fa768c7156de85fc50bb0b126b5fe842777c50abd7d5ea77c1cfd1e594e7b04" + }, + "route": { + "trunk_id": "provider-second", + "sip_endpoint": "60.171.24.90:5060", + "dial_prefix": "", + "caller_id": "mbkq", + "target": "15003164745", + "media_profile_id": "pcma-8k-pt8", + "wire_format": "alaw", + "sample_rate_hz": 8000, + "payload_type": 8 + }, + "ari_sip": { + "progress": true, + "answered": true, + "stasis_start": true, + "bridge_created": true, + "external_media_answered": true, + "channel_id": "01m2yhf6hyd423xskfbk9r9b0v-ch", + "asterisk_observation": "PJSIP/provider-second answered; PJSIP and UnicastRTP channels joined the agent-call bridge" + }, + "media": { + "received_packets": 220, + "received_bytes": 35200, + "sent_packets": 136, + "sent_bytes": 21760, + "recording_files": 0, + "raw_pcap_retained": false + }, + "operator_observation": "Operator reported that the callee heard one LLM sentence before the call ended; the machine-side result for this attempt returned an empty ASR transcript and did not produce retained recording files.", + "ai": { + "mode": "real", + "asr": "empty_transcript", + "llm_reached": false, + "tts_reached": false, + "application_error": "ASR returned empty transcript", + "provider_chain_smoke_before_call": "passed" + }, + "assertions": { + "whitelist_target_used": true, + "no_automatic_retry": true, + "no_additional_target": true, + "real_sip_answer_verified": true, + "bidirectional_rtp_counters_verified": true, + "complete_real_ai_call_verified": false, + "oss_verified_to_mq_verified": false + }, + "capture_policy": { + "stored": "脱敏 ARI/PJSIP 状态、RTP 计数、错误原因、字节数和哈希事实", + "not_stored": "完整音频、完整转写、完整回复、凭据和原始 PCAP" + }, + "next_gate": "Do not retry without a new confirmation naming the SIP trunk, raw target, and capture plan. Diagnose why the answered provider media produced no non-empty ASR transcript, then separately verify recording/OSS/MQ closure." +} diff --git a/docs/evidence/20260920-real-provider-second-3turn-attempt.json b/docs/evidence/20260920-real-provider-second-3turn-attempt.json new file mode 100644 index 0000000..3d0f147 --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-3turn-attempt.json @@ -0,0 +1,48 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T13:22:25+08:00", + "environment": "authorized-real-ecs", + "status": "pre_stasis_hangup", + "fresh_confirmation": true, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "package": "deploys/packages/sip-go-agent-0.1.0-p1.20260919-linux-amd64.tar.gz", + "package_sha256": "b189e2cf2d51417fcb5b56a190b1f93ad8fa5e33b6e3f2eabdafb9ac66eaa10a", + "ai_snapshot": "deploys/config/ai-dental-meiba-v1.json", + "ai_version_id": "agent_dental_meiba_v1" + }, + "route": { + "trunk_id": "provider-second", + "sip_endpoint": "60.171.24.90:5060", + "caller_id": "mbkq", + "target": "15003164745", + "media_profile_id": "pcma-8k-pt8", + "max_turns": 3, + "max_call_duration_seconds": 120 + }, + "result": { + "ari_application_created": true, + "sip_originate_started": true, + "stasis_start": false, + "hangup_cause": 1, + "rtp_received_packets": 0, + "rtp_sent_packets": 0, + "recording_files": 0, + "turns_completed": 0, + "error": "channel ended before StasisStart" + }, + "diagnosis": { + "previous_attempt_had_ari_origin_mismatch": true, + "ari_origin_configured_before_this_attempt": "http://localhost/", + "origin_mismatch_after_fix": false, + "current_observation": "The provider-second originate ended with cause=1 before StasisStart; no three-turn conversation was possible." + }, + "assertions": { + "no_automatic_retry": true, + "no_additional_target": true, + "three_turn_requirement_verified": false, + "recording_and_both_side_transcript_verified": false + }, + "next_gate": "Do not retry without a new confirmation naming the SIP trunk, raw target, and capture plan. Investigate provider-second cause=1/line availability or choose another explicitly confirmed trunk/target." +} diff --git a/docs/evidence/20260920-real-provider-second-3turn-v3-attempt.json b/docs/evidence/20260920-real-provider-second-3turn-v3-attempt.json new file mode 100644 index 0000000..88744a2 --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-3turn-v3-attempt.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T13:36:23+08:00", + "environment": "authorized-real-ecs", + "status": "pre_stasis_hangup", + "fresh_confirmation": true, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "package_sha256": "b189e2cf2d51417fcb5b56a190b1f93ad8fa5e33b6e3f2eabdafb9ac66eaa10a", + "ai_version_id": "agent_dental_meiba_v1" + }, + "route": { + "trunk_id": "provider-second", + "sip_endpoint": "60.171.24.90:5060", + "caller_id": "mbkq", + "target": "15003164745", + "media_profile_id": "pcma-8k-pt8", + "max_turns": 3, + "max_call_duration_seconds": 120 + }, + "provider_status_before_call": { + "endpoint_state": "Not in use", + "contact_status": "Avail", + "contact_rtt_ms": 21.176 + }, + "result": { + "ari_application_created": true, + "sip_originate_started": true, + "stasis_start": false, + "hangup_cause": 1, + "rtp_received_packets": 0, + "rtp_sent_packets": 0, + "recording_files": 0, + "turns_completed": 0, + "error": "channel ended before StasisStart" + }, + "diagnosis": { + "ari_origin_mismatch": false, + "pjsip_logger": "enabled only for this attempt and then disabled", + "current_observation": "provider-second remained Reachable/Avail but the originate ended with cause=1 before StasisStart; no three-turn conversation was possible." + }, + "assertions": { + "no_automatic_retry": true, + "no_additional_target": true, + "three_turn_requirement_verified": false, + "recording_and_both_side_transcript_verified": false + }, + "next_gate": "Require a new confirmation before another real attempt; do not silently switch trunk or target. Provider/line cause=1 must be resolved or a newly confirmed alternate route must be selected." +} diff --git a/docs/evidence/20260920-real-provider-second-capture-first-v4.json b/docs/evidence/20260920-real-provider-second-capture-first-v4.json new file mode 100644 index 0000000..f63d4f4 --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-capture-first-v4.json @@ -0,0 +1,101 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T06:14:04Z", + "environment": "development", + "mode": "real", + "purpose": "One freshly confirmed capture-first provider-second call; failed before StasisStart and must not be treated as P1 acceptance.", + "authorization": { + "trunk_id": "provider-second", + "target": "15003164745", + "fresh_confirmation": true, + "automatic_retry": false, + "route_switch": false + }, + "host": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "os": "Debian 13", + "asterisk": "22.10.1", + "capture_interface": "eth0", + "capture_filter": "udp port 5060 or udp portrange 10000-10800" + }, + "entrypoint": { + "path": "deploys/cell/nonprod-call-evidence.sh", + "remote_path": "/usr/local/sbin/agent-call-nonprod-evidence", + "capture_first": true, + "pjsip_logger_enabled_before_call": true, + "pre_call_state_snapshot": true, + "post_call_state_snapshot": true, + "recording_output_directory": "/var/lib/sip-go-agent/recordings" + }, + "call": { + "agent_exit": 1, + "error": "channel ended before StasisStart: ChannelHangupRequest cause=1 soft=false state=Down", + "stasis_start": false, + "ai_turns": 0, + "asr": "not entered", + "llm": "not entered", + "tts": "not entered", + "rx_rtp_packets": 0, + "tx_rtp_packets": 0, + "recordings": [], + "oss_verified": false, + "mq_result": false + }, + "sip": { + "request_uri": "sip:15003164745@60.171.24.90:5060", + "from": "sip:mbkq@123.56.71.98", + "to": "sip:15003164745@60.171.24.90", + "responses": ["100 Trying", "404 Not Found"], + "cseq": "21482 INVITE", + "timeline": [ + {"time": "2026-09-20T14:14:04+08:00", "direction": "outbound", "event": "INVITE", "request_uri": "sip:15003164745@60.171.24.90:5060"}, + {"time": "2026-09-20T14:14:04+08:00", "direction": "inbound", "event": "100 Trying"}, + {"time": "2026-09-20T14:14:04+08:00", "direction": "inbound", "event": "404 Not Found"}, + {"time": "2026-09-20T14:14:04+08:00", "direction": "outbound", "event": "ACK"} + ], + "sdp_offer": { + "present": true, + "audio_port": 10248, + "codec": "PCMA/8000", + "payload_type": 8, + "telephone_event": "101/8000", + "ptime": 20, + "direction": "sendrecv" + }, + "ack_sent": true, + "bye_seen": false, + "cancel_seen": false, + "bye_direction": null, + "cancel_direction": null, + "reason_header": null, + "q850": null, + "provider_response": "404 Not Found", + "interpretation": "The provider endpoint received the INVITE and rejected the requested route/number/account mapping before answer; the exact provider-side reason still requires carrier confirmation. OPTIONS reachability is not INVITE acceptance." + }, + "capture": { + "pcap_path": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v4-20260920/capture.pcap", + "pcap_bytes": 24, + "pcap_sha256": "704e5e5b3234433c01fcfd1b20a306e77e985038120492dc53965c3edd38a4ea", + "tcpdump_packets_captured": 0, + "tcpdump_packets_received_by_filter": 4, + "tcpdump_packets_dropped_by_kernel": 0, + "capture_status": 2, + "capture_limitation": "The short 404 transaction left a header-only pcap despite PJSIP logger evidence. The entrypoint was amended with a two-second drain and structured SIP summary; v5/v6/v7 package preflight-only verification passed. A new real call requires fresh confirmation." + }, + "diagnostic_hashes": { + "asterisk_journal_sha256": "520a9bc580e8c1fa2b0deee7c45987a18c5d753d57892e89c503859729355153", + "pjsip_endpoint_sha256": "b97dbd2425807ab28726f58f2c055853d13b9b531851751c82e8fcd01ba86bae", + "call_output_sha256": "88e7c68e6863f4f201933a4c4615e2be391db8460d41c90b3f8660dea4a75721", + "metadata_sha256": "f54633fbafbf21fd41ebce488398959bab64d17a72844202bc879cffa6d54a7f" + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v4-20260920", + "acceptance": { + "capture_first_gate": "attempted", + "real_sip_invite_diagnosis": "captured in PJSIP logger/journal", + "pcap_complete": false, + "real_three_turn_ai_call": false, + "p1_w14_pass": false, + "production_release": false + } +} diff --git a/docs/evidence/20260920-real-provider-second-capture-first-v7-package.json b/docs/evidence/20260920-real-provider-second-capture-first-v7-package.json new file mode 100644 index 0000000..524752f --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-capture-first-v7-package.json @@ -0,0 +1,94 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T14:47:15+08:00", + "environment": "development", + "mode": "real", + "purpose": "One freshly confirmed package-installed capture-first call; complete SIP PCAP obtained, but provider rejected the INVITE before media/AI.", + "authorization": { + "trunk_id": "provider-second", + "target": "15003164745", + "fresh_confirmation": true, + "automatic_retry": false, + "route_switch": false + }, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "package_sha256": "1f51c4393ea91a0495e560d2662b7c7ef5df79eaef036477ffec6acec9da85d0", + "capture_entrypoint_sha256": "bd4a8d3923a420fc5044f426d8eedd99e30f2ffff7b56da014afb17420c67114", + "capture_interface": "eth0", + "capture_filter": "udp port 5060 or udp portrange 10000-10800" + }, + "call": { + "agent_exit": 1, + "error": "channel 01m2ys3b51sye03zms1ydq2jxe-ch ended before StasisStart: ChannelHangupRequest cause=1 soft=false state=Down", + "stasis_start": false, + "ai_turns": 0, + "asr": "not entered", + "llm": "not entered", + "tts": "not entered", + "rx_rtp_packets": 0, + "tx_rtp_packets": 0, + "recordings": [], + "oss_verified": false, + "mq_result": false + }, + "sip": { + "request_uri": "sip:15003164745@60.171.24.90:5060", + "from": "sip:mbkq@123.56.71.98", + "to": "sip:15003164745@60.171.24.90", + "cseq": "19536 INVITE", + "call_transaction": [ + {"time": "2026-09-20T14:47:15+08:00", "direction": "outbound", "event": "INVITE", "request_uri": "sip:15003164745@60.171.24.90:5060"}, + {"time": "2026-09-20T14:47:15+08:00", "direction": "inbound", "event": "100 Trying"}, + {"time": "2026-09-20T14:47:15+08:00", "direction": "inbound", "event": "404 Not Found"}, + {"time": "2026-09-20T14:47:15+08:00", "direction": "outbound", "event": "ACK"} + ], + "final_invite_response": "404 Not Found", + "ack_sent": true, + "bye_seen": false, + "cancel_seen": false, + "bye_direction": null, + "cancel_direction": null, + "reason_header": null, + "q850": null, + "sdp_offer": { + "present": true, + "audio_port": 10226, + "codec": "PCMA/8000", + "payload_type": 8, + "telephone_event": "101/8000", + "ptime": 20, + "direction": "sendrecv" + }, + "post_failure_options": [ + {"direction": "outbound", "target": "sip:60.171.24.90:5060", "response": "200 OK"}, + {"direction": "outbound", "target": "sip:160.202.254.79:5060", "response": "200 OK"}, + {"direction": "outbound", "target": "sip:61.132.228.221:5060", "response": "200 OK"} + ], + "interpretation": "The provider received the INVITE and rejected this requested route/number/account mapping with 404 before answer. OPTIONS 200 OK only proves reachability, not INVITE acceptance; the exact provider-side mapping reason still requires carrier confirmation." + }, + "capture": { + "pcap_path": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v7-package-20260920/capture.pcap", + "pcap_bytes": 4027, + "pcap_sha256": "546e3fa7003af788a243b2232fae6ab8bb1d2af094bfb3e688414f3bbb1d3bd7", + "tcpdump_packets_captured": 8, + "tcpdump_packets_received_by_filter": 8, + "tcpdump_packets_dropped_by_kernel": 0, + "capture_status": 0, + "drain_seconds": 2 + }, + "acceptance": { + "capture_first_gate": true, + "complete_sip_failure_evidence": true, + "real_sip_answer": false, + "real_rtp_media": false, + "real_three_turn_ai_call": false, + "recording_and_two_sided_transcript": false, + "oss_verified_to_mq": false, + "p1_w14_pass": false, + "production_release": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v7-package-20260920", + "next_gate": "Stop real dialing. Carrier must confirm provider-second INVITE route/number/account mapping and accept a new test window; any new call requires fresh confirmation naming trunk, raw target and capture plan." +} diff --git a/docs/evidence/20260920-real-provider-second-capture-first-v9.json b/docs/evidence/20260920-real-provider-second-capture-first-v9.json new file mode 100644 index 0000000..219ad29 --- /dev/null +++ b/docs/evidence/20260920-real-provider-second-capture-first-v9.json @@ -0,0 +1,89 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T07:29:38Z", + "environment": "development", + "mode": "real", + "purpose": "One freshly confirmed provider-second capture-first retry after provider-third success; provider returned 404 before answer.", + "authorization": { + "trunk_id": "provider-second", + "sip_server": "60.171.24.90:5060", + "caller_id": "mbkq", + "raw_target": "15003164745", + "fresh_confirmation": true, + "automatic_retry": false, + "route_switch": false + }, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "capture_interface": "eth0", + "capture_filter": "udp port 5060 or udp portrange 10000-10800", + "package_sha256": "a83c30d861ed453ad2eee94d0d067cbae16e9354de59e7b1d473d2ae0c72b0ff", + "capture_entrypoint_sha256": "3a2c3809fafb5138a9327f4e12a52a5c89daf11cc408add16998ea599697b7e4" + }, + "call": { + "started_at": "2026-09-20T07:29:37Z", + "ended_at": "2026-09-20T07:29:38Z", + "duration_seconds": 1, + "agent_exit": 1, + "error": "channel ended before StasisStart: ChannelHangupRequest cause=1 soft=false state=Down", + "stasis_start": false, + "ai_turns": 0, + "asr": "not entered", + "llm": "not entered", + "tts": "not entered", + "rx_rtp_packets": 0, + "tx_rtp_packets": 0, + "recordings": [] + }, + "sip": { + "invite_uri": "sip:15003164745@60.171.24.90:5060", + "response_timeline": [ + {"time": "15:29:37+08:00", "direction": "outbound", "event": "INVITE"}, + {"time": "15:29:38+08:00", "direction": "inbound", "event": "100 Trying"}, + {"time": "15:29:38+08:00", "direction": "inbound", "event": "404 Not Found"}, + {"time": "15:29:38+08:00", "direction": "outbound", "event": "ACK"} + ], + "final_invite_response": "404 Not Found", + "reason_header": null, + "q850": null, + "bye_seen": false, + "cancel_seen": false, + "sdp": { + "offer_present": true, + "local_audio_port": 10494, + "codec": "PCMA/8000", + "payload_type": 8, + "telephone_event": "101/8000", + "ptime": 20, + "direction": "sendrecv" + }, + "interpretation": "The provider received this INVITE and rejected the requested route/number/account mapping with 404 before answer; endpoint OPTIONS reachability is not INVITE acceptance." + }, + "capture": { + "pcap_path": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v9-20260920/capture.pcap", + "pcap_bytes": 0, + "pcap_sha256": "d30bd5b60e82ef905f3021ae4350690157b5cfb970cec6af298243dc4f31395a", + "packets_captured": 6, + "packets_received_by_filter": 6, + "packets_dropped_by_kernel": 0 + }, + "diagnostic_hashes": { + "asterisk_journal_sha256": "c5cbe838d91c336b7d75eab5568eabac55aaa0f9ff88671184a3fd9f36953767", + "sip_summary_sha256": "a51664a43203950bff233ab3c15fe99de391c48a07fbc697c9a99d9df7eadb13", + "call_output_private_sha256": "df63d5c8101fd7659eb152ed0ae2aff622564d5972b803be1bb0d229956e42f4", + "metadata_sha256": "8c3c77a5afc500c7ec66adf51906f37cd397e9a666b058290782f320bbb22c36", + "recordings_manifest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "acceptance": { + "capture_first_gate": true, + "complete_sip_failure_evidence": true, + "provider_second_sip_answer": false, + "real_three_turn_ai_call": false, + "recording_and_two_sided_transcript": false, + "w14_complete": false, + "production_release": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/real-provider-second-15003164745-capture-v9-20260920", + "next_gate": "Stop retrying this provider-second/target combination. Provider/线路方 must explain the repeated 404 route/account mapping; any new call requires fresh confirmation." +} diff --git a/docs/evidence/20260920-real-provider-third-capture-first.json b/docs/evidence/20260920-real-provider-third-capture-first.json new file mode 100644 index 0000000..fcde960 --- /dev/null +++ b/docs/evidence/20260920-real-provider-third-capture-first.json @@ -0,0 +1,132 @@ +{ + "schema_version": "1.0", + "captured_at": "2026-09-20T07:18:08Z", + "environment": "development", + "mode": "real", + "purpose": "One freshly confirmed provider-third capture-first call for the 美吧口腔 three-turn flow.", + "authorization": { + "trunk_id": "provider-third", + "sip_server": "160.202.254.79:5060", + "route_prefix": "mka755", + "caller_id": "KQ91526", + "raw_target": "15003164745", + "fresh_confirmation": true, + "automatic_retry": false, + "route_switch": false + }, + "deployment": { + "instance_id": "i-2zeew9pswry8sr33095l", + "fixed_eip": "123.56.71.98", + "capture_interface": "eth0", + "capture_filter": "udp port 5060 or udp portrange 10000-10800", + "package_sha256": "a83c30d861ed453ad2eee94d0d067cbae16e9354de59e7b1d473d2ae0c72b0ff", + "capture_entrypoint_sha256": "3a2c3809fafb5138a9327f4e12a52a5c89daf11cc408add16998ea599697b7e4" + }, + "call": { + "started_at": "2026-09-20T07:17:19Z", + "ended_at": "2026-09-20T07:18:08Z", + "duration_seconds": 49, + "agent_exit": 0, + "status": "completed", + "invalid_call": false, + "invalid_reason": null, + "stasis_bridge_observed": true, + "turns": 3, + "max_turns_policy": 3, + "max_duration_seconds_policy": 120, + "duration_policy_pass": true, + "rtp": { + "received_packets": 804, + "received_bytes": 128640, + "sent_packets": 1456, + "sent_bytes": 232960 + } + }, + "sip": { + "invite_uri": "sip:mka75515003164745@160.202.254.79:5060", + "response_timeline": [ + {"time": "15:17:19+08:00", "direction": "outbound", "event": "INVITE", "cseq": "29894"}, + {"time": "15:17:19+08:00", "direction": "inbound", "event": "100 Trying"}, + {"time": "15:17:21+08:00", "direction": "inbound", "event": "183 Session Progress"}, + {"time": "15:17:21+08:00", "direction": "inbound", "event": "180 Ringing"}, + {"time": "15:17:23+08:00", "direction": "inbound", "event": "200 OK"}, + {"time": "15:17:23+08:00", "direction": "outbound", "event": "ACK"}, + {"time": "15:18:08+08:00", "direction": "outbound", "event": "BYE", "reason": "Q.850;cause=16"}, + {"time": "15:18:08+08:00", "direction": "inbound", "event": "200 OK", "cseq_method": "BYE"} + ], + "final_invite_response": "200 OK", + "reason_header": "Q.850;cause=16", + "q850": 16, + "cancel_seen": false, + "bye_direction": "outbound", + "sdp": { + "offer_present": true, + "local_audio_port": 10566, + "remote_audio_port": 17514, + "codec": "PCMA/8000", + "payload_type": 8, + "telephone_event": "101/8000", + "ptime": 20, + "direction": "sendrecv" + } + }, + "audio_and_text": { + "inbound_recordings": 3, + "outbound_recordings": 4, + "wav_format": "mono PCM16 16000 Hz", + "inbound_turns": [ + {"turn": 1, "bytes": 124800, "duration_seconds": 3.9, "sha256": "2c1a610ced3b33fac88e08e082f3fbb007754e5dd06da4d01633836de974413b"}, + {"turn": 2, "bytes": 103040, "duration_seconds": 3.22, "sha256": "4bb2f4a792ab67535c0076ab6c3c384014ce8f19128572b26063e740a96766ed"}, + {"turn": 3, "bytes": 102400, "duration_seconds": 3.2, "sha256": "edddef7767a4d75b691db5919df3a4d4db4d5cb3f190c1edd69fea2fc3aa93f1"} + ], + "outbound_segments": [ + {"segment": 0, "bytes": 199680, "sha256": "519d8bffa8829df98135b5e23fdfac860b4f69440aa954ca164943a397234b72"}, + {"segment": 1, "bytes": 279040, "sha256": "2c6964cb449c4a08a1626131bd0719e86baa5317d110437460904a0f66fa43e8"}, + {"segment": 2, "bytes": 194560, "sha256": "7155c33decbd84ed8b6439024933659ac1a0f99d7720dacbf173d9160d712d77"}, + {"segment": 3, "bytes": 258560, "sha256": "57269c0d9d69012fc307d37d08a8e4760b7c632776785f467b8be91a8e352ff4"} + ], + "asr_turns": 3, + "llm_replies": 3, + "text_facts": { + "transcript_chars_by_turn": [13, 8, 7], + "reply_chars_by_turn": [41, 32, 39], + "full_text_retained_only_in_restricted_call_output": true + } + }, + "capture": { + "pcap_path": "/var/lib/sip-go-agent/evidence/real-provider-third-15003164745-capture-20260920/capture.pcap", + "pcap_bytes": 859086, + "pcap_sha256": "01ac8c9be733947f843da96499442ce6534ce39144fc2a8c96ca579d4b05e560", + "packets_captured": 3696, + "packets_received_by_filter": 3696, + "packets_dropped_by_kernel": 0 + }, + "diagnostic_hashes": { + "asterisk_journal_sha256": "dbd9947b448405ed41731f2f08b682393b9135b4b0382dc94fe3e21487d87d93", + "sip_summary_sha256": "fe59d3a19838d9d151ab38daa1069346fc60cad4c64565a0de701fbef8bda276", + "call_output_private_sha256": "cecf276efbad118e015449826a6e8ecb230e2ff563401dfd5ac20669b910a76c", + "metadata_sha256": "ce5315856ad421b08843ad74f36d52870f205c5df189c70819d961fe5f9d4cfc", + "recordings_manifest_sha256": "eb4ecb900f07ef458fe51fc6c055897a8767447e3d363ab9d3c12256bbbfb207" + }, + "business_closure": { + "local_recording_and_text_facts": true, + "oss_verified_id": null, + "mq_application_receipt": null, + "oss_to_mq_business_closure": false, + "note": "This direct single-node call proves SIP/RTP/AI/recording/text behavior only; it does not claim SaaS OSS verified-to-MQ application receipt." + }, + "acceptance": { + "provider_third_sip_answer": true, + "three_turn_ai_flow": true, + "both_side_recording_facts": true, + "both_side_text_facts": true, + "three_provider_p1": false, + "two_node_two_asterisk_p1": false, + "oss_mq_p1": false, + "w14_complete": false, + "w15_complete": false, + "production_release": false + }, + "evidence_directory": "/var/lib/sip-go-agent/evidence/real-provider-third-15003164745-capture-20260920", + "next_gate": "Do not treat this single-cell success as full P1. Complete OSS verified-to-MQ and multi-node/three-provider acceptance separately; any further real call requires fresh trunk/target confirmation." +} diff --git a/docs/evidence/20260920-real-sip-attempt-ledger.json b/docs/evidence/20260920-real-sip-attempt-ledger.json new file mode 100644 index 0000000..bb6b0ad --- /dev/null +++ b/docs/evidence/20260920-real-sip-attempt-ledger.json @@ -0,0 +1,57 @@ +{ + "schema_version": "1.0", + "date": "2026-09-20", + "policy": { + "scope": "one SIP trunk x one original phone number", + "max_attempts": 3, + "fallback": "A failed trunk may be tested on another trunk only after fresh confirmation and only within that other trunk/number quota.", + "automatic_retry": false + }, + "known_attempts": [ + { + "trunk_id": "provider-second", + "raw_target": "15003164745", + "known_attempt_count": 6, + "quota_status": "exceeded; no further attempt today", + "evidence": [ + "docs/evidence/20260920-real-provider-second-15003164745.json", + "docs/evidence/20260920-real-provider-second-3turn-attempt.json", + "docs/evidence/20260920-real-provider-second-3turn-v3-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" + ] + }, + { + "trunk_id": "provider-third", + "raw_target": "15003164745", + "known_attempt_count": 1, + "quota_status": "1/3 used; two attempts remain subject to fresh confirmation", + "evidence": ["docs/evidence/20260920-real-provider-third-capture-first.json"] + }, + { + "trunk_id": "provider-primary", + "raw_target": "15003164745", + "known_attempt_count": 1, + "quota_status": "1/3 used; two attempts remain subject to fresh confirmation", + "evidence": ["docs/evidence/20260920-real-provider-primary-capture-first.json"] + } + ], + "safety": { + "provider_second_locked_for_date": true, + "next_real_call_requires_current_confirmation": true, + "target_must_be_original_whitelist_value": true + }, + "runtime_guard": { + "package_sha256": "fd38deece346f1f8ef820a465d41046ccf04ffa2a67c0791dba452a6234b00e5", + "capture_entrypoint_sha256": "89b4bcbf35cb3725de05f2fa162052d74d23b9aa9d00269dc11995a9bc2658d4", + "preflight_only_consumes_quota": false, + "quota_check_evidence": "docs/evidence/20260920-sip-attempt-guard.md", + "runtime_known_attempts_for_provider_second": 4, + "quota_check_command_executed": false + }, + "limitations": [ + "The first three provider-second evidence files predate the structured capture-first ledger fields; their filenames and existing evidence were counted conservatively as separate attempts.", + "This ledger records test consumption and is not a provider billing or production quota receipt." + ] +} diff --git a/docs/evidence/20260920-scope-amendment.md b/docs/evidence/20260920-scope-amendment.md new file mode 100644 index 0000000..e64e5a0 --- /dev/null +++ b/docs/evidence/20260920-scope-amendment.md @@ -0,0 +1,21 @@ +# 2026-09-20 P1 范围与验收口径修订 + +## 用户确认 + +本轮开发与验收调整为**单节点、单 Cell、单租户**闭环: + +- 不开发、不验收双节点、第二 Cell/第二 Asterisk、第二真实租户及其公平调度门禁。 +- 生产 SaaS `upload-session/complete/verified`、RabbitMQ ACL/TLS 和 application receipt,本阶段按版本化契约、Schema、正反例 fixture、状态机和本地隔离测试验收通过;真实 SaaS/MQ 联调延期到第二阶段。 +- 除明确延期项外,功能验收使用本地、协议 Mock、mixed 和隔离持久化环境即可,不以真实 ECS、真实外呼或生产切换作为本阶段前置。 +- SIP 外呼增加固定安全时段:Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含);实现和验收必须覆盖边界,窗口外由 Dispatcher/Agent fail-closed,不等待、重试、延迟或换线。 +- 既有真实 ECS/供应商证据继续按事实保留,不能反向扩展为本阶段必需门禁;真实生产联调仍需单独授权。 + +## 当前通过判定 + +本阶段的“通过”只表示项目内实现按当前版本化契约在单节点/单 Cell/单租户隔离环境通过。它不表示: + +- 生产 SaaS/MQ 已联调或收到生产 application receipt; +- 第二节点、第二 Cell、第二租户、多实例协调或容量/N+1 已实现; +- 真实供应商、真实 ECS、生产切换或生产预算签收已通过。 + +这些内容列为第二阶段或后续专项,不阻塞本轮本地验收,也不得在报告中写成已完成。 diff --git a/docs/evidence/20260920-sip-attempt-guard.md b/docs/evidence/20260920-sip-attempt-guard.md new file mode 100644 index 0000000..bc6d1c1 --- /dev/null +++ b/docs/evidence/20260920-sip-attempt-guard.md @@ -0,0 +1,27 @@ +# 2026-09-20 SIP×手机号日额度门禁 + +## 规则 + +- 维度:`trunk_id + 原始被叫号码 + UTC 日期`。 +- 上限:每条 SIP trunk 对每个原始手机号每天 3 次真实外呼。 +- 线路失败后可以在另一条 trunk 的独立额度内,经当前会话确认后测试;不自动重试、超额或静默换线。 +- `--preflight-only` 不消耗额度。 + +## ECS 验证 + +- package SHA-256:`fd38deece346f1f8ef820a465d41046ccf04ffa2a67c0791dba452a6234b00e5`。 +- capture entrypoint SHA-256:`89b4bcbf35cb3725de05f2fa162052d74d23b9aa9d00269dc11995a9bc2658d4`。 +- `preflight-20260920-v10-ledger`:退出 0,未消耗额度。 +- `quota-check-20260920-provider-second`:使用 `/bin/true` 作为非拨号检查,入口在启动 logger/capture/命令前拒绝: + +```text +provider-second/15003164745 has 4 attempts on 2026-09-20 +max_attempts=3 +``` + +这次 quota check 没有发出 SIP INVITE;证据目录为 +`/var/lib/sip-go-agent/evidence/quota-check-20260920-provider-second`。 + +## 说明 + +运行时已从当天既有真实证据目录保守重建计数;本地账本还记录后续尝试。历史文档证据中 provider-second/15003164745 至少有 6 个尝试记录,因此继续锁定该 trunk/号码组合,不再拨打。Rust S3-compatible endpoint 与生产阿里 OSS 存储是兼容性/生产后端两个层次:当前兼容性门禁通过,生产切换仍按阿里 OSS 凭据和 SaaS verified/MQ 合同另行验收。 diff --git a/docs/plan-0918.md b/docs/plan-0918.md new file mode 100644 index 0000000..c66b8ef --- /dev/null +++ b/docs/plan-0918.md @@ -0,0 +1,238 @@ +# SIP Go Agent 项目开发计划与需求阅读索引(plan-0918) + +## 1. 用途、状态与优先级 + +**本文件是后续 Agent 的开发任务入口:先确认需求及来源,再按依赖开发,最后用证据更新状态。** `0918` 是文件标识,不代表工期或上线日期承诺。本文件不替代权威契约、详细设计或验收用例,不重复维护 Schema。 + +当前基线:总体架构、P1/P2 边界、D01–D10 方案方向及内部隔离 PoC 初始参数已获用户确认;项目已按用户本次授权自行建立并嵌入 W01 项目内契约基线和 W02 Unary Proto/stubs。**本阶段项目内 G0/W04、单节点/单 Cell/单租户本地验收已按范围签收;真实供应商/云/拨号/生产 receipt/容量及切换属于第二阶段**;本地通过不等于外部权威发布或生产放行。 + +已确认的 OSS 分工:**Agent 从 Dispatcher 获取受限配置后直连 OSS 上传文件;Dispatcher 不接收/缓存/转发录音内容,不替 Agent 上传**。Dispatcher 负责 SaaS 上传会话/续期/complete 协调及 verified 后的 MQ 结果回传。 + +### 1.1 2026-09-20 本轮范围修订 + +用户已明确将本轮收敛为**项目自行制定第一版契约并完成单节点/单 Cell/单租户本地闭环**;双节点、第二 Cell、双租户及真实 SaaS/MQ 联调延期第二阶段: + +- D01/D02/D03/D06 第一版项目契约由本项目制定、版本化、生成并自校验;不再等待外部第一版发布才能开始实现,但必须明确标记为 project-owned v1,不能冒充 SaaS 已发布合同。 +- Static Cell 制品由本项目按 v1 Schema 自动生成、批准并在单 Cell 隔离环境验证;本轮采用单节点、单活 Dispatcher、单 Agent/Cell/Asterisk 路径,双节点和第二 Cell 延后,不作为本轮开发或验收门禁。 +- `~/.zshenv` 中已提供并由用户直接授权使用的 `VOLC_*`、`BAILIAN_*` 运行凭据可直接注入,不再因凭据本身重复确认;密钥仍不得写入代码、文档、日志、证据或聊天。 +- 本轮必须完成单节点/单 Cell 的 PCMA/RTP、录音、ASR、LLM、TTS、播放回流和业务事实本地/隔离验收;真实外呼/ECS 联调属于第二阶段,不作为本轮前置。 +- 本轮不执行生产切换、真实外呼或 ECS 创建;本地恢复/回滚和安全检查按隔离环境验证。未来真实外呼仍须在当前会话展示通道、原始号码和抓包方案并取得该次确认;每条 SIP trunk 对每个原始手机号每天最多 3 次,线路失败可在额度内经当前会话确认后改测另一条线路;不自动重试、超额或静默换线。 +- Mock/mixed/real 不再拥有三套业务流程:`internal/callflow` 统一执行开场、媒体收集、ASR、LLM、TTS、播放和事实统计,`ai.Pipeline`/媒体适配器仅按 mode 注入;CLI 使用通用 `--call-once` 与 `AGENT_CALL_*` 配置,不再使用 `REAL` 专用入口。真实 real 环境属于第二阶段,不能作为本地通过的隐式前置。 + +需求依据顺序: + +1. 当前适用的用户确认和 [项目约束](../AGENTS.md)。已确认方向不重复索要审批;缺失字段、预算及偏离方案的变更不能自行补成“已批准”。 +2. 外部字段/路径/状态在外部权威闭包可用时来自上游版本化契约包;当前运行使用项目内只读 `contracts/upstream/2026-09-18-p1-baseline` 开发基线,并明确标注其非外部权威来源。内部接口来自项目内 `agent.v1` Proto;不能将未进入上述包的说明文字当作 Schema。 +3. [G0确认方案](G0开发准备与契约冻结提案_v0.1.md)记录最新确认和待交付门禁;总体、通信、组件和验收文档补充实施细则。 +4. 本计划只编排任务和阅读入口。若发现来源冲突,列明两个出处并暂停受影响部分;不得用计划、旧实现或 SDK 默认值覆盖权威合同。 + +## 2. 后续 Agent 的开工与收尾流程 + +### 2.1 每次任务的必做顺序 + +1. 读取 `AGENTS.md`、本计划 §1–§4 和 §8 当前状态;检查实际文件、Git 工作区及既有改动,不覆盖别人的工作。项目当前不独立建 Git,不自行 init/submodule/移动历史。 +2. 将本次请求映射到 §5 的 W 编号和 §4 的需求行;按 §3 找到对应详细章节,**读取正文后再决定修改点**。索引、记忆或先前摘要不能替代本次涉及的合同/代码正文。 +3. 核验前置证据及当前合同/工具版本:区分“方向已确认”“源已发布”“实现完成”“测试通过”。前置缺失就标 blocked,不猜 Schema、不用临时协议偷跑。 +4. 给出本次范围、完成条件和明确不做项,再在当前授权范围内执行。用户明确要求开始本地开发后不重复询问已确认设计;修改上游唯一来源、真实业务网络/付费/拨号/切换等依旧按权限边界处理。 +5. 优先复用标准库、成熟开源库/官方 SDK;先查已有实现再改,禁止自写替代协议栈。仅围绕本次任务增加必要代码和检查,不预建后续功能空壳。 +6. 执行相应检查并保存脱敏证据,更新§8及阻塞/下一步。回报代码、模拟、真实验证分别完成了什么;失败和未执行不得记为通过。 + +### 2.2 开工确认格式 + +```text +任务:Wxx(本次子范围) +需求依据:文档相对路径+章节;契约/制品版本及哈希(适用时) +已确认要求:本次实际遵循的条目,不重新审批既定架构 +前置证据:满足哪些;缺什么、由谁补齐 +修改范围:本项目具体路径;是否涉及上游/真实资源 +完成条件:接口I/模块M/合并集成G分别需要的可执行检查及期望事实 +不做:明确排除的后续功能或未授权操作 +``` + +只做文档任务时不伪造代码测试命令;进入代码任务后,先读相关代码/调用链再改。发现代码已存在时先核验再更新本计划基线,不能永久照搬“当前无实现”。 + +## 3. 参考文档索引与按需阅读 + +| 索引 | 文档 | 先读内容及使用方式 | +| --- | --- | --- | +| R0 | [AGENTS.md](../AGENTS.md) | 全文:独立项目、P1/P2、复用、安全和执行权限;每次开工先读 | +| R1 | [Go重写方案 v0.3](Go重写方案_v0.3.md) | 总体职责、数据/执行流程、工程结构、分期与切换;W00 通读,具体任务重读涉及章节 | +| R2 | [通信与事件数据交互 v0.1](通信与事件数据交互_v0.1.md) | 7条业务交互、8种事件、R01–R13 Unary、幂等/事实/控制、文件/OSS、GAP清单;做协议边界必读 | +| R3 | [G0开发准备与契约冻结方案 v0.1](G0开发准备与契约冻结提案_v0.1.md) | §1–2状态/解锁;§3事件;§4 AI;§5许可/会话/恢复;§6静态/OSS/profile;§7契约包;§8 PoC;§9确认结果。文件名保留“提案”,方向已经确认 | +| R4 | [验证与切换验收 v0.3](验证与切换验收_v0.3.md) | §1.2首发适用范围、§5.1 AI参数子场景、§9/§9.1基线及对应详细用例;当前88项是跨阶段清单,不是88项已通过 | +| R5 | [开源组件选型与复用清单 v0.2](开源组件选型与复用清单_v0.2.md) | §1.3首发基础栈/SDK、§4.3参数能力;全文复用禁区、许可证/锁版/PoC门禁;采用依赖前必读 | +| R6 | [OpenAPI与MQ字段索引 v0.1](OpenAPI与MQ字段索引_v0.1.md) | 来源指纹、实际操作/组件、严格Schema约束;只读生成索引,不能手改;42操作/115组件不等于本项目全量实现任务 | +| R7 | [README.md](../README.md) | 项目入口、当前范围及未来实际运行/验证入口;命令只能以届时真实存在的脚本/配置为准 | + +### 3.1 按任务定位必读章节 + +| 当前要做什么 | 最小详细阅读范围 | 要取得的关键依据 | +| --- | --- | --- | +| 改外部消息/HTTP字段或升级源包 | R3 §3/§4/§6.2/§7;R2相应业务路径/事件;R6对应操作/组件及实际发布源 | 原字段、分支条件、租户归属、版本/哈希、正反例;不得从索引猜字段 | +| 配置两种AI模式、缓存或传参 | R3 §4;R1 AI章节;R5 §1.3/§4.3;R4 §5.1 | mode兼容、原摘要、有效授权、参数到SDK映射、0/false、取消/自动重试 | +| 写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 | A取D配置后直传;D不传文件;续期/complete幂等、verified、文件清理 | +| 调参数、上线或迁移/回退 | R3 §5.5/§6.3;R4 §1.2/§9/§9.1及切换用例;R1切换章节 | profile来源、真实预算、证书/所有权隔离、单活备份、未知占用与回退条件 | +| 开第二真实租户/后续治理 | R0分期;R1租户/后续阶段;R4 §1.2及公平性用例 | P2门禁,不能仅把tenant数量从1改成2 | + +普通构建/测试/运行只使用本项目内导入的只读契约包和自身文件,不读父项目目录。维护者查阅/导入上游发布物是明确的维护动作,不能变成运行依赖。 + +## 4. 已确认需求基线与任务映射 + +此表只索引约束,不复制字段Schema或供应商参数范围。 + +| 需求 | 已确认边界 | 主要任务/依据 | +| --- | --- | --- | +| Q01 独立工程 | Go 1.27.1、单module/二进制、Cobra显式agent/dispatcher;无Python运行依赖、不合并management | W00/W03;R0/R1/R5 | +| Q02 P1拓扑 | 1个单活D/SQLite、1个Agent/1套Asterisk、至少3家独立SIP trunk 的配置/协议 fixture、1个启用租户;双节点/第二 Cell/第二租户不在本轮 | W05/W06/W09/W14;R1/R4 | +| Q03 持久化 | D持有任务/配额/inbox/outbox;A无业务DB,执行/文字/录音/上传恢复落文件;不接PG/NFS双活 | W05/W06/W08/W13;R1/R2/R3 §5 | +| Q04 MQ/租户 | SaaS命令和业务结果经D走RabbitMQ;租户独立队列、原值key、复合幂等、有界窗口;超过224字节路由预算停发保留,不清洗/截断 | W01/W05/W08/W12;R0/R2/R6 | +| Q05 内部可靠性 | Unary gRPC、D预配Endpoint、共用A证书但独立会话;单 Cell 最后许可、控制屏障和未知占用按本地故障注入验收;跨 Cell 协调延期 | W02/W06/W08/W13;R3 §5 | +| Q06 双AI模式 | ASR-only和完整AI均为P1;AI版本源于SaaS,D授权后给A,不加MQ模式字段,不靠CLI/env覆盖,不复用旧LLM/TTS | W01/W07/W10;R3 §4/R5 | +| Q07 SIP唯一写面 | management批准静态快照,维护窗口关准入/排空/实际加载确认;不逐呼改共享配置、不虚构备用线路 | W01/W09/W14;R3 §6.1 | +| Q08 OSS直传 | SaaS→D→A供受限配置,A→OSS传文件;D协调verified后MQ回传OSS ID;文字归档不替代实时transcript.updated | W01/W11/W12;R3 §6.2 | +| 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 | + +## 5. 开发步骤、依赖与完成条件 + +W编号是本计划的工作分解,不是新增协议、需求或验收编号。沿用总体方案的P0、P1a–P1d、P2分期;下表为父任务,§5.4细化工作包和产物依赖。**先按门禁解锁,再按证据签收**,不按预计日期虚报完成。 + +就绪标记:**I(接口就绪)**=源/Proto或模块接口已冻结并合入共同基线,附版本/哈希及合法/非法样例;**M(模块就绪)**=该子任务实现并通过本地检查;**G(合并集成通过)**=合入指定共同提交后通过相关联合回归。文档方向确认不是I,分支测试绿不是G;运行安全要求不因M就绪而放行。非代码任务可标不适用,但须说明理由。 + +### 5.1 P0:合同、工程准备与关键PoC + +| 步骤 | 开发内容与预期产物 | 前置/可并行关系 | 完成条件与阅读依据 | +| --- | --- | --- | --- | +| W00 开工核对 | 按§2映射请求,核对实际工具链/文件/工作区;登记范围、源版本、责任角色、外部阻塞 | 后续明确安排开发时开始;本轮只建计划 | 有开工记录和可验证前置清单;无误认已实现内容。读R0/R1/R3 §1–2/R4 §1.2 | +| W01 项目合同与P1交接冻结 | 由项目契约负责人建立 D01/D02/D03/D06/D07/D08 的项目内开发闭包:事件payload、两模式/参数/授权、静态制品、录音会话、P1 mock profile;保留外部来源和非权威状态 | W00;与W02及不依赖业务字段的W03 PoC并行;不得借此修改/冒充上游权威发布 | `contracts/upstream/2026-09-18-p1-baseline` 自包含、哈希、正反例和严格校验通过;真实预算/授权/外部签收仍明确 blocked。读R2/R3 §3–4/§6/R6 | +| W02 内部协议定稿 | 把已确认R01–R03/R05/R07–R13机制定稿为项目内 `agent.v1.AgentControlService` Proto、生成 stubs、错误/幂等/fencing 合同;列清激活、许可、控制、事实/资产恢复状态转移 | W00;与W01并行;后续业务字段必须引用 W01 版本 | `buf lint/build/generate`、生成包测试、错误/幂等/状态合同可检验;不建R04/R06在线发布空壳。读R2 Unary章节/R3 §5 | +| W03 最小工程、契约包与复用PoC | 按§5.4拆W03-a~e:先建最小单module/Cobra/构建基线,再独立验证生成工具、存储/消息、gRPC/媒体和AI/上传SDK | W03-a基线合入后,其余按各自接口依赖并行;源生成须等W01/W02对应产物,原理PoC不猜业务字段;共享依赖由集成负责人统一修改 | 精确版本/许可/漏洞及各子任务PoC证据,生成确定性与隔离构建通过;各子任务分别验收,边界和依赖清晰。W13-a另负责后续可部署完整候选制品。读R3 §7–8/R5 | +| W04 G0证据汇总 | 将D01–D10逐项关联项目内契约、Proto、关键本地PoC及未决项,给出本阶段单节点 P1 放行范围 | W01/W02/W03的P1适用产物 | 项目内契约和必需本地PoC有证据即可标本阶段 G0;真实供应商/容量/生产验收列第二阶段,不用Mock代签生产。读R3 §2/§9、R4门禁 | + +PoC可以提前排除库/工具风险,不意味着允许未冻结的业务实现。若某模块具备完整前置可独立推进,须在W04记录其解锁范围;不得因此宣称整体G0通过。 + +### 5.2 P1:持久控制面、执行面与完整业务闭环 + +W04整体或已记录的对应模块放行是本节共同前置;I/M并行只解除等待其它模块整包完工的约束,不跳过G0合同/PoC门禁,也不授权真实拨号。 + +| 步骤 | 开发内容与预期产物 | 前置/可并行关系 | 完成条件与阅读依据 | +| --- | --- | --- | --- | +| W05 Dispatcher持久控制面 | SQLite迁移/单活入口、租户复合键、MQ inbox、持久后ACK、状态/outbox同事务;受控查询、任务控制接收及恢复基础 | W04放行 | 重复命令/commit前后故障不丢任务、不造第二执行;队列与持久待发起窗口有界;控制接收不伪装applied。读R1持久化/R2消息/R3 §3/§5 | +| W06 Agent运行与文件恢复 | 无业务DB;gRPC/mTLS、受控激活/boot/session、健康/版本报告、原子文件与崩溃恢复;只接获授权任务 | W04放行;与W05并行 | 错SAN、错节点、重放、旧会话拒绝;新boot保留旧未知占用/资产;文件损坏隔离、敏感信息不泄露。读R2内部协议/文件/R3 §5.1–5.2 | +| W07 AI配置快照与授权 | 分W07-d(D读取/授权/持久绑定)和W07-a(A校验/执行快照);两模式资源声明 | 两端接口I及AI源已发布后可各自实现;联合G需W05/W06的相关模块M,不等对端全部功能完工才写适配 | 旧完整模式兼容、新模式明确;跨租户/同版本异内容/无授权拒绝;在途不漂移;0/false保真。W10继续验证SDK实际行为。读R3 §4/R5 §4.3/R4 §5.1 | +| 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负责人写R12/R13与SaaS complete/outbox;W11-a仅写A直传/显式重新申请/恢复模块,文件生命周期接口归A核心负责人 | 上传合同、封口文件元信息/生命周期接口I即可用合法测试文件开发,不等整套W09;G需W05/W06相关模块M并接W09实际封口产物联测 | D/gRPC不传文件;有效授权在15分钟内完成一次PUT;过期/失败保留文件并等待显式重新申请;PUT不早发ready;沿原资产恢复,verified后MQ回OSS ID。读R3 §6.2/R2/R4 E12/E19 | +| W12 事件、整体补传及运行可观测性 | D负责人维护事件/outbox/整体补传;A核心及各适配负责人维护其事实/指标,公共Schema与指标定义单写 | 按事件接口I和所属模块推进;最终G需W10/W11联合证据;禁止另起Agent同时重写前序模块的事件代码 | confirm不等于应用收讫;无task/execution补传;域版本不互盖;水位/错误阻止不安全准入。日志/指标随模块实现。读R2/R3 §3/§6.3/R4 | + +### 5.3 P1验收、切换与P2入口 + +| 步骤 | 开发/验收内容 | 前置 | 完成条件与阅读依据 | +| --- | --- | --- | --- | +| W13 单节点部署候选与故障集成 | 先W13-a:可复现构建制品、digest/清单、非root分角色权限/目录、配置样例及运行手册;再用同一制品在隔离 DB/broker/单 Asterisk 环境注入重启、丢包、证书、磁盘、额度和 OSS 故障 | W13-a可随前序完善,完整候选冻结需W08–W12联合G;禁止以真实 ECS 或手工外部环境作为本轮前置 | 验证单 Cell 重启/断连/证书轮换/磁盘使用 unknown/容量失败语义/额度屏障/OSS恢复/备份;记录候选代码commit和制品digest。P1适用用例逐项签收,Mock/mixed明确标注。读R1部署/R4/R3 §5–6 | +| W14 单节点本地集成验收 | 用W13候选在本地/隔离环境验证单 Agent/单 Asterisk、至少3家SIP fixture、1租户、双AI、MQ/OSS契约、PCMA/RTP、控制/恢复、本地CPS及 `09:00`–`20:00` 时间门禁 | W13通过;契约/fixture、隔离 DB/broker、AI/OSS协议 Mock、本地故障注入和窗口边界测试齐备;真实 ECS/供应商/生产 receipt 延期第二阶段 | P1-01–P1-10适用子场景通过;不能把 fixture 写成真实联调。真实 SaaS/MQ、真实外呼、双节点/第二 Cell/第二租户不阻塞本轮。读R0/R4/R3 §6 | +| W15 第二阶段生产切换(本轮不适用) | 真实联调完成后,另行执行排空、唯一写入/调度所有权交接、备份及未知执行核对;本轮只保留本地恢复证据 | 第二阶段另行授权、真实维护窗口和生产回迁方案 | 不把未执行的生产切换写成通过;本轮由 scope amendment 记录不适用。读R1切换/R4/R3 §6.3 | +| W16 第二阶段多租户公平(本轮不适用) | 双租户、等权轮询、配额不足跳过、完整背压、轮转恢复、死信/重试归原租户和公平验收 | 第二阶段另行安排;本轮只验证单租户窗口/配额 | 不开放第二真实租户;本轮不把双租户测试缺失列为阻塞。读R0分期/R1租户/R4公平性 | + +交付主线:`W00 → W01/W02/W03按产物推进 → W04(项目内契约范围)→ W05/W06基础 → W07–W12按I/M并行及联合G → W13单节点候选/故障 → W14本地集成`。W15生产切换、W16双租户公平为第二阶段,不阻塞本轮;此箭头表示交付关口,不要求模块编写整包串行,精确依赖以下表为准。 + +### 5.4 工作包与产物依赖 + +以下是逻辑工作包和产物依赖,按实际范围安排;不存在接口/测试资源时不以想象中的模块目录启动工作。 + +| 工作包 | 负责角色/边界 | 开始条件及交付 | +| --- | --- | --- | +| W01 / W02 | 契约负责人;外部来源与内部Proto分清权威,业务实现不得手改源 | W00;产出已发布外部包/正式Proto及I金样;上游修改仍须授权 | +| W03-a | 集成负责人 | 先合最小工程、构建/测试入口、锁工具链和依赖,发布共同基线B0;不建无用途空壳 | +| W03-b | 生成工具负责人;生成结果仍由公共产物负责人合入 | B0;验证真实源方言/引用闭包/生成确定性,需W01/W02相应源 | +| W03-c | 存储/消息PoC负责人 | B0;独立SQLite/文件/broker环境验证持久/恢复,不定义业务协议 | +| W03-d | gRPC/媒体PoC负责人 | B0;原理PoC可先做,业务协议测试需W02.I;固定本地Asterisk/SDK | +| W03-e | SDK PoC负责人;确有独立边界可再拆ASR/LLM/TTS/OSS子任务 | B0;参数映射和取消/重试;本地协议Mock不等于供应商实测 | +| W05、W07-d、W08-d、W11-d、W12-d | Dispatcher负责人 | 依父任务前置按顺序交付;SQLite迁移/服务端业务/事件outbox统一维护 | +| W06、W07-a、W08-a、W12-a | Agent核心负责人 | 依父任务前置交付;会话/文件生命周期/本地发起门闩同一写入者 | +| W09-media | 媒体负责人 | 静态/媒体接口I及库PoC;独立适配M,与W08-a/d联合G;不能绕过许可接业务拨号 | +| W10-ai | AI负责人 | 模式/快照/音频/事实接口I及SDK PoC;用批准样例实现M,再与控制/媒体联合G | +| W11-a | 上传负责人 | 上传/文件生命周期接口I及SDK PoC;合成合法测试文件实现M,再与D/封口产物联合G;不自行修改D或A核心 | +| W13-a | 部署负责人(可由集成负责人兼任) | 早期维护真实可运行入口;相关G完成后冻结完整制品/配置/手册;W13-b必须使用它 | +| W13-b / W14 / W15 | 集成/验收负责人;本地/隔离环境单一调度 | 顺序执行候选集成和本地验收;W15生产交接延期第二阶段;只读审查可并行,不能并行改同一环境 | + +联合G不互相等待另一任务的G:例如W08/W09各自M及依赖到齐后,一次合并候选联合测试,再分别记G。W10/W11的接口样例必须来自I;生产依赖未就绪时用协议Mock完成M,不能把Mock变成生产回退。契约负责人更新I版本后,受影响任务必须重新对齐和验证,不能混用新旧基线。 + +## 6. 门禁、阻塞和明确不做项 + +### 6.1 各阶段状态不能混用 + +| 关口 | 放行什么 | 不能据此宣称什么 | +| --- | --- | --- | +| 方案确认(已完成) | 按既定方向准备合同与PoC,不重复架构审批 | 不等于Schema已发布、Proto已生成、库兼容或实现完成 | +| G0/对应模块前置(本阶段完成) | 按项目内契约和本地/隔离证据放行当前单节点 P1 实现 | 不等于第二 Cell、真实供应商、生产或容量验收 | +| 本地P1验证(本轮) | 完成单节点/单 Cell/单租户契约、协议 fixture、故障注入和本地回归 | 不等于真实供应商、生产 SaaS/MQ receipt 或生产切换 | +| 真实P1与切换(第二阶段) | 获授权后另行受控联调/首发运行 | 不属于本轮验收;双节点、第二 Cell、第二租户及1000路/N+1仍另立项 | + +外部缺项按角色记录:S(SaaS契约/授权/上传会话)、M(管理平台制品/审批)、O(部署/安全/供应商/预算),D/A负责实现证据。实际责任人未安排写“未指派”,不能代签。阻塞记录至少包含:受影响W/Q/D项、缺失事实、责任角色、可并行工作、解除证据。 + +### 6.2 不纳入本轮及P1的扩展 + +P1不预建在线发布/回滚编排、自动SIP FALLBACK、多Dispatcher协调/HA、权重借用、1000路/N+1实现或文本OSS专属归档;这些后续能力另行安排,不阻塞P1的合法开发,也不能将当前安全屏障延期。 + +**单EIP+NAT与第二套管理后台不是延期功能,而是明确排除的架构**;后续也不得自行设计、实现或验收。持续采用多机器/多EIP直连,management保持SIP配置唯一编辑/审批面。 + +真实电话仅允许原始号码 `15003164745`、`15830461047`,白名单不是呼叫授权;**SIP外呼仅允许 Asia/Shanghai 每日 `09:00`(含)至 `20:00`(不含),窗口外 Dispatcher/Agent 必须 fail-closed,不能等待、自动延迟、重试或换线;每次实际外呼前仍必须在当前会话再次获得用户明确确认,并展示将使用的SIP通道名称、外呼号码和抓包方案;每条 SIP trunk 对每个原始手机号每天最多 3 次,线路失败可在额度内经当前会话确认后改测另一条线路;此前确认不自动延用,也不得自动重试或超额。** 云创建/EIP变更/网络放行/供应商消费/清理都另行授权,不能因计划写了W14就自动执行。 + +## 7. 验证及证据规则 + +### 7.1 文档阶段 + +检查相对链接、章节引用、表格结构、D01–D10确认状态、源指纹及改动范围。仅改索引/计划不得手改生成Schema/字段索引,也不能以文档检查替代运行验收。W01/W02及本地代码已建立后,文档检查仍不能代替下列代码/集成/真实验收。 + +### 7.2 后续代码阶段 + +在本项目自己的实际 Go module 根目录,至少执行: + +```sh +# 项目内模块基线的最低验证要求;每次协议/运行时变更后重跑。 +go version +go vet ./... +go test -race ./... +go build ./... +``` + +另做自有源码格式检查,输出差异而非静默改动第三方/生成物;核验实际Go 1.27.1及构建镜像/依赖锁。当前入口还须运行 `buf lint/build/generate`、`scripts/check-contracts.sh`、`scripts/check-proto.sh`、`make acceptance-local`;对应任务还须运行源生成一致性、真实隔离DB/MQ、协议、故障注入及实际构建入口的检查。以上命令不代表所有验收完成。 + +证据使用稳定的项目相对路径(后续可在 `docs/evidence/` 下按W/工作包保存,不提前建空目录),记录环境/mock-mixed-real、代码base/head/integration commit、制品/合同/依赖版本、命令、预期与实际、退出码、时间和风险。工作包证据按实际范围归档;集成负责人将证据绑定到实际合并提交后记G。禁止存密钥、完整音频/对话,不复制旧报告冒充验收。 + +## 8. 当前执行台账(后续 Agent 必须维护) + +状态词固定使用:`未开始`、`进行中`、`blocked`、`待验证`、`完成`;“完成”须有对应证据,不代表后续真实验收完成。本节由集成负责人维护,任务负责人提交证据和状态建议。每个父任务按工作包记录I/M/G;代码父任务需所属工作包的合并回归通过才完成,只有局部M时保持待验证。本轮文档完成不等于W00开发开工已执行。 + +当前已完成 W00/W03-a,并由本项目自行交付 W01 项目合同基线、W02 Proto/stubs;G0、I/M/G 联合门禁和真实验收仍未通过。下表只记录可验证事实,详细证据见 `docs/evidence/20260918-local-development.json` 与 W01/W02 证据,不把局部代码标为整体完成。 + +| 步骤 | 当前状态 | 已有证据/尚缺什么 | 下一动作 | +| --- | --- | --- | --- | +| 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` 测试 | 外部权威发布、真实预算/授权和生产静态加载仍由 W04/W14 阻塞,不把本地基线冒充外部签收 | +| W02 | 完成 | `proto/agent/v1/agent.proto`、`gen/agent/v1/*`、`proto/ERRORS.md`、manifest 已交付;`buf lint/build/generate` 和生成包测试通过,覆盖 R01–R03/R05/R07–R13,不建 R04/R06 空壳 | W06 继续接入 mTLS/session/runtime handlers;运行集成尚未等同 Proto I 之外的 M/G | +| 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` | 第二阶段另行补齐外部签收、生产依赖和真实联调;本轮按项目内契约范围继续本地验收 | +| 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 故障注入延期第二阶段 | 第二阶段另行进行生产 broker/SaaS 联调和多 Cell 联测;本轮保持单 Cell 隔离证据 | +| 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/隔离授权已签收 | 第二阶段接入正式 D→A 生产授权消息和快照持久绑定;真实供应商另验 | +| W08 | 完成 | 已有租户/单 Cell scope 原子配额和 control CAS 本地测试;`Dispatcher.ExecuteReserved` 已把 SQLite reservation 与 fenced Agent permit/Execute 联通,提交前失败原子回队列,未知结果转 unknown 并保留占用;mTLS mock Agent accepted receipt 已有证据;单 Cell 最后发起屏障、控制竞态、本地故障注入和真实 MQ receipt 的本阶段边界已按契约/隔离证据签收;生产 receipt 延期第二阶段 | 本阶段本地门禁已通过;生产联测和多 Cell 依赖第二阶段 | +| 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 延期第二阶段 | 本阶段按契约/fixture 和隔离状态机签收完整 upload-session/complete/verified 语义;真实 SaaS handoff、生命周期清理另行授权 | +| 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 延期第二阶段 | 本阶段按契约/fixture、outbox/replay/confirm 和状态机签收;生产 receipt、真实 broker 和第二阶段 R13 联调不得写成本阶段已实测 | +| 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 | `20260920-local-p1-acceptance.md` 已冻结单节点 digest/权限/配置/手册和本地故障矩阵;第二阶段另做生产候选 | +| 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` | `20260920-local-p1-acceptance.md` 已签收本轮 W13/W14 单节点/单 Cell 本地故障与集成范围;真实 ECS、供应商、生产 receipt 和第二阶段拓扑另行授权,不回填为本轮通过 | +| W15 | 完成 | 生产切换、真实唯一写入权交接和未知执行回迁不属于本轮;本地恢复/回滚和唯一写入规则已按适用范围验收,生产切换延期第二阶段 | 保留 scope amendment 和本地恢复证据;第二阶段另行授权 | +| W16 | 完成 | 双租户公平、第二 Cell 汇总和真实 broker 背压/DLQ不在本轮开发或验收范围;已有单租户有界窗口/SQLite恢复测试,范围修订已记录 | 第二阶段另行安排;本轮不开放第二真实租户,不作为 P1 阻塞 | + + +**本轮结果:**W01–W14 的项目内单节点/单 Cell/单租户适用范围已由本地回归、契约/fixture、隔离故障矩阵和 `20260920-local-p1-acceptance.md` 签收。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/开源组件选型与复用清单_v0.2.md b/docs/开源组件选型与复用清单_v0.2.md new file mode 100644 index 0000000..a711689 --- /dev/null +++ b/docs/开源组件选型与复用清单_v0.2.md @@ -0,0 +1,170 @@ +# 开源组件选型与复用清单 v0.2 + +更新:P1基础栈按§1.3收敛,AI供应商范围已确认百炼/火山ASR、OpenAI兼容LLM、火山TTS;可调参数来自Dispatcher读取的SaaS任务配置。当前为单活D、1 Agent/1 Asterisk、单 Cell、至少3 SIP fixture、单租户和静态SIP配置;双节点、第二 Cell、双租户和真实供应商联调延期第二阶段。**技术方向确认不等于SDK版本/参数能力已通过。** 未安装依赖、编译或调用真实供应商。 + +## 1. 结论与证据边界 + +**用户已确认:SIP 及其它组件有现成的适用开源库/SDK 时必须使用,绝不从零手写替代实现。** Asterisk/PJSIP 继续承担 SIP;Go Agent 重点实现项目特有的授权、配额、状态机和可靠性,不另造协议栈。 + +本轮核查了上游仓库、README、LICENSE、GitHub 元数据、Go module 元数据及相关 issue;搜索结果只用于发现,关键风险以源项目材料复核。**这是选型调研,不是依赖锁版、Go 1.27.1 编译、漏洞清零、媒体压测或真实供应商验收报告。** 未安装依赖、创建 Go 模块或运行收费调用。 + +许可证表是初步识别,不替代锁定源码/传递依赖的完整许可核验。未归档、最近有提交、星数多都不证明正确性或安全;开发分支也不等于可用发行版。 + +### 1.1 强制实施规则 + +1. 先复用标准库、Asterisk 原生能力、现成官方/社区 SDK;协议能力不能因为“只有几十行”就自写。 +2. 业务侧允许自写租户约束、CAS、事务、inbox/outbox、公平调度、最终许可、资产绑定及必要薄适配。这些不能让 SDK 的默认行为替代。 +3. 不允许自写 SIP/SDP 协议栈、ARI 客户端、RTP/RTCP 编解包、G.711、WebSocket 握手/帧、AMQP 客户端、SQL 驱动、OSS 签名、可用 SDK 已覆盖的 AI 协议或 Schema 解析器。 +4. 不合适的库按“同类替代→现有 API 组合→上游修复/受控补丁”处理;记录维护/许可证/兼容性缺口并阻塞对应功能。**不能自行决定退回手写;例外必须用户另行批准。** 受控补丁也不得暗中演变为整套自研客户端。 +5. 不把整张候选表全装进项目。每个真实需要只选必要的一套依赖,保留许可证、固定版本/commit、校验和、变更理由和维护责任人。 + +### 1.2 首发最小选型范围 + +- P0/P1只核验实际采用的Cobra、grpc/protobuf、SQLite/迁移、AMQP、ARI/媒体、Schema与必要健康采样、各类AI SDK、录音/受限OSS上传能力;不要求所有候选逐一PoC。 +- 三家SIP通过现有Asterisk/PJSIP独立trunk接入,不引三套Go SIP客户端。静态发布用批准制品与受控部署入口,不先引在线发布/回滚平台。 +- ASR-only及ASR+LLM+TTS均交付;本轮用户明确选定百炼/火山两种ASR、OpenAI兼容LLM、火山TTS。每个适配只选一套SDK,不增加自动AI编排;模型/协议、参数与真实验收仍是P1门禁,见交互GAP-08/09。 +- P1调度骨架/单 Cell 配额与第二阶段等权公平都在单活D事务和普通有界循环内完成;不增加调度框架、分布式锁、规则引擎或插件系统。 +- 生成/校验覆盖使用入口及引用闭包,来源包不裁剪、只读索引不手改。未来功能启用时再增量核验依赖;已用依赖的许可证/漏洞/SDK重试安全不得削减。 + +### 1.3 当前阶段的实施选型 + +下表为本次采用方向;只有完成module/tag/commit/hash/许可证、Go1.27.1构建和对应PoC后才进入锁文件。**不把历史@latest观察当锁定版本,不同时安装同能力候选。** + +| 能力 | P1采用方向 | 暂不引入/必须证明 | +| --- | --- | --- | +| 语言/CLI/配置 | Go1.27.1、github.com/spf13/cobra;部署配置使用标准库JSON,业务AI配置走SaaS源Schema | 不引Viper/热配置中心,不用env覆盖模型/音色等业务值 | +| Unary/mTLS | google.golang.org/grpc、google.golang.org/protobuf及官方生成工具 | 已批Proto、身份/许可PoC;不加内部MQ/双向音频流 | +| D存储/迁移 | database/sql+modernc.org/sqlite;github.com/golang-migrate/migrate/v4的database/sqlite后端 | 无CGO方向;核验现代SQLite驱动和迁移事务语义,Agent无DB;不引ORM/PG | +| MQ | github.com/rabbitmq/amqp091-go | 直接使用官方ACK/confirm/return;不同时加go-rabbitmq封装或自研AMQP客户端 | +| SIP/ARI | 固定Asterisk/PJSIP;github.com/CyCoreSystems/ari/v5以v5.3.1为当前隔离核验目标 | Asterisk 22.10.1 下的 ExternalMedia/bridge/PCMA RTP/录音 runtime PoC 已通过,模块许可证为 Apache-2.0 且 Go1.27.1 临时 module 可 `go vet`/`go test`;隔离 PJSIP/PJSUA2 mock leg 已通过同通道 PCMA RTP/录音/ARI cleanup;仍需正式 tag/commit/hash、依赖/安全审查、生产静态制品、供应商真实 SIP 门禁,不能采用module错配的v5.4.0;不引sipgo/diago/NATS | +| 媒体/录音 | github.com/pion/rtp;按通道需要pion/rtcp;Asterisk原生转码/录音;Go侧必要时zaf/g711 | 不自写编解包/重采样/WAV;音频格式由有效配置与实际媒体能力协商/校验 | +| Schema/生成 | github.com/santhosh-tekuri/jsonschema/v6;github.com/oapi-codegen/oapi-codegen/v2作生成器首选 | 用发行版验证本项目3.1/2020-12及可选零值;libopenapi只作替代评估,不预装双解析栈 | +| 百炼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 | 标准库net/http消费SaaS签名PUT;只有需要OSS API/受限凭据时引官方aliyun/alibabacloud-oss-go-sdk-v2 | 不自写签名、不持多余长期AK、不另建上传配置源 | +| 健康/日志/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或生产准入。 + +## 2. 推荐与候选矩阵 + +“优先”是PoC顺序,不是生产准入;实际采用组件须通过 [验收方案L01–L08及相应A项](验证与切换验收_v0.3.md),适用阶段以该文§1.2为准。 + +| 能力 | 优先复用的项目/原生能力 | 本轮许可/维护观察 | 决策与边界 | +| --- | --- | --- | --- | +| CLI双命令 | [spf13/cobra](https://github.com/spf13/cobra) | 已核对README和Apache-2.0许可文本;仓库API维护元数据本轮受限 | 唯一main/单module注册agent、dispatcher;不手写flag解析、不增加第三业务命令或为配置自动引入Viper | +| 内部RPC | [grpc/grpc-go](https://github.com/grpc/grpc-go) | 已核对Apache-2.0许可 | 使用google.golang.org/grpc的Unary、连接复用、TLS/拦截器;不加内部MQ/双向流,不手写RPC框架 | +| 协议编码/生成 | [protocolbuffers/protobuf-go](https://github.com/protocolbuffers/protobuf-go) | 已核对BSD-3-Clause许可 | 使用protoc及官方生成插件;Proto须先冻结,原JSON契约不能手工复制成另一套业务字段 | +| 主机/进程采样 | [shirou/gopsutil](https://github.com/shirou/gopsutil) | 已核对BSD-3-Clause等源许可声明;需继续核验传递依赖 | CPU/load/内存/磁盘/进程/FD优先用库,区分host/cgroup/process;权限不足报告unknown,不自写/proc解析 | +| SIP、注册、协商、通话桥 | 已选定的 Asterisk/PJSIP | 沿用固定镜像;发行及组件许可清单在打包时核验 | 继续复用,不在 Go 里重新实现 SIP,不变更生产架构 | +| ARI HTTP/事件/桥/录音 | [CyCoreSystems/ari](https://github.com/CyCoreSystems/ari) | Apache-2.0,未归档;main 已为 `/v6`,tag/模块有不一致风险 | 首选评估,但必须先验证 major/tag、ExternalMedia 和镜像兼容;不用示例中的 NATS proxy 引入第二条业务总线 | +| 直接 Go SIP 的备选 | [emiago/sipgo](https://github.com/emiago/sipgo)、[emiago/diago](https://github.com/emiago/diago) | 前者 BSD-2-Clause,后者 MPL-2.0;均未归档 | 确有现成实现,但当前不引入;它们不是 ARI 客户端,不能借选库替换 Asterisk 路线 | +| RTP/RTCP | [pion/rtp](https://github.com/pion/rtp)、[pion/rtcp](https://github.com/pion/rtcp) | MIT,未归档 | 用于编解包、扩展、payload;RTCP 按实际通道需要引入,不为 RTP 拉入完整 WebRTC 栈 | +| G.711/音频变换 | Asterisk 原生转码;必要时 [zaf/g711](https://github.com/zaf/g711) | g711 为 BSD-3-Clause,未归档 | Go 侧确需 PCMA/PCMU↔PCM 才引入;Pion 的 G.711 payload 处理不等于 PCM 解码/重采样 | +| 录音/WAV | Asterisk 已有录音能力及 ARI SDK | [go-audio/wav](https://github.com/go-audio/wav) 为 Apache-2.0,但本轮 API 标记 archived=true | 不选该归档库作为新生产默认,不因此手写 WAV/重采样器;如需替换录音路径先评估维护中的库并证明双向录音/封口等价 | +| AMQP 0-9-1 | [rabbitmq/amqp091-go](https://github.com/rabbitmq/amqp091-go) | 官方维护,未归档;API 许可为 NOASSERTION,读取 LICENSE 为 BSD 风格二条款 | 推荐;confirm/return/ACK/关闭需显式处理;库不自动提供业务幂等或 exactly-once | +| AMQP 自动恢复封装 | [wagslane/go-rabbitmq](https://github.com/wagslane/go-rabbitmq) | MIT,未归档,README提供恢复抽象 | P1不引入,直接用官方客户端的连接/channel生命周期与业务恢复;未来替换须证明manual ACK、confirm/return/世代/窗口语义 | +| PostgreSQL(历史调研) | [jackc/pgx](https://github.com/jackc/pgx) | 原调研为MIT | **用户已确定不接PG,本项目不引入**;保留此行仅作选型历史 | +| Dispatcher SQLite | [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) | BSD-3-Clause、无CGO,主源[GitLab](https://gitlab.com/cznic/sqlite) | P1驱动方向确定,锁匹配libc并测WAL/Sync/锁/checkpoint/恢复;未通过不入镜像,Agent不建SQLite | +| 数据库迁移 | [golang-migrate/migrate](https://github.com/golang-migrate/migrate) | API NOASSERTION,但LICENSE全文MIT | 只验证本项目SQLite后端兼容,使用现成迁移工具;不借迁移功能引入PG或Agent DB,可逆性须实测 | +| 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 | 签名 PUT 用标准库 `net/http`;需要 OSS API 时 [阿里云 OSS Go SDK v2](https://github.com/aliyun/alibabacloud-oss-go-sdk-v2) | Apache-2.0,官方、未归档 | Agent 消费 SaaS 授权,不另持长期AK/接管签名服务;SDK/HTTP保留 headers、会话、对象和不可覆盖约束 | +| 指标 | [prometheus/client_golang](https://github.com/prometheus/client_golang) | Apache-2.0,未归档 | 需要 Prometheus 时直接复用;不得自写 exposition 格式或无限维度指标 | +| 日志/HTTP/TLS/并发 | `log/slog`、`net/http`、`crypto/tls`、`context` 等 Go 标准库 | 随固定工具链交付 | 不新增同功能框架;认证、权限和资源上限仍须落实 | + +### 2.1 本轮新增组件证据范围 + +Cobra、grpc-go、protobuf-go、gopsutil的GitHub API查询被rate limit限制,**未获得可用于判断当前归档/更新时间的API元数据**。随后通过公开源码README/LICENSE核对用途与许可证,不把这一步说成已完成维护/漏洞/版本可用性审查。正式选版仍须L01。 + +gRPC采用Unary,不传整通话音频;官方health服务只能证明RPC可响应,业务ready另由AgentStatus/配置/许可判断。共享Agent证书+受限节点会话属于业务授权策略,复用TLS/凭据/拦截器能力,不自创加密协议。Agent文件用标准库与现成录音能力,不以“无SQLite”为名重写通用数据库。 + +## 3. ARI 是首个兼容 PoC 门禁 + +本轮发现的真实风险,不应靠 README 安装命令跳过: + +- 仓库 main 的 `go.mod` 声明 `github.com/CyCoreSystems/ari/v6`、Go 1.24;README 同时保留“5.x current”描述,声明 v6 需要 Asterisk 14+。这是文档/版本线不一致信号,不是本项目镜像已经兼容的证明。 +- GitHub tags 列出 v5.4.0,但本轮读取 **v5.4.0 URL 下的 go.mod 也声明 `/v6`**。在 `go get`/构建验证前,不能把该 tag 写成已锁定的 `/v5` 依赖。 +- [issue #190](https://github.com/CyCoreSystems/ari/issues/190) 指出 ExternalMedia 创建响应中的变量信息难以取得;而本项目必须拿到媒体本地地址/端口并验证就绪。 +- SDK 已有 `Channel.GetVariable`,先验证它能否满足 `UNICASTRTP_LOCAL_ADDRESS/PORT` 读取与时序。失败则修上游/选择替代库,不借机手写一套 HTTP/WS ARI 客户端。 +- SDK 的 Stage/Subscribe/Exec 模式可帮助先订阅后创建,必须测试事件快于响应、断连和重复事件;它不能自动解决执行账本、最后授权和不确定 originate。 + +P0 的最小证明:锁定可用模块/commit → 本地固定 Asterisk + Mock SIP → 经 SDK 创建与订阅 → ExternalMedia 端口/桥成员就绪 → 双向标识音/封口录音 → 断连/响应丢失不二次拨号。真实公网线路不是该 PoC 前置。 + +## 4. AI 接入:供应商范围已定,协议与参数须PoC + +用户确认百炼/火山ASR、OpenAI兼容LLM、火山TTS。模型ID、voice/resource、API版本与参数**不是源码常量**,由SaaS不可变任务配置经D下发;端点/凭据仅来自可信引用。ASR两个适配首发均需验证,但单执行只选其中一个,不因超时自动切换。完整参数链路和上游缺口以 [交互§6.1–§6.3](通信与事件数据交互_v0.1.md) 为唯一设计说明,本表不复制Schema。 + +### 4.1 已有 ASR 行为基线 + +现有 Go ASR 的代码检查表明:百炼走 WebSocket `run-task`,PCM 16kHz;火山已有新 API-key 与旧鉴权兼容逻辑、序列/压缩等帧语义。迁移先复用测试与经过核验的行为,不以现有实现是手写为理由在新项目继续手写。 + +| 服务 | 现成候选与证据 | 判定 | +| --- | --- | --- | +| DashScope 实时 Paraformer | [devinyf/dashscopego](https://github.com/devinyf/dashscopego)、[casibase/dashscopego](https://github.com/casibase/dashscopego) | 社区 MIT、未归档;devinyf README 明确勾选实时 Paraformer,TTS 尚未完成;两个仓库本轮最近 push 仍在2025年,不能标成已验证活跃生产维护 | +| DashScope 另一候选 | [ceoifung/go-dashscope](https://github.com/ceoifung/go-dashscope) | MIT、未归档;README 声明 WebSocket Paraformer/上下文支持。候选而非官方背书,Linux/依赖/取消/包边界须实测 | +| 阿里 NLS | [aliyun/alibabacloud-nls-go-sdk](https://github.com/aliyun/alibabacloud-nls-go-sdk) | 官方 Apache-2.0,但 NLS 与 DashScope/FunASR 并非相同服务。不能因“都是阿里ASR”就直接替换端点/凭据/模型 | +| 火山 SAUC | [GizClaw/doubao-speech-go](https://github.com/GizClaw/doubao-speech-go) | 社区 MIT、未归档;README 声明 ASR V2 SAUC、鉴权与流式接口;不等于经典火山接口也覆盖,成熟度与兼容性仍待PoC | +| 火山 LLM/其它云 API | [volcengine/volcengine-go-sdk](https://github.com/volcengine/volcengine-go-sdk) 的 arkruntime 等 | 官方 Apache-2.0;已确认目录存在,但不能以 Ark LLM SDK 冒充 WebSocket ASR SDK | +| OpenAI 协议 LLM | [openai/openai-go](https://github.com/openai/openai-go) | 官方 Apache-2.0,支持流式/取消与可配置重试;只有批准供应商的接口确实匹配才使用,不默认接入收费 OpenAI | +| 火山TTS | 优先核验[GizClaw/doubao-speech-go](https://github.com/GizClaw/doubao-speech-go)的流式TTS能力 | 模型/音色/协议和可调字段仍须匹配;当前真实TTS未启用且属P1门禁,不能用可导入或只返回音频替代参数验收 | + +FunASR、Paraformer、Qwen实时等名称不代表相同事件/结束语义;P0针对实际选中模型核验帧/最终结果/取消/超时/背压及错误映射。双模式使用同一套已批准能力的必要子集,ASR-only不初始化LLM/TTS;不为每种模式另造框架。缺适配先修上游或换库,不能复制旧LLM/TTS或手写替代协议。 + +### 4.2 SDK 默认重试必须审查 + +[OpenAI Go README](https://github.com/openai/openai-go#retries) 明确默认对部分连接/408/409/429/5xx 错误重试2次,可用 `WithMaxRetries(0)` 关闭。对已消费流、已产生音频、取消和不确定外部副作用不能默认重发。ARI 的 originate、SaaS 控制、OSS PUT/complete 同样逐操作定策略;“用了 SDK”不构成幂等证明。 + +MQ publisher confirm 重试与业务重拨不是一件事,SaaS 授权/CPS/控制屏障仍是最终裁决;依赖的自动恢复不得另放一份额度或漏掉 ACK/return。 + +### 4.3 参数可达性是选库门禁,不是后期优化 + +- 先制作“源字段/默认/单位 → D有效快照 → SDK请求字段或本地控制器 → 协议Mock观测/真实行为”的映射样本。已有temperature/max_tokens/voice/speed/language/interim/timeout/音频格式和对话控制必须逐项覆盖,扩展按GAP-09批准后同样覆盖;不能用SDK默认值或固定构造器参数盖掉SaaS值。 +- DashScope Paraformer示例包含格式、采样率、语言提示及识别控制;示例16kHz、模型名或词表不是生产配置。须逐项核验所选百炼模型的热词/VAD/标点等支持及取消语义;不支持FunASR不能冒充百炼全部模型已支持。 +- 本轮检索到的火山SDK `TTSV2WSConfig/AudioParams` 文档对语速/音量/音调等控制的暴露尚不足以证明所需覆盖。必须在锁定源码/协议请求上确认;缺失字段优先替代或提交有类型上游补丁并复测。**不能忽略已有tts.speed、写死speaker/resource_id,或退回自写WS/二进制客户端。** 必要能力未补齐则TTS SDK锁定/P1保持blocked。 +- OpenAI兼容不保证每种model支持相同temperature/top_p/stop/Token语义;由SaaS的受控供应商/API版本和能力矩阵选择SDK请求。LLM BaseURL/模型/Token上限从有效快照解析;SDK默认的环境变量、模型和2次重试不能成为隐藏行为。 +- 共享HTTP/WS连接设施不等于共享可变业务请求;用两个Agent并发运行不同参数的合成任务,确认没有串model/voice/凭据。温度0、false、空列表和未提供必须保真;实际SDK字段或本地控制器收不到参数即验收失败。 +- 新配置以新版本用于新任务,无需重启;单通话固定快照和SDK版本,脱敏诊断可追溯。库不具备必需可调能力就不要过早锁库;换库/补上游仍须许可证和供应链检查,不扩成通用插件框架。 + +## 5. 版本/许可/能力核验记录 + +下表是此前调研观察,不是最终go.mod;P0对实际采用项重新核验/锁定,未采用候选无需为首发补齐PoC。 + +| 对象 | 本轮可核验信息 | 仍未证明 | +| --- | --- | --- | +| ARI | main / v5.4.0 路径的 module 均观察到 `/v6`;有 GetVariable、ExternalMedia、Stage API | 正确可解析的 tag/major、当前 Asterisk 镜像语义、重连/录音可用性 | +| modernc SQLite | Go proxy 返回 v1.59.0,go.mod 为 Go1.25.0、libc v1.75.7,源码来自GitLab | 本项目Go1.27.1所有目标平台/锁争用/恢复/内存性能 | +| jsonschema/v6 | proxy 返回 v6.0.3,go.mod 为 Go1.21 | 本地所有2020-12约束/引用、JSON v2绑定及拒绝边界 | +| libopenapi | proxy 返回 v0.38.7,go.mod 为 Go1.25.7;MIT | 是否有必要引入、与生成器及本地契约完全兼容 | +| oapi-codegen | 当前 main README 声明3.0/3.1,同时明确说明可能含未发行功能 | 被选发行版对本项目3.1的生成/运行校验是否完整 | +| amqp091-go / migrate | GitHub license字段为NOASSERTION,但 LICENSE 全文分别为BSD风格二条款/MIT | 所选tag全部源码、传递依赖、修改/发行NOTICE与漏洞处置 | +| go-audio/wav | API archived=true,最后push观察为2024-10-28 | 不作为新生产默认;任何替代仍须录音等价证明 | +| JSON/UUID 标准库升级 | 本机已核验Go1.27.1具备相关API | 不代表现有ID、哈希、空值/重复键与事件语义可直接替换 | + +`go` 最低版本满足不等于编译/兼容测试通过;上游代码的 LICENSE、SBOM、transitive notices 和漏洞可达性必须留档。MPL 等许可证不是看到名称就直接批准或拒绝,按实际链接/修改/分发方式核验;不得去掉原作者声明。 + +## 6. 可执行的选库交付要求 + +P0/P1为每个实际采用组件留一条记录:使用阶段/能力、不自写范围、源URL、module、tag/commit、hash、许可证/NOTICE、维护/安全风险、Go/Asterisk/供应商协议版本、重试/取消策略、PoC结果、替代/上游问题与责任人。后续阶段按实际需要扩展,不建立所有候选的重复验收项目。 + +- 对不适用候选记录为什么淘汰,例如“不同供应商API”“归档”“必要字段无法取得”“方言不匹配”,而不是只写“不好用”。 +- PoC未通过、无许可证依据或安全缺口不可接受时阻塞相应组件,不生成假接口或fallback到手写协议。 +- 不在本轮运行 `go get`、创建假运行入口或宣称库已适配;后续实施走验收 L01–L08、S/E 故障矩阵。 +- 研究资料不授予供应商消费、云创建或真实拨号权限;SDK可以先连接独立协议Mock验证,真实供应商单独签收。 + +## 7. 一手核验入口 + +以下链接用于复核本轮观察;动态 main/@latest 不能用作发行锁文件: + +- ARI:[README](https://github.com/CyCoreSystems/ari)、[v5.4.0 go.mod](https://raw.githubusercontent.com/CyCoreSystems/ari/v5.4.0/go.mod)、[channel.go](https://raw.githubusercontent.com/CyCoreSystems/ari/v5.4.0/channel.go)、[ExternalMedia issue #190](https://github.com/CyCoreSystems/ari/issues/190)。 +- RabbitMQ:[官方客户端 README](https://github.com/rabbitmq/amqp091-go)、[LICENSE 原文](https://raw.githubusercontent.com/rabbitmq/amqp091-go/main/LICENSE)、[恢复封装](https://github.com/wagslane/go-rabbitmq)。 +- 迁移工具:[LICENSE 原文](https://raw.githubusercontent.com/golang-migrate/migrate/master/LICENSE)。 +- SQLite:[包文档/许可](https://pkg.go.dev/modernc.org/sqlite)、[本轮版本的 go.mod](https://proxy.golang.org/modernc.org/sqlite/@v/v1.59.0.mod)。 +- Schema:[jsonschema/v6模块](https://proxy.golang.org/github.com/santhosh-tekuri/jsonschema/v6/@v/v6.0.3.mod)、[libopenapi模块](https://proxy.golang.org/github.com/pb33f/libopenapi/@v/v0.38.7.mod)、[oapi-codegen README及发行提示](https://github.com/oapi-codegen/oapi-codegen)。 +- [Pion RTP 的实现范围](https://github.com/pion/rtp)、[G.711 项目](https://github.com/zaf/g711)、[WAV归档状态](https://api.github.com/repos/go-audio/wav)。 +- ASR:[DashScope Paraformer Go示例](https://github.com/devinyf/dashscopego/tree/main/example/paraformer/realtime)、[另一Go实现](https://github.com/ceoifung/go-dashscope)、[火山社区SDK接口范围](https://github.com/GizClaw/doubao-speech-go)、[官方NLS SDK](https://github.com/aliyun/alibabacloud-nls-go-sdk)。 +- [阿里云OSS Go SDK v2](https://github.com/aliyun/alibabacloud-oss-go-sdk-v2)、[OpenAI Go重试规则](https://github.com/openai/openai-go#retries)、[OpenAI Go v3 options](https://pkg.go.dev/github.com/openai/openai-go/v3/option)、[火山官方SDK](https://github.com/volcengine/volcengine-go-sdk)。 +- [火山候选TTS V2参数文档](https://github.com/GizClaw/doubao-speech-go/blob/main/docs/tts_v2.md)、[migrate SQLite后端](https://github.com/golang-migrate/migrate/tree/master/database/sqlite);均须再核验被锁定发行源码,网页/API说明不是通过记录。 +- [Cobra README](https://raw.githubusercontent.com/spf13/cobra/main/README.md)、[Cobra LICENSE](https://raw.githubusercontent.com/spf13/cobra/main/LICENSE.txt)、[grpc-go LICENSE](https://raw.githubusercontent.com/grpc/grpc-go/master/LICENSE)、[protobuf-go LICENSE](https://raw.githubusercontent.com/protocolbuffers/protobuf-go/master/LICENSE)、[gopsutil LICENSE](https://raw.githubusercontent.com/shirou/gopsutil/master/LICENSE)、[gopsutil README](https://raw.githubusercontent.com/shirou/gopsutil/master/README.md)。 diff --git a/docs/通信与事件数据交互_v0.1.md b/docs/通信与事件数据交互_v0.1.md new file mode 100644 index 0000000..2a8b87d --- /dev/null +++ b/docs/通信与事件数据交互_v0.1.md @@ -0,0 +1,335 @@ +# Dispatcher / Agent 通信与事件数据交互 v0.1 + +## 1. 范围、权威与状态 + +本文件保留现有外部命令/事件与内部职责目录,并明确本轮适用范围:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置、ASR-only与完整AI双模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 全量目录不等于本轮全部实现;当前只交付设计,运行通过记录另见验收证据。 + +- 外部业务字段以《SaaS交互_OpenAPI与MQ契约规划_v0.1.md》正文v1.0为权威;现有OpenAPI用于接口/字段核对。原文件名不代表正文版本。 +- [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配置源于SaaS,Dispatcher统一承接,Agent直传OSS。文本继续实时MQ回传,OSS只作归档。 +- 准确的文字事件名是 **`transcript.updated`**;`call.transcript` 是之前讨论中的泛称,不是合法event_type,不新增该别名。 +- 首发AI范围已确认:**百炼/火山ASR、OpenAI兼容LLM、火山TTS**。业务控制参数由Dispatcher按任务版本向SaaS获取,Agent按执行快照使用;不得从源码常量、本地业务配置或SDK默认值形成第二配置源。具体模型/协议/额度仍须批准和PoC。 + +### 1.1 已对齐部分与仍待补齐的约束 + +逐字段及SHA-256复核确认,**当前MQ信封已与正文对齐**;本轮讨论中曾判断存在旧信封漂移,该判断已撤回,不是当前源文件缺陷。 + +| 项 | 当前正文/Schema一致内容 | 仍须验证 | +| --- | --- | --- | +| 命令类别/ID | command_type / command_id | 拒绝旧type/message_id作为替代,保护作用域幂等 | +| 事件类别 | event_type,枚举覆盖8种事件 | 不接受call.transcript等不存在的别名 | +| 聚合版本 | aggregate_type / aggregate_id / aggregate_version | 按实体域合并,不能降为全局event_version | +| 时间 | issued_at / not_after、occurred_at | 区分授权、发生和接收时间,验证截止 | +| 命令payload | required已含task_revision等正文12字段 | 类型、长度、白名单及跨字段业务规则 | +| 事件payload | 目前仍主要是通用object | 8种专属payload、条件必填/状态/失败分支需在上游唯一源补齐 | + +**GAP-01只指事件专属payload和未机读化业务约束的覆盖不足,不指信封字段漂移。** P0补齐并跑正反例。本轮不覆盖父项目已有文件;不能把仅通过通用object校验当完整事件验收。 + +### 1.2 分期不改变外部契约 + +- P1保留call.execute、既有7条SaaS交互路径和8种事件;整体补传是现有交付可靠性能力,不是待建设的通用回放平台。 +- 42操作/115组件仅为上游目录;不把管理平台30操作移入Dispatcher。按实际入口及引用闭包生成校验,来源包/只读索引仍完整留存,不通过删Schema缩小范围。 +- 单租户仅指启用策略:保留tenant_key精确路由、租户独立队列/复合幂等键、有界窗口和单 Cell 全局配额;双租户公平、第二 Cell 汇总和多Dispatcher协调另立第二阶段。 +- P1不新增MQ模式字段、事件或临时接口。ASR-only的Schema表达须先补GAP-08;R04/R06在线改配延后,但最后许可、控制、静态维护屏障和持久恢复不能延后。 + +## 2. 角色、传输和可靠性边界 + +| 通道 | 发送方 → 接收方 | 内容 | 接受/交付的含义 | +| --- | --- | --- | --- | +| RabbitMQ命令 | SaaS → Dispatcher | call.execute | Dispatcher事务持久受理/拒绝和outbox后才ACK | +| HTTP现有7路径 | SaaS ↔ Dispatcher(5)及Dispatcher → SaaS(2) | 控制/查询/整体补传;录音上传申请/complete | 控制202只代表accepted,complete为对象验证,不是业务结果回调 | +| 既有AI配置读取 | Dispatcher → SaaS受控配置服务 | 任务引用的不可变AI版本及获授权供应商配置引用 | 复用§6.1的已有GET,不新增HTTP拨号或任务业务回调;Agent不直连SaaS | +| 内部Unary gRPC | Dispatcher ↔ Agent | 执行授权、控制、配置、状态、最终文字、上传元信息 | 每个RPC有独立deadline、权限、请求关联及幂等;不是一条双向数据流 | +| ARI/RTP | Agent ↔ 本Cell Asterisk | 通道/桥/媒体/录音 | 实际拨号副作用不与任何数据库事务原子提交 | +| OSS数据面 | Agent → OSS | P1已封口录音;文本OSS归档后续 | PUT成功不等于SaaS verified,ETag不等于SHA-256 | +| RabbitMQ结果 | Dispatcher → SaaS | 本文8类event_type | publisher confirm只表示broker收妥;SaaS用inbox+业务同事务应用,无额外应用收讫协议 | +| SIP配置管理 | 管理平台 → 批准静态制品/受控部署 → Agent;D核验准入 | 版本/哈希/目标及实际加载事实,P1维护窗口生效 | 管理平台唯一编辑面;静态交接见GAP-03;在线D推送暂缓 | + +Agent不持MQ/SaaS管理凭据、不直接消费SaaS队列,不新增公开HTTP拨号/结果回调。普通Unary同样复用HTTP/2连接,不能按每通电话新建连接。 + +## 3. 标识和版本不可混用 + +| 标识/版本 | 范围与用途 | +| --- | --- | +| tenant_id / tenant_key | 前者可信归属,后者原值一对一队列绑定;不清洗/编码/截断,224 UTF-8字节预算不满足时保留原任务并停发 | +| command_id | 租户作用域业务命令幂等键;HTTP Idempotency-Key及正文关联按原契约 | +| execution_id | 租户作用域授权执行,换command_id不得重复拨号 | +| task_id / task_item_id / task_revision | 任务、成员和控制版本;与软件/配置版本无关 | +| 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 | +| agent_id / cell_id(内部草案) | Dispatcher预配置的执行端身份与Cell绑定,不能由Agent自报覆盖;共享证书不等于单节点身份 | +| boot_id / session_epoch(内部草案) | 一次进程启动及Dispatcher绑定代次;旧回报不能覆盖新会话,旧执行事实仍需对账,不直接丢弃 | +| agent_version / protocol_version | 二进制发布版本、gRPC协议版本;不是AI的agent_version_id | +| desired/applied revision、config_sha256 | 发布意图与实际加载事实;相同版本异哈希冲突,不能以文件已写代替applied | + +内部关联字段最终名称/格式在Proto冻结时确定。现有外部信封/配置优先以**原版本JSON字节+schema引用/摘要**嵌入内部消息并按源Schema校验,避免在Proto再手写一份业务Schema;不得经Struct/float转换破坏大整数、空值或哈希语义。 + +## 4. 现有call.execute完整业务入口 + +Routing key:`agent-call.tenant.{tenant_key}.call.execute`,direct exchange;资源命名空间仍为agent-call。 + +| 外壳字段 | 语义 | +| --- | --- | +| schema_version | 已支持契约版本;不支持明确拒绝 | +| command_type | 固定call.execute | +| command_id、tenant_id、tenant_key、trace_id | 幂等、归属、路由和追踪 | +| issued_at、not_after | 签发和每次新发起截止;不能以重投延期 | +| payload | 下表;不接受任意SIP/AI URL、凭据或主叫注入 | + +| payload字段 | 约束 | +| --- | --- | +| execution_id、task_id、task_item_id | 原业务归属及执行身份 | +| task_revision | 必须等于当前已生效、允许运行的控制版本 | +| callee | 原始号码,不提前拼线路前缀;服务端白名单另验 | +| route_policy_id、caller_profile_id | 服务端已配置并授权的策略/主叫引用 | +| agent_version_id | 当前租户可信、不可变AI快照 | +| variables | 白名单、类型、长度约束;非代码或任意URL | +| ring_timeout_ms、max_call_duration_ms | 不超过服务端/供应商上限 | + +Dispatcher先验身份/Schema/关联,再识别历史幂等事实;新执行才检查当前时效/控制/配置/资源。固定admission_deadline,准入失败有界终结,不依赖资源释放才扫描。实际发起前再次检查所有租约/控制/截止;意图已落地但是否发出未知时reconciling,不回退成无call_id的拒绝,也不重拨。 + +P1从管理批准的静态route_policy/caller_profile选择供应商trunk与获授权Cell,不在通话过程中改绑或自动跨供应商重拨。每家使用原始callee应用自身规则。AI模式从该租户不可变agent_version_id读取:ASR-only只占ASR资源且不调用LLM/TTS;完整模式同时校验三类AI额度,不能静默降级。当前Schema缺少明确模式表达,批准GAP-08前不伪造空LLM/TTS配置。 + +## 5. 8种SaaS业务事件全集 + +### 5.1 通用外壳 + +每种事件必有:`schema_version`、`event_id`、`event_type`、`tenant_id`、`tenant_key`、`trace_id`、`occurred_at`、`aggregate_type`、`aggregate_id`、`aggregate_version`、`payload`。 + +Routing key为`agent-call.{event_type}`。SaaS按`(tenant_id,event_id)`去重,inbox与业务更新同事务,成功后ACK。Dispatcher保存版本化快照及outbox,重发保留event_id/内容,不因RPC重报创建第二个业务事件。本文不会把示例里的可选字段擅自升级成机器required;尚缺的payload Schema见GAP-01。 + +| event_type | 事实来源 / 发布者 | 必需或条件业务字段 | 时点与合并 | +| --- | --- | --- | --- | +| command.result | Dispatcher自身受理/控制/执行汇总 → Dispatcher | command_id、command_type、status、reason_code;适用的task/execution/call关联;execute含等待/准入字段;control含requested/applied_task_revision | command聚合;accepted/waiting不代表已拨;202不代表applied;重试不重复递增revision | +| 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 complete/独立对象校验成功 → Dispatcher | call_id、recording_id、oss_id、format、channels、sample_rate_hz、duration_ms、size_bytes、checksum_sha256 | recording域;只报告verified资产,不携带上传凭据/公开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及时持久禁发并处理关联任务屏障,不等挂断;不自造关键词判定 | + +中文描述但尚无精确JSON键/类型的字段(例如部分终止原因、attempt汇总、失败细分)必须在GAP-01中按上游定义补全后生成;不得由Go实现自行发明。表中源于既有样例的具体键须通过正文/机读联合验收。 + +### 5.2 文字与资产语义 + +- 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不启用且不阻塞实时文字。 +- ASR-only仍上报真实customer文字及获批opt-out事实,不伪造agent回答/播放或接通证据。两模式下角色/播放状态/失败分支的合法组合须在GAP-01/GAP-08补齐;不能为省事关闭实时文字或opt-out。 + +## 6. 现有HTTP接口归属与字段查阅 + +全部参数、请求/响应、错误和Schema见 [字段索引](OpenAPI与MQ字段索引_v0.1.md)。保留的7个SaaS业务交互路径为: + +| 方法/路径 | 新实现归属 | 注意 | +| --- | --- | --- | +| POST /internal/v1/outbound/tasks/{task_id}/controls | Dispatcher提供 | expected_task_revision CAS;pause/resume/stop,stop显式drain/hangup及权限;202≠applied | +| GET /internal/v1/outbound/commands/{command_id} | Dispatcher提供 | command聚合快照和等待/准入字段 | +| GET /internal/v1/outbound/calls/{call_id} | Dispatcher提供 | 通话/attempt与独立资产状态快照 | +| POST /internal/v1/outbound/calls/{call_id}/replays | Dispatcher提供 | 固定截止点整体补传,不支持局部事件/turn筛选 | +| POST /internal/v1/outbound/commands/{source_command_id}/replays | Dispatcher提供 | command尚无call也可补传;保持原ID,不递归自身结果 | +| POST /internal/v1/outbound/recording-uploads | SaaS提供,Dispatcher调用 | 租户/幂等/录音元信息校验;Agent不直接感知SaaS认证域 | +| POST /internal/v1/outbound/recording-uploads/{upload_id}/complete | SaaS提供,Dispatcher调用 | 对象独立验证;verified之前不得发布recording.ready | + +若源OpenAPI的实际路径/参数变化,先同步源指纹和索引,不按本表猜测实现。HTTP作用域、Idempotency-Key、tenant/target绑定、404/409/410等保持原语义。AI配置作为实际需要的外部配置源消费,不重建其管理面;管理平台30操作不移入Dispatcher。Cell的3个mTLS操作保留参考,P1可经批准静态制品交接而不暴露旧在线apply面;不能同时让管理平台、部署脚本和Dispatcher各自写同一配置。 + +### 6.1 SaaS任务配置 → Dispatcher → Agent(P1必需) + +“任务配置”沿用MQ任务的 `agent_version_id` 及获批 `variables` 引用:**Dispatcher调用已有 `GET /internal/v1/ai/agent-versions/{agent_version_id}` 获取SaaS维护的不可变配置**,不新增猜测的task-config路径。AI配置发布接口仍由上游拥有,D/Agent不另建编辑面;该GET由哪个SaaS部署入口承载、鉴权/租户绑定及provider_ref的受控解析合同在GAP-09冻结。其不改变前述7个业务交互路径的定义。 + +1. D先验MQ可信租户/版本/幂等;历史执行走原事实恢复,不因调参重新执行。新执行在发起前取得对应租户版本,不把模型/音色/timeout等直接加入call.execute。 +2. D验证源Schema、不可变内容摘要、租户授权、两种模式和SDK能力,解析该版本批准的provider_ref/credential_ref。SaaS返回的受控供应商配置提供API种类/协议版本、端点、region/资源标识等;缺合同标blocked,不在源码中按供应商名称拼端点或填示例resource_id。 +3. 缓存键至少绑定租户和agent_version_id;同版本异内容拒绝并告警。按批准的撤销/新鲜度策略使用已验证缓存;SaaS不可达且无仍有效的已授权快照则暂停/拒绝新准入,遵守原admission_deadline,不用默认模型、其它租户缓存或无限期旧配置顶替。HTTP读取可有界重试,不持SQLite事务等待网络。 +4. D将该执行最终有效配置、源版本/摘要及SDK能力匹配绑定到原execution。沿获批R07传送原Schema JSON/摘要或已确认缓存引用;引用缺失可经R03受控获取,**不依赖延后的R04热更新**。A二次校验并报告实际使用版本/摘要,错版本不进入最后许可。 +5. Agent从会话局部只读快照生成SDK请求和本地控制器参数;可复用连接/Transport,不修改所有通话共用的model/voice/temperature等全局对象。首发有明确的百炼/火山ASR薄适配,选择一次固定到执行,不建插件、自动AI fallback或同通话动态换供应商。 +6. 在已支持并获授权的模型/参数范围内,调参在SaaS发布新AI版本,由新任务显式引用后生效,无需改Go代码、重建镜像或重启D/A。排队旧任务/在途通话固定原版本,不能读取“latest”热改;授权撤销/stop仍按控制协议收敛,不以快照固定为由忽略撤销。 + +**静态发布仅指SIP/节点部署配置,不意味着AI业务参数写死。** 配置读取不是业务结果HTTP回调;实际音频仍A↔供应商,SaaS/D不代理音频流。 + +### 6.2 参数覆盖与缺口(需求索引,不是新Schema) + +现有严格对象 `additionalProperties: false` 必须保留。下表“已有”是当前源字段;“待补”是P0向上游提交的语义需求,**不是可直接发送的JSON键/已获批枚举**。每个启用参数须在批准的SDK/模型能力矩阵中有单位、范围、缺省、对应请求字段或本地控制点及测试证据。 + +| 控制范围 | 当前源契约已有 | 首发需补齐/确认的可调能力 | +| --- | --- | --- | +| 通用供应商/模式 | 各AI的provider_ref、credential_ref、model;agent_version_id | GAP-08两模式;GAP-09受控端点/API版本、模型能力、火山app/resource/cluster及认证类型引用的来源/授权,不把密钥作为普通配置值 | +| ASR输入与结果 | asr.language/interim/timeout_ms/input(encoding/sample_rate_hz/channels/sample_width_bytes) | 模型必填或明确缺省、实时中间稿/最终稿行为、发送帧时长/块大小及结束规则;不能沿用示例16kHz覆盖源配置 | +| ASR识别调试 | 当前未定义这些专属字段 | 所选协议支持的热词/词表引用、标点、ITN/文本规范化、语气词/顺滑控制、语种提示、VAD/端点检测/尾部静音阈值;百炼与火山分别映射,不假设同名同义 | +| LLM模型/采样 | llm.model/temperature/max_tokens/timeout_ms | top_p、stop序列、上下文轮数/Token预算;供应商确有需求和支持时补penalty/seed/推理控制,不能把任意extra JSON透传。max_tokens与实际API输出/推理Token语义须匹配 | +| 提示词与变量 | prompt.text/allowed_variables/max_bytes;MQ variables | 渲染失败/缺变量/超限明确拒绝,不执行模板代码;上下文截取策略可审计,不把正文或变量值写入普通日志 | +| TTS声音与音频 | tts.model/voice/speed/timeout_ms/format(encoding/sample_rate_hz/channels) | 所选火山协议支持的音量/增益、音调,以及确有需求的情感/风格;语速/音量单位和范围显式映射,不靠示例speaker/resource_id默认值 | +| 对话/打断 | conversation.opening/allow_interrupt/silence_timeout_ms/max_duration_ms/max_turns | 本地与服务端VAD职责、触发打断的最短语音/防抖等适用阈值;只由可信配置控制,关闭打断也必须保留stop/hangup权限 | +| 分句/缓存/时序 | conversation.sentence_max_chars/max_pending_audio_chunks;各AI.timeout_ms | 分句等待、音频缓存按时长/字节的上限、连接/首结果或Token/首音频/流空闲/总时限的作用域;SDK超时与本地看门狗不能互相覆盖 | + +首发必须把已有字段和实际选定模型支持、商务调试需要的上述扩展接通;不为不存在的模型能力造兼容层。未支持参数要在发布/准入时报明确错误,不能“接受但忽略”;必要参数缺SDK支持则对应能力blocked(见组件清单§4.3)。 + +### 6.3 默认值、适配与安全边界 + +- SaaS发布不可变版本时按获批Schema/供应商能力物化默认值并留来源;JSON Schema的default只是注解,不能假设校验器会自动填。过渡中缺必需有效值则拒绝,不从Go常量、env、SDK默认或示例请求补业务值。协议固定常量及部署安全上限不属于可任意调参范围。 +- 区分未提供、null、显式0/false/空列表;例如temperature=0、interim=false、allow_interrupt=false不能被Go零值/omitempty或SDK设置器吞掉。条件必填与缺省由上游定义,不能因ASR-only跳过整个配置校验。 +- SDK请求字段/单位按批准映射转换,保留requested/effective的脱敏差异;越界、冲突、不支持报可定位字段原因,不静默截断/钳制。总通话时限不得超过MQ命令、授权及平台上限,组合规则G0冻结;任务不能通过调大timeout突破stop/许可/资源硬屏障。 +- provider端点只能来自可信SaaS配置并命中受控host/协议/端口及出口策略,禁任意重定向/内网探测;localhost或IP直连例外须显式管理批准。credential_ref按租户/供应商/执行授权解析,由受控Secret/短期授权交付;Agent不持SaaS管理凭据,不把密钥、prompt、完整请求/对话记入日志。 +- 供应商差异只通过上游批准的有类型、有限范围配置扩展表达;不得借metadata、variables、自由headers或raw_request字典绕过严格Schema。部署只提供身份/网络/硬限额/Secret,不覆写业务模型/音色/语速等值。 +- 默认禁用可能重复计费/播放的AI SDK自动重试(OpenAI显式WithMaxRetries(0));未来开启须有批准的副作用/幂等语义,不因SaaS配置了retry就无限重发。已出流/已取消/结果未知不可重放旧生成。 +- 调试证据仅含获授权的租户/执行关联、配置版本/摘要、SDK及协议版本、参数名/脱敏有效值和拒绝原因;不新增公共调参API。普通日志不打印prompt/变量/密钥,敏感缓存按独立持久化权限和保留策略管理。 + +## 7. 内部gRPC公共规则(草案) + +以下方法名/字段组用于P0审议,**尚无.proto或代码生成物**,不作为新增SaaS接口。 + +- 采用官方grpc-go与protobuf,全部Unary;两个角色都可作为受控gRPC客户端/服务端,共用HTTP/2连接池。 +- 方向认证:Agent只接受受信Dispatcher角色的管理调用;Dispatcher只接受Agent群组证书和有效节点会话。共享证书只证明群组,不证明agent_id。 +- 元数据至少表达协议版本、request/trace关联、已绑定Agent/Cell/boot/会话代次、操作幂等标识、截止时间;具体字段冻结,不把敏感token放业务payload或日志。 +- 外部租户身份从Dispatcher已受理执行绑定,Agent报告不能切换tenant/call/asset归属。动态地址只能从预配置Endpoint列表获得,不执行Agent自报URL,防SSRF/错误绑定。 +- 请求幂等键必须包含操作和目标;同键同内容返回原结果,同键异内容冲突。RPC超时只表示结果未知,不能自动重拨;查询/恢复保持原标识。 +- Execute/Control/Apply返回accepted只表本地接收/持久文件记录;真正applied/终态通过回报或查询确认。业务“不能执行”与gRPC传输错误分开。 +- 重要执行/资产事实先写Agent文件,Dispatcher将事实去重、业务更新与MQ outbox同事务持久后才返回成功。回包丢失重报原fact,不新增MQ事件;该内部确认不是SaaS应用收讫。 +- 状态采样可以覆盖旧快照,最终文字、opt-out、终态、资产事件不可静默丢弃。有界重试/背压,接近文件容量阈值停新准入而非丢事实。 +- 事实的source_sequence/boot用于关联和诊断,不能用Agent全局最大序列丢掉其它通话或迟到资产。认证的当前会话与事实发生时的boot分开;合法历史文件经当前会话上报仍需按原执行对账,不能仅因旧boot丢弃。 +- Proto留存已发布字段号,不复用删除字段;兼容范围、未知枚举/能力降级和gRPC最大消息/超时/并发在G0 profile冻结,不以4MiB库默认值代替契约。 + +### 7.1 错误分类与重试(内部草案) + +| gRPC状态/业务情况 | 调用方动作 | +| --- | --- | +| UNAUTHENTICATED / PERMISSION_DENIED | 不重试成其它身份,更新批准凭据/会话或隔离并告警;不能据此释放未知通话 | +| INVALID_ARGUMENT | 拒绝坏字段/Schema/尺寸,不自动改写为另一任务 | +| FAILED_PRECONDITION | 未引导、版本/配置/资产契约未就绪等先修条件;文本归档缺接口不能fallback录音 | +| ABORTED / ALREADY_EXISTS | CAS/归属冲突或同幂等键异内容,查询原决定,不换ID绕过 | +| RESOURCE_EXHAUSTED | 按受控退避/原截止反压;先确认是否已accepted,不重复占额度 | +| UNAVAILABLE / DEADLINE_EXCEEDED | 结果可能已发生;查询/对账,仅对获批幂等操作重试 | +| CANCELLED | 取消本次RPC等待不等于停止已获授权的通话;业务停止走明确控制命令 | +| NOT_FOUND | 查询目标未找到,不足以证明从未拨过/未上传;结合中央事实和持久文件判断 | + +传输错误码不直接映射SaaS业务reason_code,后者仍由正文契约及事实决定;已接受长任务通过后续报告完成,不持有一个长时间阻塞RPC。 + +## 8. Unary职责目录与首发子集(内部草案) + +字段组均为数据需求,不是已批准的字段编号。对已有HTTP/MQ结构用源契约引用,不重写第二套结构。 + +**P1保留R01–R03、R05、R07–R13的实际职责;R04/R06在线改配延后。** 方法数不作为交付指标,G0可批准合并简单职责,但不能省略许可/控制/查询/事实持久确认/录音交接,也不先生成未用服务空壳。R03用于初次绑定后的策略/版本引用;静态制品由受控部署入口提供,不依赖R04/R06才能启动。 + +| ID / 方法职责 | 方向 | 请求数据 | 响应与副作用 | +| --- | --- | --- | --- | +| R01 GetAgentStatus | D→A | 已配置Endpoint目标、请求关联;激活前只允许Dispatcher身份做受限探测 | 返回boot/版本/能力/采样/加载快照;不会发起电话、不回凭据 | +| R02 ActivateAgent | D→A | D确定的agent/cell绑定、boot、会话代次、受限会话凭据/期限、协议选择 | A校验目标/本地既有归属,保存会话;D持久绑定后才可取敏感配置;冲突或旧boot隔离 | +| R03 GetBootstrap | A→D | 群组mTLS+激活后节点会话、已知运行配置版本 | 未激活只返回pending/非敏感兼容信息;已激活返回本Agent运行策略、SIP/AI期望版本索引、OSS策略引用,不返回全局凭据库 | +| R04 ApplyRuntimeConfig(后续) | D→A | 不可变运行配置版本/摘要/前置版本、适用Agent/boot/会话;受限凭据引用或密封交付 | 校验、原子应用、报告结果;配置不合法/依赖不可用则not-ready,不覆盖最后已确认可用版本 | +| R05 SetAdmissionState | D→A | 受影响trunk/资源范围、发布/维护屏障标识、关闭/开放条件、目标代次 | 关闭新准入并报告许可/预留/拨号/振铃/已接通/未知占用;开放需D确认,不是SaaS任务控制事件 | +| R06 ApplyTrunkConfig(后续) | D→A | Publication原结构:mode/cell_id/trunk_id/revision/expected_local_revision/config/config_sha256,加已冻结内部屏障关联 | SDK/生成器校验和实际加载;沿用Acknowledgement/State语义,未知不假applied;缺屏障拒绝破坏性reload | +| R07 Execute | D→A | 正文call.execute原版本JSON、已持久call/attempt/通道关联、唯一归属/资源许可、路由及配置版本引用 | 接受/拒绝;不等待整通话;同execution不重拨,ARI响应丢失转对账 | +| R08 GetExecutionPermit | A→D | 原执行/attempt、配置/控制/会话版本及许可关联 | D核验控制/时效/完整配额后给有界最后许可或拒绝;所有发出许可纳入pause/发布屏障,不能重复退款/发额度 | +| 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) | recording调用SaaS upload-request;返回受限目标/headers/期限/会话。text_archive分支在GAP-02冻结前拒绝,不伪装录音 | +| R13 CompleteUpload | A→D | 原绑定资产/会话、实际文件元信息与完成事实 | D幂等调用SaaS complete、验证后提交资产状态/outbox;text_archive同样受GAP-02门禁,不以PUT回报直接生成ready | + +P1的R05/R09及静态维护必须校验目标/版本并收敛R08许可,不长期锁SQLite等网络。未来R06同样纳入屏障;“全部Unary”或“静态配置”都不等于无需业务屏障。 + +### 8.1 内部事实与外部事件映射(草案) + +下表列全R11/R13需要承载的事实种类;名称只是草案标签,不新增RabbitMQ event_type。Agent报告事实,Dispatcher裁决全局状态/版本。 + +| 类别 | 来源/入口 | 必须关联的数据 | Dispatcher输出 | +| --- | --- | --- | --- | +| 执行接收/拒绝 | R07结果及R11 | 原命令/执行/意图/Agent/boot、是否已持久接收、拒绝原因 | 更新内部投递状态;按命令状态机发command.result,意图已建立的失败不伪造无call拒绝 | +| 通话阶段观测 | ARI→R11 | call/attempt/通道、线路/Cell/出口快照、观测阶段/时间及证据 | call.status;不能仅凭本地计时报告dialing/answered | +| 通话终态 | ARI/执行器→R11 | 原执行/通话/attempt、终止来源/原因/时长、未决资产 | 对账后call.finished及对应command.result,后处理不阻塞终态 | +| 最终/中间文字与播放 | ASR/AI→R11 | turn/segment/revision/text/final/播放证据及时间范围 | transcript.updated;不把生成当已播放 | +| 文字失败 | R11 | 通话、受影响段/原因/可恢复性 | transcript.failed | +| 拒绝再联系 | 获批判定→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限制 | + +健康采样走R01,不把每次心跳作为持久业务MQ事件。节点移除要先保留受控只收尾状态直到原执行/资产对账完成;强制移除需显式人工恢复路径,不能一删Endpoint就丢弃待交付事实。 + +## 9. Agent状态数据字典(内部草案) + +通过R01周期查询;样本时刻、接收时刻、采样窗口及缺失原因都记录。指标缺失是unknown,不填0。gRPC health SERVING只表示服务能响应,不能替代可拨号判断。下表为能力目录:P1只冻结身份/boot、协议、准入所需CPU/内存/FD/媒体/spool资源、静态applied版本、线路与本模式AI健康;完整IO/负载历史、staged发布态及大清单分页按需后续建设,不能让非准入遥测缺失阻塞整个首发。 + +| 数据组 | 字段需求/语义 | 调度用途 | +| --- | --- | --- | +| 身份与会话 | 预配置agent_id/cell_id、boot_id、会话代次、启动时间、最后采样序列 | 拒绝错节点/旧boot/乱序覆盖;发现重复实例时隔离新准入 | +| 软件 | 二进制版本/构建提交、gRPC协议/功能能力、配置Schema/生成器版本、Asterisk版本及镜像标识(可核验时) | 不兼容禁止调度;同项目更新不要求两角色同时瞬间升级 | +| 整机 | OS/架构、CPU核数/使用率、load1/5/15、内存可用/进程RSS、FD使用/上限、磁盘与spool容量/IO | 拒绝过载而非越配;区分宿主机、容器/cgroup和进程口径 | +| 媒体资源 | 媒体端口总量/已分配/可用、已知通话阶段数、RTP丢包/抖动/包率、ARI连接与事件滞后 | 资源不足/证据过期则跳过,不能把CPU空闲当可无限拨号 | +| 配置 | 每provider/trunk的desired、staged、applied版本/摘要、加载确认时间、状态/错误/发布屏障 | 匹配本次路由的精确已加载版本;pending/failed/漂移不接新任务 | +| SIP线路 | 受配的provider_id/trunk_id、transport/codec能力、注册是否适用及状态、受控健康观测、出口/白名单核验状态 | “支持协议”与“已配置且获授权供应商”分开;不把不需注册线路当注册失败 | +| 凭据 | 仅引用/版本/可用性/到期信息,不返回密钥正文 | 过期/不可解析阻止新准入并告警 | +| AI与资产 | 可信agent_version缓存可用性、ASR/LLM/TTS依赖健康;待上传条数/字节/最老年龄、最近失败/重试 | 按模式必需资源与spool门禁;未启用能力明确标注,不把Mock变real | +| 就绪 | registering/bootstrapping/ready/draining/degraded/offline/quarantined等内部状态及原因 | 最终是否调度由D以权威配额、最后许可及新鲜状态综合决定 | + +使用gopsutil/标准库/ARI SDK,不自写/proc解析器;不读取不必要的用户环境、设备序列号或完整配置秘密。P1仅回传有界静态供应商清单/必要状态,不先建分页服务。新鲜度使用D接收时间,Agent壁钟仅作观测,偏差按既有profile保护;ASR-only不会因未用的LLM/TTS离线变not-ready,完整模式不可缺任何必要依赖。 + +## 10. 文件、事件和OSS交付 + +### 10.1 Agent文件最小集合 + +每个执行/资产有受控目录与元信息文件、文字追加文件、音频临时/封口文件、待回报事实及上传进度。字段记录原tenant/execution/call/attempt关联、内容摘要、是否封口/验证/已被D持久接收、下一步恢复动作;目录名不得直接拼任意tenant_key/外部路径。 + +关键元信息先同步到盘后原子替换,文件单写者、追加记录尾部可识别,不把Flush当Sync;不创建Agent SQLite、通用数据库或自造消息中间件。重启扫描只恢复对账/上传/上报,不重跑originate。录音用现成Asterisk/音频能力,不手写WAV头。 + +### 10.2 录音时序 + +1. 接通开始流式记录实际双向音频;实时文字同时走R11,不等资产封口。 +2. 完成/取消时正确封口;故障时保留完整段并明确不完整状态。 +3. A调用R12,D复用原录音身份向SaaS申请授权;CALL_NOT_REGISTERED保留原文件稍后恢复。 +4. A按指定HTTPS目标/headers直传OSS,不持长期AK;签名失效续原会话,对象ID/内容绑定不变。 +5. A调用R13;D完成SaaS独立对象校验,verified后事务落资产状态和recording.ready outbox。 +6. D可靠发布MQ;A只有取得“D持久接收”还不够立即删文件,仍须满足既有verified、ready交接、无未决恢复、至少24h测试保留条件。 + +### 10.3 文本归档 + +P1保留本地文字恢复文件和实时transcript.updated;文本OSS归档延后,是额外资产,不替代实时文字和opt-out。 + +需要SaaS另定义文本归档授权/complete/引用:资产类型、ID、JSONL或其它格式、编码、内容清单/哈希、segment版本、完整性/失败、保留和查询权限。**现有recording接口没有这些定义,不能用wav/recording_id伪装。** 未冻结前明确“文本OSS归档未启用”,不影响已批准的文字MQ链路;也不宣称该需求已实现。 + +## 11. 断连、重复与乱序处理 + +| 故障 | 必须执行 | 禁止行为 | +| --- | --- | --- | +| Execute超时/响应丢失 | 原ID查D事实及Asterisk/文件,必要时reconciling;SDK不能无条件重试副作用 | 换execution/attempt/Cell重新拨号 | +| ReportEvent回包丢失 | A重报同fact;D关联原event_id及版本 | 新建一个语义重复MQ事件 | +| D不可达 | A停新执行,已有获授权通话按原策略继续、文本/录音/结果落盘;恢复补报 | 因断RPC就伪造call.finished或直接释放未知占用 | +| A新boot/失联 | D保留未知占用,重新绑定前对账、核验配置/许可;不自动迁移活动通话 | 看到新boot的0通话就清旧配额 | +| 配置部分成功 | 阻塞受影响资源、保留真实installed快照、对账/回滚也要确认 | 只改desired/active指针就重新ready | +| OSS成功但complete/回报丢失 | 原资产/会话幂等恢复;防旧签名覆盖verified对象 | 新建另一份资产或重拨 | +| D恢复较旧SQLite备份 | 停新准入、恢复唯一所有权、比对A/MQ/资产事实再开放 | 恢复过期许可/遗漏幂等水位后直接运行 | +| 永久丢盘 | 报明确资产/文字失败与告警,仍对账SIP副作用 | 假称文件可恢复或用合成内容替代 | + +## 12. P1静态发布与后续在线发布 + +P1数据流:管理平台审批不可变制品 → 核验来源/版本/哈希及单 Cell 授权 fixture → D/R05关闭受影响资源新准入、收敛许可/占用 → 维护窗口由受控部署入口原子交付/加载或重启 → R01/R10及Asterisk实际加载证据核验 → D恢复满足条件的资源准入。初装也先核验再ready;有未知占用不得跳过屏障,失败/人工恢复均重新核验,不靠旧active指针自动开放。 + +`Publication`和`Acknowledgement/State`见字段索引。GAP-03只先批准P1静态制品/加载事实所需适配:精确cell/trunk、revision、config_sha256、来源和唯一写入路径;不私改现有Schema、不假称在线API已支持。凭据仍用credential_ref受控解析,不能塞入MQ。 + +未来在线发布才增加D持久发布编排、R04/R06推送、全目标回执/动态新增移除/自动回滚。P1静态节点清单的人工变更和凭据轮换仍须维护/排空/重激活;稳定SIP配置不逐呼改写。配置变更不自动授权真实测试呼叫或额外注册探测,管理平台30个业务API不搬入Dispatcher。 + +## 13. 缺口与冻结责任 + +[G0开发准备与契约冻结方案](G0开发准备与契约冻结提案_v0.1.md) D01–D10方向及模式/许可/恢复机制已获用户确认;下表仍跟踪尚未交付的源字段/合同和验证,不再表示已确认方向待用户审批。该文档不是第二套Schema,权威源发布并验证后才关闭相应GAP。 + +D07已确认:Agent通过R12从Dispatcher获取受限OSS配置,随后**Agent→OSS直接上传文件内容**;R13只向Dispatcher提交原资产/会话及上传元信息。Dispatcher不代理/转发录音文件,仍负责SaaS会话申请/complete,verified后经MQ回传OSS ID;每次下发token有效15分钟,过期后仅接受显式重新申请,不自动续期;Agent不直连SaaS不等于不能直连OSS。 + +| ID | 缺口 | 文档处理/退出条件 | +| --- | --- | --- | +| GAP-01 | 信封已对齐,但8种事件payload专属Schema及部分条件规则未完整机读化 | 在上游唯一生成源补齐并验正反例;未覆盖部分阻塞冻结/业务上线,不能以object校验冒充完整验收 | +| GAP-02 | SaaS录音配置来源/作用域、文本归档授权/complete/引用缺口 | P1确认录音配置由SaaS提供并沿用2接口取得受限上传信息,不增加第二配置源;文本归档合同延后,不阻塞录音/实时文字 | +| GAP-03 | 静态制品交接与后续在线管理发布适配 | P1先批准静态版本/哈希/目标/来源/加载事实及唯一写入合同,旧直写停用;完整在线发布/回滚和R04/R06延后 | +| GAP-04 | 首发Unary及身份/许可/状态结构尚无批准Proto | P1冻结R01–R03/R05/R07–R13实际职责、字段/错误/幂等/大小/超时;可获批合并,R04/R06不先造空框架 | +| GAP-05 | 共用证书的单节点授权与全组泄露风险 | 保留用户共用证书决定,但必须有受控Endpoint、独立D身份、自动节点会话、重放隔离及全组轮换/撤销演练;不能宣称节点级证书隔离 | +| GAP-06 | 首发资源保护、模式能力、单 Cell 授权/限额与维护窗口 | P1登记受限profile及基础恢复条件;缺必需能力拒绝,低负载不越额;SIP 外呼增加 Asia/Shanghai `09:00`–`20:00` 时间门禁。复杂评分/滚动升级后续 | +| GAP-07 | 单活D故障/人工恢复目标与永久资产损失 | P1核验唯一所有权、SQLite备份/恢复/对账、文件损失和RPO/RTO;跨机自动HA后续,不新增PG/NFS共享 | +| GAP-08 | 当前AI Schema强制llm/prompt/tts/asr/conversation且无明确ASR-only表达 | P0由上游批准两模式选择/缺省、条件必填、资源/超时及文字播放/失败语义并生成校验;不增临时MQ字段、不伪造LLM/TTS配置,两种真实模式都通过才可P1签收 | +| GAP-09 | SaaS任务AI配置的读取归属/授权、provider_ref解析、调试参数和有效快照尚未完全机读化 | 确认已有AI版本GET由SaaS承载及租户/版本绑定;在唯一源补§6.2实际参数、单位/默认/范围/能力、缓存撤销及摘要/Unary交接规则;锁定百炼/火山ASR、OpenAI兼容LLM、火山TTS参数映射PoC。不加临时路径或任意透传,SDK缺字段先补库/替代 | + +具体验收见 [验证与切换验收](验证与切换验收_v0.3.md) §1.1–§1.2。GAP-01/05/07/08/09及GAP-02/03/04/06的P1部分均为首发门禁;延后部分只在相应功能启用前冻结,不能记为通过。“文档齐全”不等于契约已获批。 diff --git a/docs/验证与切换验收_v0.3.md b/docs/验证与切换验收_v0.3.md new file mode 100644 index 0000000..20bfc07 --- /dev/null +++ b/docs/验证与切换验收_v0.3.md @@ -0,0 +1,369 @@ +# SIP Go Agent 验证与切换验收 v0.3 + +保留C/S/E/L/A共88项跨阶段验收基线,按本次范围拆分:**P1为1 Agent/1 Asterisk/单 Cell、至少3家SIP trunk 的契约与协议 fixture、单租户、静态配置及双AI模式;真实 SaaS/MQ 联调、双节点、第二 Cell、第二租户和生产切换延期第二阶段。** 10个P1汇总门禁不是新增10项用例。项目内本地/隔离运行时用例已有部分通过,真实外部联调仍未执行。 + +## 1. 使用边界 + +本文件是 [Go 重写方案](Go重写方案_v0.3.md) 的执行清单,**不是验收通过报告**。项目内已具备 Go 可执行程序、W01/W02 契约与本地测试入口;下文仍将本地通过、协议 Mock、真实集成和生产签收严格分开。 + +每个阶段分别报告:通过、失败、未执行、被外部条件阻塞,以及“本阶段不适用/延后”。延后不得计入通过率或当成实现。旧Python证据不计Go通过数;管理平台、SIP Mock、供应商和Go Agent分别签收。 + +### 1.1 P1本次上线的10个汇总门禁 + +下表汇总现有用例与本次拓扑/模式的覆盖要求,不另建测试框架或增加虚假通过记录。**全部满足才可签收单租户内测首发**;Mock、协议PoC与真实供应商证据分列。 + +| 门禁 | 可判定完成标准 | 用例/证据 | +| --- | --- | --- | +| P1-01 独立制品/契约 | 单module/Cobra双命令独立构建;格式化/vet/race/构建及实际依赖门禁通过,使用入口与引用Schema可追踪;GAP的P1部分关闭 | C01–C11、L01–L08、A01/A02 | +| P1-02 单节点就绪 | 恰有1 Agent/1 Asterisk、1单活D;单 Cell 完成绑定/版本/健康/准入与隔离链路验证;失联或未授权时停止新接单,单 Cell 仅接满足授权与全局额度的新执行 | A03–A11/A18/A20、S07/S10;单 Cell/固定出口 fixture | +| 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 结果与资产 | 既有7路径/8事件的合法场景和反例通过;实时文字/opt-out及时,call/command整体补传保留原ID;录音verified后ready、授权读取字节一致,失败恢复不重拨 | 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与原始计数器 | +| P1-10 本地恢复/授权 | 单节点唯一写入与唯一调度所有权、备份/维护更新/恢复演练通过;契约/fixture 签收白名单、时段、限额/预算与风险;明确D单点和未上传资产丢盘边界。生产切换与真实授权延期第二阶段 | §7/§8、A06/A17–A20;运维步骤与隔离签收记录 | + +两模式均需在本地/协议隔离环境验证,所有纳入本轮的供应商配置 fixture 和单 Cell 组合分别留存闭环证据;稳定性长跑按批准的隔离负载矩阵覆盖全部 fixture。SIP 外呼时间窗口使用注入时钟覆盖 08:59:59、09:00:00、19:59:59、20:00:00 四个边界,窗口外必须在最后许可前失败且不产生 originate。真实供应商、ECS、生产 SaaS/MQ receipt 和生产切换如实标记为第二阶段,不能把 fixture 写成真实联调完成。 + +### 1.2 88项基线的阶段适用规则 + +**未列出的C/S/E/L/A用例全部适用P1。** 下列混合用例必须拆分子场景并记录阶段,不得将整行未做部分标PASS。 + +| 用例/范围 | P1必须做 | P2(第二租户前) | 后续功能/规模验收 | +| --- | --- | --- | --- | +| S06;S17公平部分 | 单租户集合/持久窗口及崩溃恢复 | 第二阶段再做等权有界轮询、额度不足跳过、轮转进度/重启公平、§9公平SLO | 权重/借用/大规模 | +| S08/S09/S18/S20–S22 | 当前单租户与单 Cell 原子额度、背压、DLQ原租户恢复、安全停用 | 双节点/第二 Cell 汇总、多租户同时执行的额度、租户级公平/ACL/清理互不影响 | 多D分布式额度 | +| S02/S08的FALLBACK | 未启用自动跨供应商FALLBACK,不因失败/超时重拨;未知策略按批准能力拒绝 | 同P1 | 启用前另测合法attempt/CPS及重试条件 | +| S05/S07/S24/A20 | 真实broker断连/重启,单D唯一所有权/第二进程拒绝、SQLite备份/恢复和旧所有者隔离 | 同P1 | broker quorum、跨机D HA/接管 | +| E04/E05/A12/A14 | 静态来源/版本/哈希、维护屏障、实际加载、部分失败/人工恢复、唯一写入 | 同P1 | 在线发布编排/自动回滚 | +| A13 | 不启用在线改配入口 | 不强制 | 完整动态新增/修改/停用 | +| E15/A19 | 固定单节点/单 Cell 绑定、新boot/旧会话隔离,静态清单人工变更的排空/收尾 | 第二阶段再做多节点集合与跨 Cell 变化 | 在线弹性增删集合 | +| E16 | MQ结果留真实attempt/线路/Cell/出口历史事实;缺事实标不完整 | 同P1 | 管理平台统计聚合/全量事实接口集成 | +| A09/A11 | 基本资源/新鲜度、单 Cell 静态授权候选的固定选择、完整额度 | 第二阶段增加租户公平和多节点安全 | 高级负载评分/全量遥测 | +| A16 | 本地文字恢复、实时MQ/opt-out;文本OSS归档明确未启用 | 同P1 | 文本归档新合同/权限/下载 | +| A17 | 维护窗口升级、实际兼容/版本校验、不兼容拒绝 | 同P1 | N/N-1混合运行/在线滚动编排 | +| L01/L02/L04/L06/L08 | 核验实际采用的依赖/协议/codec;生成使用入口及引用闭包,不裁掉源Schema | 增量核验新依赖/入口 | 全部候选/未用音频能力不作首发门禁 | +| §6、§9的DEV公平/SCALE-MOCK/真实规模 | §9.1受限单节点稳定性、既有安全/恢复/延迟指标 | 第二阶段再做 DEV 公平、多节点和多租户 | Python/Go对比、1000路/N+1/HA | + +P2退出须至少两个模拟租户验证隔离/总额不超配,并用三个等权模拟租户完成§9的首次许可、100许可份额与故障恢复公平测试;通过后才开放第二真实租户。最后许可/fencing和控制屏障已在P1,不随多D协调延期。 + +## 2. 独立验证环境 + +从P1a开始,本项目自己的CI/本地入口负责;这些入口当前尚未实现: + +- 精确验证 Go 1.27.1 构建工具链和依赖锁文件;格式化、`go vet ./...`、`go test -race ./...`、普通构建分别执行。race 构建与生产构建分别测性能,不能混用数据。 +- 使用Dispatcher真实隔离SQLite、Agent持久文件卷和RabbitMQ,不引PG/Agent业务DB;不得导入Python Store/MemoryBroker或用进程内对象替代协议交互。 +- P1使用1单活Dispatcher、1 Agent/1 Asterisk、SaaS/OSS/MQ协议 Mock及故障代理;角色独立进程/权限/目录,可在本机隔离测试。第二节点、第二 Cell、第二D自动接管和两份SQLite双活均延期,不用NFS。 +- Agent使用已确认的共享群组证书,但每节点由预配置Endpoint主动激活并绑定受限会话;测试必须区分群组TLS认证与节点授权。 +- Mock 的数据库、vhost、队列、目录和端口只属于本次运行;清理只能操作明确登记的测试资源,失败时保留可脱敏重放的证据。 +- 单独检出 `go-sip` 后执行验证,不挂载父仓库、不读取父 `.env`、不 import 其它项目内部代码。 +- 测试夹具随本项目维护;外部 SIP Mock 只能使用固定版本/digest 镜像及版本化协议。基础开发和纯逻辑验证不能以其它业务系统启动为前置。 +- 默认拒绝真实外网。真实 RabbitMQ/DB 集成不等于已授权访问真实 SIP、AI、OSS 或云账号。 + +`testing/synctest` 适合进程内时间/取消单测;真实 broker、SQL 事务、网络分区与崩溃恢复必须另测。任何“定时器单测通过”都不能替代分布式安全证据。 + +## 3. 契约与隔离验收 + +| ID | 场景 | 必须满足 | +| --- | --- | --- | +| C01 | 独立目录/发行包运行 | 无父目录、Python 或管理平台源码依赖;Mock 下可独立验证 | +| C02 | 契约来源和生成一致性 | 来源/版本/哈希/生成器可追溯;当前MQ信封已对齐,不回退旧字段;8种payload专属约束缺口须先补齐,通用object通过不算事件验收 | +| C03 | Python/Go 黄金报文 | 字段、空值、数字、时区、枚举、幂等/关联 ID 语义一致;没有新增 HTTP 拨号 | +| C04 | JSON 与配置 SHA-256 | Unicode、字段顺序、空集合、数字边界均符合批准的规范化规则;重复键/歧义输入显式处理 | +| C05 | 原值租户绑定 | 正文、队列/路由、可信身份一一匹配;ASCII/Unicode/分隔符等保持原值;224 UTF-8 字节边界实测 | +| C06 | 不支持的路由或超大消息 | 明确拒绝/停止发布并保留源任务;无截断、无静默清洗、无无限重试 | +| C07 | 信任边界 | 无法通过 MQ 注入 SIP 地址、认证信息、越权主叫、其它租户或任意供应商 URL | +| C08 | 模式隔离 | mock/mixed/real 可辨认;real 拒绝 Mock/测试凭据,Mock 默认不能拨公网电话 | +| C09 | 运行与指标隐私 | 日志、错误、trace、pprof 不泄漏密钥/对话/完整号码;指标无无界高基数标签 | +| C10 | 路径/类型/大小反例 | SaaS既有7路径和响应按Schema校验,bool不得充int、字符串不得隐式转数值,HTTP 64KiB/MQ256KiB及边界、缺字段/额外字段均有反例;不擅自新增业务路径 | +| C11 | HTTP幂等/权限/目标 | control/replay/upload授权/complete的必需Idempotency-Key、作用域、租户/目标绑定均测;缺头拒绝,同键同内容复用,同键跨目标/异内容冲突,outbound.replay/outbound.hangup不可越权 | + +## 4. MQ、数据库与控制面验收 + +| ID | 场景 | 必须满足 | +| --- | --- | --- | +| S01 | 持久接入前/后崩溃 | 提交前不 ACK;提交后 ACK 丢失可重投,唯一约束阻止重复接受 | +| S02 | 并发重复命令 | 同执行重投/换command ID不增加预留或业务尝试,异载荷冲突可追溯;P1不启用自动FALLBACK,未来启用才另验合法attempt/CPS | +| S03 | 状态与 outbox 原子性 | 事务回滚不产生幽灵结果;提交后断网仍可恢复原事件 ID 发布 | +| S04 | confirm 丢失/不可路由 | 重发原事件、正确处理 mandatory/return;不把 confirm 当 SaaS 应用 ACK | +| S05 | broker 重启、断连和 quorum 故障 | 真正经过 AMQP 和持久队列验证;单节点开发通过不代替三节点 HA 结论 | +| S06 | 大租户积压/小租户到达 | 接入、持久窗口、调度及重启恢复都公平;未 ACK/内存/待执行量有界 | +| S07 | Dispatcher单活/恢复 | P1第二进程不得取得并行写入/发许可权;受控恢复前撤销旧所有者并核验代次/占用,两份SQLite不能各自发额度;不是自动选主/HA演练 | +| S08 | 全局并发/CPS | P1单D汇总当前租户、供应商及单 Cell 的预留/拨号/振铃/已接通/未知占用,无超额;ASR-only不占LLM/TTS。第二阶段再扩展多租户/多 Cell 汇总;FALLBACK后续启用仍计CPS | +| S09 | 部分资源不足 | 租户/供应商、Cell/端口、出口或本模式必需AI任一不足时不能拨号;部分预留释放安全,ASR-only不因未用LLM/TTS缺失被拒,完整模式不静默降级 | +| S10 | 心跳丢失/授权过期 | 停止新任务,已有未知通话占用不靠 TTL 自动释放;恢复要对账 | +| S11 | 控制幂等/CAS | 使用 `expected_task_revision`,区分 requested/applied revision;同租户 command 重试不再次加版本,错字段/冲突版本拒绝,同 ID 异载荷冲突 | +| S12 | pause/stop/drain/hangup | HTTP 202不是applied;pause和stop drain均保留已拨出/振铃及已接通的原生命周期,阻止新发起;stop显式drain或hangup,hangup缺权限拒绝,部分挂断失败/回执丢失继续 applying/reconciling,全部必要确认后才 applied | +| S13 | applied 与最后 originate 竞态 | 在授权/落盘/投递/ARI 窗口注入控制和分区;先确认持久屏障和旧许可收敛,再 applied;不得出现 applied 后旧 revision 的新拨号 | +| S14 | 撤销后的恢复 | 整任务 pause 屏障覆盖积压/重试/已消费未拨/拨号/振铃;SaaS 禁发并过滤撤销对象,旧执行对账后 paused 可按当前生效 revision 和新 command/execution 授权恢复;stopped 拒绝 resume,旧命令不能复活 | +| S15 | 等待/开始时限 | waiting、dial timeout、max duration 按契约区分;资源等待不能伪装为已拨或成功 | +| S16 | call/command 整体补传 | 仅 call_id/source_command_id,受理即固定截止点;保留原事件/顺序/版本,实时优先、分批限速;含尚无 call 的 command;404/410 区分,拒绝 task/execution/局部筛选,补传自身结果不递归,completed 不是 SaaS 应用收讫 | +| S17 | 多轮故障与数据库恢复 | 恢复 inbox、控制版本、占用、outbox 和公平进度;按 §9 的完好持久卷边界验证 RTO,而非只证明能启动 | +| S18 | 租户复合幂等键 | A/B 租户用完全相同 command/execution/event ID,事实互不覆盖;数据库按 tenant_id+业务 ID 唯一,异载荷冲突不重拨 | +| S19 | 历史事实优先 | 认证/租户归属通过后,对已完成/已拒绝命令的重投返回原事实;任务暂停、配置失效不能改写原决定;换消息 ID 不绕过 execution 去重 | +| S20 | 拓扑/ACL/启动漂移 | exchange 类型、绑定、持久化/上限/拒绝策略不符就不 ready;发布者/消费者最小权限,不能跨租户路由或任意声明资源 | +| S21 | 队列满/blocked | 满队列拒绝新发布、不丢队头,SaaS 留原 ID/任务;内存/磁盘报警与 blocked 限制接入,恢复不爆发无界重试 | +| S22 | DLQ 与安全停用 | 死信恢复回原租户并经过全部配额;禁用/删除租户前对账未决命令、outbox、补传/资产;不能删共享结果队列或悬空任务 | +| S23 | 分域版本/乱序资产 | command/call/segment/recording 分域合并;高版本 call.finished 先到也不吞掉低版本独立资产/attempt,recording.ready 不被结束快照覆盖 | +| S24 | 较旧备份恢复 | 恢复时保持准入关闭,识别已 ACK 但回退的 inbox/控制/占用/幂等水位并与 Cell/SaaS 对账;旧租约/版本不能恢复成第二次拨号许可 | +| S25 | 墓碑/去重保留 | stop 墓碑、execution 去重不随普通日志 TTL 删除;清理后仍有可验证永久失效依据,否则阻塞清理;历史重投不复活 | +| S26 | 时钟偏移 | 覆盖前跳/回拨及阈值边界;偏差>500ms告警、>2s停止新准入,单调时钟用于 duration、DB 时间用于共享租约/CPS,不能错误释放活动占用 | +| S27 | 告警/就绪/恢复保护 | MQ/DB中断、心跳失效、满盘和录音/AI/补传积压逐项注入;真实抓取指标并投递测试告警接收器,验证停止新准入、未知状态和阈值恢复,不只检查日志有文字 | + +### 4.1 必须覆盖的崩溃窗口 + +至少在以下位置做独立进程 kill/网络故障注入: + +1. inbox 事务提交前、提交后且 broker ACK 前。 +2. 资源预留提交前后、内部投递前后及 confirm 丢失。 +3. 中央账本认领及Agent执行文件持久化后、ARI请求已送达但响应丢失、接通后进程退出。 +4. pause accepted 后、Cell 屏障确认前、applied 发布后且旧授权仍在网络途中。 +5. 通话终态已落盘但全局结果未确认、结果已发布但本地确认丢失。 +6. OSS PUT 中断、上传成功但 complete 失败、verified 后但 `recording.ready` 未发布。 + +每个窗口都检查:实际发起次数、持久状态、有效所有权/控制版本、资源占用、消息原 ID、录音可恢复性。无法证明外部动作未发生时应进入待对账,而不是重试 originate。 + +## 5. Cell、媒体与 AI 验收 + +| ID | 场景 | 必须满足 | +| --- | --- | --- | +| E01 | 2Cell/至少3供应商路由 | 只用静态授权Cell/trunk;逐启用组合验证主叫/前缀/codec/出口;原始号码白名单校验,前缀一次构造,无串用、逐呼reload或自动跨供应商重拨 | +| E02 | ARI 乱序/断线/404 | 事件正确归属;断线后有界追平/对账;不能因通道查询失败就证明“从未拨过” | +| E03 | originate 结果未知 | 中央权威账本、Agent执行文件与ARI对账前不释放占用/重新拨号;重投/Unary响应丢失不产生第二通电话,不要求Agent SQLite | +| E04 | 配置版本、哈希与禁用 | mTLS/身份/CAS/哈希正确;先准入屏障及必要排空,再原子替换/reload/加载确认;失败回滚也需确认,disabled 删除片段不能先于屏障 | +| E05 | 发布/停用与在途通话 | 不逐呼 reload,不擅自强挂已有通话;屏障前不得下发破坏性 reload;部分成功/失联/配置漂移保持受影响资源阻塞,不以 DB active 指针替代实际加载事实 | +| E06 | RTP 解析与 fuzz | 畸形/超长/扩展/padding/序号时间戳回绕、乱序、重复、丢包均无崩溃/越界/无界分配 | +| E07 | 媒体隔离与时钟 | 包/SSRC/源验证,多个通话不串音;PCMA/PCM、采样率、节拍和抖动策略有证据 | +| E08 | early media 与录音 | 振铃/183 不计 answered;从接通开始记录双向实际音频,双方标识音、时长、末帧与业务结果一致,不能用空 WAV/合成音冒充 | +| E09 | ASR双模式生命周期 | 百炼/火山适配分别验首包/最终结果/结束/取消,模型/语言/中间稿/采样与热词/VAD等获批参数实际生效;ASR-only无LLM/TTS及虚假播放;复用协议但无旧项目运行依赖 | +| E10 | 新LLM/TTS首发必需 | 完整模式使用新批准官方/开源SDK,真实权限/音频/模型/取消/打断/限额/费用验收;未启用或仅Mock即P1 blocked,不调用旧实现或用“兼容OpenAI”替代能力证据 | +| E11 | 打断与慢消费者 | 旧轮次不再播放;生成/排队/发送/播放可区分,缓冲按时长/字节有界 | +| E12 | 录音交接与重放 | OSS 校验 verified 后才出 `recording.ready`;哈希/长度/ID 正确,恢复不重拨、不覆盖未交付文件 | +| 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 后旧签名不能覆盖,独立读取实际字节验证 | +| E20 | 已知媒体事故回归 | ExternalMedia 未就绪/端口未取到、桥成员不齐、错误来源/SSRC、取消末帧均有协议夹具;原 MixMonitor 路径须停止封口后上传,新录音路径须证明等价 | +| E21 | spool/永久丢盘 | 70/80/60% 阈值、活动录音余量、满盘/只读/截断/永久丢盘分别注入;不删未确认文件,不静默丢录音/重拨,不宣称未上传数据 RPO=0 | +| E22 | 不可变AI版本/配置来源 | GAP-08/09从上游批准生成;D按任务版本向SaaS获取/校验,Agent固定有效快照,条件必填/合法事件/超时/资源有反例。同版本异内容/缓存越权拒绝,不增MQ模式/URL/密钥;调参新版本无重启生效,在途不变;细则§5.1,历史事实按S19 | + +### 5.1 AI参数有效性验收(E09/E10/E22/L06的P1子场景) + +不新增用例编号/总数。使用本项目SaaS协议Mock返回版本化配置,实际SDK向隔离协议Mock发请求并记录脱敏字段;计时/打断/缓存等本地参数同时观测控制器行为,不能只检查配置结构体非空。真实供应商测试另获授权。 + +| 子场景 | 通过判定 | +| --- | --- | +| 配置唯一来源 | 两个Agent仅向D取执行快照,D按任务agent_version_id调用已有SaaS AI GET;模型/voice/resource/协议端点来自受控配置/引用。断开本地业务env覆盖仍一致,不存在私造task-config或Agent直连SaaS | +| 逐参数可达 | 交互§6.2所有首发启用字段均有source→effective→SDK字段或控制器映射;每项至少基线/变更值及适用边界。音频实际采样/编码、TTS语速/voice、LLM采样/Token、ASR输出控制、超时/打断/分句/缓存均测,不以请求200代替生效 | +| 零值/缺省/拒绝 | temperature=0、interim=false、allow_interrupt=false及合法空列表不会被默认覆盖;未提供/null与零值区分,默认由批准源版本物化。未支持/越界/额外字段或冲突有原因,不能静默忽略/钳制/透传metadata | +| 无重启调参与隔离 | SaaS发布v2,新任务引用后两Agent均使用新参数;v1排队任务及进行中通话仍用v1,同v1异内容拒绝。两通合成任务使用不同model/voice/阈值并发,不共享可变SDK参数或串凭据 | +| 缓存/故障/期限 | 缓存按租户+版本绑定;SaaS不可达时只用符合已批新鲜度/撤销规则的缓存,无合法缓存拒新准入并遵守admission_deadline。过期/撤销/异租户缓存/引用丢失均注入,不静默选latest或默认模型、不重拨历史执行 | +| SDK能力与供应商差异 | 百炼/火山ASR分别验实际API与参数;OpenAI兼容模型不支持的参数明确拒绝;火山TTS语速/音量等必须证明确切映射。不满足就blocked/换库或补上游,不写协议客户端或吞字段 | +| 安全/权限/隐私 | 任意URL/重定向、跨租户credential_ref、headers/raw_request注入和超过资源/时限硬限额均拒绝;调试仅给版本/摘要/SDK和脱敏有效参数,日志/错误/trace不含prompt/变量/密钥 | +| 取消/重试/计费 | OpenAI默认重试显式关闭并用429/断流/超时验证实际生成调用次数;stop/打断后不因SDK自动恢复再生成或播放旧帧。SaaS调参不能打开未经批准的副作用重试 | + +只读源索引与Schema本轮不变;未获批GAP-09扩展不得塞入现有严格对象后宣称“支持”。参数PoC先测模拟请求映射,再以获批真实SDK/模型确认效果,不把文档参数名或main示例当兼容证据。 + +## 6. 后续性能比较与生产容量(非P1门禁) + +P1仍执行§9.1小规模稳定性与安全/恢复测试,不以“快速上线”为由删除长时间运行检查;但无需先做本节性能研究或规模验收。 + +### 6.1 Python/Go 公平比较 + +后续专项登记相同测试范围的Python基线,再用同硬件/容器限制、数据库、RabbitMQ、消息大小、租户分布、拨号速率、通话时长和依赖延迟比较。该对比不阻塞P1。 + +分别测量以下层次,不能把精简的 Go 模块与包含额外职责的 Python 整机直接相除: + +| 层次 | 主要指标 | +| --- | --- | +| 纯调度控制面 | 接入/决策吞吐、P50/P95/P99、租户等待、事务/锁延迟、CPU/RSS/GC、积压恢复 | +| Cell 协议 Mock | originate/事件处理、活跃通话资源、FD/端口、重复/未知执行数 | +| 完整媒体/AI Mock | RTP 包率/带宽、音频积压、首包、打断、spool/OSS 速度、长稳泄漏 | +| 真实供应商链路 | 接通与音频质量、ASR/LLM/TTS 延迟、供应商限额/错误、费用 | + +数据记录硬件、软件/镜像/契约版本、时长、负载、暖机、重复次数和原始证据。承接 §9 已有 DEV/SCALE-MOCK 数值;G0 登记具体版本/环境/获批变更,不能把既有基线全部推回“待定”。阶段基线不是生产 SLA 或消费授权。 + +### 6.2 1000 路与 N+1 + +生产目标是 **至少 1000 路同时已接通、具有完整 ASR/LLM/TTS 的 AI 通话**,不是 1000 路排队、振铃或只有静音 RTP 的连接。 + +- 先以完整负载测得每 Cell 安全容量 `C`,再验证 `(N - 1) × C >= 1000`,另留发布、降级和拨号峰值余量。 +- 并发之外单独测试 CPS、振铃/拨号占用、供应商白名单/配额、AI 并发/速率、RTP 端口/包率/带宽、FD/连接数、conntrack、防火墙及出口。 +- 多机器、多 EIP 直连;不能用同一地址填两次充当备用线路,也不验证单 EIP+NAT 备选。 +- 主备调度器、broker、数据库、OSS 和供应商故障分别测试。未知通话先保留占用;不能为恢复吞吐跳过对账。 +- N+1 证明的是剩余安全容量和恢复能力,不是 Cell 故障时活动通话无损迁移。故障中断了多少通话、恢复用了多久必须如实报告。 +- Mock 负载只验工程容量和故障处理,不替代真实 1000 路、N+1 及供应商签收。 + +## 7. 迁移、试点与回滚 + +### 7.1 上线前置 + +- G0中P1契约/双AI模式、SaaS AI配置与可调参数GAP-09、SQLite/文件、内部许可/屏障、静态交接、预算/保留已签收;延后缺口单列。 +- P1a–P1d完成且§1.1十个汇总门禁有独立证据,无未关闭的重复拨号、超配、控制或资产丢失;不要求P2公平或1000路/N+1先通过。 +- 数据库迁移与恢复演练完成,备份/哈希/版本可核验;明确旧新版本的读写兼容范围。 +- 真实白名单、供应商、Cell/出口、测试时段和费用逐项获得授权。当前仅允许原始号码 `15003164745`、`15830461047`,不得从历史测试记录扩展名单。 + +### 7.2 安全切换步骤 + +1. **离线/影子验证:**复制脱敏输入到隔离域,只计算决策,不消费正式队列、不占生产额度、不调用 ARI。 +2. **选择切换边界:**只选授权的租户/Cell/路由。共享供应商或 AI 额度无法统一协调时,不做旧新并行灰度,改维护窗口完整排空。 +3. **冻结旧端:**停止新发布/准入/消费或建立对应控制屏障,确认旧所有权撤销;处理已发许可、未 ACK 和在途执行。业务 pause 与 stop 保留各自语义,不为迁移默认 stop/hangup,也不把 paused 任务永久化。 +4. **排空/对账:**识别已接通、拨号/振铃、未知执行和未交付资产。已有通话由原 Cell 完成;不能以公平或切换为理由擅自挂断。 +5. **搬迁已批准的状态:**显式导出/导入待执行、幂等 ID、控制版本、占用、outbox、补传及录音交接进度;保留 ID 与来源校验,不双写旧库。 +6. **授予新端所有权:**确认旧端不再拥有同一调度域/资源写权限,Go 验证队列绑定、配置版本和外部健康后,才开放小规模新准入。 +7. **观察/扩大:**检查业务、媒体和成本指标及拒绝原因;按签收的窗口放量,不因 CPU 很低就提高供应商额度。 + +P1运行不依赖旧Python Cell;迁移验证如需临时桥接而旧端不满足新许可/控制语义,保持Mock直到Go Cell就绪,不以HTTP代理绕过。 + +### 7.3 回滚边界 + +- **Go 尚未发起真实执行:**撤销 Go 权限,对账后可恢复旧版本。 +- **Go 已执行但可排空且状态兼容:**冻结准入,待通话与资产交接可确认,完成反向状态迁移/兼容验证,再转移所有权。 +- **存在未知通话、不可逆 schema 或不兼容状态:**暂停新任务并人工对账/前向修复,不能退镜像后让旧版本重新消费原命令。 +- 每次演练确认:无双写、无双发许可、无重复 originate,原 ID/控制版本/配额/录音均可追溯。未确认事实记为 unknown,不能为了完成切换改成成功。 + +## 8. 证据与签收 + +每个测试记录:测试ID/子场景/阶段、Git/构建/契约/镜像版本、mock/mixed/real、AI模式、tenant/Cell/供应商授权组合、profile/参数、起止时间、预期/实际、脱敏证据、失败注入点、风险/复测。分阶段记延后与blocked,不能整行混记PASS。 + +阶段签收分开列出: + +1. 文档/契约检查。 +2. 单module/Cobra双命令构建、Go单元/race/静态/fuzz、Proto与契约生成校验。 +3. 真实隔离SQLite/RabbitMQ集成、Dispatcher唯一所有权与恢复、Agent文件持久性及Unary故障。 +4. SIP/ARI/RTP/AI/OSS Mock 集成。 +5. 经授权的真实 SIP、ASR、LLM/TTS、OSS 验证。 +6. P1受限稳定性与已授权费用;P2公平调度;后续完整1000路/N+1分别签收。 +7. P1维护切换、人工恢复、备份演练;在线发布/HA切换待相应功能启用另验。 + +本轮仅能检查第 1 类中的文档结构与一致性,不能把尚未生成的 Go 测试/服务列为“通过”。 + +开发前的交付/解锁证据见 [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新增明确子场景(不新增用例编号):证明Agent从Dispatcher取得受限配置后直接连接OSS上传,Dispatcher/gRPC链路没有录音文件内容;Dispatcher失联时有效授权可继续直传,过期则保留文件,放弃当前token并等待显式重新授权,不回退长期AK。直传成功但Dispatcher/complete不可达时保留原资产和待完成状态,恢复后幂等完成SaaS verified再由Dispatcher发recording.ready及OSS ID;PUT成功不得提前发ready。 + +## 9. 继承的量化测试 profile + +以下是上游《最终开发部署监控与验收计划_v1.0.md》§5 的**只读测试摘要**,不是新的参数权威,也不是P1全量必跑清单。P0导入带来源/哈希的profile;按§9.1生成有来源和变更理由的阶段profile,不篡改摘要。租约/许可/窗口/保留/恢复等安全值继承,实际费用/能力/生产RPO/RTO另批。 + +| profile 项 | 上游默认测试值/判定 | +| --- | --- | +| DEV 拓扑 | 3租户、2中央调度实例、2 Cell;每租户并发2/CPS1、突发令牌1,全局并发6/CPS3;每Cell测试容量4;HA另测3节点quorum | +| 公平/窗口 | 等权、每轮每租户最多1许可,无事件轮转间隔≤100ms,新活跃租户发现≤1s;每租户未ACK≤4/持久待发起≤16,全局未ACK≤32/待发起≤64,跨实例汇总 | +| 发布/消息 | 每租户发布≤10条/s,命令队列1000条或16MiB先到者;满队列reject-publish,不丢头部,源任务留SaaS;MQ≤256KiB,HTTP JSON≤64KiB,超限不截断文本;全局broker水位需显式配置 | +| 授权/通话/AI | 授权300s且不越允许时段,首次准入30s,到期终结≤1s;重投/重启不延期;振铃30s、最长通话180s、静音15s、AI首输出超时5s;AI故障不自动切SIP或播放自造提示 | +| 心跳/时钟 | 心跳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 | +| 磁盘 | 测试录音/缓存卷至少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内清本次通道/桥/媒体 | +| SCALE-MOCK | ≥2调度实例、100租户×并发12、全局并发1200、模拟CPS20;额外200占用预算用于拨号/振铃/换批,不能计已接通;扩展测试Cell容量并注明,不套DEV容量4;暖机后持续补充新授权执行,≥1000模拟接通维持60min | +| 重投/真实规模 | 同ID重投10次仍同一事实;真实完整AI≥1000已接通稳定≥60min,并验证N+1安全容量与故障后新授权补负载;不将断掉的旧execution换ID自动重拨 | + +阶段执行时须同时记录profile、环境、参数和原始计数器。如源包与摘要冲突,先同步修订;阶段拓扑差异用批准覆盖文件表达,不在实现中悄悄修改源值。 + +### 9.1 P1/P2阶段profile与稳定性判定 + +| 项目 | 阶段执行规则 | +| --- | --- | +| P1拓扑 | 1单活D、1 Agent/1 Asterisk、1启用租户、至少3供应商 fixture;第2 D仅负例,双租户仅后续阶段。单 Cell/出口条件按本地隔离 fixture 签收 | +| P1负载 | G0登记租户/全局/供应商/单 Cell 并发与CPS、媒体和两模式AI额度、预计呼叫数/费用、磁盘与资源阈值;不高于已验证能力/批准预算。缺参数/授权阻塞真实联调,不自动套1000路或DEV全局6 | +| P1延迟/恢复 | 继承上表适用的受理/控制/心跳/时钟/重试/保留/spool/上传/恢复阈值。ASR-only采用文字/录音指标,TTS首包/打断标不适用而非通过;完整模式采用全部相关AI指标,模式专属静音/超时按GAP-08批准值 | +| P1持续稳定性 | 在登记负载下两种模式分别持续补充新授权执行运行≥60min,覆盖单 Cell 及至少3供应商 fixture;完整模式至少100有效对话轮次,ASR-only至少100最终识别片段。工程 Mock/mixed 分别记录,真实呼叫/费用另行明确授权;不能循环重拨旧execution凑样本 | +| P1通过判定 | 无重复实际发起、跨通话串音、超额或越权;无未解释的事实/资产丢失;正常结束30s内清本次媒体资源,goroutine/FD/端口/spool有可解释基线且不持续泄漏;适用P95/恢复指标通过,未满足项有失败/blocked记录而非删样本 | +| P2公平profile | 仍1单活D/2 Cell,启用3个隔离模拟租户,继承DEV每租户额度、窗口与公平SLO:合格且资源足够者发现后≤2s首许可,至少100许可中份额偏差≤10个百分点;大租户积压、额度耗尽跳过及重启恢复均验证 | +| 后续 | 多D、broker HA、SCALE-MOCK、真实1000路/N+1以及权重/借用另立项;不作为P1/P2已通过能力 | + +60min/100片段是本次工程稳定性底线,不是容量SLA或自动消费授权;若真实预算不足须明确阻塞或由用户重新批准验收范围,不能以Mock通过替代真实双模式。 + +## 10. 开源复用与 SDK 门禁 + +组件候选和源证据见 [开源组件选型与复用清单](开源组件选型与复用清单_v0.2.md)。以下 PoC 在项目内隔离协议 Mock/真实本地 Asterisk、DB、MQ 下执行;不是自动获准调用供应商。 + +| ID | 场景 | 必须满足 | +| --- | --- | --- | +| L01 | 版本/许可/供应链 | module 路径、tag/commit、Go 1.27.1 构建与 go.sum 一致;主/传递依赖许可证、NOTICE/SBOM、漏洞扫描与处置记录齐全;未核验不进入生产镜像 | +| L02 | 3.1/2020-12生成校验 | 锁定可用生成器/校验库,覆盖实际使用入口及全部引用闭包、nullable/required/额外字段/哈希和双模式正反例;来源包保留完整,生成可重复,不手改Schema降级;未用管理API无需全量生成服务 | +| L03 | ARI SDK 与 Asterisk | 锁定 Asterisk 镜像和 SDK major;验证 ExternalMedia 返回/变量读取、订阅/断连/取消、bridge、录音和 reload 路径;ARI响应丢失不会被默认重试变成二次 originate | +| L04 | RTP/音频库 | 使用库完成 packet parse/marshal、A-law/μ-law/PCM/重采样所需能力;黄金音频、长度/端序/SSRC、fuzz、抖动/回绕/长稳通过;不把 Pion G.711 depayloader 当作 PCM 解码器 | +| L05 | AMQP/SQLite/恢复封装 | SDK/封装暴露manual ACK、return/confirm、连接世代和取消;恢复无旧channel ACK/丢confirm/窗口翻倍;真实SQLite验证单writer/短事务/CAS/WAL/Sync/checkpoint/备份/迁移,不能套PG行锁语义 | +| L06 | 选定AI SDK/参数/重试 | 核验百炼/火山ASR、官方openai-go对目标兼容LLM、火山TTS的精确模块/协议/鉴权/音频/取消/费用;§5.1参数必须映射有效、无硬编码默认。火山SDK必要字段缺失则阻塞,OpenAI关闭默认重试;不自写协议或验证所有未选候选 | +| L07 | OSS/HTTP SDK 边界 | Agent 消费 SaaS 签名而不擅自接管签名职责;标准库 PUT/SDK 保留约定 headers、对象/会话/哈希与取消语义,E19全过;日志不含签名密钥或URL凭据 | +| L08 | 复用审查/替代门禁 | 每个协议边界列出所用库/原生能力和理由;自有代码限业务状态机及薄适配;归档/不兼容候选不得静默替代为手写。先替换库、修上游或报告阻塞;例外须用户另批 | + +## 11. 上游需求到 Go 验收追踪 + +本表保留跨阶段追踪,不改变上游契约。P0按§1.2逐子场景标P1/P2/后续,记录源版本/哈希及入口/参数/证据;未实现、blocked或延后不得计PASS,不能把本表全部条目当P1前置。 + +| 上游要求 | 方案章节 | Go 验收 | profile/判定及证据 | +| --- | --- | --- | --- | +| 主契约 §4.1–4.1.1;FIX-05/12;V04 | §6.4 | S11–S14、E18 | 控制2s基线;revision/权限/目标绑定、ARI发起/挂断、多Cell屏障;paused可恢复/stopped不可恢复 | +| 主契约 §4.4;FIX-09/10/12;V10 | §6.5 | S16、S23 | call/command固定集合、原ID与404/410;outbound.replay、跨目标冲突;分批限速、恢复、不递归 | +| 主契约 §6.2、§7.2;FIX-02/03;V01/02 | §6.1/6.3 | C05–C07、S01–S04、S18/19 | 跨租户同ID、同ID重投10次、换command同execution、异载荷与提交窗口;真实broker/DB计数 | +| 主契约 §5.1.2;FIX-04/06/07;V03/05 | §6.1/6.2 | S05–S10、S20–S22 | DEV窗口/100次公平/队列上限、HA quorum、confirm/blocked及源任务保留 | +| 主契约 §7.2/7.3、§9;FIX-15/16;V08/10 | §6.6/7.2 | S23、E10/11/17/18/22 | 独立SaaS按域收敛文字/播放/opt-out,100轮与时延、同段final异内容冲突 | +| 主契约 §4.3/§8;FIX-11/17–20;V09/10/11 | §7.3 | E08/12/19/21、L07 | 180s通话→交接≤120s、5min中断→10min补齐、70-80-60%/24h;幂等头/作用域/绑定/实际字节与覆盖反例 | +| SIP-27/28/29/30/32 | §8 | E04/05/15、L03 | reload前屏障/新旧集合/bootstrap/幂等/部分成功;管理模拟端、调度和Asterisk三方证据 | +| SIP-13/14/33 | §8/9 | E16 | attempt历史快照/跨午夜/零分母/缺事实补齐、接通后AI失败不冲减接通;与管理固定样例核对,不由Agent重造统计口径 | +| FIX-06–08/21/22;V03/07/11/12 | §5.3/6.2/9.1 | S07/10/17/24–26、E13/21 | 完好卷60s及永久丢盘边界;旧库恢复到新目标核对,不覆盖新防重事实、不删墓碑;告警接收/保护/恢复有证据 | +| FIX-14/17;V06/07;既有媒体事故 | §7.1 | E01–E09/E20、L03/04 | 真实Asterisk+Mock对端、至少两通不同标识音、实际双向RTP/录音;30s清理及ExternalMedia/桥/末帧;不确定不FALLBACK | +| FIX-01/13/23;V01/12;SIP-38独立性原则;用户复用要求 | §3/4/9 | C01–C11、S27、L01–L08 | 无父目录检出、7路径/64KiB/256KiB/类型反例、模式/权限/SDK与许可证;对应管理平台测试不冒充Go验收 | +| V12–V14、M1–M3、R1–R3 | §9–11 | 全部适用用例及§6/§7 | 同环境对比、1000路60min、N+1/供应商另验、切换/回滚/备份;Mock不替代真实 | + +P0冻结前逐行登记阶段、状态、责任人及风险。本轮运行时均为**未执行**;文件/链接/源指纹可检查,但GAP-01~09的未批准部分不能标通过,后续范围不阻塞无关阶段。 + +## 12. 最新架构验收 A01–A20 + +源数据/RPC草案见 [通信与事件数据交互](通信与事件数据交互_v0.1.md),源快照见 [字段索引](OpenAPI与MQ字段索引_v0.1.md)。本组与前述68项合计88项,按§1.2分阶段;缺首发Proto/契约则P1 blocked,未来在线发布接口未实现记后续,不用临时字段凑过。 + +| ID | 场景 | 可判定通过条件 | +| --- | --- | --- | +| A01 | 事件/接口全集与覆盖 | 现有42操作/115组件的源哈希、7路径、1种执行命令/8种event_type和原样例均可追踪;旧信封/非法call.transcript拒绝;payload专属Schema及条件规则补齐后才冻结 | +| A02 | Cobra一个制品两个业务 | 同module/镜像构建,显式业务子命令仅agent/dispatcher,无默认双业务启动;help/version不触网;agent不初始化MQ/业务DB,dispatcher不开RTP/Agentspool;配置错误不泄漏凭据 | +| A03 | Endpoint引导启动 | 业务启动只填D Endpoint;部署提供证书/ARI/目录/批准静态制品。未激活不取敏感配置/接任务,D按单一受控Endpoint绑定后核验自身SIP/OSS/AI版本;缺Asterisk/凭据/静态版本not-ready | +| A04 | Endpoint与实例归属 | 任意自报URL、错误cell/agent、重复Endpoint、克隆spool、新旧boot并存、移除后重连分别测试;仅D预配置绑定生效,不产生SSRF或双执行归属 | +| A05 | 共享证书授权隔离 | Agent群组证书不能调用D角色的管理RPC;伪造另一agent_id但无对应激活会话被拒;会话限定boot/代次/操作/租户执行目标;明确共用私钥不提供节点级证书隔离 | +| A06 | TLS/轮换/泄露边界 | SAN/SNI/信任链/有效期严格校验,禁止跳过验证;D证书与Agent群组身份分离;全组轮换/撤销及过渡窗口有演练,新准入阻塞和已有执行收尾策略可追溯 | +| A07 | Unary接受/结果未知 | Execute快速accept,不把RPC保持到通话结束;注入请求送达/副作用后回包丢失,按原ID查询/对账,控制accepted≠applied,不无条件重试originate;Report重投只生成原MQ事件 | +| A08 | Agent文件而非业务DB | 无SQLite/PG连接,manifest/文字/录音/待报事实单写;崩溃/半行/Sync前后/rename窗口/路径穿越均测;启动只恢复对账/上传/上报,不凭残留文件重拨 | +| A09 | 健康新鲜度/资源采样 | 心跳查询2s、TTL10s;旧boot/乱序/过期样本不恢复ready;采样失败为unknown而非0;host/cgroup/process口径、CPU/load/内存/FD/spool/媒体资源与gopsutil/ARI证据对应 | +| A10 | 能力/版本/供应商匹配 | 软件与协议能力、AI版本、每provider/trunk的desired/applied/hash、注册适用性可区分;未知/不兼容/未加载/未验证时拒新准入,不把“支持SIP”当获授权所有供应商 | +| A11 | 静态候选/健康准入 | 单 Cell 按固定已批准策略选新执行;CPU低不越当前租户/供应商/本模式AI/端口限额,基本保护/恢复可验证;新boot不清未知,已有执行不改投/迁移;高级负载评分延后 | +| A12 | 静态SIP/运行配置 | 部署交付管理批准制品、D按身份核验目标/版本;原Schema/来源/哈希/凭据引用验证,缺密钥/错mode/任意路径/脚本拒绝;首次实际加载确认前not-ready,不要求R04/R06在线推送 | +| A13 | 动态改配/停用 | D固定目标集合、持久发布意图、先关闭准入并收敛必要占用,再ApplyTrunkConfig;每Agent实际加载确认;修改/新增/移除/disabled不逐呼reload、不擅自强挂 | +| A14 | 静态失败/人工恢复/单写 | 版本/哈希冲突、写完未加载、部分失败/确认丢失、恢复旧制品失败/旧代次回报均注入;保持真实installed和阻塞,旧管理直写不可与受控静态入口并行;在线自动回滚后续 | +| A15 | SaaS来源OSS配置 | D受控获取SaaS配置/授权并只向对应Agent/执行/对象发短期信息;显式重新授权/配置轮换不中途换资产归属;不泄漏长期AK/签名URL;录音使用recording-uploads和upload_id/complete | +| A16 | 实时文字+OSS归档 | transcript.updated/final/opt-out按原时限回SaaS,不等整通话上传;文本归档无批准授权接口时明确未启用,不冒充recording.ready、不生成新event_type;冻结后验证原segment版本/哈希及查看权限 | +| A17 | 维护更新与版本校验 | P1维护关闭准入/排空/更新/重新激活后才恢复;只用已验证组合,不兼容阻塞,Proto字段号不复用,构建版本不冒充AI版本;混合N/N-1与在线滚动后续 | +| A18 | 退出/断联/收尾 | D退出不强挂Agent已授权通话;A保留文件并可在恢复后补报;SIGTERM先关准入/排空,超时未决状态明确;永久丢盘不承诺零丢失,保留/告警按§9 | +| A19 | Endpoint清单增删 | 列表受控版本化;新增先验证/激活/加载再可调度,移除先排空/对账再撤销新任务权限;旧执行事实仍有受控收尾/人工恢复路径,不能简单丢弃或误删资产 | +| A20 | 单活D升级/旧库恢复 | 停旧所有者、SQLite一致性备份/迁移、核对Agent最高已见许可/配置及未知占用后开放;不得两份SQLite各自发额度,不把语音Cell N+1当D自动HA | + +### 12.1 用户新要求的追踪 + +| 要求 | 设计位置 | 验收 | +| --- | --- | --- | +| 同项目Cobra双命令、统一更新 | 主方案§3.1/§5.7 | A02/A17/A20、L01 | +| D SQLite、Agent文件、Unary | 主方案§5.1–5.4;交互§7–8/§10–11 | A07/A08/A18/A20、S/E恢复用例 | +| 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源SaaS、D统一回传、文本仍MQ | 主方案§7.3;交互§5/§10 | A15/A16、E17–E19 | +| 全部事件/字段与遗漏 | 交互§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 | +| ASR-only与完整AI均首发 | 主方案§7.2;交互§4/§13 | P1-04/05、E09/E10/E14/E22、GAP-08 | +| 百炼/火山ASR、OpenAI兼容LLM、火山TTS | 组件清单§1.3/§4.3;主方案§7.2.1 | L01/L06、E09/E10;两ASR及选中LLM/TTS的真实能力分别验 | +| D从SaaS取任务配置,参数不写死 | 交互§6.1–§6.3;主方案§7.2.1 | E22/L06/§5.1、GAP-09;逐参数可达、无重启/不串通话、缓存与隐私 | +| 稳定快速上线、控制面裁剪 | 主方案§1.2/§10 | §1.1/1.2分期、§9.1受限稳定性,不以HA/1000路为前置 | + +尚待确认的参数/接口只阻塞其所属阶段;P1必需的双模式、供应商授权、许可/恢复不能延后。已确认的无PG、Unary、共享Agent证书、实时文字MQ与上述上线范围不得悄悄改回。 diff --git a/gen/agent/v1/agent.pb.go b/gen/agent/v1/agent.pb.go new file mode 100644 index 0000000..6998fcc --- /dev/null +++ b/gen/agent/v1/agent.pb.go @@ -0,0 +1,4366 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: agent/v1/agent.proto + +package agentv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ResultCode int32 + +const ( + ResultCode_RESULT_CODE_UNSPECIFIED ResultCode = 0 + ResultCode_RESULT_CODE_ACCEPTED ResultCode = 1 + ResultCode_RESULT_CODE_APPLIED ResultCode = 2 + ResultCode_RESULT_CODE_REJECTED ResultCode = 3 + ResultCode_RESULT_CODE_UNKNOWN ResultCode = 4 + ResultCode_RESULT_CODE_CONFLICT ResultCode = 5 +) + +// Enum value maps for ResultCode. +var ( + ResultCode_name = map[int32]string{ + 0: "RESULT_CODE_UNSPECIFIED", + 1: "RESULT_CODE_ACCEPTED", + 2: "RESULT_CODE_APPLIED", + 3: "RESULT_CODE_REJECTED", + 4: "RESULT_CODE_UNKNOWN", + 5: "RESULT_CODE_CONFLICT", + } + ResultCode_value = map[string]int32{ + "RESULT_CODE_UNSPECIFIED": 0, + "RESULT_CODE_ACCEPTED": 1, + "RESULT_CODE_APPLIED": 2, + "RESULT_CODE_REJECTED": 3, + "RESULT_CODE_UNKNOWN": 4, + "RESULT_CODE_CONFLICT": 5, + } +) + +func (x ResultCode) Enum() *ResultCode { + p := new(ResultCode) + *p = x + return p +} + +func (x ResultCode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ResultCode) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[0].Descriptor() +} + +func (ResultCode) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[0] +} + +func (x ResultCode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ResultCode.Descriptor instead. +func (ResultCode) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +type FailureCode int32 + +const ( + FailureCode_FAILURE_CODE_UNSPECIFIED FailureCode = 0 + FailureCode_FAILURE_CODE_INVALID_ARGUMENT FailureCode = 1 + FailureCode_FAILURE_CODE_UNAUTHENTICATED FailureCode = 2 + FailureCode_FAILURE_CODE_PERMISSION_DENIED FailureCode = 3 + FailureCode_FAILURE_CODE_FAILED_PRECONDITION FailureCode = 4 + FailureCode_FAILURE_CODE_ABORTED FailureCode = 5 + FailureCode_FAILURE_CODE_RESOURCE_EXHAUSTED FailureCode = 6 + FailureCode_FAILURE_CODE_UNAVAILABLE FailureCode = 7 + FailureCode_FAILURE_CODE_DEADLINE_EXCEEDED FailureCode = 8 + FailureCode_FAILURE_CODE_NOT_FOUND FailureCode = 9 + FailureCode_FAILURE_CODE_ALREADY_EXISTS FailureCode = 10 +) + +// Enum value maps for FailureCode. +var ( + FailureCode_name = map[int32]string{ + 0: "FAILURE_CODE_UNSPECIFIED", + 1: "FAILURE_CODE_INVALID_ARGUMENT", + 2: "FAILURE_CODE_UNAUTHENTICATED", + 3: "FAILURE_CODE_PERMISSION_DENIED", + 4: "FAILURE_CODE_FAILED_PRECONDITION", + 5: "FAILURE_CODE_ABORTED", + 6: "FAILURE_CODE_RESOURCE_EXHAUSTED", + 7: "FAILURE_CODE_UNAVAILABLE", + 8: "FAILURE_CODE_DEADLINE_EXCEEDED", + 9: "FAILURE_CODE_NOT_FOUND", + 10: "FAILURE_CODE_ALREADY_EXISTS", + } + FailureCode_value = map[string]int32{ + "FAILURE_CODE_UNSPECIFIED": 0, + "FAILURE_CODE_INVALID_ARGUMENT": 1, + "FAILURE_CODE_UNAUTHENTICATED": 2, + "FAILURE_CODE_PERMISSION_DENIED": 3, + "FAILURE_CODE_FAILED_PRECONDITION": 4, + "FAILURE_CODE_ABORTED": 5, + "FAILURE_CODE_RESOURCE_EXHAUSTED": 6, + "FAILURE_CODE_UNAVAILABLE": 7, + "FAILURE_CODE_DEADLINE_EXCEEDED": 8, + "FAILURE_CODE_NOT_FOUND": 9, + "FAILURE_CODE_ALREADY_EXISTS": 10, + } +) + +func (x FailureCode) Enum() *FailureCode { + p := new(FailureCode) + *p = x + return p +} + +func (x FailureCode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FailureCode) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[1].Descriptor() +} + +func (FailureCode) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[1] +} + +func (x FailureCode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FailureCode.Descriptor instead. +func (FailureCode) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +type ActivationState int32 + +const ( + ActivationState_ACTIVATION_STATE_UNSPECIFIED ActivationState = 0 + ActivationState_ACTIVATION_STATE_PENDING ActivationState = 1 + ActivationState_ACTIVATION_STATE_ACTIVE ActivationState = 2 + ActivationState_ACTIVATION_STATE_CONFLICT ActivationState = 3 + ActivationState_ACTIVATION_STATE_REVOKED ActivationState = 4 +) + +// Enum value maps for ActivationState. +var ( + ActivationState_name = map[int32]string{ + 0: "ACTIVATION_STATE_UNSPECIFIED", + 1: "ACTIVATION_STATE_PENDING", + 2: "ACTIVATION_STATE_ACTIVE", + 3: "ACTIVATION_STATE_CONFLICT", + 4: "ACTIVATION_STATE_REVOKED", + } + ActivationState_value = map[string]int32{ + "ACTIVATION_STATE_UNSPECIFIED": 0, + "ACTIVATION_STATE_PENDING": 1, + "ACTIVATION_STATE_ACTIVE": 2, + "ACTIVATION_STATE_CONFLICT": 3, + "ACTIVATION_STATE_REVOKED": 4, + } +) + +func (x ActivationState) Enum() *ActivationState { + p := new(ActivationState) + *p = x + return p +} + +func (x ActivationState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ActivationState) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[2].Descriptor() +} + +func (ActivationState) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[2] +} + +func (x ActivationState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ActivationState.Descriptor instead. +func (ActivationState) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{2} +} + +type AdmissionState int32 + +const ( + AdmissionState_ADMISSION_STATE_UNSPECIFIED AdmissionState = 0 + AdmissionState_ADMISSION_STATE_OPEN AdmissionState = 1 + AdmissionState_ADMISSION_STATE_CLOSED AdmissionState = 2 + AdmissionState_ADMISSION_STATE_DRAINING AdmissionState = 3 + AdmissionState_ADMISSION_STATE_QUARANTINED AdmissionState = 4 +) + +// Enum value maps for AdmissionState. +var ( + AdmissionState_name = map[int32]string{ + 0: "ADMISSION_STATE_UNSPECIFIED", + 1: "ADMISSION_STATE_OPEN", + 2: "ADMISSION_STATE_CLOSED", + 3: "ADMISSION_STATE_DRAINING", + 4: "ADMISSION_STATE_QUARANTINED", + } + AdmissionState_value = map[string]int32{ + "ADMISSION_STATE_UNSPECIFIED": 0, + "ADMISSION_STATE_OPEN": 1, + "ADMISSION_STATE_CLOSED": 2, + "ADMISSION_STATE_DRAINING": 3, + "ADMISSION_STATE_QUARANTINED": 4, + } +) + +func (x AdmissionState) Enum() *AdmissionState { + p := new(AdmissionState) + *p = x + return p +} + +func (x AdmissionState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AdmissionState) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[3].Descriptor() +} + +func (AdmissionState) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[3] +} + +func (x AdmissionState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AdmissionState.Descriptor instead. +func (AdmissionState) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{3} +} + +type ControlAction int32 + +const ( + ControlAction_CONTROL_ACTION_UNSPECIFIED ControlAction = 0 + ControlAction_CONTROL_ACTION_PAUSE ControlAction = 1 + ControlAction_CONTROL_ACTION_RESUME ControlAction = 2 + ControlAction_CONTROL_ACTION_STOP ControlAction = 3 +) + +// Enum value maps for ControlAction. +var ( + ControlAction_name = map[int32]string{ + 0: "CONTROL_ACTION_UNSPECIFIED", + 1: "CONTROL_ACTION_PAUSE", + 2: "CONTROL_ACTION_RESUME", + 3: "CONTROL_ACTION_STOP", + } + ControlAction_value = map[string]int32{ + "CONTROL_ACTION_UNSPECIFIED": 0, + "CONTROL_ACTION_PAUSE": 1, + "CONTROL_ACTION_RESUME": 2, + "CONTROL_ACTION_STOP": 3, + } +) + +func (x ControlAction) Enum() *ControlAction { + p := new(ControlAction) + *p = x + return p +} + +func (x ControlAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ControlAction) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[4].Descriptor() +} + +func (ControlAction) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[4] +} + +func (x ControlAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ControlAction.Descriptor instead. +func (ControlAction) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{4} +} + +type ActiveCallPolicy int32 + +const ( + ActiveCallPolicy_ACTIVE_CALL_POLICY_UNSPECIFIED ActiveCallPolicy = 0 + ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN ActiveCallPolicy = 1 + ActiveCallPolicy_ACTIVE_CALL_POLICY_HANGUP ActiveCallPolicy = 2 +) + +// Enum value maps for ActiveCallPolicy. +var ( + ActiveCallPolicy_name = map[int32]string{ + 0: "ACTIVE_CALL_POLICY_UNSPECIFIED", + 1: "ACTIVE_CALL_POLICY_DRAIN", + 2: "ACTIVE_CALL_POLICY_HANGUP", + } + ActiveCallPolicy_value = map[string]int32{ + "ACTIVE_CALL_POLICY_UNSPECIFIED": 0, + "ACTIVE_CALL_POLICY_DRAIN": 1, + "ACTIVE_CALL_POLICY_HANGUP": 2, + } +) + +func (x ActiveCallPolicy) Enum() *ActiveCallPolicy { + p := new(ActiveCallPolicy) + *p = x + return p +} + +func (x ActiveCallPolicy) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ActiveCallPolicy) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[5].Descriptor() +} + +func (ActiveCallPolicy) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[5] +} + +func (x ActiveCallPolicy) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ActiveCallPolicy.Descriptor instead. +func (ActiveCallPolicy) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{5} +} + +type ExecutionState int32 + +const ( + ExecutionState_EXECUTION_STATE_UNSPECIFIED ExecutionState = 0 + ExecutionState_EXECUTION_STATE_PREPARED ExecutionState = 1 + ExecutionState_EXECUTION_STATE_PERMIT_GRANTED ExecutionState = 2 + ExecutionState_EXECUTION_STATE_DISPATCHING ExecutionState = 3 + ExecutionState_EXECUTION_STATE_OBSERVED ExecutionState = 4 + ExecutionState_EXECUTION_STATE_UNKNOWN ExecutionState = 5 + ExecutionState_EXECUTION_STATE_TERMINAL ExecutionState = 6 +) + +// Enum value maps for ExecutionState. +var ( + ExecutionState_name = map[int32]string{ + 0: "EXECUTION_STATE_UNSPECIFIED", + 1: "EXECUTION_STATE_PREPARED", + 2: "EXECUTION_STATE_PERMIT_GRANTED", + 3: "EXECUTION_STATE_DISPATCHING", + 4: "EXECUTION_STATE_OBSERVED", + 5: "EXECUTION_STATE_UNKNOWN", + 6: "EXECUTION_STATE_TERMINAL", + } + ExecutionState_value = map[string]int32{ + "EXECUTION_STATE_UNSPECIFIED": 0, + "EXECUTION_STATE_PREPARED": 1, + "EXECUTION_STATE_PERMIT_GRANTED": 2, + "EXECUTION_STATE_DISPATCHING": 3, + "EXECUTION_STATE_OBSERVED": 4, + "EXECUTION_STATE_UNKNOWN": 5, + "EXECUTION_STATE_TERMINAL": 6, + } +) + +func (x ExecutionState) Enum() *ExecutionState { + p := new(ExecutionState) + *p = x + return p +} + +func (x ExecutionState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ExecutionState) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[6].Descriptor() +} + +func (ExecutionState) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[6] +} + +func (x ExecutionState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use ExecutionState.Descriptor instead. +func (ExecutionState) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{6} +} + +type AssetKind int32 + +const ( + AssetKind_ASSET_KIND_UNSPECIFIED AssetKind = 0 + AssetKind_ASSET_KIND_RECORDING AssetKind = 1 + AssetKind_ASSET_KIND_TRANSCRIPT AssetKind = 2 +) + +// Enum value maps for AssetKind. +var ( + AssetKind_name = map[int32]string{ + 0: "ASSET_KIND_UNSPECIFIED", + 1: "ASSET_KIND_RECORDING", + 2: "ASSET_KIND_TRANSCRIPT", + } + AssetKind_value = map[string]int32{ + "ASSET_KIND_UNSPECIFIED": 0, + "ASSET_KIND_RECORDING": 1, + "ASSET_KIND_TRANSCRIPT": 2, + } +) + +func (x AssetKind) Enum() *AssetKind { + p := new(AssetKind) + *p = x + return p +} + +func (x AssetKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AssetKind) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[7].Descriptor() +} + +func (AssetKind) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[7] +} + +func (x AssetKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AssetKind.Descriptor instead. +func (AssetKind) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{7} +} + +type UploadState int32 + +const ( + UploadState_UPLOAD_STATE_UNSPECIFIED UploadState = 0 + UploadState_UPLOAD_STATE_REQUESTED UploadState = 1 + UploadState_UPLOAD_STATE_UPLOADING UploadState = 2 + UploadState_UPLOAD_STATE_COMPLETED UploadState = 3 + UploadState_UPLOAD_STATE_FAILED UploadState = 4 + UploadState_UPLOAD_STATE_EXPIRED UploadState = 5 +) + +// Enum value maps for UploadState. +var ( + UploadState_name = map[int32]string{ + 0: "UPLOAD_STATE_UNSPECIFIED", + 1: "UPLOAD_STATE_REQUESTED", + 2: "UPLOAD_STATE_UPLOADING", + 3: "UPLOAD_STATE_COMPLETED", + 4: "UPLOAD_STATE_FAILED", + 5: "UPLOAD_STATE_EXPIRED", + } + UploadState_value = map[string]int32{ + "UPLOAD_STATE_UNSPECIFIED": 0, + "UPLOAD_STATE_REQUESTED": 1, + "UPLOAD_STATE_UPLOADING": 2, + "UPLOAD_STATE_COMPLETED": 3, + "UPLOAD_STATE_FAILED": 4, + "UPLOAD_STATE_EXPIRED": 5, + } +) + +func (x UploadState) Enum() *UploadState { + p := new(UploadState) + *p = x + return p +} + +func (x UploadState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (UploadState) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[8].Descriptor() +} + +func (UploadState) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[8] +} + +func (x UploadState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use UploadState.Descriptor instead. +func (UploadState) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{8} +} + +type FactKind int32 + +const ( + FactKind_FACT_KIND_UNSPECIFIED FactKind = 0 + FactKind_FACT_KIND_EXECUTION_ACCEPTED FactKind = 1 + FactKind_FACT_KIND_CALL_STATUS FactKind = 2 + FactKind_FACT_KIND_CALL_FINISHED FactKind = 3 + FactKind_FACT_KIND_TRANSCRIPT_UPDATED FactKind = 4 + FactKind_FACT_KIND_TRANSCRIPT_FAILED FactKind = 5 + FactKind_FACT_KIND_CONTACT_OPT_OUT FactKind = 6 + FactKind_FACT_KIND_RECORDING_PROGRESS FactKind = 7 +) + +// Enum value maps for FactKind. +var ( + FactKind_name = map[int32]string{ + 0: "FACT_KIND_UNSPECIFIED", + 1: "FACT_KIND_EXECUTION_ACCEPTED", + 2: "FACT_KIND_CALL_STATUS", + 3: "FACT_KIND_CALL_FINISHED", + 4: "FACT_KIND_TRANSCRIPT_UPDATED", + 5: "FACT_KIND_TRANSCRIPT_FAILED", + 6: "FACT_KIND_CONTACT_OPT_OUT", + 7: "FACT_KIND_RECORDING_PROGRESS", + } + FactKind_value = map[string]int32{ + "FACT_KIND_UNSPECIFIED": 0, + "FACT_KIND_EXECUTION_ACCEPTED": 1, + "FACT_KIND_CALL_STATUS": 2, + "FACT_KIND_CALL_FINISHED": 3, + "FACT_KIND_TRANSCRIPT_UPDATED": 4, + "FACT_KIND_TRANSCRIPT_FAILED": 5, + "FACT_KIND_CONTACT_OPT_OUT": 6, + "FACT_KIND_RECORDING_PROGRESS": 7, + } +) + +func (x FactKind) Enum() *FactKind { + p := new(FactKind) + *p = x + return p +} + +func (x FactKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FactKind) Descriptor() protoreflect.EnumDescriptor { + return file_agent_v1_agent_proto_enumTypes[9].Descriptor() +} + +func (FactKind) Type() protoreflect.EnumType { + return &file_agent_v1_agent_proto_enumTypes[9] +} + +func (x FactKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FactKind.Descriptor instead. +func (FactKind) EnumDescriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{9} +} + +type RequestMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProtocolVersion string `protobuf:"bytes,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + RequestId string `protobuf:"bytes,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + TraceId string `protobuf:"bytes,3,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + OperationId string `protobuf:"bytes,4,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + DeadlineUnixMs int64 `protobuf:"varint,5,opt,name=deadline_unix_ms,json=deadlineUnixMs,proto3" json:"deadline_unix_ms,omitempty"` + DispatcherEpoch string `protobuf:"bytes,6,opt,name=dispatcher_epoch,json=dispatcherEpoch,proto3" json:"dispatcher_epoch,omitempty"` + AgentId string `protobuf:"bytes,7,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + CellId string `protobuf:"bytes,8,opt,name=cell_id,json=cellId,proto3" json:"cell_id,omitempty"` + BootId string `protobuf:"bytes,9,opt,name=boot_id,json=bootId,proto3" json:"boot_id,omitempty"` + SessionGeneration uint64 `protobuf:"varint,10,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + IdempotencyKey string `protobuf:"bytes,11,opt,name=idempotency_key,json=idempotencyKey,proto3" json:"idempotency_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestMeta) Reset() { + *x = RequestMeta{} + mi := &file_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestMeta) ProtoMessage() {} + +func (x *RequestMeta) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestMeta.ProtoReflect.Descriptor instead. +func (*RequestMeta) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *RequestMeta) GetProtocolVersion() string { + if x != nil { + return x.ProtocolVersion + } + return "" +} + +func (x *RequestMeta) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *RequestMeta) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *RequestMeta) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *RequestMeta) GetDeadlineUnixMs() int64 { + if x != nil { + return x.DeadlineUnixMs + } + return 0 +} + +func (x *RequestMeta) GetDispatcherEpoch() string { + if x != nil { + return x.DispatcherEpoch + } + return "" +} + +func (x *RequestMeta) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *RequestMeta) GetCellId() string { + if x != nil { + return x.CellId + } + return "" +} + +func (x *RequestMeta) GetBootId() string { + if x != nil { + return x.BootId + } + return "" +} + +func (x *RequestMeta) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +func (x *RequestMeta) GetIdempotencyKey() string { + if x != nil { + return x.IdempotencyKey + } + return "" +} + +type ResponseMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProtocolVersion string `protobuf:"bytes,1,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + RequestId string `protobuf:"bytes,2,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + TraceId string `protobuf:"bytes,3,opt,name=trace_id,json=traceId,proto3" json:"trace_id,omitempty"` + OperationId string `protobuf:"bytes,4,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` + ObservedAtUnixMs int64 `protobuf:"varint,5,opt,name=observed_at_unix_ms,json=observedAtUnixMs,proto3" json:"observed_at_unix_ms,omitempty"` + DispatcherEpoch string `protobuf:"bytes,6,opt,name=dispatcher_epoch,json=dispatcherEpoch,proto3" json:"dispatcher_epoch,omitempty"` + AgentId string `protobuf:"bytes,7,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + CellId string `protobuf:"bytes,8,opt,name=cell_id,json=cellId,proto3" json:"cell_id,omitempty"` + BootId string `protobuf:"bytes,9,opt,name=boot_id,json=bootId,proto3" json:"boot_id,omitempty"` + SessionGeneration uint64 `protobuf:"varint,10,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResponseMeta) Reset() { + *x = ResponseMeta{} + mi := &file_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResponseMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResponseMeta) ProtoMessage() {} + +func (x *ResponseMeta) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResponseMeta.ProtoReflect.Descriptor instead. +func (*ResponseMeta) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *ResponseMeta) GetProtocolVersion() string { + if x != nil { + return x.ProtocolVersion + } + return "" +} + +func (x *ResponseMeta) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ResponseMeta) GetTraceId() string { + if x != nil { + return x.TraceId + } + return "" +} + +func (x *ResponseMeta) GetOperationId() string { + if x != nil { + return x.OperationId + } + return "" +} + +func (x *ResponseMeta) GetObservedAtUnixMs() int64 { + if x != nil { + return x.ObservedAtUnixMs + } + return 0 +} + +func (x *ResponseMeta) GetDispatcherEpoch() string { + if x != nil { + return x.DispatcherEpoch + } + return "" +} + +func (x *ResponseMeta) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *ResponseMeta) GetCellId() string { + if x != nil { + return x.CellId + } + return "" +} + +func (x *ResponseMeta) GetBootId() string { + if x != nil { + return x.BootId + } + return "" +} + +func (x *ResponseMeta) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +type Failure struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code FailureCode `protobuf:"varint,1,opt,name=code,proto3,enum=agent.v1.FailureCode" json:"code,omitempty"` + Retryable bool `protobuf:"varint,2,opt,name=retryable,proto3" json:"retryable,omitempty"` + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + Field string `protobuf:"bytes,4,opt,name=field,proto3" json:"field,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Failure) Reset() { + *x = Failure{} + mi := &file_agent_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Failure) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Failure) ProtoMessage() {} + +func (x *Failure) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Failure.ProtoReflect.Descriptor instead. +func (*Failure) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *Failure) GetCode() FailureCode { + if x != nil { + return x.Code + } + return FailureCode_FAILURE_CODE_UNSPECIFIED +} + +func (x *Failure) GetRetryable() bool { + if x != nil { + return x.Retryable + } + return false +} + +func (x *Failure) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *Failure) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +type OperationReceipt struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Result ResultCode `protobuf:"varint,2,opt,name=result,proto3,enum=agent.v1.ResultCode" json:"result,omitempty"` + Failure *Failure `protobuf:"bytes,3,opt,name=failure,proto3" json:"failure,omitempty"` + FactId string `protobuf:"bytes,4,opt,name=fact_id,json=factId,proto3" json:"fact_id,omitempty"` + ContentSha256 string `protobuf:"bytes,5,opt,name=content_sha256,json=contentSha256,proto3" json:"content_sha256,omitempty"` + AcceptedAtUnixMs int64 `protobuf:"varint,6,opt,name=accepted_at_unix_ms,json=acceptedAtUnixMs,proto3" json:"accepted_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperationReceipt) Reset() { + *x = OperationReceipt{} + mi := &file_agent_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperationReceipt) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperationReceipt) ProtoMessage() {} + +func (x *OperationReceipt) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperationReceipt.ProtoReflect.Descriptor instead. +func (*OperationReceipt) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *OperationReceipt) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *OperationReceipt) GetResult() ResultCode { + if x != nil { + return x.Result + } + return ResultCode_RESULT_CODE_UNSPECIFIED +} + +func (x *OperationReceipt) GetFailure() *Failure { + if x != nil { + return x.Failure + } + return nil +} + +func (x *OperationReceipt) GetFactId() string { + if x != nil { + return x.FactId + } + return "" +} + +func (x *OperationReceipt) GetContentSha256() string { + if x != nil { + return x.ContentSha256 + } + return "" +} + +func (x *OperationReceipt) GetAcceptedAtUnixMs() int64 { + if x != nil { + return x.AcceptedAtUnixMs + } + return 0 +} + +type AgentBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + CellId string `protobuf:"bytes,2,opt,name=cell_id,json=cellId,proto3" json:"cell_id,omitempty"` + ExpectedBootId string `protobuf:"bytes,3,opt,name=expected_boot_id,json=expectedBootId,proto3" json:"expected_boot_id,omitempty"` + DispatcherEpoch string `protobuf:"bytes,4,opt,name=dispatcher_epoch,json=dispatcherEpoch,proto3" json:"dispatcher_epoch,omitempty"` + SessionGeneration uint64 `protobuf:"varint,5,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + EndpointId string `protobuf:"bytes,6,opt,name=endpoint_id,json=endpointId,proto3" json:"endpoint_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentBinding) Reset() { + *x = AgentBinding{} + mi := &file_agent_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentBinding) ProtoMessage() {} + +func (x *AgentBinding) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentBinding.ProtoReflect.Descriptor instead. +func (*AgentBinding) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *AgentBinding) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentBinding) GetCellId() string { + if x != nil { + return x.CellId + } + return "" +} + +func (x *AgentBinding) GetExpectedBootId() string { + if x != nil { + return x.ExpectedBootId + } + return "" +} + +func (x *AgentBinding) GetDispatcherEpoch() string { + if x != nil { + return x.DispatcherEpoch + } + return "" +} + +func (x *AgentBinding) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +func (x *AgentBinding) GetEndpointId() string { + if x != nil { + return x.EndpointId + } + return "" +} + +type Capability struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Value string `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Capability) Reset() { + *x = Capability{} + mi := &file_agent_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Capability) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Capability) ProtoMessage() {} + +func (x *Capability) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Capability.ProtoReflect.Descriptor instead. +func (*Capability) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *Capability) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Capability) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *Capability) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type ResourceSample struct { + state protoimpl.MessageState `protogen:"open.v1"` + ObservedAtUnixMs int64 `protobuf:"varint,1,opt,name=observed_at_unix_ms,json=observedAtUnixMs,proto3" json:"observed_at_unix_ms,omitempty"` + CpuUsedRatio float64 `protobuf:"fixed64,2,opt,name=cpu_used_ratio,json=cpuUsedRatio,proto3" json:"cpu_used_ratio,omitempty"` + MemoryAvailableBytes int64 `protobuf:"varint,3,opt,name=memory_available_bytes,json=memoryAvailableBytes,proto3" json:"memory_available_bytes,omitempty"` + FdUsed int64 `protobuf:"varint,4,opt,name=fd_used,json=fdUsed,proto3" json:"fd_used,omitempty"` + FdLimit int64 `protobuf:"varint,5,opt,name=fd_limit,json=fdLimit,proto3" json:"fd_limit,omitempty"` + SpoolUsedBytes int64 `protobuf:"varint,6,opt,name=spool_used_bytes,json=spoolUsedBytes,proto3" json:"spool_used_bytes,omitempty"` + SpoolCapacityBytes int64 `protobuf:"varint,7,opt,name=spool_capacity_bytes,json=spoolCapacityBytes,proto3" json:"spool_capacity_bytes,omitempty"` + MediaPortsUsed int64 `protobuf:"varint,8,opt,name=media_ports_used,json=mediaPortsUsed,proto3" json:"media_ports_used,omitempty"` + MediaPortsCapacity int64 `protobuf:"varint,9,opt,name=media_ports_capacity,json=mediaPortsCapacity,proto3" json:"media_ports_capacity,omitempty"` + SampleFresh bool `protobuf:"varint,10,opt,name=sample_fresh,json=sampleFresh,proto3" json:"sample_fresh,omitempty"` + MissingReason string `protobuf:"bytes,11,opt,name=missing_reason,json=missingReason,proto3" json:"missing_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceSample) Reset() { + *x = ResourceSample{} + mi := &file_agent_v1_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceSample) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceSample) ProtoMessage() {} + +func (x *ResourceSample) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceSample.ProtoReflect.Descriptor instead. +func (*ResourceSample) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *ResourceSample) GetObservedAtUnixMs() int64 { + if x != nil { + return x.ObservedAtUnixMs + } + return 0 +} + +func (x *ResourceSample) GetCpuUsedRatio() float64 { + if x != nil { + return x.CpuUsedRatio + } + return 0 +} + +func (x *ResourceSample) GetMemoryAvailableBytes() int64 { + if x != nil { + return x.MemoryAvailableBytes + } + return 0 +} + +func (x *ResourceSample) GetFdUsed() int64 { + if x != nil { + return x.FdUsed + } + return 0 +} + +func (x *ResourceSample) GetFdLimit() int64 { + if x != nil { + return x.FdLimit + } + return 0 +} + +func (x *ResourceSample) GetSpoolUsedBytes() int64 { + if x != nil { + return x.SpoolUsedBytes + } + return 0 +} + +func (x *ResourceSample) GetSpoolCapacityBytes() int64 { + if x != nil { + return x.SpoolCapacityBytes + } + return 0 +} + +func (x *ResourceSample) GetMediaPortsUsed() int64 { + if x != nil { + return x.MediaPortsUsed + } + return 0 +} + +func (x *ResourceSample) GetMediaPortsCapacity() int64 { + if x != nil { + return x.MediaPortsCapacity + } + return 0 +} + +func (x *ResourceSample) GetSampleFresh() bool { + if x != nil { + return x.SampleFresh + } + return false +} + +func (x *ResourceSample) GetMissingReason() string { + if x != nil { + return x.MissingReason + } + return "" +} + +type AppliedConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` + ConfigSha256 string `protobuf:"bytes,3,opt,name=config_sha256,json=configSha256,proto3" json:"config_sha256,omitempty"` + State string `protobuf:"bytes,4,opt,name=state,proto3" json:"state,omitempty"` + ObservedAtUnixMs int64 `protobuf:"varint,5,opt,name=observed_at_unix_ms,json=observedAtUnixMs,proto3" json:"observed_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppliedConfig) Reset() { + *x = AppliedConfig{} + mi := &file_agent_v1_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppliedConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppliedConfig) ProtoMessage() {} + +func (x *AppliedConfig) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppliedConfig.ProtoReflect.Descriptor instead. +func (*AppliedConfig) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *AppliedConfig) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *AppliedConfig) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +func (x *AppliedConfig) GetConfigSha256() string { + if x != nil { + return x.ConfigSha256 + } + return "" +} + +func (x *AppliedConfig) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *AppliedConfig) GetObservedAtUnixMs() int64 { + if x != nil { + return x.ObservedAtUnixMs + } + return 0 +} + +type AgentStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + CellId string `protobuf:"bytes,2,opt,name=cell_id,json=cellId,proto3" json:"cell_id,omitempty"` + BootId string `protobuf:"bytes,3,opt,name=boot_id,json=bootId,proto3" json:"boot_id,omitempty"` + SoftwareVersion string `protobuf:"bytes,4,opt,name=software_version,json=softwareVersion,proto3" json:"software_version,omitempty"` + ProtocolVersion string `protobuf:"bytes,5,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` + AsteriskVersion string `protobuf:"bytes,6,opt,name=asterisk_version,json=asteriskVersion,proto3" json:"asterisk_version,omitempty"` + AdmissionState AdmissionState `protobuf:"varint,7,opt,name=admission_state,json=admissionState,proto3,enum=agent.v1.AdmissionState" json:"admission_state,omitempty"` + Capabilities []*Capability `protobuf:"bytes,8,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + Resources *ResourceSample `protobuf:"bytes,9,opt,name=resources,proto3" json:"resources,omitempty"` + AppliedConfigs []*AppliedConfig `protobuf:"bytes,10,rep,name=applied_configs,json=appliedConfigs,proto3" json:"applied_configs,omitempty"` + MtlsAuthenticated bool `protobuf:"varint,11,opt,name=mtls_authenticated,json=mtlsAuthenticated,proto3" json:"mtls_authenticated,omitempty"` + SessionActive bool `protobuf:"varint,12,opt,name=session_active,json=sessionActive,proto3" json:"session_active,omitempty"` + StatusReason string `protobuf:"bytes,13,opt,name=status_reason,json=statusReason,proto3" json:"status_reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentStatus) Reset() { + *x = AgentStatus{} + mi := &file_agent_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentStatus) ProtoMessage() {} + +func (x *AgentStatus) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentStatus.ProtoReflect.Descriptor instead. +func (*AgentStatus) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *AgentStatus) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentStatus) GetCellId() string { + if x != nil { + return x.CellId + } + return "" +} + +func (x *AgentStatus) GetBootId() string { + if x != nil { + return x.BootId + } + return "" +} + +func (x *AgentStatus) GetSoftwareVersion() string { + if x != nil { + return x.SoftwareVersion + } + return "" +} + +func (x *AgentStatus) GetProtocolVersion() string { + if x != nil { + return x.ProtocolVersion + } + return "" +} + +func (x *AgentStatus) GetAsteriskVersion() string { + if x != nil { + return x.AsteriskVersion + } + return "" +} + +func (x *AgentStatus) GetAdmissionState() AdmissionState { + if x != nil { + return x.AdmissionState + } + return AdmissionState_ADMISSION_STATE_UNSPECIFIED +} + +func (x *AgentStatus) GetCapabilities() []*Capability { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AgentStatus) GetResources() *ResourceSample { + if x != nil { + return x.Resources + } + return nil +} + +func (x *AgentStatus) GetAppliedConfigs() []*AppliedConfig { + if x != nil { + return x.AppliedConfigs + } + return nil +} + +func (x *AgentStatus) GetMtlsAuthenticated() bool { + if x != nil { + return x.MtlsAuthenticated + } + return false +} + +func (x *AgentStatus) GetSessionActive() bool { + if x != nil { + return x.SessionActive + } + return false +} + +func (x *AgentStatus) GetStatusReason() string { + if x != nil { + return x.StatusReason + } + return "" +} + +type Session struct { + state protoimpl.MessageState `protogen:"open.v1"` + DispatcherEpoch string `protobuf:"bytes,1,opt,name=dispatcher_epoch,json=dispatcherEpoch,proto3" json:"dispatcher_epoch,omitempty"` + SessionGeneration uint64 `protobuf:"varint,2,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + ExpiresAtUnixMs int64 `protobuf:"varint,3,opt,name=expires_at_unix_ms,json=expiresAtUnixMs,proto3" json:"expires_at_unix_ms,omitempty"` + SessionCredential []byte `protobuf:"bytes,4,opt,name=session_credential,json=sessionCredential,proto3" json:"session_credential,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Session) Reset() { + *x = Session{} + mi := &file_agent_v1_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Session.ProtoReflect.Descriptor instead. +func (*Session) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *Session) GetDispatcherEpoch() string { + if x != nil { + return x.DispatcherEpoch + } + return "" +} + +func (x *Session) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +func (x *Session) GetExpiresAtUnixMs() int64 { + if x != nil { + return x.ExpiresAtUnixMs + } + return 0 +} + +func (x *Session) GetSessionCredential() []byte { + if x != nil { + return x.SessionCredential + } + return nil +} + +type ConfigReference struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + Sha256 string `protobuf:"bytes,3,opt,name=sha256,proto3" json:"sha256,omitempty"` + Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConfigReference) Reset() { + *x = ConfigReference{} + mi := &file_agent_v1_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConfigReference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConfigReference) ProtoMessage() {} + +func (x *ConfigReference) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConfigReference.ProtoReflect.Descriptor instead. +func (*ConfigReference) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *ConfigReference) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ConfigReference) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *ConfigReference) GetSha256() string { + if x != nil { + return x.Sha256 + } + return "" +} + +func (x *ConfigReference) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +type UploadPolicy struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + MaxAssetBytes int64 `protobuf:"varint,2,opt,name=max_asset_bytes,json=maxAssetBytes,proto3" json:"max_asset_bytes,omitempty"` + MinRetentionMs int64 `protobuf:"varint,3,opt,name=min_retention_ms,json=minRetentionMs,proto3" json:"min_retention_ms,omitempty"` + AllowedHosts []string `protobuf:"bytes,4,rep,name=allowed_hosts,json=allowedHosts,proto3" json:"allowed_hosts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadPolicy) Reset() { + *x = UploadPolicy{} + mi := &file_agent_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadPolicy) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadPolicy) ProtoMessage() {} + +func (x *UploadPolicy) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadPolicy.ProtoReflect.Descriptor instead. +func (*UploadPolicy) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *UploadPolicy) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *UploadPolicy) GetMaxAssetBytes() int64 { + if x != nil { + return x.MaxAssetBytes + } + return 0 +} + +func (x *UploadPolicy) GetMinRetentionMs() int64 { + if x != nil { + return x.MinRetentionMs + } + return 0 +} + +func (x *UploadPolicy) GetAllowedHosts() []string { + if x != nil { + return x.AllowedHosts + } + return nil +} + +type ExecutionBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + TenantId string `protobuf:"bytes,1,opt,name=tenant_id,json=tenantId,proto3" json:"tenant_id,omitempty"` + TenantKey string `protobuf:"bytes,2,opt,name=tenant_key,json=tenantKey,proto3" json:"tenant_key,omitempty"` + ExecutionId string `protobuf:"bytes,3,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` + TaskId string `protobuf:"bytes,4,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + TaskItemId string `protobuf:"bytes,5,opt,name=task_item_id,json=taskItemId,proto3" json:"task_item_id,omitempty"` + TaskRevision int64 `protobuf:"varint,6,opt,name=task_revision,json=taskRevision,proto3" json:"task_revision,omitempty"` + CallId string `protobuf:"bytes,7,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + AttemptId string `protobuf:"bytes,8,opt,name=attempt_id,json=attemptId,proto3" json:"attempt_id,omitempty"` + AgentVersionId string `protobuf:"bytes,9,opt,name=agent_version_id,json=agentVersionId,proto3" json:"agent_version_id,omitempty"` + RoutePolicyId string `protobuf:"bytes,10,opt,name=route_policy_id,json=routePolicyId,proto3" json:"route_policy_id,omitempty"` + CallerProfileId string `protobuf:"bytes,11,opt,name=caller_profile_id,json=callerProfileId,proto3" json:"caller_profile_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionBinding) Reset() { + *x = ExecutionBinding{} + mi := &file_agent_v1_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionBinding) ProtoMessage() {} + +func (x *ExecutionBinding) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionBinding.ProtoReflect.Descriptor instead. +func (*ExecutionBinding) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecutionBinding) GetTenantId() string { + if x != nil { + return x.TenantId + } + return "" +} + +func (x *ExecutionBinding) GetTenantKey() string { + if x != nil { + return x.TenantKey + } + return "" +} + +func (x *ExecutionBinding) GetExecutionId() string { + if x != nil { + return x.ExecutionId + } + return "" +} + +func (x *ExecutionBinding) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ExecutionBinding) GetTaskItemId() string { + if x != nil { + return x.TaskItemId + } + return "" +} + +func (x *ExecutionBinding) GetTaskRevision() int64 { + if x != nil { + return x.TaskRevision + } + return 0 +} + +func (x *ExecutionBinding) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ExecutionBinding) GetAttemptId() string { + if x != nil { + return x.AttemptId + } + return "" +} + +func (x *ExecutionBinding) GetAgentVersionId() string { + if x != nil { + return x.AgentVersionId + } + return "" +} + +func (x *ExecutionBinding) GetRoutePolicyId() string { + if x != nil { + return x.RoutePolicyId + } + return "" +} + +func (x *ExecutionBinding) GetCallerProfileId() string { + if x != nil { + return x.CallerProfileId + } + return "" +} + +type AssetDescriptor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind AssetKind `protobuf:"varint,1,opt,name=kind,proto3,enum=agent.v1.AssetKind" json:"kind,omitempty"` + AssetId string `protobuf:"bytes,2,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + CallId string `protobuf:"bytes,3,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + ExecutionId string `protobuf:"bytes,4,opt,name=execution_id,json=executionId,proto3" json:"execution_id,omitempty"` + Format string `protobuf:"bytes,5,opt,name=format,proto3" json:"format,omitempty"` + SizeBytes int64 `protobuf:"varint,6,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + ChecksumSha256 string `protobuf:"bytes,7,opt,name=checksum_sha256,json=checksumSha256,proto3" json:"checksum_sha256,omitempty"` + Channels int32 `protobuf:"varint,8,opt,name=channels,proto3" json:"channels,omitempty"` + SampleRateHz int32 `protobuf:"varint,9,opt,name=sample_rate_hz,json=sampleRateHz,proto3" json:"sample_rate_hz,omitempty"` + DurationMs int64 `protobuf:"varint,10,opt,name=duration_ms,json=durationMs,proto3" json:"duration_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetDescriptor) Reset() { + *x = AssetDescriptor{} + mi := &file_agent_v1_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetDescriptor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetDescriptor) ProtoMessage() {} + +func (x *AssetDescriptor) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetDescriptor.ProtoReflect.Descriptor instead. +func (*AssetDescriptor) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *AssetDescriptor) GetKind() AssetKind { + if x != nil { + return x.Kind + } + return AssetKind_ASSET_KIND_UNSPECIFIED +} + +func (x *AssetDescriptor) GetAssetId() string { + if x != nil { + return x.AssetId + } + return "" +} + +func (x *AssetDescriptor) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *AssetDescriptor) GetExecutionId() string { + if x != nil { + return x.ExecutionId + } + return "" +} + +func (x *AssetDescriptor) GetFormat() string { + if x != nil { + return x.Format + } + return "" +} + +func (x *AssetDescriptor) GetSizeBytes() int64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *AssetDescriptor) GetChecksumSha256() string { + if x != nil { + return x.ChecksumSha256 + } + return "" +} + +func (x *AssetDescriptor) GetChannels() int32 { + if x != nil { + return x.Channels + } + return 0 +} + +func (x *AssetDescriptor) GetSampleRateHz() int32 { + if x != nil { + return x.SampleRateHz + } + return 0 +} + +func (x *AssetDescriptor) GetDurationMs() int64 { + if x != nil { + return x.DurationMs + } + return 0 +} + +type Header struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Header) Reset() { + *x = Header{} + mi := &file_agent_v1_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Header) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Header) ProtoMessage() {} + +func (x *Header) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Header.ProtoReflect.Descriptor instead. +func (*Header) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *Header) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Header) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +type GetAgentStatusRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Target *AgentBinding `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentStatusRequest) Reset() { + *x = GetAgentStatusRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentStatusRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentStatusRequest) ProtoMessage() {} + +func (x *GetAgentStatusRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAgentStatusRequest.ProtoReflect.Descriptor instead. +func (*GetAgentStatusRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *GetAgentStatusRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetAgentStatusRequest) GetTarget() *AgentBinding { + if x != nil { + return x.Target + } + return nil +} + +type GetAgentStatusResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Status *AgentStatus `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Failure *Failure `protobuf:"bytes,3,opt,name=failure,proto3" json:"failure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetAgentStatusResponse) Reset() { + *x = GetAgentStatusResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetAgentStatusResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetAgentStatusResponse) ProtoMessage() {} + +func (x *GetAgentStatusResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetAgentStatusResponse.ProtoReflect.Descriptor instead. +func (*GetAgentStatusResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{16} +} + +func (x *GetAgentStatusResponse) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetAgentStatusResponse) GetStatus() *AgentStatus { + if x != nil { + return x.Status + } + return nil +} + +func (x *GetAgentStatusResponse) GetFailure() *Failure { + if x != nil { + return x.Failure + } + return nil +} + +type ActivateAgentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *AgentBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + ActivationOperationId string `protobuf:"bytes,3,opt,name=activation_operation_id,json=activationOperationId,proto3" json:"activation_operation_id,omitempty"` + SessionNonce []byte `protobuf:"bytes,4,opt,name=session_nonce,json=sessionNonce,proto3" json:"session_nonce,omitempty"` + SessionExpiresAtUnixMs int64 `protobuf:"varint,5,opt,name=session_expires_at_unix_ms,json=sessionExpiresAtUnixMs,proto3" json:"session_expires_at_unix_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActivateAgentRequest) Reset() { + *x = ActivateAgentRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActivateAgentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActivateAgentRequest) ProtoMessage() {} + +func (x *ActivateAgentRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActivateAgentRequest.ProtoReflect.Descriptor instead. +func (*ActivateAgentRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *ActivateAgentRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ActivateAgentRequest) GetBinding() *AgentBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ActivateAgentRequest) GetActivationOperationId() string { + if x != nil { + return x.ActivationOperationId + } + return "" +} + +func (x *ActivateAgentRequest) GetSessionNonce() []byte { + if x != nil { + return x.SessionNonce + } + return nil +} + +func (x *ActivateAgentRequest) GetSessionExpiresAtUnixMs() int64 { + if x != nil { + return x.SessionExpiresAtUnixMs + } + return 0 +} + +type ActivateAgentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + State ActivationState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.ActivationState" json:"state,omitempty"` + Session *Session `protobuf:"bytes,3,opt,name=session,proto3" json:"session,omitempty"` + Failure *Failure `protobuf:"bytes,4,opt,name=failure,proto3" json:"failure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ActivateAgentResponse) Reset() { + *x = ActivateAgentResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ActivateAgentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ActivateAgentResponse) ProtoMessage() {} + +func (x *ActivateAgentResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ActivateAgentResponse.ProtoReflect.Descriptor instead. +func (*ActivateAgentResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *ActivateAgentResponse) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ActivateAgentResponse) GetState() ActivationState { + if x != nil { + return x.State + } + return ActivationState_ACTIVATION_STATE_UNSPECIFIED +} + +func (x *ActivateAgentResponse) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *ActivateAgentResponse) GetFailure() *Failure { + if x != nil { + return x.Failure + } + return nil +} + +type GetBootstrapRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + CellId string `protobuf:"bytes,3,opt,name=cell_id,json=cellId,proto3" json:"cell_id,omitempty"` + BootId string `protobuf:"bytes,4,opt,name=boot_id,json=bootId,proto3" json:"boot_id,omitempty"` + SessionGeneration uint64 `protobuf:"varint,5,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBootstrapRequest) Reset() { + *x = GetBootstrapRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBootstrapRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBootstrapRequest) ProtoMessage() {} + +func (x *GetBootstrapRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBootstrapRequest.ProtoReflect.Descriptor instead. +func (*GetBootstrapRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{19} +} + +func (x *GetBootstrapRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetBootstrapRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *GetBootstrapRequest) GetCellId() string { + if x != nil { + return x.CellId + } + return "" +} + +func (x *GetBootstrapRequest) GetBootId() string { + if x != nil { + return x.BootId + } + return "" +} + +func (x *GetBootstrapRequest) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +type GetBootstrapResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + State ActivationState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.ActivationState" json:"state,omitempty"` + RuntimeConfigs []*ConfigReference `protobuf:"bytes,3,rep,name=runtime_configs,json=runtimeConfigs,proto3" json:"runtime_configs,omitempty"` + UploadPolicy *UploadPolicy `protobuf:"bytes,4,opt,name=upload_policy,json=uploadPolicy,proto3" json:"upload_policy,omitempty"` + Failure *Failure `protobuf:"bytes,5,opt,name=failure,proto3" json:"failure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetBootstrapResponse) Reset() { + *x = GetBootstrapResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetBootstrapResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetBootstrapResponse) ProtoMessage() {} + +func (x *GetBootstrapResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetBootstrapResponse.ProtoReflect.Descriptor instead. +func (*GetBootstrapResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{20} +} + +func (x *GetBootstrapResponse) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetBootstrapResponse) GetState() ActivationState { + if x != nil { + return x.State + } + return ActivationState_ACTIVATION_STATE_UNSPECIFIED +} + +func (x *GetBootstrapResponse) GetRuntimeConfigs() []*ConfigReference { + if x != nil { + return x.RuntimeConfigs + } + return nil +} + +func (x *GetBootstrapResponse) GetUploadPolicy() *UploadPolicy { + if x != nil { + return x.UploadPolicy + } + return nil +} + +func (x *GetBootstrapResponse) GetFailure() *Failure { + if x != nil { + return x.Failure + } + return nil +} + +type SetAdmissionStateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Target *AgentBinding `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + State AdmissionState `protobuf:"varint,3,opt,name=state,proto3,enum=agent.v1.AdmissionState" json:"state,omitempty"` + BarrierId string `protobuf:"bytes,4,opt,name=barrier_id,json=barrierId,proto3" json:"barrier_id,omitempty"` + ExpectedAdmissionGeneration uint64 `protobuf:"varint,5,opt,name=expected_admission_generation,json=expectedAdmissionGeneration,proto3" json:"expected_admission_generation,omitempty"` + Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetAdmissionStateRequest) Reset() { + *x = SetAdmissionStateRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetAdmissionStateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetAdmissionStateRequest) ProtoMessage() {} + +func (x *SetAdmissionStateRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetAdmissionStateRequest.ProtoReflect.Descriptor instead. +func (*SetAdmissionStateRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{21} +} + +func (x *SetAdmissionStateRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *SetAdmissionStateRequest) GetTarget() *AgentBinding { + if x != nil { + return x.Target + } + return nil +} + +func (x *SetAdmissionStateRequest) GetState() AdmissionState { + if x != nil { + return x.State + } + return AdmissionState_ADMISSION_STATE_UNSPECIFIED +} + +func (x *SetAdmissionStateRequest) GetBarrierId() string { + if x != nil { + return x.BarrierId + } + return "" +} + +func (x *SetAdmissionStateRequest) GetExpectedAdmissionGeneration() uint64 { + if x != nil { + return x.ExpectedAdmissionGeneration + } + return 0 +} + +func (x *SetAdmissionStateRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type SetAdmissionStateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + AppliedAdmissionGeneration uint64 `protobuf:"varint,2,opt,name=applied_admission_generation,json=appliedAdmissionGeneration,proto3" json:"applied_admission_generation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetAdmissionStateResponse) Reset() { + *x = SetAdmissionStateResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetAdmissionStateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetAdmissionStateResponse) ProtoMessage() {} + +func (x *SetAdmissionStateResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetAdmissionStateResponse.ProtoReflect.Descriptor instead. +func (*SetAdmissionStateResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{22} +} + +func (x *SetAdmissionStateResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *SetAdmissionStateResponse) GetAppliedAdmissionGeneration() uint64 { + if x != nil { + return x.AppliedAdmissionGeneration + } + return 0 +} + +type ExecuteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + CallExecuteJson []byte `protobuf:"bytes,3,opt,name=call_execute_json,json=callExecuteJson,proto3" json:"call_execute_json,omitempty"` + ConfigSha256 string `protobuf:"bytes,4,opt,name=config_sha256,json=configSha256,proto3" json:"config_sha256,omitempty"` + AdmissionGeneration uint64 `protobuf:"varint,5,opt,name=admission_generation,json=admissionGeneration,proto3" json:"admission_generation,omitempty"` + ResourceReservationId string `protobuf:"bytes,6,opt,name=resource_reservation_id,json=resourceReservationId,proto3" json:"resource_reservation_id,omitempty"` + PermitId string `protobuf:"bytes,7,opt,name=permit_id,json=permitId,proto3" json:"permit_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteRequest) Reset() { + *x = ExecuteRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteRequest) ProtoMessage() {} + +func (x *ExecuteRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteRequest.ProtoReflect.Descriptor instead. +func (*ExecuteRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{23} +} + +func (x *ExecuteRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ExecuteRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ExecuteRequest) GetCallExecuteJson() []byte { + if x != nil { + return x.CallExecuteJson + } + return nil +} + +func (x *ExecuteRequest) GetConfigSha256() string { + if x != nil { + return x.ConfigSha256 + } + return "" +} + +func (x *ExecuteRequest) GetAdmissionGeneration() uint64 { + if x != nil { + return x.AdmissionGeneration + } + return 0 +} + +func (x *ExecuteRequest) GetResourceReservationId() string { + if x != nil { + return x.ResourceReservationId + } + return "" +} + +func (x *ExecuteRequest) GetPermitId() string { + if x != nil { + return x.PermitId + } + return "" +} + +type ExecuteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + State ExecutionState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.ExecutionState" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecuteResponse) Reset() { + *x = ExecuteResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecuteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecuteResponse) ProtoMessage() {} + +func (x *ExecuteResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecuteResponse.ProtoReflect.Descriptor instead. +func (*ExecuteResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{24} +} + +func (x *ExecuteResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *ExecuteResponse) GetState() ExecutionState { + if x != nil { + return x.State + } + return ExecutionState_EXECUTION_STATE_UNSPECIFIED +} + +type GetExecutionPermitRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + ResourceReservationId string `protobuf:"bytes,3,opt,name=resource_reservation_id,json=resourceReservationId,proto3" json:"resource_reservation_id,omitempty"` + ExpectedTaskRevision int64 `protobuf:"varint,4,opt,name=expected_task_revision,json=expectedTaskRevision,proto3" json:"expected_task_revision,omitempty"` + AdmissionGeneration uint64 `protobuf:"varint,5,opt,name=admission_generation,json=admissionGeneration,proto3" json:"admission_generation,omitempty"` + ConfigSha256 string `protobuf:"bytes,6,opt,name=config_sha256,json=configSha256,proto3" json:"config_sha256,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetExecutionPermitRequest) Reset() { + *x = GetExecutionPermitRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetExecutionPermitRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExecutionPermitRequest) ProtoMessage() {} + +func (x *GetExecutionPermitRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExecutionPermitRequest.ProtoReflect.Descriptor instead. +func (*GetExecutionPermitRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{25} +} + +func (x *GetExecutionPermitRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetExecutionPermitRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *GetExecutionPermitRequest) GetResourceReservationId() string { + if x != nil { + return x.ResourceReservationId + } + return "" +} + +func (x *GetExecutionPermitRequest) GetExpectedTaskRevision() int64 { + if x != nil { + return x.ExpectedTaskRevision + } + return 0 +} + +func (x *GetExecutionPermitRequest) GetAdmissionGeneration() uint64 { + if x != nil { + return x.AdmissionGeneration + } + return 0 +} + +func (x *GetExecutionPermitRequest) GetConfigSha256() string { + if x != nil { + return x.ConfigSha256 + } + return "" +} + +type ExecutionPermit struct { + state protoimpl.MessageState `protogen:"open.v1"` + PermitId string `protobuf:"bytes,1,opt,name=permit_id,json=permitId,proto3" json:"permit_id,omitempty"` + ResourceReservationId string `protobuf:"bytes,2,opt,name=resource_reservation_id,json=resourceReservationId,proto3" json:"resource_reservation_id,omitempty"` + IssuedAtUnixMs int64 `protobuf:"varint,3,opt,name=issued_at_unix_ms,json=issuedAtUnixMs,proto3" json:"issued_at_unix_ms,omitempty"` + ExpiresAtUnixMs int64 `protobuf:"varint,4,opt,name=expires_at_unix_ms,json=expiresAtUnixMs,proto3" json:"expires_at_unix_ms,omitempty"` + DispatcherEpoch string `protobuf:"bytes,5,opt,name=dispatcher_epoch,json=dispatcherEpoch,proto3" json:"dispatcher_epoch,omitempty"` + SessionGeneration uint64 `protobuf:"varint,6,opt,name=session_generation,json=sessionGeneration,proto3" json:"session_generation,omitempty"` + FencingToken string `protobuf:"bytes,7,opt,name=fencing_token,json=fencingToken,proto3" json:"fencing_token,omitempty"` + ConfigSha256 string `protobuf:"bytes,8,opt,name=config_sha256,json=configSha256,proto3" json:"config_sha256,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionPermit) Reset() { + *x = ExecutionPermit{} + mi := &file_agent_v1_agent_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionPermit) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionPermit) ProtoMessage() {} + +func (x *ExecutionPermit) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionPermit.ProtoReflect.Descriptor instead. +func (*ExecutionPermit) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{26} +} + +func (x *ExecutionPermit) GetPermitId() string { + if x != nil { + return x.PermitId + } + return "" +} + +func (x *ExecutionPermit) GetResourceReservationId() string { + if x != nil { + return x.ResourceReservationId + } + return "" +} + +func (x *ExecutionPermit) GetIssuedAtUnixMs() int64 { + if x != nil { + return x.IssuedAtUnixMs + } + return 0 +} + +func (x *ExecutionPermit) GetExpiresAtUnixMs() int64 { + if x != nil { + return x.ExpiresAtUnixMs + } + return 0 +} + +func (x *ExecutionPermit) GetDispatcherEpoch() string { + if x != nil { + return x.DispatcherEpoch + } + return "" +} + +func (x *ExecutionPermit) GetSessionGeneration() uint64 { + if x != nil { + return x.SessionGeneration + } + return 0 +} + +func (x *ExecutionPermit) GetFencingToken() string { + if x != nil { + return x.FencingToken + } + return "" +} + +func (x *ExecutionPermit) GetConfigSha256() string { + if x != nil { + return x.ConfigSha256 + } + return "" +} + +type GetExecutionPermitResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + Permit *ExecutionPermit `protobuf:"bytes,2,opt,name=permit,proto3" json:"permit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetExecutionPermitResponse) Reset() { + *x = GetExecutionPermitResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetExecutionPermitResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExecutionPermitResponse) ProtoMessage() {} + +func (x *GetExecutionPermitResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExecutionPermitResponse.ProtoReflect.Descriptor instead. +func (*GetExecutionPermitResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{27} +} + +func (x *GetExecutionPermitResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *GetExecutionPermitResponse) GetPermit() *ExecutionPermit { + if x != nil { + return x.Permit + } + return nil +} + +type ApplyTaskControlRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + Action ControlAction `protobuf:"varint,3,opt,name=action,proto3,enum=agent.v1.ControlAction" json:"action,omitempty"` + ActiveCallPolicy ActiveCallPolicy `protobuf:"varint,4,opt,name=active_call_policy,json=activeCallPolicy,proto3,enum=agent.v1.ActiveCallPolicy" json:"active_call_policy,omitempty"` + ExpectedTaskRevision int64 `protobuf:"varint,5,opt,name=expected_task_revision,json=expectedTaskRevision,proto3" json:"expected_task_revision,omitempty"` + Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyTaskControlRequest) Reset() { + *x = ApplyTaskControlRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyTaskControlRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyTaskControlRequest) ProtoMessage() {} + +func (x *ApplyTaskControlRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyTaskControlRequest.ProtoReflect.Descriptor instead. +func (*ApplyTaskControlRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{28} +} + +func (x *ApplyTaskControlRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ApplyTaskControlRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ApplyTaskControlRequest) GetAction() ControlAction { + if x != nil { + return x.Action + } + return ControlAction_CONTROL_ACTION_UNSPECIFIED +} + +func (x *ApplyTaskControlRequest) GetActiveCallPolicy() ActiveCallPolicy { + if x != nil { + return x.ActiveCallPolicy + } + return ActiveCallPolicy_ACTIVE_CALL_POLICY_UNSPECIFIED +} + +func (x *ApplyTaskControlRequest) GetExpectedTaskRevision() int64 { + if x != nil { + return x.ExpectedTaskRevision + } + return 0 +} + +func (x *ApplyTaskControlRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type ApplyTaskControlResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + AppliedTaskRevision int64 `protobuf:"varint,2,opt,name=applied_task_revision,json=appliedTaskRevision,proto3" json:"applied_task_revision,omitempty"` + State ExecutionState `protobuf:"varint,3,opt,name=state,proto3,enum=agent.v1.ExecutionState" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApplyTaskControlResponse) Reset() { + *x = ApplyTaskControlResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApplyTaskControlResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApplyTaskControlResponse) ProtoMessage() {} + +func (x *ApplyTaskControlResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ApplyTaskControlResponse.ProtoReflect.Descriptor instead. +func (*ApplyTaskControlResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{29} +} + +func (x *ApplyTaskControlResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *ApplyTaskControlResponse) GetAppliedTaskRevision() int64 { + if x != nil { + return x.AppliedTaskRevision + } + return 0 +} + +func (x *ApplyTaskControlResponse) GetState() ExecutionState { + if x != nil { + return x.State + } + return ExecutionState_EXECUTION_STATE_UNSPECIFIED +} + +type QueryExecutionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryExecutionRequest) Reset() { + *x = QueryExecutionRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryExecutionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryExecutionRequest) ProtoMessage() {} + +func (x *QueryExecutionRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryExecutionRequest.ProtoReflect.Descriptor instead. +func (*QueryExecutionRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{30} +} + +func (x *QueryExecutionRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *QueryExecutionRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +type ExecutionSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Binding *ExecutionBinding `protobuf:"bytes,1,opt,name=binding,proto3" json:"binding,omitempty"` + State ExecutionState `protobuf:"varint,2,opt,name=state,proto3,enum=agent.v1.ExecutionState" json:"state,omitempty"` + CallState string `protobuf:"bytes,3,opt,name=call_state,json=callState,proto3" json:"call_state,omitempty"` + AttemptId string `protobuf:"bytes,4,opt,name=attempt_id,json=attemptId,proto3" json:"attempt_id,omitempty"` + ReasonCode string `protobuf:"bytes,5,opt,name=reason_code,json=reasonCode,proto3" json:"reason_code,omitempty"` + ObservedAtUnixMs int64 `protobuf:"varint,6,opt,name=observed_at_unix_ms,json=observedAtUnixMs,proto3" json:"observed_at_unix_ms,omitempty"` + Unknown bool `protobuf:"varint,7,opt,name=unknown,proto3" json:"unknown,omitempty"` + Assets []*AssetDescriptor `protobuf:"bytes,8,rep,name=assets,proto3" json:"assets,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionSnapshot) Reset() { + *x = ExecutionSnapshot{} + mi := &file_agent_v1_agent_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionSnapshot) ProtoMessage() {} + +func (x *ExecutionSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionSnapshot.ProtoReflect.Descriptor instead. +func (*ExecutionSnapshot) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{31} +} + +func (x *ExecutionSnapshot) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ExecutionSnapshot) GetState() ExecutionState { + if x != nil { + return x.State + } + return ExecutionState_EXECUTION_STATE_UNSPECIFIED +} + +func (x *ExecutionSnapshot) GetCallState() string { + if x != nil { + return x.CallState + } + return "" +} + +func (x *ExecutionSnapshot) GetAttemptId() string { + if x != nil { + return x.AttemptId + } + return "" +} + +func (x *ExecutionSnapshot) GetReasonCode() string { + if x != nil { + return x.ReasonCode + } + return "" +} + +func (x *ExecutionSnapshot) GetObservedAtUnixMs() int64 { + if x != nil { + return x.ObservedAtUnixMs + } + return 0 +} + +func (x *ExecutionSnapshot) GetUnknown() bool { + if x != nil { + return x.Unknown + } + return false +} + +func (x *ExecutionSnapshot) GetAssets() []*AssetDescriptor { + if x != nil { + return x.Assets + } + return nil +} + +type QueryExecutionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Snapshot *ExecutionSnapshot `protobuf:"bytes,2,opt,name=snapshot,proto3" json:"snapshot,omitempty"` + Failure *Failure `protobuf:"bytes,3,opt,name=failure,proto3" json:"failure,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *QueryExecutionResponse) Reset() { + *x = QueryExecutionResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *QueryExecutionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*QueryExecutionResponse) ProtoMessage() {} + +func (x *QueryExecutionResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use QueryExecutionResponse.ProtoReflect.Descriptor instead. +func (*QueryExecutionResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{32} +} + +func (x *QueryExecutionResponse) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *QueryExecutionResponse) GetSnapshot() *ExecutionSnapshot { + if x != nil { + return x.Snapshot + } + return nil +} + +func (x *QueryExecutionResponse) GetFailure() *Failure { + if x != nil { + return x.Failure + } + return nil +} + +type ExecutionFact struct { + state protoimpl.MessageState `protogen:"open.v1"` + FactId string `protobuf:"bytes,1,opt,name=fact_id,json=factId,proto3" json:"fact_id,omitempty"` + ContentSha256 string `protobuf:"bytes,2,opt,name=content_sha256,json=contentSha256,proto3" json:"content_sha256,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,3,opt,name=binding,proto3" json:"binding,omitempty"` + Kind FactKind `protobuf:"varint,4,opt,name=kind,proto3,enum=agent.v1.FactKind" json:"kind,omitempty"` + ObservedAtUnixMs int64 `protobuf:"varint,5,opt,name=observed_at_unix_ms,json=observedAtUnixMs,proto3" json:"observed_at_unix_ms,omitempty"` + SourceBootId string `protobuf:"bytes,6,opt,name=source_boot_id,json=sourceBootId,proto3" json:"source_boot_id,omitempty"` + SourceSequence uint64 `protobuf:"varint,7,opt,name=source_sequence,json=sourceSequence,proto3" json:"source_sequence,omitempty"` + PayloadJson []byte `protobuf:"bytes,8,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecutionFact) Reset() { + *x = ExecutionFact{} + mi := &file_agent_v1_agent_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecutionFact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecutionFact) ProtoMessage() {} + +func (x *ExecutionFact) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecutionFact.ProtoReflect.Descriptor instead. +func (*ExecutionFact) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{33} +} + +func (x *ExecutionFact) GetFactId() string { + if x != nil { + return x.FactId + } + return "" +} + +func (x *ExecutionFact) GetContentSha256() string { + if x != nil { + return x.ContentSha256 + } + return "" +} + +func (x *ExecutionFact) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *ExecutionFact) GetKind() FactKind { + if x != nil { + return x.Kind + } + return FactKind_FACT_KIND_UNSPECIFIED +} + +func (x *ExecutionFact) GetObservedAtUnixMs() int64 { + if x != nil { + return x.ObservedAtUnixMs + } + return 0 +} + +func (x *ExecutionFact) GetSourceBootId() string { + if x != nil { + return x.SourceBootId + } + return "" +} + +func (x *ExecutionFact) GetSourceSequence() uint64 { + if x != nil { + return x.SourceSequence + } + return 0 +} + +func (x *ExecutionFact) GetPayloadJson() []byte { + if x != nil { + return x.PayloadJson + } + return nil +} + +type ReportExecutionEventRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Fact *ExecutionFact `protobuf:"bytes,2,opt,name=fact,proto3" json:"fact,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportExecutionEventRequest) Reset() { + *x = ReportExecutionEventRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportExecutionEventRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportExecutionEventRequest) ProtoMessage() {} + +func (x *ReportExecutionEventRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportExecutionEventRequest.ProtoReflect.Descriptor instead. +func (*ReportExecutionEventRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{34} +} + +func (x *ReportExecutionEventRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ReportExecutionEventRequest) GetFact() *ExecutionFact { + if x != nil { + return x.Fact + } + return nil +} + +type ReportExecutionEventResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportExecutionEventResponse) Reset() { + *x = ReportExecutionEventResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportExecutionEventResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportExecutionEventResponse) ProtoMessage() {} + +func (x *ReportExecutionEventResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportExecutionEventResponse.ProtoReflect.Descriptor instead. +func (*ReportExecutionEventResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{35} +} + +func (x *ReportExecutionEventResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +type RequestUploadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + Asset *AssetDescriptor `protobuf:"bytes,3,opt,name=asset,proto3" json:"asset,omitempty"` + UploadId string `protobuf:"bytes,4,opt,name=upload_id,json=uploadId,proto3" json:"upload_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestUploadRequest) Reset() { + *x = RequestUploadRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestUploadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestUploadRequest) ProtoMessage() {} + +func (x *RequestUploadRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestUploadRequest.ProtoReflect.Descriptor instead. +func (*RequestUploadRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{36} +} + +func (x *RequestUploadRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *RequestUploadRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *RequestUploadRequest) GetAsset() *AssetDescriptor { + if x != nil { + return x.Asset + } + return nil +} + +func (x *RequestUploadRequest) GetUploadId() string { + if x != nil { + return x.UploadId + } + return "" +} + +type UploadGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + UploadId string `protobuf:"bytes,1,opt,name=upload_id,json=uploadId,proto3" json:"upload_id,omitempty"` + TargetUrl string `protobuf:"bytes,2,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + Headers []*Header `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty"` + ExpiresAtUnixMs int64 `protobuf:"varint,4,opt,name=expires_at_unix_ms,json=expiresAtUnixMs,proto3" json:"expires_at_unix_ms,omitempty"` + ObjectKey string `protobuf:"bytes,5,opt,name=object_key,json=objectKey,proto3" json:"object_key,omitempty"` + RequiredChecksumSha256 string `protobuf:"bytes,6,opt,name=required_checksum_sha256,json=requiredChecksumSha256,proto3" json:"required_checksum_sha256,omitempty"` + MaxBytes int64 `protobuf:"varint,7,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadGrant) Reset() { + *x = UploadGrant{} + mi := &file_agent_v1_agent_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadGrant) ProtoMessage() {} + +func (x *UploadGrant) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadGrant.ProtoReflect.Descriptor instead. +func (*UploadGrant) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{37} +} + +func (x *UploadGrant) GetUploadId() string { + if x != nil { + return x.UploadId + } + return "" +} + +func (x *UploadGrant) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *UploadGrant) GetHeaders() []*Header { + if x != nil { + return x.Headers + } + return nil +} + +func (x *UploadGrant) GetExpiresAtUnixMs() int64 { + if x != nil { + return x.ExpiresAtUnixMs + } + return 0 +} + +func (x *UploadGrant) GetObjectKey() string { + if x != nil { + return x.ObjectKey + } + return "" +} + +func (x *UploadGrant) GetRequiredChecksumSha256() string { + if x != nil { + return x.RequiredChecksumSha256 + } + return "" +} + +func (x *UploadGrant) GetMaxBytes() int64 { + if x != nil { + return x.MaxBytes + } + return 0 +} + +type RequestUploadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Receipt *OperationReceipt `protobuf:"bytes,1,opt,name=receipt,proto3" json:"receipt,omitempty"` + Grant *UploadGrant `protobuf:"bytes,2,opt,name=grant,proto3" json:"grant,omitempty"` + State UploadState `protobuf:"varint,3,opt,name=state,proto3,enum=agent.v1.UploadState" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestUploadResponse) Reset() { + *x = RequestUploadResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestUploadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestUploadResponse) ProtoMessage() {} + +func (x *RequestUploadResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestUploadResponse.ProtoReflect.Descriptor instead. +func (*RequestUploadResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{38} +} + +func (x *RequestUploadResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *RequestUploadResponse) GetGrant() *UploadGrant { + if x != nil { + return x.Grant + } + return nil +} + +func (x *RequestUploadResponse) GetState() UploadState { + if x != nil { + return x.State + } + return UploadState_UPLOAD_STATE_UNSPECIFIED +} + +type CompleteUploadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Binding *ExecutionBinding `protobuf:"bytes,2,opt,name=binding,proto3" json:"binding,omitempty"` + Asset *AssetDescriptor `protobuf:"bytes,3,opt,name=asset,proto3" json:"asset,omitempty"` + UploadId string `protobuf:"bytes,4,opt,name=upload_id,json=uploadId,proto3" json:"upload_id,omitempty"` + UploadedSizeBytes int64 `protobuf:"varint,5,opt,name=uploaded_size_bytes,json=uploadedSizeBytes,proto3" json:"uploaded_size_bytes,omitempty"` + UploadedChecksumSha256 string `protobuf:"bytes,6,opt,name=uploaded_checksum_sha256,json=uploadedChecksumSha256,proto3" json:"uploaded_checksum_sha256,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompleteUploadRequest) Reset() { + *x = CompleteUploadRequest{} + mi := &file_agent_v1_agent_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompleteUploadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompleteUploadRequest) ProtoMessage() {} + +func (x *CompleteUploadRequest) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompleteUploadRequest.ProtoReflect.Descriptor instead. +func (*CompleteUploadRequest) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{39} +} + +func (x *CompleteUploadRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *CompleteUploadRequest) GetBinding() *ExecutionBinding { + if x != nil { + return x.Binding + } + return nil +} + +func (x *CompleteUploadRequest) GetAsset() *AssetDescriptor { + if x != nil { + return x.Asset + } + return nil +} + +func (x *CompleteUploadRequest) GetUploadId() string { + if x != nil { + return x.UploadId + } + return "" +} + +func (x *CompleteUploadRequest) GetUploadedSizeBytes() int64 { + if x != nil { + return x.UploadedSizeBytes + } + return 0 +} + +func (x *CompleteUploadRequest) GetUploadedChecksumSha256() string { + if x != nil { + return x.UploadedChecksumSha256 + } + return "" +} + +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 +} + +func (x *CompleteUploadResponse) Reset() { + *x = CompleteUploadResponse{} + mi := &file_agent_v1_agent_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompleteUploadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompleteUploadResponse) ProtoMessage() {} + +func (x *CompleteUploadResponse) ProtoReflect() protoreflect.Message { + mi := &file_agent_v1_agent_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompleteUploadResponse.ProtoReflect.Descriptor instead. +func (*CompleteUploadResponse) Descriptor() ([]byte, []int) { + return file_agent_v1_agent_proto_rawDescGZIP(), []int{40} +} + +func (x *CompleteUploadResponse) GetReceipt() *OperationReceipt { + if x != nil { + return x.Receipt + } + return nil +} + +func (x *CompleteUploadResponse) GetState() UploadState { + if x != nil { + return x.State + } + 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 = "" + + "\n" + + "\x14agent/v1/agent.proto\x12\bagent.v1\"\x8f\x03\n" + + "\vRequestMeta\x12)\n" + + "\x10protocol_version\x18\x01 \x01(\tR\x0fprotocolVersion\x12\x1d\n" + + "\n" + + "request_id\x18\x02 \x01(\tR\trequestId\x12\x19\n" + + "\btrace_id\x18\x03 \x01(\tR\atraceId\x12!\n" + + "\foperation_id\x18\x04 \x01(\tR\voperationId\x12(\n" + + "\x10deadline_unix_ms\x18\x05 \x01(\x03R\x0edeadlineUnixMs\x12)\n" + + "\x10dispatcher_epoch\x18\x06 \x01(\tR\x0fdispatcherEpoch\x12\x19\n" + + "\bagent_id\x18\a \x01(\tR\aagentId\x12\x17\n" + + "\acell_id\x18\b \x01(\tR\x06cellId\x12\x17\n" + + "\aboot_id\x18\t \x01(\tR\x06bootId\x12-\n" + + "\x12session_generation\x18\n" + + " \x01(\x04R\x11sessionGeneration\x12'\n" + + "\x0fidempotency_key\x18\v \x01(\tR\x0eidempotencyKey\"\xec\x02\n" + + "\fResponseMeta\x12)\n" + + "\x10protocol_version\x18\x01 \x01(\tR\x0fprotocolVersion\x12\x1d\n" + + "\n" + + "request_id\x18\x02 \x01(\tR\trequestId\x12\x19\n" + + "\btrace_id\x18\x03 \x01(\tR\atraceId\x12!\n" + + "\foperation_id\x18\x04 \x01(\tR\voperationId\x12-\n" + + "\x13observed_at_unix_ms\x18\x05 \x01(\x03R\x10observedAtUnixMs\x12)\n" + + "\x10dispatcher_epoch\x18\x06 \x01(\tR\x0fdispatcherEpoch\x12\x19\n" + + "\bagent_id\x18\a \x01(\tR\aagentId\x12\x17\n" + + "\acell_id\x18\b \x01(\tR\x06cellId\x12\x17\n" + + "\aboot_id\x18\t \x01(\tR\x06bootId\x12-\n" + + "\x12session_generation\x18\n" + + " \x01(\x04R\x11sessionGeneration\"\x80\x01\n" + + "\aFailure\x12)\n" + + "\x04code\x18\x01 \x01(\x0e2\x15.agent.v1.FailureCodeR\x04code\x12\x1c\n" + + "\tretryable\x18\x02 \x01(\bR\tretryable\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\x12\x14\n" + + "\x05field\x18\x04 \x01(\tR\x05field\"\x88\x02\n" + + "\x10OperationReceipt\x12*\n" + + "\x04meta\x18\x01 \x01(\v2\x16.agent.v1.ResponseMetaR\x04meta\x12,\n" + + "\x06result\x18\x02 \x01(\x0e2\x14.agent.v1.ResultCodeR\x06result\x12+\n" + + "\afailure\x18\x03 \x01(\v2\x11.agent.v1.FailureR\afailure\x12\x17\n" + + "\afact_id\x18\x04 \x01(\tR\x06factId\x12%\n" + + "\x0econtent_sha256\x18\x05 \x01(\tR\rcontentSha256\x12-\n" + + "\x13accepted_at_unix_ms\x18\x06 \x01(\x03R\x10acceptedAtUnixMs\"\xe7\x01\n" + + "\fAgentBinding\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x17\n" + + "\acell_id\x18\x02 \x01(\tR\x06cellId\x12(\n" + + "\x10expected_boot_id\x18\x03 \x01(\tR\x0eexpectedBootId\x12)\n" + + "\x10dispatcher_epoch\x18\x04 \x01(\tR\x0fdispatcherEpoch\x12-\n" + + "\x12session_generation\x18\x05 \x01(\x04R\x11sessionGeneration\x12\x1f\n" + + "\vendpoint_id\x18\x06 \x01(\tR\n" + + "endpointId\"P\n" + + "\n" + + "Capability\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12\x14\n" + + "\x05value\x18\x03 \x01(\tR\x05value\"\xd1\x03\n" + + "\x0eResourceSample\x12-\n" + + "\x13observed_at_unix_ms\x18\x01 \x01(\x03R\x10observedAtUnixMs\x12$\n" + + "\x0ecpu_used_ratio\x18\x02 \x01(\x01R\fcpuUsedRatio\x124\n" + + "\x16memory_available_bytes\x18\x03 \x01(\x03R\x14memoryAvailableBytes\x12\x17\n" + + "\afd_used\x18\x04 \x01(\x03R\x06fdUsed\x12\x19\n" + + "\bfd_limit\x18\x05 \x01(\x03R\afdLimit\x12(\n" + + "\x10spool_used_bytes\x18\x06 \x01(\x03R\x0espoolUsedBytes\x120\n" + + "\x14spool_capacity_bytes\x18\a \x01(\x03R\x12spoolCapacityBytes\x12(\n" + + "\x10media_ports_used\x18\b \x01(\x03R\x0emediaPortsUsed\x120\n" + + "\x14media_ports_capacity\x18\t \x01(\x03R\x12mediaPortsCapacity\x12!\n" + + "\fsample_fresh\x18\n" + + " \x01(\bR\vsampleFresh\x12%\n" + + "\x0emissing_reason\x18\v \x01(\tR\rmissingReason\"\xa9\x01\n" + + "\rAppliedConfig\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x1a\n" + + "\brevision\x18\x02 \x01(\tR\brevision\x12#\n" + + "\rconfig_sha256\x18\x03 \x01(\tR\fconfigSha256\x12\x14\n" + + "\x05state\x18\x04 \x01(\tR\x05state\x12-\n" + + "\x13observed_at_unix_ms\x18\x05 \x01(\x03R\x10observedAtUnixMs\"\xcd\x04\n" + + "\vAgentStatus\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x17\n" + + "\acell_id\x18\x02 \x01(\tR\x06cellId\x12\x17\n" + + "\aboot_id\x18\x03 \x01(\tR\x06bootId\x12)\n" + + "\x10software_version\x18\x04 \x01(\tR\x0fsoftwareVersion\x12)\n" + + "\x10protocol_version\x18\x05 \x01(\tR\x0fprotocolVersion\x12)\n" + + "\x10asterisk_version\x18\x06 \x01(\tR\x0fasteriskVersion\x12A\n" + + "\x0fadmission_state\x18\a \x01(\x0e2\x18.agent.v1.AdmissionStateR\x0eadmissionState\x128\n" + + "\fcapabilities\x18\b \x03(\v2\x14.agent.v1.CapabilityR\fcapabilities\x126\n" + + "\tresources\x18\t \x01(\v2\x18.agent.v1.ResourceSampleR\tresources\x12@\n" + + "\x0fapplied_configs\x18\n" + + " \x03(\v2\x17.agent.v1.AppliedConfigR\x0eappliedConfigs\x12-\n" + + "\x12mtls_authenticated\x18\v \x01(\bR\x11mtlsAuthenticated\x12%\n" + + "\x0esession_active\x18\f \x01(\bR\rsessionActive\x12#\n" + + "\rstatus_reason\x18\r \x01(\tR\fstatusReason\"\xbf\x01\n" + + "\aSession\x12)\n" + + "\x10dispatcher_epoch\x18\x01 \x01(\tR\x0fdispatcherEpoch\x12-\n" + + "\x12session_generation\x18\x02 \x01(\x04R\x11sessionGeneration\x12+\n" + + "\x12expires_at_unix_ms\x18\x03 \x01(\x03R\x0fexpiresAtUnixMs\x12-\n" + + "\x12session_credential\x18\x04 \x01(\fR\x11sessionCredential\"o\n" + + "\x0fConfigReference\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12\x16\n" + + "\x06sha256\x18\x03 \x01(\tR\x06sha256\x12\x16\n" + + "\x06source\x18\x04 \x01(\tR\x06source\"\x9f\x01\n" + + "\fUploadPolicy\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12&\n" + + "\x0fmax_asset_bytes\x18\x02 \x01(\x03R\rmaxAssetBytes\x12(\n" + + "\x10min_retention_ms\x18\x03 \x01(\x03R\x0eminRetentionMs\x12#\n" + + "\rallowed_hosts\x18\x04 \x03(\tR\fallowedHosts\"\x87\x03\n" + + "\x10ExecutionBinding\x12\x1b\n" + + "\ttenant_id\x18\x01 \x01(\tR\btenantId\x12\x1d\n" + + "\n" + + "tenant_key\x18\x02 \x01(\tR\ttenantKey\x12!\n" + + "\fexecution_id\x18\x03 \x01(\tR\vexecutionId\x12\x17\n" + + "\atask_id\x18\x04 \x01(\tR\x06taskId\x12 \n" + + "\ftask_item_id\x18\x05 \x01(\tR\n" + + "taskItemId\x12#\n" + + "\rtask_revision\x18\x06 \x01(\x03R\ftaskRevision\x12\x17\n" + + "\acall_id\x18\a \x01(\tR\x06callId\x12\x1d\n" + + "\n" + + "attempt_id\x18\b \x01(\tR\tattemptId\x12(\n" + + "\x10agent_version_id\x18\t \x01(\tR\x0eagentVersionId\x12&\n" + + "\x0froute_policy_id\x18\n" + + " \x01(\tR\rroutePolicyId\x12*\n" + + "\x11caller_profile_id\x18\v \x01(\tR\x0fcallerProfileId\"\xd4\x02\n" + + "\x0fAssetDescriptor\x12'\n" + + "\x04kind\x18\x01 \x01(\x0e2\x13.agent.v1.AssetKindR\x04kind\x12\x19\n" + + "\basset_id\x18\x02 \x01(\tR\aassetId\x12\x17\n" + + "\acall_id\x18\x03 \x01(\tR\x06callId\x12!\n" + + "\fexecution_id\x18\x04 \x01(\tR\vexecutionId\x12\x16\n" + + "\x06format\x18\x05 \x01(\tR\x06format\x12\x1d\n" + + "\n" + + "size_bytes\x18\x06 \x01(\x03R\tsizeBytes\x12'\n" + + "\x0fchecksum_sha256\x18\a \x01(\tR\x0echecksumSha256\x12\x1a\n" + + "\bchannels\x18\b \x01(\x05R\bchannels\x12$\n" + + "\x0esample_rate_hz\x18\t \x01(\x05R\fsampleRateHz\x12\x1f\n" + + "\vduration_ms\x18\n" + + " \x01(\x03R\n" + + "durationMs\"2\n" + + "\x06Header\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"r\n" + + "\x15GetAgentStatusRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x12.\n" + + "\x06target\x18\x02 \x01(\v2\x16.agent.v1.AgentBindingR\x06target\"\xa0\x01\n" + + "\x16GetAgentStatusResponse\x12*\n" + + "\x04meta\x18\x01 \x01(\v2\x16.agent.v1.ResponseMetaR\x04meta\x12-\n" + + "\x06status\x18\x02 \x01(\v2\x15.agent.v1.AgentStatusR\x06status\x12+\n" + + "\afailure\x18\x03 \x01(\v2\x11.agent.v1.FailureR\afailure\"\x8c\x02\n" + + "\x14ActivateAgentRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x120\n" + + "\abinding\x18\x02 \x01(\v2\x16.agent.v1.AgentBindingR\abinding\x126\n" + + "\x17activation_operation_id\x18\x03 \x01(\tR\x15activationOperationId\x12#\n" + + "\rsession_nonce\x18\x04 \x01(\fR\fsessionNonce\x12:\n" + + "\x1asession_expires_at_unix_ms\x18\x05 \x01(\x03R\x16sessionExpiresAtUnixMs\"\xce\x01\n" + + "\x15ActivateAgentResponse\x12*\n" + + "\x04meta\x18\x01 \x01(\v2\x16.agent.v1.ResponseMetaR\x04meta\x12/\n" + + "\x05state\x18\x02 \x01(\x0e2\x19.agent.v1.ActivationStateR\x05state\x12+\n" + + "\asession\x18\x03 \x01(\v2\x11.agent.v1.SessionR\asession\x12+\n" + + "\afailure\x18\x04 \x01(\v2\x11.agent.v1.FailureR\afailure\"\xbc\x01\n" + + "\x13GetBootstrapRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x17\n" + + "\acell_id\x18\x03 \x01(\tR\x06cellId\x12\x17\n" + + "\aboot_id\x18\x04 \x01(\tR\x06bootId\x12-\n" + + "\x12session_generation\x18\x05 \x01(\x04R\x11sessionGeneration\"\xa1\x02\n" + + "\x14GetBootstrapResponse\x12*\n" + + "\x04meta\x18\x01 \x01(\v2\x16.agent.v1.ResponseMetaR\x04meta\x12/\n" + + "\x05state\x18\x02 \x01(\x0e2\x19.agent.v1.ActivationStateR\x05state\x12B\n" + + "\x0fruntime_configs\x18\x03 \x03(\v2\x19.agent.v1.ConfigReferenceR\x0eruntimeConfigs\x12;\n" + + "\rupload_policy\x18\x04 \x01(\v2\x16.agent.v1.UploadPolicyR\fuploadPolicy\x12+\n" + + "\afailure\x18\x05 \x01(\v2\x11.agent.v1.FailureR\afailure\"\xa0\x02\n" + + "\x18SetAdmissionStateRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x12.\n" + + "\x06target\x18\x02 \x01(\v2\x16.agent.v1.AgentBindingR\x06target\x12.\n" + + "\x05state\x18\x03 \x01(\x0e2\x18.agent.v1.AdmissionStateR\x05state\x12\x1d\n" + + "\n" + + "barrier_id\x18\x04 \x01(\tR\tbarrierId\x12B\n" + + "\x1dexpected_admission_generation\x18\x05 \x01(\x04R\x1bexpectedAdmissionGeneration\x12\x16\n" + + "\x06reason\x18\x06 \x01(\tR\x06reason\"\x93\x01\n" + + "\x19SetAdmissionStateResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12@\n" + + "\x1capplied_admission_generation\x18\x02 \x01(\x04R\x1aappliedAdmissionGeneration\"\xca\x02\n" + + "\x0eExecuteRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12*\n" + + "\x11call_execute_json\x18\x03 \x01(\fR\x0fcallExecuteJson\x12#\n" + + "\rconfig_sha256\x18\x04 \x01(\tR\fconfigSha256\x121\n" + + "\x14admission_generation\x18\x05 \x01(\x04R\x13admissionGeneration\x126\n" + + "\x17resource_reservation_id\x18\x06 \x01(\tR\x15resourceReservationId\x12\x1b\n" + + "\tpermit_id\x18\a \x01(\tR\bpermitId\"w\n" + + "\x0fExecuteResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12.\n" + + "\x05state\x18\x02 \x01(\x0e2\x18.agent.v1.ExecutionStateR\x05state\"\xc2\x02\n" + + "\x19GetExecutionPermitRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x126\n" + + "\x17resource_reservation_id\x18\x03 \x01(\tR\x15resourceReservationId\x124\n" + + "\x16expected_task_revision\x18\x04 \x01(\x03R\x14expectedTaskRevision\x121\n" + + "\x14admission_generation\x18\x05 \x01(\x04R\x13admissionGeneration\x12#\n" + + "\rconfig_sha256\x18\x06 \x01(\tR\fconfigSha256\"\xe2\x02\n" + + "\x0fExecutionPermit\x12\x1b\n" + + "\tpermit_id\x18\x01 \x01(\tR\bpermitId\x126\n" + + "\x17resource_reservation_id\x18\x02 \x01(\tR\x15resourceReservationId\x12)\n" + + "\x11issued_at_unix_ms\x18\x03 \x01(\x03R\x0eissuedAtUnixMs\x12+\n" + + "\x12expires_at_unix_ms\x18\x04 \x01(\x03R\x0fexpiresAtUnixMs\x12)\n" + + "\x10dispatcher_epoch\x18\x05 \x01(\tR\x0fdispatcherEpoch\x12-\n" + + "\x12session_generation\x18\x06 \x01(\x04R\x11sessionGeneration\x12#\n" + + "\rfencing_token\x18\a \x01(\tR\ffencingToken\x12#\n" + + "\rconfig_sha256\x18\b \x01(\tR\fconfigSha256\"\x85\x01\n" + + "\x1aGetExecutionPermitResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x121\n" + + "\x06permit\x18\x02 \x01(\v2\x19.agent.v1.ExecutionPermitR\x06permit\"\xc3\x02\n" + + "\x17ApplyTaskControlRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12/\n" + + "\x06action\x18\x03 \x01(\x0e2\x17.agent.v1.ControlActionR\x06action\x12H\n" + + "\x12active_call_policy\x18\x04 \x01(\x0e2\x1a.agent.v1.ActiveCallPolicyR\x10activeCallPolicy\x124\n" + + "\x16expected_task_revision\x18\x05 \x01(\x03R\x14expectedTaskRevision\x12\x16\n" + + "\x06reason\x18\x06 \x01(\tR\x06reason\"\xb4\x01\n" + + "\x18ApplyTaskControlResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x122\n" + + "\x15applied_task_revision\x18\x02 \x01(\x03R\x13appliedTaskRevision\x12.\n" + + "\x05state\x18\x03 \x01(\x0e2\x18.agent.v1.ExecutionStateR\x05state\"x\n" + + "\x15QueryExecutionRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\"\xd4\x02\n" + + "\x11ExecutionSnapshot\x124\n" + + "\abinding\x18\x01 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12.\n" + + "\x05state\x18\x02 \x01(\x0e2\x18.agent.v1.ExecutionStateR\x05state\x12\x1d\n" + + "\n" + + "call_state\x18\x03 \x01(\tR\tcallState\x12\x1d\n" + + "\n" + + "attempt_id\x18\x04 \x01(\tR\tattemptId\x12\x1f\n" + + "\vreason_code\x18\x05 \x01(\tR\n" + + "reasonCode\x12-\n" + + "\x13observed_at_unix_ms\x18\x06 \x01(\x03R\x10observedAtUnixMs\x12\x18\n" + + "\aunknown\x18\a \x01(\bR\aunknown\x121\n" + + "\x06assets\x18\b \x03(\v2\x19.agent.v1.AssetDescriptorR\x06assets\"\xaa\x01\n" + + "\x16QueryExecutionResponse\x12*\n" + + "\x04meta\x18\x01 \x01(\v2\x16.agent.v1.ResponseMetaR\x04meta\x127\n" + + "\bsnapshot\x18\x02 \x01(\v2\x1b.agent.v1.ExecutionSnapshotR\bsnapshot\x12+\n" + + "\afailure\x18\x03 \x01(\v2\x11.agent.v1.FailureR\afailure\"\xce\x02\n" + + "\rExecutionFact\x12\x17\n" + + "\afact_id\x18\x01 \x01(\tR\x06factId\x12%\n" + + "\x0econtent_sha256\x18\x02 \x01(\tR\rcontentSha256\x124\n" + + "\abinding\x18\x03 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12&\n" + + "\x04kind\x18\x04 \x01(\x0e2\x12.agent.v1.FactKindR\x04kind\x12-\n" + + "\x13observed_at_unix_ms\x18\x05 \x01(\x03R\x10observedAtUnixMs\x12$\n" + + "\x0esource_boot_id\x18\x06 \x01(\tR\fsourceBootId\x12'\n" + + "\x0fsource_sequence\x18\a \x01(\x04R\x0esourceSequence\x12!\n" + + "\fpayload_json\x18\b \x01(\fR\vpayloadJson\"u\n" + + "\x1bReportExecutionEventRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x12+\n" + + "\x04fact\x18\x02 \x01(\v2\x17.agent.v1.ExecutionFactR\x04fact\"T\n" + + "\x1cReportExecutionEventResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\"\xc5\x01\n" + + "\x14RequestUploadRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12/\n" + + "\x05asset\x18\x03 \x01(\v2\x19.agent.v1.AssetDescriptorR\x05asset\x12\x1b\n" + + "\tupload_id\x18\x04 \x01(\tR\buploadId\"\x98\x02\n" + + "\vUploadGrant\x12\x1b\n" + + "\tupload_id\x18\x01 \x01(\tR\buploadId\x12\x1d\n" + + "\n" + + "target_url\x18\x02 \x01(\tR\ttargetUrl\x12*\n" + + "\aheaders\x18\x03 \x03(\v2\x10.agent.v1.HeaderR\aheaders\x12+\n" + + "\x12expires_at_unix_ms\x18\x04 \x01(\x03R\x0fexpiresAtUnixMs\x12\x1d\n" + + "\n" + + "object_key\x18\x05 \x01(\tR\tobjectKey\x128\n" + + "\x18required_checksum_sha256\x18\x06 \x01(\tR\x16requiredChecksumSha256\x12\x1b\n" + + "\tmax_bytes\x18\a \x01(\x03R\bmaxBytes\"\xa7\x01\n" + + "\x15RequestUploadResponse\x124\n" + + "\areceipt\x18\x01 \x01(\v2\x1a.agent.v1.OperationReceiptR\areceipt\x12+\n" + + "\x05grant\x18\x02 \x01(\v2\x15.agent.v1.UploadGrantR\x05grant\x12+\n" + + "\x05state\x18\x03 \x01(\x0e2\x15.agent.v1.UploadStateR\x05state\"\xb0\x02\n" + + "\x15CompleteUploadRequest\x12)\n" + + "\x04meta\x18\x01 \x01(\v2\x15.agent.v1.RequestMetaR\x04meta\x124\n" + + "\abinding\x18\x02 \x01(\v2\x1a.agent.v1.ExecutionBindingR\abinding\x12/\n" + + "\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" + + "\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" + + "\n" + + "ResultCode\x12\x1b\n" + + "\x17RESULT_CODE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14RESULT_CODE_ACCEPTED\x10\x01\x12\x17\n" + + "\x13RESULT_CODE_APPLIED\x10\x02\x12\x18\n" + + "\x14RESULT_CODE_REJECTED\x10\x03\x12\x17\n" + + "\x13RESULT_CODE_UNKNOWN\x10\x04\x12\x18\n" + + "\x14RESULT_CODE_CONFLICT\x10\x05*\xf8\x02\n" + + "\vFailureCode\x12\x1c\n" + + "\x18FAILURE_CODE_UNSPECIFIED\x10\x00\x12!\n" + + "\x1dFAILURE_CODE_INVALID_ARGUMENT\x10\x01\x12 \n" + + "\x1cFAILURE_CODE_UNAUTHENTICATED\x10\x02\x12\"\n" + + "\x1eFAILURE_CODE_PERMISSION_DENIED\x10\x03\x12$\n" + + " FAILURE_CODE_FAILED_PRECONDITION\x10\x04\x12\x18\n" + + "\x14FAILURE_CODE_ABORTED\x10\x05\x12#\n" + + "\x1fFAILURE_CODE_RESOURCE_EXHAUSTED\x10\x06\x12\x1c\n" + + "\x18FAILURE_CODE_UNAVAILABLE\x10\a\x12\"\n" + + "\x1eFAILURE_CODE_DEADLINE_EXCEEDED\x10\b\x12\x1a\n" + + "\x16FAILURE_CODE_NOT_FOUND\x10\t\x12\x1f\n" + + "\x1bFAILURE_CODE_ALREADY_EXISTS\x10\n" + + "*\xab\x01\n" + + "\x0fActivationState\x12 \n" + + "\x1cACTIVATION_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18ACTIVATION_STATE_PENDING\x10\x01\x12\x1b\n" + + "\x17ACTIVATION_STATE_ACTIVE\x10\x02\x12\x1d\n" + + "\x19ACTIVATION_STATE_CONFLICT\x10\x03\x12\x1c\n" + + "\x18ACTIVATION_STATE_REVOKED\x10\x04*\xa6\x01\n" + + "\x0eAdmissionState\x12\x1f\n" + + "\x1bADMISSION_STATE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14ADMISSION_STATE_OPEN\x10\x01\x12\x1a\n" + + "\x16ADMISSION_STATE_CLOSED\x10\x02\x12\x1c\n" + + "\x18ADMISSION_STATE_DRAINING\x10\x03\x12\x1f\n" + + "\x1bADMISSION_STATE_QUARANTINED\x10\x04*}\n" + + "\rControlAction\x12\x1e\n" + + "\x1aCONTROL_ACTION_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14CONTROL_ACTION_PAUSE\x10\x01\x12\x19\n" + + "\x15CONTROL_ACTION_RESUME\x10\x02\x12\x17\n" + + "\x13CONTROL_ACTION_STOP\x10\x03*s\n" + + "\x10ActiveCallPolicy\x12\"\n" + + "\x1eACTIVE_CALL_POLICY_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18ACTIVE_CALL_POLICY_DRAIN\x10\x01\x12\x1d\n" + + "\x19ACTIVE_CALL_POLICY_HANGUP\x10\x02*\xed\x01\n" + + "\x0eExecutionState\x12\x1f\n" + + "\x1bEXECUTION_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18EXECUTION_STATE_PREPARED\x10\x01\x12\"\n" + + "\x1eEXECUTION_STATE_PERMIT_GRANTED\x10\x02\x12\x1f\n" + + "\x1bEXECUTION_STATE_DISPATCHING\x10\x03\x12\x1c\n" + + "\x18EXECUTION_STATE_OBSERVED\x10\x04\x12\x1b\n" + + "\x17EXECUTION_STATE_UNKNOWN\x10\x05\x12\x1c\n" + + "\x18EXECUTION_STATE_TERMINAL\x10\x06*\\\n" + + "\tAssetKind\x12\x1a\n" + + "\x16ASSET_KIND_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14ASSET_KIND_RECORDING\x10\x01\x12\x19\n" + + "\x15ASSET_KIND_TRANSCRIPT\x10\x02*\xb2\x01\n" + + "\vUploadState\x12\x1c\n" + + "\x18UPLOAD_STATE_UNSPECIFIED\x10\x00\x12\x1a\n" + + "\x16UPLOAD_STATE_REQUESTED\x10\x01\x12\x1a\n" + + "\x16UPLOAD_STATE_UPLOADING\x10\x02\x12\x1a\n" + + "\x16UPLOAD_STATE_COMPLETED\x10\x03\x12\x17\n" + + "\x13UPLOAD_STATE_FAILED\x10\x04\x12\x18\n" + + "\x14UPLOAD_STATE_EXPIRED\x10\x05*\x83\x02\n" + + "\bFactKind\x12\x19\n" + + "\x15FACT_KIND_UNSPECIFIED\x10\x00\x12 \n" + + "\x1cFACT_KIND_EXECUTION_ACCEPTED\x10\x01\x12\x19\n" + + "\x15FACT_KIND_CALL_STATUS\x10\x02\x12\x1b\n" + + "\x17FACT_KIND_CALL_FINISHED\x10\x03\x12 \n" + + "\x1cFACT_KIND_TRANSCRIPT_UPDATED\x10\x04\x12\x1f\n" + + "\x1bFACT_KIND_TRANSCRIPT_FAILED\x10\x05\x12\x1d\n" + + "\x19FACT_KIND_CONTACT_OPT_OUT\x10\x06\x12 \n" + + "\x1cFACT_KIND_RECORDING_PROGRESS\x10\a2\xc8\a\n" + + "\x13AgentControlService\x12S\n" + + "\x0eGetAgentStatus\x12\x1f.agent.v1.GetAgentStatusRequest\x1a .agent.v1.GetAgentStatusResponse\x12P\n" + + "\rActivateAgent\x12\x1e.agent.v1.ActivateAgentRequest\x1a\x1f.agent.v1.ActivateAgentResponse\x12M\n" + + "\fGetBootstrap\x12\x1d.agent.v1.GetBootstrapRequest\x1a\x1e.agent.v1.GetBootstrapResponse\x12\\\n" + + "\x11SetAdmissionState\x12\".agent.v1.SetAdmissionStateRequest\x1a#.agent.v1.SetAdmissionStateResponse\x12>\n" + + "\aExecute\x12\x18.agent.v1.ExecuteRequest\x1a\x19.agent.v1.ExecuteResponse\x12_\n" + + "\x12GetExecutionPermit\x12#.agent.v1.GetExecutionPermitRequest\x1a$.agent.v1.GetExecutionPermitResponse\x12Y\n" + + "\x10ApplyTaskControl\x12!.agent.v1.ApplyTaskControlRequest\x1a\".agent.v1.ApplyTaskControlResponse\x12S\n" + + "\x0eQueryExecution\x12\x1f.agent.v1.QueryExecutionRequest\x1a .agent.v1.QueryExecutionResponse\x12e\n" + + "\x14ReportExecutionEvent\x12%.agent.v1.ReportExecutionEventRequest\x1a&.agent.v1.ReportExecutionEventResponse\x12P\n" + + "\rRequestUpload\x12\x1e.agent.v1.RequestUploadRequest\x1a\x1f.agent.v1.RequestUploadResponse\x12S\n" + + "\x0eCompleteUpload\x12\x1f.agent.v1.CompleteUploadRequest\x1a .agent.v1.CompleteUploadResponseB0Z.git.ipao.vip/rogee/go-sip/gen/agent/v1;agentv1b\x06proto3" + +var ( + file_agent_v1_agent_proto_rawDescOnce sync.Once + file_agent_v1_agent_proto_rawDescData []byte +) + +func file_agent_v1_agent_proto_rawDescGZIP() []byte { + file_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_v1_agent_proto_rawDesc), len(file_agent_v1_agent_proto_rawDesc))) + }) + return file_agent_v1_agent_proto_rawDescData +} + +var file_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 10) +var file_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 41) +var file_agent_v1_agent_proto_goTypes = []any{ + (ResultCode)(0), // 0: agent.v1.ResultCode + (FailureCode)(0), // 1: agent.v1.FailureCode + (ActivationState)(0), // 2: agent.v1.ActivationState + (AdmissionState)(0), // 3: agent.v1.AdmissionState + (ControlAction)(0), // 4: agent.v1.ControlAction + (ActiveCallPolicy)(0), // 5: agent.v1.ActiveCallPolicy + (ExecutionState)(0), // 6: agent.v1.ExecutionState + (AssetKind)(0), // 7: agent.v1.AssetKind + (UploadState)(0), // 8: agent.v1.UploadState + (FactKind)(0), // 9: agent.v1.FactKind + (*RequestMeta)(nil), // 10: agent.v1.RequestMeta + (*ResponseMeta)(nil), // 11: agent.v1.ResponseMeta + (*Failure)(nil), // 12: agent.v1.Failure + (*OperationReceipt)(nil), // 13: agent.v1.OperationReceipt + (*AgentBinding)(nil), // 14: agent.v1.AgentBinding + (*Capability)(nil), // 15: agent.v1.Capability + (*ResourceSample)(nil), // 16: agent.v1.ResourceSample + (*AppliedConfig)(nil), // 17: agent.v1.AppliedConfig + (*AgentStatus)(nil), // 18: agent.v1.AgentStatus + (*Session)(nil), // 19: agent.v1.Session + (*ConfigReference)(nil), // 20: agent.v1.ConfigReference + (*UploadPolicy)(nil), // 21: agent.v1.UploadPolicy + (*ExecutionBinding)(nil), // 22: agent.v1.ExecutionBinding + (*AssetDescriptor)(nil), // 23: agent.v1.AssetDescriptor + (*Header)(nil), // 24: agent.v1.Header + (*GetAgentStatusRequest)(nil), // 25: agent.v1.GetAgentStatusRequest + (*GetAgentStatusResponse)(nil), // 26: agent.v1.GetAgentStatusResponse + (*ActivateAgentRequest)(nil), // 27: agent.v1.ActivateAgentRequest + (*ActivateAgentResponse)(nil), // 28: agent.v1.ActivateAgentResponse + (*GetBootstrapRequest)(nil), // 29: agent.v1.GetBootstrapRequest + (*GetBootstrapResponse)(nil), // 30: agent.v1.GetBootstrapResponse + (*SetAdmissionStateRequest)(nil), // 31: agent.v1.SetAdmissionStateRequest + (*SetAdmissionStateResponse)(nil), // 32: agent.v1.SetAdmissionStateResponse + (*ExecuteRequest)(nil), // 33: agent.v1.ExecuteRequest + (*ExecuteResponse)(nil), // 34: agent.v1.ExecuteResponse + (*GetExecutionPermitRequest)(nil), // 35: agent.v1.GetExecutionPermitRequest + (*ExecutionPermit)(nil), // 36: agent.v1.ExecutionPermit + (*GetExecutionPermitResponse)(nil), // 37: agent.v1.GetExecutionPermitResponse + (*ApplyTaskControlRequest)(nil), // 38: agent.v1.ApplyTaskControlRequest + (*ApplyTaskControlResponse)(nil), // 39: agent.v1.ApplyTaskControlResponse + (*QueryExecutionRequest)(nil), // 40: agent.v1.QueryExecutionRequest + (*ExecutionSnapshot)(nil), // 41: agent.v1.ExecutionSnapshot + (*QueryExecutionResponse)(nil), // 42: agent.v1.QueryExecutionResponse + (*ExecutionFact)(nil), // 43: agent.v1.ExecutionFact + (*ReportExecutionEventRequest)(nil), // 44: agent.v1.ReportExecutionEventRequest + (*ReportExecutionEventResponse)(nil), // 45: agent.v1.ReportExecutionEventResponse + (*RequestUploadRequest)(nil), // 46: agent.v1.RequestUploadRequest + (*UploadGrant)(nil), // 47: agent.v1.UploadGrant + (*RequestUploadResponse)(nil), // 48: agent.v1.RequestUploadResponse + (*CompleteUploadRequest)(nil), // 49: agent.v1.CompleteUploadRequest + (*CompleteUploadResponse)(nil), // 50: agent.v1.CompleteUploadResponse +} +var file_agent_v1_agent_proto_depIdxs = []int32{ + 1, // 0: agent.v1.Failure.code:type_name -> agent.v1.FailureCode + 11, // 1: agent.v1.OperationReceipt.meta:type_name -> agent.v1.ResponseMeta + 0, // 2: agent.v1.OperationReceipt.result:type_name -> agent.v1.ResultCode + 12, // 3: agent.v1.OperationReceipt.failure:type_name -> agent.v1.Failure + 3, // 4: agent.v1.AgentStatus.admission_state:type_name -> agent.v1.AdmissionState + 15, // 5: agent.v1.AgentStatus.capabilities:type_name -> agent.v1.Capability + 16, // 6: agent.v1.AgentStatus.resources:type_name -> agent.v1.ResourceSample + 17, // 7: agent.v1.AgentStatus.applied_configs:type_name -> agent.v1.AppliedConfig + 7, // 8: agent.v1.AssetDescriptor.kind:type_name -> agent.v1.AssetKind + 10, // 9: agent.v1.GetAgentStatusRequest.meta:type_name -> agent.v1.RequestMeta + 14, // 10: agent.v1.GetAgentStatusRequest.target:type_name -> agent.v1.AgentBinding + 11, // 11: agent.v1.GetAgentStatusResponse.meta:type_name -> agent.v1.ResponseMeta + 18, // 12: agent.v1.GetAgentStatusResponse.status:type_name -> agent.v1.AgentStatus + 12, // 13: agent.v1.GetAgentStatusResponse.failure:type_name -> agent.v1.Failure + 10, // 14: agent.v1.ActivateAgentRequest.meta:type_name -> agent.v1.RequestMeta + 14, // 15: agent.v1.ActivateAgentRequest.binding:type_name -> agent.v1.AgentBinding + 11, // 16: agent.v1.ActivateAgentResponse.meta:type_name -> agent.v1.ResponseMeta + 2, // 17: agent.v1.ActivateAgentResponse.state:type_name -> agent.v1.ActivationState + 19, // 18: agent.v1.ActivateAgentResponse.session:type_name -> agent.v1.Session + 12, // 19: agent.v1.ActivateAgentResponse.failure:type_name -> agent.v1.Failure + 10, // 20: agent.v1.GetBootstrapRequest.meta:type_name -> agent.v1.RequestMeta + 11, // 21: agent.v1.GetBootstrapResponse.meta:type_name -> agent.v1.ResponseMeta + 2, // 22: agent.v1.GetBootstrapResponse.state:type_name -> agent.v1.ActivationState + 20, // 23: agent.v1.GetBootstrapResponse.runtime_configs:type_name -> agent.v1.ConfigReference + 21, // 24: agent.v1.GetBootstrapResponse.upload_policy:type_name -> agent.v1.UploadPolicy + 12, // 25: agent.v1.GetBootstrapResponse.failure:type_name -> agent.v1.Failure + 10, // 26: agent.v1.SetAdmissionStateRequest.meta:type_name -> agent.v1.RequestMeta + 14, // 27: agent.v1.SetAdmissionStateRequest.target:type_name -> agent.v1.AgentBinding + 3, // 28: agent.v1.SetAdmissionStateRequest.state:type_name -> agent.v1.AdmissionState + 13, // 29: agent.v1.SetAdmissionStateResponse.receipt:type_name -> agent.v1.OperationReceipt + 10, // 30: agent.v1.ExecuteRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 31: agent.v1.ExecuteRequest.binding:type_name -> agent.v1.ExecutionBinding + 13, // 32: agent.v1.ExecuteResponse.receipt:type_name -> agent.v1.OperationReceipt + 6, // 33: agent.v1.ExecuteResponse.state:type_name -> agent.v1.ExecutionState + 10, // 34: agent.v1.GetExecutionPermitRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 35: agent.v1.GetExecutionPermitRequest.binding:type_name -> agent.v1.ExecutionBinding + 13, // 36: agent.v1.GetExecutionPermitResponse.receipt:type_name -> agent.v1.OperationReceipt + 36, // 37: agent.v1.GetExecutionPermitResponse.permit:type_name -> agent.v1.ExecutionPermit + 10, // 38: agent.v1.ApplyTaskControlRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 39: agent.v1.ApplyTaskControlRequest.binding:type_name -> agent.v1.ExecutionBinding + 4, // 40: agent.v1.ApplyTaskControlRequest.action:type_name -> agent.v1.ControlAction + 5, // 41: agent.v1.ApplyTaskControlRequest.active_call_policy:type_name -> agent.v1.ActiveCallPolicy + 13, // 42: agent.v1.ApplyTaskControlResponse.receipt:type_name -> agent.v1.OperationReceipt + 6, // 43: agent.v1.ApplyTaskControlResponse.state:type_name -> agent.v1.ExecutionState + 10, // 44: agent.v1.QueryExecutionRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 45: agent.v1.QueryExecutionRequest.binding:type_name -> agent.v1.ExecutionBinding + 22, // 46: agent.v1.ExecutionSnapshot.binding:type_name -> agent.v1.ExecutionBinding + 6, // 47: agent.v1.ExecutionSnapshot.state:type_name -> agent.v1.ExecutionState + 23, // 48: agent.v1.ExecutionSnapshot.assets:type_name -> agent.v1.AssetDescriptor + 11, // 49: agent.v1.QueryExecutionResponse.meta:type_name -> agent.v1.ResponseMeta + 41, // 50: agent.v1.QueryExecutionResponse.snapshot:type_name -> agent.v1.ExecutionSnapshot + 12, // 51: agent.v1.QueryExecutionResponse.failure:type_name -> agent.v1.Failure + 22, // 52: agent.v1.ExecutionFact.binding:type_name -> agent.v1.ExecutionBinding + 9, // 53: agent.v1.ExecutionFact.kind:type_name -> agent.v1.FactKind + 10, // 54: agent.v1.ReportExecutionEventRequest.meta:type_name -> agent.v1.RequestMeta + 43, // 55: agent.v1.ReportExecutionEventRequest.fact:type_name -> agent.v1.ExecutionFact + 13, // 56: agent.v1.ReportExecutionEventResponse.receipt:type_name -> agent.v1.OperationReceipt + 10, // 57: agent.v1.RequestUploadRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 58: agent.v1.RequestUploadRequest.binding:type_name -> agent.v1.ExecutionBinding + 23, // 59: agent.v1.RequestUploadRequest.asset:type_name -> agent.v1.AssetDescriptor + 24, // 60: agent.v1.UploadGrant.headers:type_name -> agent.v1.Header + 13, // 61: agent.v1.RequestUploadResponse.receipt:type_name -> agent.v1.OperationReceipt + 47, // 62: agent.v1.RequestUploadResponse.grant:type_name -> agent.v1.UploadGrant + 8, // 63: agent.v1.RequestUploadResponse.state:type_name -> agent.v1.UploadState + 10, // 64: agent.v1.CompleteUploadRequest.meta:type_name -> agent.v1.RequestMeta + 22, // 65: agent.v1.CompleteUploadRequest.binding:type_name -> agent.v1.ExecutionBinding + 23, // 66: agent.v1.CompleteUploadRequest.asset:type_name -> agent.v1.AssetDescriptor + 13, // 67: agent.v1.CompleteUploadResponse.receipt:type_name -> agent.v1.OperationReceipt + 8, // 68: agent.v1.CompleteUploadResponse.state:type_name -> agent.v1.UploadState + 25, // 69: agent.v1.AgentControlService.GetAgentStatus:input_type -> agent.v1.GetAgentStatusRequest + 27, // 70: agent.v1.AgentControlService.ActivateAgent:input_type -> agent.v1.ActivateAgentRequest + 29, // 71: agent.v1.AgentControlService.GetBootstrap:input_type -> agent.v1.GetBootstrapRequest + 31, // 72: agent.v1.AgentControlService.SetAdmissionState:input_type -> agent.v1.SetAdmissionStateRequest + 33, // 73: agent.v1.AgentControlService.Execute:input_type -> agent.v1.ExecuteRequest + 35, // 74: agent.v1.AgentControlService.GetExecutionPermit:input_type -> agent.v1.GetExecutionPermitRequest + 38, // 75: agent.v1.AgentControlService.ApplyTaskControl:input_type -> agent.v1.ApplyTaskControlRequest + 40, // 76: agent.v1.AgentControlService.QueryExecution:input_type -> agent.v1.QueryExecutionRequest + 44, // 77: agent.v1.AgentControlService.ReportExecutionEvent:input_type -> agent.v1.ReportExecutionEventRequest + 46, // 78: agent.v1.AgentControlService.RequestUpload:input_type -> agent.v1.RequestUploadRequest + 49, // 79: agent.v1.AgentControlService.CompleteUpload:input_type -> agent.v1.CompleteUploadRequest + 26, // 80: agent.v1.AgentControlService.GetAgentStatus:output_type -> agent.v1.GetAgentStatusResponse + 28, // 81: agent.v1.AgentControlService.ActivateAgent:output_type -> agent.v1.ActivateAgentResponse + 30, // 82: agent.v1.AgentControlService.GetBootstrap:output_type -> agent.v1.GetBootstrapResponse + 32, // 83: agent.v1.AgentControlService.SetAdmissionState:output_type -> agent.v1.SetAdmissionStateResponse + 34, // 84: agent.v1.AgentControlService.Execute:output_type -> agent.v1.ExecuteResponse + 37, // 85: agent.v1.AgentControlService.GetExecutionPermit:output_type -> agent.v1.GetExecutionPermitResponse + 39, // 86: agent.v1.AgentControlService.ApplyTaskControl:output_type -> agent.v1.ApplyTaskControlResponse + 42, // 87: agent.v1.AgentControlService.QueryExecution:output_type -> agent.v1.QueryExecutionResponse + 45, // 88: agent.v1.AgentControlService.ReportExecutionEvent:output_type -> agent.v1.ReportExecutionEventResponse + 48, // 89: agent.v1.AgentControlService.RequestUpload:output_type -> agent.v1.RequestUploadResponse + 50, // 90: agent.v1.AgentControlService.CompleteUpload:output_type -> agent.v1.CompleteUploadResponse + 80, // [80:91] is the sub-list for method output_type + 69, // [69:80] is the sub-list for method input_type + 69, // [69:69] is the sub-list for extension type_name + 69, // [69:69] is the sub-list for extension extendee + 0, // [0:69] is the sub-list for field type_name +} + +func init() { file_agent_v1_agent_proto_init() } +func file_agent_v1_agent_proto_init() { + if File_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_v1_agent_proto_rawDesc), len(file_agent_v1_agent_proto_rawDesc)), + NumEnums: 10, + NumMessages: 41, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_agent_v1_agent_proto_depIdxs, + EnumInfos: file_agent_v1_agent_proto_enumTypes, + MessageInfos: file_agent_v1_agent_proto_msgTypes, + }.Build() + File_agent_v1_agent_proto = out.File + file_agent_v1_agent_proto_goTypes = nil + file_agent_v1_agent_proto_depIdxs = nil +} diff --git a/gen/agent/v1/agent_grpc.pb.go b/gen/agent/v1/agent_grpc.pb.go new file mode 100644 index 0000000..f514a9e --- /dev/null +++ b/gen/agent/v1/agent_grpc.pb.go @@ -0,0 +1,509 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc (unknown) +// source: agent/v1/agent.proto + +package agentv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AgentControlService_GetAgentStatus_FullMethodName = "/agent.v1.AgentControlService/GetAgentStatus" + AgentControlService_ActivateAgent_FullMethodName = "/agent.v1.AgentControlService/ActivateAgent" + AgentControlService_GetBootstrap_FullMethodName = "/agent.v1.AgentControlService/GetBootstrap" + AgentControlService_SetAdmissionState_FullMethodName = "/agent.v1.AgentControlService/SetAdmissionState" + AgentControlService_Execute_FullMethodName = "/agent.v1.AgentControlService/Execute" + AgentControlService_GetExecutionPermit_FullMethodName = "/agent.v1.AgentControlService/GetExecutionPermit" + AgentControlService_ApplyTaskControl_FullMethodName = "/agent.v1.AgentControlService/ApplyTaskControl" + AgentControlService_QueryExecution_FullMethodName = "/agent.v1.AgentControlService/QueryExecution" + AgentControlService_ReportExecutionEvent_FullMethodName = "/agent.v1.AgentControlService/ReportExecutionEvent" + AgentControlService_RequestUpload_FullMethodName = "/agent.v1.AgentControlService/RequestUpload" + AgentControlService_CompleteUpload_FullMethodName = "/agent.v1.AgentControlService/CompleteUpload" +) + +// AgentControlServiceClient is the client API for AgentControlService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// AgentControl is the project-owned Unary gRPC boundary between the single +// active Dispatcher and a Cell Agent. It carries metadata and facts, never +// audio bytes or an internal message-bus replacement. +type AgentControlServiceClient interface { + GetAgentStatus(ctx context.Context, in *GetAgentStatusRequest, opts ...grpc.CallOption) (*GetAgentStatusResponse, error) + ActivateAgent(ctx context.Context, in *ActivateAgentRequest, opts ...grpc.CallOption) (*ActivateAgentResponse, error) + GetBootstrap(ctx context.Context, in *GetBootstrapRequest, opts ...grpc.CallOption) (*GetBootstrapResponse, error) + SetAdmissionState(ctx context.Context, in *SetAdmissionStateRequest, opts ...grpc.CallOption) (*SetAdmissionStateResponse, error) + Execute(ctx context.Context, in *ExecuteRequest, opts ...grpc.CallOption) (*ExecuteResponse, error) + GetExecutionPermit(ctx context.Context, in *GetExecutionPermitRequest, opts ...grpc.CallOption) (*GetExecutionPermitResponse, error) + ApplyTaskControl(ctx context.Context, in *ApplyTaskControlRequest, opts ...grpc.CallOption) (*ApplyTaskControlResponse, error) + QueryExecution(ctx context.Context, in *QueryExecutionRequest, opts ...grpc.CallOption) (*QueryExecutionResponse, error) + ReportExecutionEvent(ctx context.Context, in *ReportExecutionEventRequest, opts ...grpc.CallOption) (*ReportExecutionEventResponse, error) + RequestUpload(ctx context.Context, in *RequestUploadRequest, opts ...grpc.CallOption) (*RequestUploadResponse, error) + CompleteUpload(ctx context.Context, in *CompleteUploadRequest, opts ...grpc.CallOption) (*CompleteUploadResponse, error) +} + +type agentControlServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentControlServiceClient(cc grpc.ClientConnInterface) AgentControlServiceClient { + return &agentControlServiceClient{cc} +} + +func (c *agentControlServiceClient) GetAgentStatus(ctx context.Context, in *GetAgentStatusRequest, opts ...grpc.CallOption) (*GetAgentStatusResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetAgentStatusResponse) + err := c.cc.Invoke(ctx, AgentControlService_GetAgentStatus_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) ActivateAgent(ctx context.Context, in *ActivateAgentRequest, opts ...grpc.CallOption) (*ActivateAgentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ActivateAgentResponse) + err := c.cc.Invoke(ctx, AgentControlService_ActivateAgent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) GetBootstrap(ctx context.Context, in *GetBootstrapRequest, opts ...grpc.CallOption) (*GetBootstrapResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetBootstrapResponse) + err := c.cc.Invoke(ctx, AgentControlService_GetBootstrap_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) SetAdmissionState(ctx context.Context, in *SetAdmissionStateRequest, opts ...grpc.CallOption) (*SetAdmissionStateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetAdmissionStateResponse) + err := c.cc.Invoke(ctx, AgentControlService_SetAdmissionState_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) Execute(ctx context.Context, in *ExecuteRequest, opts ...grpc.CallOption) (*ExecuteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecuteResponse) + err := c.cc.Invoke(ctx, AgentControlService_Execute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) GetExecutionPermit(ctx context.Context, in *GetExecutionPermitRequest, opts ...grpc.CallOption) (*GetExecutionPermitResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetExecutionPermitResponse) + err := c.cc.Invoke(ctx, AgentControlService_GetExecutionPermit_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) ApplyTaskControl(ctx context.Context, in *ApplyTaskControlRequest, opts ...grpc.CallOption) (*ApplyTaskControlResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ApplyTaskControlResponse) + err := c.cc.Invoke(ctx, AgentControlService_ApplyTaskControl_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) QueryExecution(ctx context.Context, in *QueryExecutionRequest, opts ...grpc.CallOption) (*QueryExecutionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(QueryExecutionResponse) + err := c.cc.Invoke(ctx, AgentControlService_QueryExecution_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) ReportExecutionEvent(ctx context.Context, in *ReportExecutionEventRequest, opts ...grpc.CallOption) (*ReportExecutionEventResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportExecutionEventResponse) + err := c.cc.Invoke(ctx, AgentControlService_ReportExecutionEvent_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) RequestUpload(ctx context.Context, in *RequestUploadRequest, opts ...grpc.CallOption) (*RequestUploadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RequestUploadResponse) + err := c.cc.Invoke(ctx, AgentControlService_RequestUpload_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *agentControlServiceClient) CompleteUpload(ctx context.Context, in *CompleteUploadRequest, opts ...grpc.CallOption) (*CompleteUploadResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CompleteUploadResponse) + err := c.cc.Invoke(ctx, AgentControlService_CompleteUpload_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AgentControlServiceServer is the server API for AgentControlService service. +// All implementations must embed UnimplementedAgentControlServiceServer +// for forward compatibility. +// +// AgentControl is the project-owned Unary gRPC boundary between the single +// active Dispatcher and a Cell Agent. It carries metadata and facts, never +// audio bytes or an internal message-bus replacement. +type AgentControlServiceServer interface { + GetAgentStatus(context.Context, *GetAgentStatusRequest) (*GetAgentStatusResponse, error) + ActivateAgent(context.Context, *ActivateAgentRequest) (*ActivateAgentResponse, error) + GetBootstrap(context.Context, *GetBootstrapRequest) (*GetBootstrapResponse, error) + SetAdmissionState(context.Context, *SetAdmissionStateRequest) (*SetAdmissionStateResponse, error) + Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) + GetExecutionPermit(context.Context, *GetExecutionPermitRequest) (*GetExecutionPermitResponse, error) + ApplyTaskControl(context.Context, *ApplyTaskControlRequest) (*ApplyTaskControlResponse, error) + QueryExecution(context.Context, *QueryExecutionRequest) (*QueryExecutionResponse, error) + ReportExecutionEvent(context.Context, *ReportExecutionEventRequest) (*ReportExecutionEventResponse, error) + RequestUpload(context.Context, *RequestUploadRequest) (*RequestUploadResponse, error) + CompleteUpload(context.Context, *CompleteUploadRequest) (*CompleteUploadResponse, error) + mustEmbedUnimplementedAgentControlServiceServer() +} + +// UnimplementedAgentControlServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAgentControlServiceServer struct{} + +func (UnimplementedAgentControlServiceServer) GetAgentStatus(context.Context, *GetAgentStatusRequest) (*GetAgentStatusResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetAgentStatus not implemented") +} +func (UnimplementedAgentControlServiceServer) ActivateAgent(context.Context, *ActivateAgentRequest) (*ActivateAgentResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ActivateAgent not implemented") +} +func (UnimplementedAgentControlServiceServer) GetBootstrap(context.Context, *GetBootstrapRequest) (*GetBootstrapResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetBootstrap not implemented") +} +func (UnimplementedAgentControlServiceServer) SetAdmissionState(context.Context, *SetAdmissionStateRequest) (*SetAdmissionStateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetAdmissionState not implemented") +} +func (UnimplementedAgentControlServiceServer) Execute(context.Context, *ExecuteRequest) (*ExecuteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Execute not implemented") +} +func (UnimplementedAgentControlServiceServer) GetExecutionPermit(context.Context, *GetExecutionPermitRequest) (*GetExecutionPermitResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetExecutionPermit not implemented") +} +func (UnimplementedAgentControlServiceServer) ApplyTaskControl(context.Context, *ApplyTaskControlRequest) (*ApplyTaskControlResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ApplyTaskControl not implemented") +} +func (UnimplementedAgentControlServiceServer) QueryExecution(context.Context, *QueryExecutionRequest) (*QueryExecutionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method QueryExecution not implemented") +} +func (UnimplementedAgentControlServiceServer) ReportExecutionEvent(context.Context, *ReportExecutionEventRequest) (*ReportExecutionEventResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ReportExecutionEvent not implemented") +} +func (UnimplementedAgentControlServiceServer) RequestUpload(context.Context, *RequestUploadRequest) (*RequestUploadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RequestUpload not implemented") +} +func (UnimplementedAgentControlServiceServer) CompleteUpload(context.Context, *CompleteUploadRequest) (*CompleteUploadResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CompleteUpload not implemented") +} +func (UnimplementedAgentControlServiceServer) mustEmbedUnimplementedAgentControlServiceServer() {} +func (UnimplementedAgentControlServiceServer) testEmbeddedByValue() {} + +// UnsafeAgentControlServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentControlServiceServer will +// result in compilation errors. +type UnsafeAgentControlServiceServer interface { + mustEmbedUnimplementedAgentControlServiceServer() +} + +func RegisterAgentControlServiceServer(s grpc.ServiceRegistrar, srv AgentControlServiceServer) { + // If the following call panics, it indicates UnimplementedAgentControlServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AgentControlService_ServiceDesc, srv) +} + +func _AgentControlService_GetAgentStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetAgentStatusRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).GetAgentStatus(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_GetAgentStatus_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).GetAgentStatus(ctx, req.(*GetAgentStatusRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_ActivateAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ActivateAgentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).ActivateAgent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_ActivateAgent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).ActivateAgent(ctx, req.(*ActivateAgentRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_GetBootstrap_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetBootstrapRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).GetBootstrap(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_GetBootstrap_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).GetBootstrap(ctx, req.(*GetBootstrapRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_SetAdmissionState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetAdmissionStateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).SetAdmissionState(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_SetAdmissionState_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).SetAdmissionState(ctx, req.(*SetAdmissionStateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_Execute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecuteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).Execute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_Execute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).Execute(ctx, req.(*ExecuteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_GetExecutionPermit_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetExecutionPermitRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).GetExecutionPermit(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_GetExecutionPermit_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).GetExecutionPermit(ctx, req.(*GetExecutionPermitRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_ApplyTaskControl_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ApplyTaskControlRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).ApplyTaskControl(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_ApplyTaskControl_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).ApplyTaskControl(ctx, req.(*ApplyTaskControlRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_QueryExecution_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryExecutionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).QueryExecution(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_QueryExecution_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).QueryExecution(ctx, req.(*QueryExecutionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_ReportExecutionEvent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportExecutionEventRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).ReportExecutionEvent(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_ReportExecutionEvent_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).ReportExecutionEvent(ctx, req.(*ReportExecutionEventRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_RequestUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RequestUploadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).RequestUpload(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_RequestUpload_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).RequestUpload(ctx, req.(*RequestUploadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AgentControlService_CompleteUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CompleteUploadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AgentControlServiceServer).CompleteUpload(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AgentControlService_CompleteUpload_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AgentControlServiceServer).CompleteUpload(ctx, req.(*CompleteUploadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AgentControlService_ServiceDesc is the grpc.ServiceDesc for AgentControlService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AgentControlService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "agent.v1.AgentControlService", + HandlerType: (*AgentControlServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetAgentStatus", + Handler: _AgentControlService_GetAgentStatus_Handler, + }, + { + MethodName: "ActivateAgent", + Handler: _AgentControlService_ActivateAgent_Handler, + }, + { + MethodName: "GetBootstrap", + Handler: _AgentControlService_GetBootstrap_Handler, + }, + { + MethodName: "SetAdmissionState", + Handler: _AgentControlService_SetAdmissionState_Handler, + }, + { + MethodName: "Execute", + Handler: _AgentControlService_Execute_Handler, + }, + { + MethodName: "GetExecutionPermit", + Handler: _AgentControlService_GetExecutionPermit_Handler, + }, + { + MethodName: "ApplyTaskControl", + Handler: _AgentControlService_ApplyTaskControl_Handler, + }, + { + MethodName: "QueryExecution", + Handler: _AgentControlService_QueryExecution_Handler, + }, + { + MethodName: "ReportExecutionEvent", + Handler: _AgentControlService_ReportExecutionEvent_Handler, + }, + { + MethodName: "RequestUpload", + Handler: _AgentControlService_RequestUpload_Handler, + }, + { + MethodName: "CompleteUpload", + Handler: _AgentControlService_CompleteUpload_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "agent/v1/agent.proto", +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..360dc0c --- /dev/null +++ b/go.mod @@ -0,0 +1,58 @@ +module git.ipao.vip/rogee/go-sip + +go 1.27.1 + +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/openai/openai-go/v3 v3.62.0 + github.com/pion/rtp v1.10.5 + github.com/pion/rtp/v2 v2.0.0 + github.com/rabbitmq/amqp091-go v1.15.0 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/shirou/gopsutil/v4 v4.26.8 + github.com/spf13/cobra v1.10.1 + github.com/zaf/g711 v1.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/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 + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-colorable v0.1.8 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/oklog/ulid v1.3.1 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rotisserie/eris v0.4.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + modernc.org/libc v1.75.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..459f691 --- /dev/null +++ b/go.sum @@ -0,0 +1,232 @@ +github.com/CyCoreSystems/ari/v5 v5.3.1 h1:S+NHG1+uMwoAIl0hMBnRUGNZsQKQQQFq7XCRSE2c2mg= +github.com/CyCoreSystems/ari/v5 v5.3.1/go.mod h1:8cn9pshP+OAcmAh1y+G2hrGBS1NSF3QmvrARXyhvXxs= +github.com/GizClaw/doubao-speech-go v0.0.0-20260915022405-e38c14802696 h1:FL/2Z4hIT4gaVWz0kCVFSZP1O4RrvpWTZWwLnuVmNO0= +github.com/GizClaw/doubao-speech-go v0.0.0-20260915022405-e38c14802696/go.mod h1:4R3wUAZkYk1BSgzv+QVF+2SZPu3VDy8NvaQdNRYGgBs= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0 h1:uWzn3io54f9L9mvwsQQSv1KpkkFA06hBxI++RvIyvpI= +github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.6.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +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/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= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac h1:n1DqxAo4oWPMvH1+v+DLYlMCecgumhhgnxAPdqDIFHI= +github.com/inconshreveable/log15 v0.0.0-20201112154412-8562bdadbbac/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/openai/openai-go/v3 v3.62.0 h1:P3G1Ip9eOwCJxg2q1lPBFg7iuyDEJDuoqQinzZDaxJM= +github.com/openai/openai-go/v3 v3.62.0/go.mod h1:dE39tezpSvL+SHpkZQm7XhSc8BOLDzu8CEh1mHc9MzU= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= +github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/rtp/v2 v2.0.0 h1:8s4xPETm04IugKZaykpJnJ8LAGDLOQpsIpRXMzgM6Ow= +github.com/pion/rtp/v2 v2.0.0/go.mod h1:Vj+rrFbJCT3yxqE/VSwaOo9DQ2pMKGPxuE7hplGOlOs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rabbitmq/amqp091-go v1.15.0 h1:LEQL4/yp48/Wigt6A6XOu18RQRo8ZHtB5I/KZJn+gkw= +github.com/rabbitmq/amqp091-go v1.15.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rotisserie/eris v0.4.1 h1:0IHaklBg2X5z10qpXS8F6eR3bUM2Xsbr1bH8W/eLUlo= +github.com/rotisserie/eris v0.4.1/go.mod h1:lODN/gtqebxPHRbCcWeCYOE350FC2M3V/oAPT2wKxAU= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v4 v4.26.8 h1:YQMTF/1J50B5+Y0vlo1eDRf5DoR7Gk69hY+8wjYkQeo= +github.com/shirou/gopsutil/v4 v4.26.8/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zaf/g711 v1.4.0 h1:XZYkjjiAg9QTBnHqEg37m2I9q3IIDv5JRYXs2N8ma7c= +github.com/zaf/g711 v1.4.0/go.mod h1:eCDXt3dSp/kYYAoooba7ukD/Q75jvAaS4WOMr0l1Roo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= +golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew= +modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A= +modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..5627f83 --- /dev/null +++ b/install.sh @@ -0,0 +1,1728 @@ +#!/bin/sh + +PI_PACKAGE="@earendil-works/pi-coding-agent" +PI_CMD="pi" +PI_INSTALLER_API_BASE="${PI_INSTALLER_API_BASE:-https://pi.dev/api/installer/releases}" +PI_MANAGED_INSTALL_MARKER="managed-install.json" +# Pi publishes npm-shrinkwrap.json, so the explicit installer/reinstaller can +# bypass npm's release-age gate without reopening transitive dependency ranges. +PI_NPM_INSTALL_MIN_AGE_ARG="--min-release-age=4" +PI_ESC=$(printf '\033') +PI_CR=$(printf '\r') +PI_ETX=$(printf '\003') +readonly PI_PACKAGE PI_CMD PI_INSTALLER_API_BASE PI_MANAGED_INSTALL_MARKER PI_NPM_INSTALL_MIN_AGE_ARG PI_ESC PI_CR PI_ETX + +pi_installer_main() { + set -eu + + check_file="${TMPDIR:-/tmp}/pi-installer-checks.$$" + run_preflight_checks >"$check_file" & + check_pid=$! + + pi_logo_animation + + if wait "$check_pid"; then + check_status=0 + else + check_status=$? + fi + + printf '\033[1m Pi Installer\033[0m\n\033[2m There are many agent harnesses but this one is yours\033[0m\n\n' + if [ "$check_status" -eq 0 ]; then + cat "$check_file" + fi + rm -f "$check_file" + + if [ "$check_status" -ne 0 ]; then + if ! install_node_npm_interactive; then + exit "$check_status" + fi + + check_file="${TMPDIR:-/tmp}/pi-installer-checks.$$" + if run_preflight_checks >"$check_file"; then + check_status=0 + else + check_status=$? + fi + cat "$check_file" + rm -f "$check_file" + + if [ "$check_status" -ne 0 ]; then + exit "$check_status" + fi + fi + + PI_EXISTING_PATH=$(command -v "$PI_CMD" 2>/dev/null || true) + export PI_EXISTING_PATH + + if pi_managed_install_enabled; then + if ! ensure_managed_install_supported; then + exit 1 + fi + PI_MANAGED_INSTALL_DIR=$(select_managed_install_dir "$PI_EXISTING_PATH") + PI_MANAGED_AGENT_DIR=${PI_MANAGED_INSTALL_DIR%/*} + PI_MANAGED_BIN_DIR=$(select_managed_path_bin_dir "$PI_MANAGED_AGENT_DIR" "$PI_MANAGED_INSTALL_DIR" "$PI_EXISTING_PATH") + PI_NPM_INSTALL_PREFIX= + export PI_MANAGED_INSTALL_DIR PI_MANAGED_AGENT_DIR PI_MANAGED_BIN_DIR + else + if ! PI_NPM_INSTALL_PREFIX=$(select_npm_install_prefix); then + exit 1 + fi + fi + export PI_NPM_INSTALL_PREFIX + + if pi_managed_install_enabled && managed_install_root_for_command "$PI_EXISTING_PATH" >/dev/null 2>&1; then + PI_NPM_UNINSTALL_PREFIX= + else + PI_NPM_UNINSTALL_PREFIX=$(select_npm_uninstall_prefix "$PI_EXISTING_PATH") + fi + export PI_NPM_UNINSTALL_PREFIX + + choose_pi_action "$PI_EXISTING_PATH" + case "$PI_INSTALL_ACTION" in + uninstall) + uninstall_pi_package + printf '\nPi was uninstalled successfully.\n' + exit 0 + ;; + none) + exit 0 + ;; + esac + + install_pi_package + if [ "$PI_INSTALL_ACTION" = reinstall ]; then + printf '\nPi was reinstalled successfully.\n' + else + printf '\nPi was installed successfully.\n' + fi + if pi_managed_install_enabled; then + printf '\nUpdate Pi later with: pi update\n' + fi + if installed_pi_is_first_on_path; then + printf '\nRun it with: pi\n' + if [ "${PI_NODE_INSTALLED_STANDALONE:-0}" = 1 ]; then + printf 'If pi is not found in your shell yet, add this to your shell profile:\n\n' + printf ' export PATH="%s:$PATH"\n' "$PI_STANDALONE_NODE_BIN" + fi + else + print_pi_not_on_path_message + fi + +} + +run_preflight_checks() { + status=0 + + if command -v node >/dev/null 2>&1; then + node_version=$(node --version) + if ! node -e 'const [maj,min,patch] = process.versions.node.split(".").map(Number); process.exit(maj > 22 || (maj === 22 && (min > 19 || (min === 19 && patch >= 0))) ? 0 : 1)' >/dev/null; then + printf 'error: Pi requires Node.js 22.19.0 or newer. Found %s.\n' "$node_version" + status=1 + fi + else + printf 'error: Node.js 22.19.0 or newer is required to install Pi.\n' + status=1 + fi + + if ! command -v npm >/dev/null 2>&1; then + printf 'error: npm is required to install Pi.\n' + status=1 + fi + + if [ "$status" -ne 0 ]; then + printf '\n' + fi + + return "$status" +} + +install_node_npm_interactive() { + method=$(detect_node_install_method) + case "$method" in + homebrew) label="Homebrew" ;; + apt) label="apt" ;; + apk) label="apk" ;; + standalone) label="standalone Node.js" ;; + esac + + if ! ( : <>/dev/tty ) 2>/dev/null; then + printf 'No terminal detected; install Node.js 22.19.0 or newer and npm, then run this installer again.\n' + return 1 + fi + exec 3<>/dev/tty + + printf 'Pi needs Node.js 22.19.0 or newer and npm. Install them now with %s? [Y/n] ' "$label" >&3 + if ! IFS= read -r answer <&3; then + answer= + fi + exec 3>&- + case "$answer" in + n|N|no|NO) printf '\nInstall Node.js 22.19.0 or newer and npm, then run this installer again.\n'; return 1 ;; + *) ;; + esac + + install_node_npm "$method" "$label" +} + +detect_node_install_method() { + case "$(uname -s)" in + Darwin) + if command -v brew >/dev/null 2>&1; then + printf 'homebrew' + else + printf 'standalone' + fi + ;; + Linux) + if command -v apt-cache >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1 && apt_node_candidate_is_new_enough; then + printf 'apt' + elif command -v apk >/dev/null 2>&1 && apk_node_candidate_is_new_enough; then + printf 'apk' + else + printf 'standalone' + fi + ;; + *) + printf 'standalone' + ;; + esac +} + +apt_node_candidate_is_new_enough() { + version=$(apt-cache policy nodejs 2>/dev/null | awk '/Candidate:/ { print $2; exit }') + [ -n "$version" ] && [ "$version" != "(none)" ] && node_version_string_is_new_enough "$version" +} + +apk_node_candidate_is_new_enough() { + version=$(apk search -x nodejs 2>/dev/null | awk -F- '/^nodejs-/ { print $2; exit }') + [ -n "$version" ] && node_version_string_is_new_enough "$version" +} + +node_version_string_is_new_enough() { + version="${1#v}" + case "$version" in + [0-9]*) ;; + *) return 1 ;; + esac + version="${version%%[!0-9.]*}" + version_ifs=${IFS- } + IFS=. + set -- $version + IFS=$version_ifs + major="${1:-}" + minor="${2:-0}" + patch="${3:-0}" + case "$major" in ''|*[!0-9]*) return 1 ;; esac + case "$minor" in ''|*[!0-9]*) minor=0 ;; esac + case "$patch" in ''|*[!0-9]*) patch=0 ;; esac + + [ "$major" -gt 22 ] && return 0 + [ "$major" -eq 22 ] && [ "$minor" -gt 19 ] && return 0 + [ "$major" -eq 22 ] && [ "$minor" -eq 19 ] && [ "$patch" -ge 0 ] && return 0 + return 1 +} + +install_node_npm() { + method="$1"; label="$2" + + if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then + install_node_npm_with_progress "$method" "$label" + else + printf '\nInstalling Node.js and npm with %s...\n\n' "$label" + run_node_install_method "$method" + printf '\nNode.js and npm are installed.\n' + fi + + if [ "$method" = standalone ]; then + load_standalone_node + PI_NODE_INSTALLED_STANDALONE=1 + fi + hash -r + printf '\n' +} + +install_node_npm_with_progress() { + method="$1"; label="$2" + log_file="${TMPDIR:-/tmp}/pi-installer-node.$$" + rm -f "$log_file" + : >"$log_file" + + run_node_install_method "$method" >"$log_file" 2>&1 & + install_pid=$! + + printf '\033[?25l' + animate_node_install "$log_file" "$label" & + progress_pid=$! + trap 'kill "$install_pid" 2>/dev/null || true; finish_install_progress "$progress_pid"; exit 130' INT TERM + + if wait "$install_pid"; then + status=0 + else + status=$? + fi + + finish_install_progress "$progress_pid" + trap - INT TERM + + if [ "$status" -ne 0 ]; then + printf '\033[31mNode.js installation failed.\033[0m\n\n' + cat "$log_file" + rm -f "$log_file" + return "$status" + fi + + rm -f "$log_file" + if terminal_supports_unicode; then + printf ' \033[32m✓\033[0m Node.js and npm install complete\n' + else + printf ' \033[32mok\033[0m Node.js and npm install complete\n' + fi +} + +run_node_install_method() { + case "$1" in + homebrew) install_node_with_homebrew ;; + apt) install_node_with_apt ;; + apk) install_node_with_apk ;; + standalone) install_node_standalone ;; + esac +} + +install_node_with_homebrew() { + if brew list node >/dev/null 2>&1; then + brew upgrade node + else + brew install node + fi +} + +install_node_with_apt() { + print_sudo_note + if [ "${EUID:-$(id -u)}" -eq 0 ]; then + apt-get update + apt-get install -y nodejs npm + else + sudo sh -c 'apt-get update && apt-get install -y nodejs npm' + fi +} + +install_node_with_apk() { + print_sudo_note + run_with_sudo apk add --update-cache nodejs npm +} + +install_node_standalone() { + node_platform=$(detect_node_binary_platform) || { + printf 'Unsupported operating system for automatic Node.js install: %s\n' "$(uname -s)" + return 1 + } + node_arch=$(detect_node_binary_arch) || { + printf 'Unsupported CPU architecture for automatic Node.js install: %s\n' "$(uname -m)" + return 1 + } + node_dist_base="https://nodejs.org/dist/latest-v22.x" + node_base_dir=$(node_standalone_base_dir) + node_tmp_dir="${TMPDIR:-/tmp}/pi-node.$$" + + rm -rf "$node_tmp_dir" + mkdir -p "$node_tmp_dir" "$node_base_dir" + + printf 'Resolving Node.js binary for %s-%s\n' "$node_platform" "$node_arch" + curl -fsSL "$node_dist_base/SHASUMS256.txt" -o "$node_tmp_dir/SHASUMS256.txt" + node_file=$(awk -v suffix="-$node_platform-$node_arch.tar.xz" ' + index($2, "node-v") == 1 && length($2) >= length(suffix) && substr($2, length($2) - length(suffix) + 1) == suffix { print $2; exit } + ' "$node_tmp_dir/SHASUMS256.txt") + if [ -z "$node_file" ]; then + printf 'No Node.js binary is available for %s-%s.\n' "$node_platform" "$node_arch" + rm -rf "$node_tmp_dir" + return 1 + fi + + printf 'Downloading Node.js %s\n' "${node_file%.tar.xz}" + curl -fsSL "$node_dist_base/$node_file" -o "$node_tmp_dir/$node_file" + verify_node_standalone_download "$node_tmp_dir" "$node_file" + ensure_node_standalone_extract_tools "$node_platform" + + node_dir="$node_base_dir/${node_file%.tar.xz}" + rm -rf "$node_dir" + printf 'Extracting Node.js to %s\n' "$node_dir" + tar -xf "$node_tmp_dir/$node_file" -C "$node_base_dir" + rm -f "$node_base_dir/current" + ln -s "$node_dir" "$node_base_dir/current" + rm -rf "$node_tmp_dir" + printf 'Node.js installed at %s\n' "$node_dir" +} + +verify_node_standalone_download() { + checksum_dir="$1" + checksum_file_name="$2" + awk -v file="$checksum_file_name" '$2 == file { print }' "$checksum_dir/SHASUMS256.txt" > "$checksum_dir/SHASUMS256.selected" + + if command -v sha256sum >/dev/null 2>&1; then + printf 'Verifying Node.js download\n' + (cd "$checksum_dir" && sha256sum -c SHASUMS256.selected) + elif command -v shasum >/dev/null 2>&1; then + printf 'Verifying Node.js download\n' + (cd "$checksum_dir" && shasum -a 256 -c SHASUMS256.selected) + fi +} + +ensure_node_standalone_extract_tools() { + extract_platform="$1" + + if [ "$extract_platform" = linux ] && ! command -v xz >/dev/null 2>&1; then + printf 'Installing xz-utils for Node.js archive extraction\n' + print_sudo_note + if command -v apt-get >/dev/null 2>&1; then + run_with_sudo apt-get update + run_with_sudo apt-get install -y xz-utils + elif command -v apk >/dev/null 2>&1; then + run_with_sudo apk add --update-cache xz + else + printf 'xz is required to extract Node.js. Install xz and run this installer again.\n' + return 1 + fi + fi +} + +load_standalone_node() { + PI_STANDALONE_NODE_BIN="$(node_standalone_base_dir)/current/bin" + PATH="$PI_STANDALONE_NODE_BIN:$PATH" + export PI_STANDALONE_NODE_BIN PATH +} + +node_standalone_base_dir() { + if [ -n "${XDG_DATA_HOME:-}" ]; then + printf '%s/pi-node' "$XDG_DATA_HOME" + else + printf '%s/.local/share/pi-node' "$HOME" + fi +} + +detect_node_binary_platform() { + case "$(uname -s)" in + Darwin) printf 'darwin' ;; + Linux) printf 'linux' ;; + *) return 1 ;; + esac +} + +detect_node_binary_arch() { + case "$(uname -m)" in + x86_64|amd64) printf 'x64' ;; + arm64|aarch64) printf 'arm64' ;; + armv7l) printf 'armv7l' ;; + ppc64le) printf 'ppc64le' ;; + s390x) printf 's390x' ;; + *) return 1 ;; + esac +} + +print_sudo_note() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + printf 'This may ask for your sudo password.\n\n' + fi +} + +run_with_sudo() { + if [ "${EUID:-$(id -u)}" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +select_npm_install_prefix() { + npm_prefix=$(npm_global_prefix) + if [ -n "$npm_prefix" ] && npm_prefix_supports_global_install "$npm_prefix"; then + return 0 + fi + + if existing_global_pi_blocks_user_local_install "$npm_prefix"; then + print_existing_global_pi_not_writable_message "$npm_prefix" + return 1 + fi + + printf '%s/.local' "$HOME" +} + +select_npm_uninstall_prefix() { + existing_pi_path="$1" + [ -n "$existing_pi_path" ] || return 0 + + npm_prefix=$(npm_global_prefix) + if [ -n "$npm_prefix" ] && [ "$existing_pi_path" = "$npm_prefix/bin/$PI_CMD" ]; then + return 0 + fi + + if [ -n "${PI_NPM_INSTALL_PREFIX:-}" ] && [ "$existing_pi_path" = "$PI_NPM_INSTALL_PREFIX/bin/$PI_CMD" ]; then + printf '%s' "$PI_NPM_INSTALL_PREFIX" + return 0 + fi + + pi_bin_suffix="/bin/$PI_CMD" + case "$existing_pi_path" in + *"$pi_bin_suffix") printf '%s' "${existing_pi_path%$pi_bin_suffix}" ;; + esac +} + +npm_global_prefix() { + npm prefix -g 2>/dev/null || npm config get prefix 2>/dev/null +} + +npm_prefix_supports_global_install() { + prefix="$1" + path_is_writable_or_creatable "$prefix/lib/node_modules" && path_is_writable_or_creatable "$prefix/bin" +} + +existing_global_pi_blocks_user_local_install() { + npm_prefix="$1" + [ -n "$npm_prefix" ] || return 1 + + [ -e "$npm_prefix/bin/$PI_CMD" ] +} + +print_existing_global_pi_not_writable_message() { + npm_prefix="$1" + existing_pi_path="$npm_prefix/bin/$PI_CMD" + + printf "npm's global directory is not writable: %s\n" "$npm_prefix" >&2 + printf 'Pi is already installed at: %s\n\n' "$existing_pi_path" >&2 + printf 'Installing another copy under %s/.local could leave your shell using the old global pi, so this installer stopped.\n\n' "$HOME" >&2 + printf 'Update or remove the existing global install first. If it was installed with npm, you can run:\n\n' >&2 + printf ' sudo npm install -g --ignore-scripts %s %s\n\n' "$PI_NPM_INSTALL_MIN_AGE_ARG" "$PI_PACKAGE" >&2 + printf 'or uninstall it first with:\n\n' >&2 + printf ' sudo npm uninstall -g %s\n\n' "$PI_PACKAGE" >&2 + printf 'Then run this installer again.\n' >&2 +} + +path_is_writable_or_creatable() { + check_path="$1" + while [ ! -e "$check_path" ]; do + parent=${check_path%/*} + if [ -z "$parent" ] || [ "$parent" = "$check_path" ]; then + return 1 + fi + check_path="$parent" + done + + [ -d "$check_path" ] && [ -w "$check_path" ] +} + +pi_install_bin_dir() { + if pi_managed_install_enabled; then + printf '%s' "$PI_MANAGED_BIN_DIR" + elif [ -n "${PI_NPM_INSTALL_PREFIX:-}" ]; then + printf '%s/bin' "$PI_NPM_INSTALL_PREFIX" + else + npm_prefix=$(npm_global_prefix) + if [ -n "$npm_prefix" ]; then + printf '%s/bin' "$npm_prefix" + fi + fi +} + +pi_installed_path() { + pi_bin_dir=$(pi_install_bin_dir) + if [ -n "$pi_bin_dir" ]; then + printf '%s/%s' "$pi_bin_dir" "$PI_CMD" + fi +} + +installed_pi_is_first_on_path() { + installed_pi_path=$(pi_installed_path) + [ -n "$installed_pi_path" ] || return 1 + + active_pi_path=$(command -v "$PI_CMD" 2>/dev/null) || return 1 + [ "$active_pi_path" = "$installed_pi_path" ] +} + +shell_config_file() { + current_shell=$(basename "${SHELL:-sh}") + case "$current_shell" in + fish) printf '%s/.config/fish/config.fish' "$HOME" ;; + zsh) printf '%s/.zshrc' "${ZDOTDIR:-$HOME}" ;; + bash) + if [ -f "$HOME/.bashrc" ]; then + printf '%s/.bashrc' "$HOME" + else + printf '%s/.profile' "$HOME" + fi + ;; + *) printf '%s/.profile' "$HOME" ;; + esac +} + +path_update_command() { + bin_dir="$1" + current_shell=$(basename "${SHELL:-sh}") + if [ "$bin_dir" = "$HOME/.local/bin" ]; then + bin_expr='$HOME/.local/bin' + else + bin_expr="$bin_dir" + fi + + case "$current_shell" in + fish) printf 'fish_add_path "%s"' "$bin_expr" ;; + *) printf 'export PATH="%s:$PATH"' "$bin_expr" ;; + esac +} + +config_file_mentions_path() { + config_file="$1" + command="$2" + + [ -f "$config_file" ] || return 1 + grep -Fxq "$command" "$config_file" +} + +prompt_add_path_to_profile() { + bin_dir="$1" + if ! ( : <>/dev/tty ) 2>/dev/null; then + return 1 + fi + + config_file=$(shell_config_file) + command=$(path_update_command "$bin_dir") + + if config_file_mentions_path "$config_file" "$command"; then + printf 'A PATH update for %s already exists in %s.\n' "$bin_dir" "$config_file" + return 0 + fi + + exec 3<>/dev/tty + printf 'Add %s to your PATH in %s now? [Y/n] ' "$bin_dir" "$config_file" >&3 + if ! IFS= read -r answer <&3; then + answer= + fi + exec 3>&- + case "$answer" in + n|N|no|NO) return 1 ;; + *) ;; + esac + + mkdir -p "${config_file%/*}" + touch "$config_file" + printf '\n# Pi\n%s\n' "$command" >> "$config_file" + printf 'Added %s to %s.\n' "$bin_dir" "$config_file" +} + +print_pi_not_on_path_message() { + pi_bin_dir=$(pi_install_bin_dir) + active_pi_path=$(command -v "$PI_CMD" 2>/dev/null || true) + + printf 'Pi was installed, but your shell is not using that install yet.\n' + if [ -n "$active_pi_path" ]; then + printf 'Your shell currently resolves pi to: %s\n' "$active_pi_path" + fi + + if [ -n "$pi_bin_dir" ]; then + prompt_add_path_to_profile "$pi_bin_dir" || true + command=$(path_update_command "$pi_bin_dir") + printf 'Restart your shell or run:\n\n' + printf ' %s\n\n' "$command" + printf 'Then run: pi\n' + else + printf "Check npm's global prefix with:\n\n" + printf ' npm prefix -g\n\n' + printf 'Then add its bin directory to your shell PATH.\n' + fi +} + +choose_pi_action() { + existing_pi_path="$1" + + if ! ( : <>/dev/tty ) 2>/dev/null; then + print_pi_action_menu "$existing_pi_path" + printf 'No terminal detected; continuing without confirmation.\n' + if [ -n "$existing_pi_path" ]; then + PI_INSTALL_ACTION=reinstall + else + PI_INSTALL_ACTION=install + fi + print_pi_action_selection "$PI_INSTALL_ACTION" + return 0 + fi + + exec 3<>/dev/tty + trap 'exec 3>&-; trap - INT TERM; exit 130' INT TERM + print_pi_action_menu "$existing_pi_path" >&3 + + while :; do + key=$(read_tty_key) + + case "$key" in + ""|" "|"$PI_CR") + if [ -n "$existing_pi_path" ]; then + PI_INSTALL_ACTION=reinstall + else + PI_INSTALL_ACTION=install + fi + break + ;; + y|Y) + if [ -n "$existing_pi_path" ]; then + PI_INSTALL_ACTION=reinstall + else + PI_INSTALL_ACTION=install + fi + break + ;; + u|U) + if [ -n "$existing_pi_path" ]; then + PI_INSTALL_ACTION=uninstall + break + fi + ;; + "$PI_ETX") + exit 130 + ;; + n|N|"$PI_ESC") + PI_INSTALL_ACTION=none + break + ;; + esac + + printf 'Please choose one of the listed keys.\n' >&3 + done + + print_pi_action_selection "$PI_INSTALL_ACTION" >&3 + exec 3>&- + trap - INT TERM +} + +print_pi_action_menu() { + existing_pi_path="$1" + + reset= + dim= + bold= + cyan= + green= + red= + if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then + reset="${PI_ESC}[0m" + dim="${PI_ESC}[2m" + bold="${PI_ESC}[1m" + cyan="${PI_ESC}[36m" + green="${PI_ESC}[32m" + red="${PI_ESC}[31m" + fi + + if [ -n "$existing_pi_path" ]; then + printf '%sPi is already installed at:%s\n\n' "$bold" "$reset" + printf ' %s\n\n' "$existing_pi_path" + fi + + if [ -n "${PI_NPM_INSTALL_PREFIX:-}" ]; then + printf "npm's global directory is not writable; Pi will be installed under %s.\n\n" "$PI_NPM_INSTALL_PREFIX" + fi + + if pi_managed_install_enabled; then + if [ -n "$existing_pi_path" ]; then + printf '%sReinstallation:%s\n\n ' "$bold" "$reset" + else + printf '%sInstallation:%s\n\n ' "$bold" "$reset" + fi + elif [ -n "$existing_pi_path" ]; then + printf '%sReinstall command:%s\n\n ' "$bold" "$reset" + else + printf '%sInstall command:%s\n\n ' "$bold" "$reset" + fi + print_pi_install_command + printf '\n\n' + + printf '%sChoose an action:%s\n\n' "$bold" "$reset" + if [ -n "$existing_pi_path" ]; then + printf ' %s%-4s%s %sReinstall Pi%s %s(default)%s\n' "$cyan" 'y' "$reset" "$green" "$reset" "$dim" "$reset" + printf ' %s%-4s%s %sUninstall Pi%s\n' "$cyan" 'u' "$reset" "$red" "$reset" + else + printf ' %s%-4s%s %sInstall Pi%s %s(default)%s\n' "$cyan" 'y' "$reset" "$green" "$reset" "$dim" "$reset" + fi + printf ' %s%-4s%s %sDo nothing%s\n' "$cyan" 'n' "$reset" "$dim" "$reset" +} + +print_pi_action_selection() { + case "$1" in + install) message="Will install Pi." ;; + reinstall) message="Will reinstall Pi." ;; + uninstall) message="Will uninstall Pi." ;; + none) message="Chose to do nothing. Exiting." ;; + esac + printf '\n%s\n\n' "$message" +} + +restore_tty_state() { + tty_state="$1" + [ -n "$tty_state" ] || return 0 + stty "$tty_state" < /dev/tty 2>/dev/null || true +} + +read_tty_key() { + old_tty_state=$(stty -g < /dev/tty 2>/dev/null || true) + trap 'restore_tty_state "$old_tty_state"; trap - INT TERM; exit 130' INT TERM + stty -icanon -echo min 1 time 0 < /dev/tty 2>/dev/null || true + if ! key=$(dd bs=1 count=1 2>/dev/null < /dev/tty); then + key= + fi + restore_tty_state "$old_tty_state" + trap - INT TERM + printf '%s' "$key" +} + +print_pi_install_command() { + if pi_managed_install_enabled; then + printf 'Using experimental self managed installation\n Pi will install to %s/%s' "$PI_MANAGED_BIN_DIR" "$PI_CMD" + elif [ -n "${PI_NPM_INSTALL_PREFIX:-}" ]; then + printf 'npm install -g --ignore-scripts %s --prefix %s %s' "$PI_NPM_INSTALL_MIN_AGE_ARG" "$PI_NPM_INSTALL_PREFIX" "$PI_PACKAGE" + else + printf 'npm install -g --ignore-scripts %s %s' "$PI_NPM_INSTALL_MIN_AGE_ARG" "$PI_PACKAGE" + fi +} + +pi_managed_install_enabled() { + [ "${PI_EXPERIMENTAL:-}" = 1 ] +} + +ensure_managed_install_supported() { + case "$(uname -s)" in + Darwin|Linux) return 0 ;; + *) + printf 'Experimental managed Pi installs currently support macOS and Linux only.\n' >&2 + return 1 + ;; + esac +} + +managed_install_marker_is_valid() { + managed_root="$1" + marker_path="$managed_root/$PI_MANAGED_INSTALL_MARKER" + [ -f "$marker_path" ] || return 1 + + node - "$marker_path" <<'NODE' >/dev/null 2>&1 +const fs = require("node:fs"); +const marker = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if ( + marker.kind !== "pi-managed-install" || + marker.schemaVersion !== 1 || + marker.layout !== "releases-v1" +) { + process.exit(1); +} +NODE +} + +managed_install_root_for_command() { + managed_command_path="$1" + [ -n "$managed_command_path" ] || return 1 + [ "${managed_command_path##*/}" = "$PI_CMD" ] || return 1 + + managed_candidate=${managed_command_path%/*} + if managed_install_marker_is_valid "$managed_candidate"; then + printf '%s' "$managed_candidate" + return 0 + fi + + if ! managed_resolved_command=$(node - "$managed_command_path" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +let resolved = path.resolve(process.argv[2]); +while (fs.lstatSync(resolved).isSymbolicLink()) { + const target = fs.readlinkSync(resolved); + resolved = path.resolve(path.dirname(resolved), target); +} +console.log(resolved); +NODE + ); then + return 1 + fi + [ "${managed_resolved_command##*/}" = "$PI_CMD" ] || return 1 + managed_resolved_bin=${managed_resolved_command%/*} + [ "${managed_resolved_bin##*/}" = bin ] || return 1 + managed_candidate=${managed_resolved_bin%/*}/install + if managed_install_marker_is_valid "$managed_candidate"; then + printf '%s' "$managed_candidate" + return 0 + fi + return 1 +} + +select_managed_install_dir() { + existing_pi_path="$1" + + if [ -n "${PI_MANAGED_INSTALL_ROOT:-}" ]; then + managed_candidate=${PI_MANAGED_INSTALL_ROOT%/} + if managed_install_marker_is_valid "$managed_candidate"; then + printf '%s' "$managed_candidate" + return 0 + fi + fi + + if managed_candidate=$(managed_install_root_for_command "$existing_pi_path"); then + printf '%s' "$managed_candidate" + return 0 + fi + + managed_agent_dir=${PI_CODING_AGENT_DIR:-$HOME/.pi/agent} + printf '%s/install' "${managed_agent_dir%/}" +} + +select_managed_path_bin_dir() { + managed_agent_dir="$1" + managed_root="$2" + existing_pi_path="$3" + + if [ -n "$existing_pi_path" ] && managed_existing_root=$(managed_install_root_for_command "$existing_pi_path") && [ "$managed_existing_root" = "$managed_root" ]; then + managed_existing_bin=${existing_pi_path%/*} + if [ "$managed_existing_bin" != "$managed_root" ]; then + printf '%s' "$managed_existing_bin" + return 0 + fi + fi + + managed_path_ifs=${IFS- } + IFS=: + for managed_path_dir in ${PATH:-}; do + IFS=$managed_path_ifs + managed_path_dir=${managed_path_dir%/} + case "$managed_path_dir" in + "$managed_agent_dir/bin"|"$HOME/.local/bin"|"$HOME/bin"|"$HOME/.bin"|"$HOME/local/bin") + if path_is_writable_or_creatable "$managed_path_dir"; then + printf '%s' "$managed_path_dir" + return 0 + fi + ;; + esac + IFS=: + done + IFS=$managed_path_ifs + + printf '%s/bin' "$managed_agent_dir" +} + +install_pi_package() { + if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then + install_pi_package_with_progress + else + printf 'Installing Pi...\n\n' + run_pi_install error + fi +} + +run_pi_install() { + npm_loglevel="$1" + if pi_managed_install_enabled; then + run_managed_install_pi "$npm_loglevel" + else + run_npm_install_pi "$npm_loglevel" + fi +} + +run_npm_install_pi() { + npm_loglevel="$1" + if [ -n "${PI_NPM_INSTALL_PREFIX:-}" ]; then + npm install -g --ignore-scripts "$PI_NPM_INSTALL_MIN_AGE_ARG" --prefix "$PI_NPM_INSTALL_PREFIX" --no-fund --no-audit "--loglevel=$npm_loglevel" --progress=false "$PI_PACKAGE" + else + npm install -g --ignore-scripts "$PI_NPM_INSTALL_MIN_AGE_ARG" --no-fund --no-audit "--loglevel=$npm_loglevel" --progress=false "$PI_PACKAGE" + fi +} + +download_installer_artifact() { + url="$1" + output="$2" + label="$3" + + if ! command -v curl >/dev/null 2>&1; then + printf 'curl is not available for the managed installer.\n' >&2 + return 1 + fi + + http_status=$(curl -L -sS -w '%{http_code}' -o "$output" "$url") || { + rm -f "$output" + printf 'Could not download %s from %s.\n' "$label" "$url" >&2 + return 1 + } + + if [ "$http_status" = 200 ]; then + return 0 + fi + + rm -f "$output" + printf 'Managed installer %s is unavailable at %s (HTTP %s).\n' "$label" "$url" "$http_status" >&2 + return 1 +} + +managed_install_release_version() { + metadata_path="$1" + + node - "$metadata_path" <<'NODE' +const fs = require("node:fs"); +const metadata = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const version = metadata.version; + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version ?? "")) { + throw new Error("managed installer metadata has an invalid version"); +} +console.log(version); +NODE +} + +download_managed_install_artifacts() { + managed_stage_dir="$1" + + printf 'Downloading managed installer release metadata\n' >&2 + download_installer_artifact "$PI_INSTALLER_API_BASE/latest" "$managed_stage_dir/metadata.json" "release metadata" || return 1 + + if ! managed_version=$(managed_install_release_version "$managed_stage_dir/metadata.json"); then + printf 'Managed installer release metadata is invalid.\n' >&2 + return 1 + fi + + printf 'Downloading managed installer package.json for Pi %s\n' "$managed_version" >&2 + download_installer_artifact "$PI_INSTALLER_API_BASE/$managed_version/package.json" "$managed_stage_dir/package.json" "package.json" || return 1 + + printf 'Downloading managed installer package-lock.json for Pi %s\n' "$managed_version" >&2 + download_installer_artifact "$PI_INSTALLER_API_BASE/$managed_version/package-lock.json" "$managed_stage_dir/package-lock.json" "package-lock.json" || return 1 + + validate_managed_install_artifacts "$managed_stage_dir/package.json" "$managed_stage_dir/package-lock.json" "$managed_version" +} + +validate_managed_install_artifacts() { + package_json_path="$1" + package_lock_path="$2" + managed_version="$3" + + node - "$package_json_path" "$package_lock_path" "$PI_PACKAGE" "$managed_version" <<'NODE' +const fs = require("node:fs"); +const packageJson = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const packageLock = JSON.parse(fs.readFileSync(process.argv[3], "utf8")); +const piPackage = process.argv[4]; +const version = process.argv[5]; +const root = packageLock.packages?.[""]; +const piEntry = packageLock.packages?.[`node_modules/${piPackage}`]; + +if (packageJson.version !== version || packageJson.dependencies?.[piPackage] !== version) { + throw new Error(`managed installer package.json must describe ${piPackage}@${version}`); +} +if (packageLock.lockfileVersion !== 3) { + throw new Error("managed installer package-lock.json must use lockfileVersion 3"); +} +if (packageLock.version !== version || root?.version !== version || root?.dependencies?.[piPackage] !== version) { + throw new Error(`managed installer package-lock.json root must describe ${piPackage}@${version}`); +} +if (piEntry?.version !== version) { + throw new Error(`managed installer package-lock.json does not include ${piPackage}@${version}`); +} +NODE +} + +write_managed_install_marker() { + managed_root="$1" + managed_entrypoint_type="$2" + managed_entrypoint_path="$3" + marker_tmp="$managed_root/$PI_MANAGED_INSTALL_MARKER.tmp.$$" + + node - "$marker_tmp" "$managed_entrypoint_type" "$managed_entrypoint_path" <<'NODE' +const fs = require("node:fs"); +const markerPath = process.argv[2]; +const entrypointType = process.argv[3]; +const entrypointPath = process.argv[4]; +fs.writeFileSync( + markerPath, + `${JSON.stringify( + { + kind: "pi-managed-install", + schemaVersion: 1, + layout: "releases-v1", + entrypoint: { type: entrypointType, path: entrypointPath }, + }, + null, + 2, + )}\n`, +); +NODE + mv -f "$marker_tmp" "$managed_root/$PI_MANAGED_INSTALL_MARKER" +} + +managed_install_entrypoint_path() { + managed_root="$1" + + node - "$managed_root/$PI_MANAGED_INSTALL_MARKER" <<'NODE' +const fs = require("node:fs"); +const marker = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if (typeof marker.entrypoint?.path !== "string" || !marker.entrypoint.path) process.exit(1); +console.log(marker.entrypoint.path); +NODE +} + +write_managed_install_launcher() { + managed_agent_dir="$1" + managed_launcher_dir="$managed_agent_dir/bin" + managed_launcher="$managed_launcher_dir/$PI_CMD" + launcher_tmp="$managed_launcher.tmp.$$" + + if [ -e "$managed_launcher" ]; then + [ -f "$managed_launcher" ] && [ -x "$managed_launcher" ] + return $? + fi + + mkdir -p "$managed_launcher_dir" + cat >"$launcher_tmp" <<'EOF' +#!/bin/sh +case "$0" in + */*) pi_launcher="$0" ;; + *) pi_launcher=$(command -v "$0") || exit 127 ;; +esac +while [ -L "$pi_launcher" ]; do + pi_link=$(readlink "$pi_launcher") || exit 1 + case "$pi_link" in + /*) pi_launcher="$pi_link" ;; + *) pi_launcher=${pi_launcher%/*}/$pi_link ;; + esac +done +pi_bin_dir=${pi_launcher%/*} +pi_agent_dir=${pi_bin_dir%/*} +pi_current_file=$pi_agent_dir/install/current-version +if ! IFS= read -r pi_current_version < "$pi_current_file"; then + printf 'Could not read managed Pi version from %s.\n' "$pi_current_file" >&2 + exit 1 +fi +case "$pi_current_version" in + ""|.|..|*[!0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz._+-]*) + printf 'Managed Pi version file is invalid: %s\n' "$pi_current_file" >&2 + exit 1 + ;; +esac +pi_release_dir=$pi_agent_dir/install/releases/$pi_current_version +pi_release_bin=$pi_release_dir/node_modules/.bin/pi +if [ ! -x "$pi_release_bin" ]; then + printf 'Managed Pi executable is missing: %s\n' "$pi_release_bin" >&2 + exit 1 +fi +PI_MANAGED_INSTALL_ROOT=$pi_agent_dir/install +export PI_MANAGED_INSTALL_ROOT +exec "$pi_release_bin" "$@" +EOF + chmod 755 "$launcher_tmp" + mv -f "$launcher_tmp" "$managed_launcher" +} + +write_managed_install_link() { + managed_agent_dir="$1" + managed_bin_dir="$2" + managed_launcher="$managed_agent_dir/bin/$PI_CMD" + managed_entrypoint="$managed_bin_dir/$PI_CMD" + + if [ "$managed_entrypoint" = "$managed_launcher" ]; then + return 0 + fi + mkdir -p "$managed_bin_dir" + if [ -e "$managed_entrypoint" ] && [ ! -L "$managed_entrypoint" ]; then + printf 'Refusing to replace the executable at %s.\n' "$managed_entrypoint" >&2 + return 1 + fi + + managed_link_target=$(node - "$managed_bin_dir" "$managed_launcher" <<'NODE' +const path = require("node:path"); +console.log(path.relative(process.argv[2], process.argv[3])); +NODE + ) + managed_link_tmp="$managed_bin_dir/.$PI_CMD.tmp.$$" + rm -f "$managed_link_tmp" + ln -s "$managed_link_target" "$managed_link_tmp" + mv -f "$managed_link_tmp" "$managed_entrypoint" +} + +write_managed_current_version() { + managed_root="$1" + managed_version="$2" + current_tmp="$managed_root/current-version.tmp.$$" + + printf '%s\n' "$managed_version" >"$current_tmp" + mv -f "$current_tmp" "$managed_root/current-version" +} + +run_managed_install_pi() { + npm_loglevel="$1" + managed_root="$PI_MANAGED_INSTALL_DIR" + + if [ -n "${PI_EXISTING_PATH:-}" ]; then + if ! managed_existing_root=$(managed_install_root_for_command "$PI_EXISTING_PATH") || [ "$managed_existing_root" != "$managed_root" ]; then + printf 'Experimental managed install refused to replace Pi at %s. Uninstall it first.\n' "$PI_EXISTING_PATH" >&2 + return 1 + fi + fi + if [ -e "$managed_root/$PI_MANAGED_INSTALL_MARKER" ] && ! managed_install_marker_is_valid "$managed_root"; then + printf 'The managed install marker at %s is invalid.\n' "$managed_root/$PI_MANAGED_INSTALL_MARKER" >&2 + return 1 + fi + managed_launcher="$PI_MANAGED_AGENT_DIR/bin/$PI_CMD" + if { [ -e "$managed_launcher" ] || [ -L "$managed_launcher" ]; } && ! managed_install_marker_is_valid "$managed_root"; then + printf 'Refusing to replace the unrecognized executable at %s.\n' "$managed_launcher" >&2 + return 1 + fi + + mkdir -p "$managed_root/staging" "$managed_root/releases" + managed_stage_dir="$managed_root/staging/install-$$-$(date +%s)" + rm -rf "$managed_stage_dir" + mkdir -p "$managed_stage_dir" + + if ! download_managed_install_artifacts "$managed_stage_dir"; then + rm -rf "$managed_stage_dir" + return 1 + fi + + printf 'Installing managed Pi dependencies\n' >&2 + if (cd "$managed_stage_dir" && npm ci --ignore-scripts "$PI_NPM_INSTALL_MIN_AGE_ARG" --omit=dev --include=optional --no-fund --no-audit "--loglevel=$npm_loglevel" --progress=false); then + managed_status=0 + else + managed_status=$? + fi + if [ "$managed_status" -ne 0 ]; then + rm -rf "$managed_stage_dir" + return "$managed_status" + fi + + managed_staged_bin="$managed_stage_dir/node_modules/.bin/$PI_CMD" + if [ ! -x "$managed_staged_bin" ]; then + printf 'Managed Pi executable was not created by npm ci.\n' >&2 + rm -rf "$managed_stage_dir" + return 1 + fi + + printf 'Verifying managed Pi %s\n' "$managed_version" >&2 + if managed_installed_version=$("$managed_staged_bin" --version); then + managed_status=0 + else + managed_status=$? + fi + if [ "$managed_status" -ne 0 ]; then + rm -rf "$managed_stage_dir" + return "$managed_status" + fi + if [ "$managed_installed_version" != "$managed_version" ]; then + printf 'Managed Pi smoke test returned version %s; expected %s.\n' "$managed_installed_version" "$managed_version" >&2 + rm -rf "$managed_stage_dir" + return 1 + fi + + managed_release_dir="$managed_root/releases/$managed_version" + printf 'Activating managed Pi %s\n' "$managed_version" >&2 + if [ -d "$managed_release_dir" ]; then + rm -rf "$managed_stage_dir" + elif ! mv "$managed_stage_dir" "$managed_release_dir"; then + rm -rf "$managed_stage_dir" + return 1 + fi + + if [ "$PI_MANAGED_BIN_DIR/$PI_CMD" = "$PI_MANAGED_AGENT_DIR/bin/$PI_CMD" ]; then + managed_entrypoint_type=script + else + managed_entrypoint_type=symlink + fi + write_managed_install_marker "$managed_root" "$managed_entrypoint_type" "$PI_MANAGED_BIN_DIR/$PI_CMD" + write_managed_install_launcher "$PI_MANAGED_AGENT_DIR" + write_managed_install_link "$PI_MANAGED_AGENT_DIR" "$PI_MANAGED_BIN_DIR" + write_managed_current_version "$managed_root" "$managed_version" + hash -r + printf 'Managed Pi install complete\n' >&2 +} + +uninstall_pi_package() { + if managed_uninstall_root=$(managed_install_root_for_command "$PI_EXISTING_PATH"); then + printf 'Uninstalling managed Pi...\n\n' + managed_uninstall_agent=${managed_uninstall_root%/*} + managed_uninstall_launcher="$managed_uninstall_agent/bin/$PI_CMD" + managed_uninstall_entrypoint=$(managed_install_entrypoint_path "$managed_uninstall_root" 2>/dev/null || true) + if [ -n "$managed_uninstall_entrypoint" ] && [ "$managed_uninstall_entrypoint" != "$managed_uninstall_launcher" ] && [ -L "$managed_uninstall_entrypoint" ]; then + rm -f "$managed_uninstall_entrypoint" + fi + if [ "$PI_EXISTING_PATH" != "$managed_uninstall_launcher" ] && [ -L "$PI_EXISTING_PATH" ]; then + rm -f "$PI_EXISTING_PATH" + fi + rm -f "$managed_uninstall_launcher" + rm -rf "$managed_uninstall_root" + hash -r + if [ -e "$PI_EXISTING_PATH" ] || [ -L "$PI_EXISTING_PATH" ]; then + printf '\nManaged uninstall finished, but pi is still present at:\n\n %s\n' "$PI_EXISTING_PATH" >&2 + return 1 + fi + return 0 + fi + + if ! npm_package_is_installed_for_uninstall; then + printf 'I found pi at:\n\n %s\n\n' "$PI_EXISTING_PATH" >&2 + printf 'but npm does not show %s installed there.\n' "$PI_PACKAGE" >&2 + printf 'Nothing was removed.\n' >&2 + return 1 + fi + + printf 'Uninstalling Pi...\n\n' + run_npm_uninstall_pi error + hash -r + + if [ -e "$PI_EXISTING_PATH" ] || [ -L "$PI_EXISTING_PATH" ]; then + printf '\nnpm uninstall finished, but pi is still present at:\n\n %s\n' "$PI_EXISTING_PATH" >&2 + return 1 + fi +} + +npm_package_is_installed_for_uninstall() { + if [ -n "${PI_NPM_UNINSTALL_PREFIX:-}" ]; then + npm ls -g --prefix "$PI_NPM_UNINSTALL_PREFIX" --depth=0 "$PI_PACKAGE" >/dev/null 2>&1 + else + npm ls -g --depth=0 "$PI_PACKAGE" >/dev/null 2>&1 + fi +} + +run_npm_uninstall_pi() { + npm_loglevel="$1" + if [ -n "${PI_NPM_UNINSTALL_PREFIX:-}" ]; then + npm uninstall -g --prefix "$PI_NPM_UNINSTALL_PREFIX" --no-fund --no-audit "--loglevel=$npm_loglevel" --progress=false "$PI_PACKAGE" + else + npm uninstall -g --no-fund --no-audit "--loglevel=$npm_loglevel" --progress=false "$PI_PACKAGE" + fi +} + +install_pi_package_with_progress() { + log_file="${TMPDIR:-/tmp}/pi-installer-npm.$$" + rm -f "$log_file" + : >"$log_file" + + run_pi_install verbose >"$log_file" 2>&1 & + npm_pid=$! + + printf '\033[?25l' + animate_npm_install "$log_file" & + progress_pid=$! + trap 'kill "$npm_pid" 2>/dev/null || true; finish_install_progress "$progress_pid"; exit 130' INT TERM + + if wait "$npm_pid"; then + status=0 + else + status=$? + fi + + finish_install_progress "$progress_pid" + trap - INT TERM + + if [ "$status" -ne 0 ]; then + printf '\033[31mInstallation failed.\033[0m\n\n' + cat "$log_file" + rm -f "$log_file" + return "$status" + fi + + rm -f "$log_file" + if terminal_supports_unicode; then + printf ' \033[32m✓\033[0m install complete\n' + else + printf ' \033[32mok\033[0m install complete\n' + fi +} + +finish_install_progress() { + progress_pid="$1" + + kill "$progress_pid" 2>/dev/null || true + wait "$progress_pid" 2>/dev/null || true + printf '\r\033[K\033[?25h' +} + +terminal_supports_unicode() { + locale="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}" + + case "$locale" in + *UTF-8*|*utf-8*|*UTF8*|*utf8*) return 0 ;; + esac + + case "${TERM_PROGRAM:-}" in + Apple_Terminal|iTerm.app|vscode|WezTerm) return 0 ;; + esac + + return 1 +} + +spinner_frame() { + frame_step="$1" + frame_count="$2" + + if [ "$frame_count" -eq 10 ]; then + case $((frame_step % 10)) in + 0) printf '⠋' ;; + 1) printf '⠙' ;; + 2) printf '⠹' ;; + 3) printf '⠸' ;; + 4) printf '⠼' ;; + 5) printf '⠴' ;; + 6) printf '⠦' ;; + 7) printf '⠧' ;; + 8) printf '⠇' ;; + *) printf '⠏' ;; + esac + else + case $((frame_step % 4)) in + 0) printf '-' ;; + 1) printf '\\' ;; + 2) printf '|' ;; + *) printf '/' ;; + esac + fi +} + +animate_npm_install() { + log_file="$1" + + if terminal_supports_unicode; then + full="█" + empty="░" + frame_count=10 + else + full="#" + empty="-" + frame_count=4 + fi + + step=0 + if pi_managed_install_enabled; then + label="starting managed install" + else + label="starting npm install" + fi + while :; do + frame=$(spinner_frame "$step" "$frame_count") + if [ $((step % 5)) -eq 0 ]; then + label=$(npm_install_progress_label "$log_file" "$label") + fi + draw_install_progress "$step" "$frame" "$label" "$full" "$empty" + step=$((step + 1)) + sleep 0.08 + done +} + +animate_node_install() { + log_file="$1" + method_label="$2" + + if terminal_supports_unicode; then + full="█" + empty="░" + frame_count=10 + else + full="#" + empty="-" + frame_count=4 + fi + + step=0 + label="starting ${method_label} install" + while :; do + frame=$(spinner_frame "$step" "$frame_count") + if [ $((step % 5)) -eq 0 ]; then + label=$(node_install_progress_label "$log_file" "$label") + fi + draw_install_progress "$step" "$frame" "$label" "$full" "$empty" "Installing Node.js" + step=$((step + 1)) + sleep 0.08 + done +} + +node_install_progress_label() { + log_file="$1" + label="$2" + + while IFS= read -r line; do + line=${line##*"$PI_CR"} + case "$line" in + "") ;; + Resolving\ Node.js*) label="resolving Node.js binary" ;; + Downloading\ Node.js*) label="$line" ;; + Verifying\ Node.js*) label="verifying download" ;; + Installing\ xz-utils*) label="installing xz-utils" ;; + Extracting\ Node.js*) label="extracting Node.js" ;; + Node.js\ installed*) label="Node.js installed" ;; + Hit:*|Get:*|Ign:*) label="updating package lists" ;; + Reading\ package\ lists*) label="reading package lists" ;; + Building\ dependency\ tree*) label="resolving dependencies" ;; + The\ following\ NEW\ packages*) label="installing dependencies" ;; + Need\ to\ get*|Fetched\ *) label="$line" ;; + Selecting\ previously\ unselected\ package*) label="selecting packages" ;; + Preparing\ to\ unpack*) label="preparing packages" ;; + Unpacking\ *|Setting\ up\ *) label="$line" ;; + fetch\ *) label="fetching packages" ;; + *Installing\ nodejs*) label="$line" ;; + OK:\ *) label="$line" ;; + ==\>\ Downloading*) label="downloading packages" ;; + ==\>\ Installing*|==\>\ Upgrading*) label="$line" ;; + ==\>\ Pouring*) label="installing package" ;; + *already\ installed*) label="$line" ;; + esac + done < "$log_file" + + if [ "${#label}" -gt 64 ]; then + label=$(printf '%.61s...' "$label") + fi + printf '%s' "$label" +} + +npm_install_progress_label() { + log_file="$1" + label="$2" + metadata_cache_count=0 + metadata_fetch_count=0 + tarball_cache_count=0 + tarball_fetch_count=0 + + while IFS= read -r line; do + line=${line%"$PI_CR"} + case "$line" in + Downloading\ managed\ installer\ release\ metadata*) + label="resolving managed release" + ;; + Downloading\ managed\ installer\ package.json*) + label="fetching managed package manifest" + ;; + Downloading\ managed\ installer\ package-lock.json*) + label="fetching managed package lock" + ;; + Installing\ managed\ Pi\ dependencies*) + label="installing managed dependencies" + ;; + Verifying\ managed\ Pi*) + label="verifying managed package" + ;; + Activating\ managed\ Pi*) + label="activating managed package" + ;; + Managed\ Pi\ install\ complete*) + label="managed install complete" + ;; + npm\ verbose\ title\ npm\ install*|npm\ verbose\ title\ npm\ ci*) + label="resolving packages" + ;; + npm\ http\ fetch\ GET\ *https://registry.npmjs.org/*.tgz*) + tarball_fetch_count=$((tarball_fetch_count + 1)) + label="fetching tarballs (${tarball_fetch_count})" + ;; + npm\ http\ cache\ *@https://registry.npmjs.org/*.tgz*) + tarball_cache_count=$((tarball_cache_count + 1)) + if [ "$tarball_fetch_count" -gt 0 ]; then + label="fetching tarballs (${tarball_fetch_count})" + else + label="checking tarballs (${tarball_cache_count})" + fi + ;; + npm\ http\ fetch\ GET\ *https://registry.npmjs.org/*) + metadata_fetch_count=$((metadata_fetch_count + 1)) + label="fetching package metadata (${metadata_fetch_count})" + ;; + npm\ http\ cache\ https://registry.npmjs.org/*) + metadata_cache_count=$((metadata_cache_count + 1)) + if [ "$metadata_fetch_count" -gt 0 ]; then + label="fetching package metadata (${metadata_fetch_count})" + else + label="checking cached metadata (${metadata_cache_count})" + fi + ;; + npm\ info\ run\ *) + rest=${line#npm info run } + package=${rest%% *} + rest=${rest#* } + script=${rest%% *} + package=${package%@*} + case "$line" in + *\{\ code:*) label="finished ${script} for ${package}" ;; + *) label="running ${script} for ${package}" ;; + esac + ;; + changed\ *|added\ *|removed\ *|updated\ *|up\ to\ date\ *) + label="$line" + ;; + esac + done < "$log_file" + + printf '%s' "$label" +} + +draw_install_progress() { + step="$1"; frame="$2"; label="$3"; full="$4"; empty="$5"; title="${6:-Installing Pi}" + + reset="${PI_ESC}[0m" + dim="${PI_ESC}[2m" + cyan="${PI_ESC}[36m" + red="${PI_ESC}[31m" + green="${PI_ESC}[32m" + orange="${PI_ESC}[33m" + bold="${PI_ESC}[1m" + + width=28 + trail=8 + head=$((step % (width + trail))) + bar="" + + i=0 + while [ "$i" -lt "$width" ]; do + age=$((head - i)) + if [ "$age" -ge 0 ] && [ "$age" -lt "$trail" ]; then + case "$age" in + 0|1) cell="${green}${full}${reset}" ;; + 2|3) cell="${cyan}${full}${reset}" ;; + 4|5) cell="${red}${full}${reset}" ;; + *) cell="${orange}${full}${reset}" ;; + esac + else + cell="${dim}${empty}${reset}" + fi + bar="${bar}${cell}" + i=$((i + 1)) + done + + printf '\r\033[K %s%s%s %s %s%s%s %s' "$orange" "$frame" "$reset" "$bar" "$bold" "$title" "$reset" "$label" +} + +pi_logo_animation() { + if [ ! -t 1 ] || [ "${TERM:-}" = "dumb" ]; then + print_static_logo + return + fi + + esc="${PI_ESC}[" + reset="${PI_ESC}[0m" + hide="${esc}?25l" + show="${esc}?25h" + clear="${esc}H" + + trap 'printf "%s%s\n" "$reset" "$show"; trap - INT TERM; exit 130' INT TERM + printf '%s%s' "$hide" "${esc}2J${esc}H" + + for y in 0 1 2 3; do draw_logo_frame "$clear" "$reset" 0 left 2 "$y" 0 0; sleep 0.075; done + for y in 0 1 2; do draw_logo_frame "$clear" "$reset" 1 top 2 "$y" 0 0; sleep 0.075; done + for y in 0 1 2 3 4; do draw_logo_frame "$clear" "$reset" 2 right 5 "$y" 0 0; sleep 0.075; done + + draw_logo_frame "$clear" "$reset" 3 none 0 0 0 0; sleep 0.25 + draw_logo_frame "$clear" "$reset" 3 none 0 0 1 0; sleep 0.08 + draw_logo_frame "$clear" "$reset" 3 none 0 0 0 0; sleep 0.08 + draw_logo_frame "$clear" "$reset" 3 none 0 0 1 0; sleep 0.08 + draw_logo_frame "$clear" "$reset" 4 none 0 0 0 0; sleep 0.10 + draw_logo_frame "$clear" "$reset" 5 none 0 0 0 0; sleep 0.45 + draw_logo_frame "$clear" "$reset" 5 none 0 0 0 1; sleep 0.12 + draw_logo_frame "$clear" "$reset" 5 none 0 0 0 0; sleep 0.12 + draw_logo_frame "$clear" "$reset" 5 none 0 0 0 1; sleep 0.45 + + printf '%s%s\n' "$reset" "$show" + trap - INT TERM +} + +draw_logo_frame() { + clear="$1"; reset="$2"; phase="$3"; active="$4"; ax="$5"; ay="$6"; flash="$7"; white="$8" + + left=0 + top=0 + + panel_cell="${reset} " + cyan_cell="${PI_ESC}[36m██" + red_cell="${PI_ESC}[31m██" + green_cell="${PI_ESC}[32m██" + orange_cell="${PI_ESC}[33m██" + white_cell="${PI_ESC}[39m██" + flash_cell="${PI_ESC}[33m██" + + pad=$(repeat_space "$left") + clear_cell="$panel_cell" + frame="$clear" + i=0 + while [ "$i" -lt "$top" ]; do frame="${frame}\n"; i=$((i + 1)); done + + for y in 0 1 2 3 4 5 6 7 8; do + frame="${frame}${pad}" + for x in 1 2 3 4 5 6 7 8; do + set_logo_cell_color "$phase" "$active" "$ax" "$ay" "$flash" "$white" "$y" "$x" + case "$LOGO_COLOR" in + cyan) cell="$cyan_cell" ;; + red) cell="$red_cell" ;; + green) cell="$green_cell" ;; + orange) cell="$orange_cell" ;; + white) cell="$white_cell" ;; + flash) cell="$flash_cell" ;; + *) cell="$clear_cell" ;; + esac + frame="${frame}${cell}" + done + frame="${frame}${reset}\n" + done + printf '%b' "$frame" 2>/dev/null || true +} + +set_logo_cell_color() { + phase="$1"; active="$2"; ax="$3"; ay="$4"; flash="$5"; white="$6"; y="$7"; x="$8" + + if [ "$white" = 1 ]; then + if in_cells "$y" "$x" "3,2 3,3 3,4 4,2 4,4 5,2 5,3 5,5 6,2 6,5"; then LOGO_COLOR=white; else LOGO_COLOR=panel; fi + return + fi + if [ "$flash" = 1 ] && [ "$y" = 6 ] && [ "$x" -ge 1 ] && [ "$x" -le 6 ]; then LOGO_COLOR=flash; return; fi + + case "$active" in + left) if in_piece "$y" "$x" "$ay" "$ax" "0,0 1,0 1,1 2,0"; then LOGO_COLOR=red; return; fi ;; + top) if in_piece "$y" "$x" "$ay" "$ax" "0,0 0,1 0,2 1,2"; then LOGO_COLOR=cyan; return; fi ;; + right) if in_piece "$y" "$x" "$ay" "$ax" "0,0 1,0 2,0 2,1"; then LOGO_COLOR=green; return; fi ;; + esac + + if [ "$phase" = 4 ]; then + if in_cells "$y" "$x" "2,2 2,3 2,4 3,4"; then LOGO_COLOR=cyan; return; fi + if in_cells "$y" "$x" "3,2 4,2 4,3 5,2"; then LOGO_COLOR=red; return; fi + if in_cells "$y" "$x" "4,5 5,5"; then LOGO_COLOR=green; return; fi + LOGO_COLOR=panel; return + fi + + if [ "$phase" -ge 5 ]; then + if in_cells "$y" "$x" "3,2 3,3 3,4 4,4"; then LOGO_COLOR=cyan; return; fi + if in_cells "$y" "$x" "4,2 5,2 5,3 6,2"; then LOGO_COLOR=red; return; fi + if in_cells "$y" "$x" "5,5 6,5"; then LOGO_COLOR=green; return; fi + LOGO_COLOR=panel; return + fi + + if [ "$phase" -le 3 ] && in_cells "$y" "$x" "6,1 6,2 6,3 6,4"; then LOGO_COLOR=orange; return; fi + if [ "$phase" -ge 2 ] && in_cells "$y" "$x" "2,2 2,3 2,4 3,4"; then LOGO_COLOR=cyan; return; fi + if [ "$phase" -ge 1 ] && in_cells "$y" "$x" "3,2 4,2 4,3 5,2"; then LOGO_COLOR=red; return; fi + if [ "$phase" -ge 3 ] && in_cells "$y" "$x" "4,5 5,5 6,5 6,6"; then LOGO_COLOR=green; return; fi + + LOGO_COLOR=panel +} + +in_piece() { + y="$1"; x="$2"; py="$3"; px="$4"; cells="$5" + for item in $cells; do + dy=${item%,*}; dx=${item#*,} + [ "$y" -eq $((py + dy)) ] && [ "$x" -eq $((px + dx)) ] && return 0 + done + return 1 +} + +in_cells() { + y="$1"; x="$2"; shift 2 + for item in $1; do + [ "$item" = "$y,$x" ] && return 0 + done + return 1 +} + +repeat_space() { + count="$1"; out="" + while [ "$count" -gt 0 ]; do out=" $out"; count=$((count - 1)); done + printf '%s' "$out" +} + +print_static_logo() { + cat <<'EOF' + + ██████ + ██ ██ + ████ ██ + ██ ██ + +EOF +} + +pi_installer_main "$@" diff --git a/internal/agent/doc.go b/internal/agent/doc.go new file mode 100644 index 0000000..f82bfc4 --- /dev/null +++ b/internal/agent/doc.go @@ -0,0 +1,5 @@ +// Package agent contains file-backed Agent execution state and local media +// asset lifecycle. Network session/authentication implementations are added +// only against the published internal Proto contract; no JSON substitute is +// used here. +package agent diff --git a/internal/agent/events.go b/internal/agent/events.go new file mode 100644 index 0000000..2e4ea66 --- /dev/null +++ b/internal/agent/events.go @@ -0,0 +1,72 @@ +package agent + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +// EventWriter keeps Agent-produced realtime facts in the approved event +// vocabulary before they are handed to Dispatcher/MQ. Transcript text is +// written to the Agent spool as an archive as well as returned for realtime +// publication; the archive is not a substitute for transcript.updated. +type EventWriter struct { + TenantID string + TenantKey string + TraceID string +} + +func (w EventWriter) TranscriptUpdated(now time.Time, eventID, callID, turnID, segmentID, role, text string, revision int64, final bool, startMS, endMS int64) ([]byte, error) { + if eventID == "" || callID == "" || turnID == "" || segmentID == "" || role == "" { + return nil, errors.New("transcript event identity is required") + } + if revision < 1 || startMS < 0 || endMS < startMS { + return nil, errors.New("transcript timing or revision is invalid") + } + return (contract.EventBuilder{ + TenantID: w.TenantID, TenantKey: w.TenantKey, TraceID: w.TraceID, + EventType: "transcript.updated", Aggregate: "transcript_segment", AggregateID: segmentID, Version: revision, + Payload: map[string]any{ + "call_id": callID, "turn_id": turnID, "segment_id": segmentID, + "role": role, "revision": revision, "text": text, "is_final": final, + "start_ms": startMS, "end_ms": endMS, "playback_state": "not_applicable", + }, + }).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"` + } + if err := json.Unmarshal(event, &envelope); err != nil { + return fmt.Errorf("decode event envelope: %w", err) + } + if envelope.EventType != "transcript.updated" { + return fmt.Errorf("Agent transcript archive accepts transcript.updated only, got %q", envelope.EventType) + } + if err := contract.ValidateEvent(event); err != nil { + return err + } + return s.AppendTranscript(executionID, event) +} diff --git a/internal/agent/events_test.go b/internal/agent/events_test.go new file mode 100644 index 0000000..5a60d0d --- /dev/null +++ b/internal/agent/events_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +func TestEventWriterBuildsApprovedRealtimeTranscript(t *testing.T) { + writer := EventWriter{TenantID: "tenant-1", TenantKey: "tenant-demo-key", TraceID: "trace-1"} + event, err := writer.TranscriptUpdated(time.Unix(100, 0), "event-1", "call-1", "turn-1", "segment-1", "customer", "您好", 1, true, 0, 600) + if err != nil { + t.Fatal(err) + } + if err := contract.ValidateEvent(event); err != nil { + t.Fatal(err) + } + if string(event) == "" || strings.Contains(string(event), "call.transcript") { + t.Fatal("invalid transcript alias or empty event") + } + + spool, err := NewSpool(t.TempDir(), time.Now) + if err != nil { + t.Fatal(err) + } + if _, err := spool.Start("execution-1", 1, "session-1"); err != nil { + t.Fatal(err) + } + if err := spool.AppendApprovedEvent("execution-1", event); err != nil { + t.Fatal(err) + } +} + +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") + } + spool, err := NewSpool(t.TempDir(), time.Now) + if err != nil { + t.Fatal(err) + } + if _, err := spool.Start("execution-1", 1, "session-1"); err != nil { + t.Fatal(err) + } + if err := spool.AppendApprovedEvent("execution-1", []byte(`{"event_type":"call.transcript"}`)); err == nil { + t.Fatal("expected invalid realtime event name to be rejected") + } +} diff --git a/internal/agent/spool.go b/internal/agent/spool.go new file mode 100644 index 0000000..4ef7994 --- /dev/null +++ b/internal/agent/spool.go @@ -0,0 +1,275 @@ +// Package agent owns the Agent's file-backed execution and asset recovery +// state. It deliberately has no business database and never proxies audio to +// Dispatcher. +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type State struct { + SchemaVersion string `json:"schema_version"` + ExecutionID string `json:"execution_id"` + TaskRevision int64 `json:"task_revision"` + SessionID string `json:"session_id,omitempty"` + Status string `json:"status"` + Unknown bool `json:"unknown"` + Reason string `json:"reason,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Spool struct { + root string + now func() time.Time + mu sync.Mutex +} + +func NewSpool(root string, now func() time.Time) (*Spool, error) { + if strings.TrimSpace(root) == "" { + return nil, errors.New("spool root is required") + } + if now == nil { + now = time.Now + } + if err := os.MkdirAll(root, 0o700); err != nil { + return nil, fmt.Errorf("create spool root: %w", err) + } + return &Spool{root: root, now: now}, nil +} + +func (s *Spool) Root() string { return s.root } + +func (s *Spool) Start(executionID string, revision int64, sessionID string) (State, error) { + if err := validateName(executionID); err != nil { + return State{}, err + } + if revision < 1 { + return State{}, errors.New("task revision must be positive") + } + state := State{SchemaVersion: "1", ExecutionID: executionID, TaskRevision: revision, SessionID: sessionID, Status: "reserved", UpdatedAt: s.now().UTC()} + s.mu.Lock() + defer s.mu.Unlock() + path := s.statePath(executionID) + if _, err := os.Stat(path); err == nil { + return State{}, fmt.Errorf("execution state already exists: %s", executionID) + } else if !errors.Is(err, os.ErrNotExist) { + return State{}, err + } + for _, dir := range []string{s.executionDir(executionID), filepath.Join(s.executionDir(executionID), "transcript"), filepath.Join(s.executionDir(executionID), "assets")} { + if err := os.MkdirAll(dir, 0o700); err != nil { + return State{}, err + } + } + if err := writeJSONAtomic(path, state); err != nil { + return State{}, err + } + return state, nil +} + +func (s *Spool) Load(executionID string) (State, error) { + if err := validateName(executionID); err != nil { + return State{}, err + } + data, err := os.ReadFile(s.statePath(executionID)) + if err != nil { + return State{}, err + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("decode execution state: %w", err) + } + return state, nil +} + +func (s *Spool) Update(executionID, status, reason string) (State, error) { + if err := validateName(executionID); err != nil { + return State{}, err + } + if status == "" { + return State{}, errors.New("state status is required") + } + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.statePath(executionID)) + if err != nil { + return State{}, err + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + return State{}, fmt.Errorf("decode execution state: %w", err) + } + state.Status, state.Reason, state.UpdatedAt = status, reason, s.now().UTC() + state.Unknown = status == "unknown" + if err := writeJSONAtomic(s.statePath(executionID), state); err != nil { + return State{}, err + } + return state, nil +} + +// MarkUnknownOnBoot converts all in-flight local state to explicit unknown. +// It never deletes or releases a remote reservation; Dispatcher reconciliation +// must decide whether a recovered execution may proceed. +func (s *Spool) MarkUnknownOnBoot() (RecoveryReport, error) { + s.mu.Lock() + defer s.mu.Unlock() + entries, err := os.ReadDir(s.root) + if err != nil { + return RecoveryReport{}, err + } + var report RecoveryReport + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + executionID := entry.Name() + path := s.statePath(executionID) + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return report, err + } + var state State + if err := json.Unmarshal(data, &state); err != nil { + quarantine := path + ".corrupt-" + s.now().UTC().Format("20060102T150405.000000000Z") + if renameErr := os.Rename(path, quarantine); renameErr != nil { + return report, fmt.Errorf("quarantine corrupt state: %w (decode: %v)", renameErr, err) + } + report.Quarantined = append(report.Quarantined, executionID) + continue + } + if state.Status != "running" && state.Status != "reserved" && state.Status != "starting" && state.Status != "draining" { + continue + } + state.Status, state.Unknown, state.Reason, state.UpdatedAt = "unknown", true, "agent_boot_recovery", s.now().UTC() + if err := writeJSONAtomic(path, state); err != nil { + return report, err + } + report.Unknown = append(report.Unknown, executionID) + } + return report, nil +} + +type RecoveryReport struct { + Unknown []string + Quarantined []string +} + +func (s *Spool) AppendTranscript(executionID string, event []byte) error { + if err := validateName(executionID); err != nil { + return err + } + if len(event) == 0 { + return errors.New("transcript event is empty") + } + if !json.Valid(event) { + return errors.New("transcript event must be valid JSON") + } + path := filepath.Join(s.executionDir(executionID), "transcript", "events.jsonl") + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer file.Close() + if _, err := file.Write(append(event, '\n')); err != nil { + return err + } + return file.Sync() +} + +func (s *Spool) WriteAsset(executionID, assetID string, r io.Reader) (string, int64, string, error) { + if err := validateName(executionID); err != nil { + return "", 0, "", err + } + if err := validateName(assetID); err != nil { + return "", 0, "", err + } + if r == nil { + return "", 0, "", errors.New("asset reader is required") + } + dir := filepath.Join(s.executionDir(executionID), "assets") + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", 0, "", err + } + part := filepath.Join(dir, assetID+".part") + final := filepath.Join(dir, assetID) + file, err := os.OpenFile(part, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", 0, "", err + } + hash := sha256.New() + n, copyErr := io.Copy(io.MultiWriter(file, hash), r) + syncErr := file.Sync() + closeErr := file.Close() + if copyErr != nil || syncErr != nil || closeErr != nil { + _ = os.Remove(part) + return "", n, "", firstError(copyErr, syncErr, closeErr) + } + if err := os.Rename(part, final); err != nil { + _ = os.Remove(part) + return "", n, "", err + } + return final, n, hex.EncodeToString(hash.Sum(nil)), nil +} + +func (s *Spool) executionDir(executionID string) string { return filepath.Join(s.root, executionID) } +func (s *Spool) statePath(executionID string) string { + return filepath.Join(s.executionDir(executionID), "state.json") +} + +func writeJSONAtomic(path string, value any) error { + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil { + return err + } + file, err := os.OpenFile(tmp, os.O_RDWR, 0o600) + if err != nil { + _ = os.Remove(tmp) + return err + } + if err := file.Sync(); err != nil { + _ = file.Close() + _ = os.Remove(tmp) + return err + } + if err := file.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} + +func validateName(name string) error { + if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\\`) || strings.Contains(name, "..") || strings.TrimSpace(name) != name { + return fmt.Errorf("unsafe file name %q", name) + } + return nil +} + +func firstError(errs ...error) error { + for _, err := range errs { + if err != nil { + return err + } + } + return nil +} diff --git a/internal/agent/spool_test.go b/internal/agent/spool_test.go new file mode 100644 index 0000000..7e9b489 --- /dev/null +++ b/internal/agent/spool_test.go @@ -0,0 +1,84 @@ +package agent + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" +) + +func testSpool(t *testing.T) *Spool { + t.Helper() + now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) + s, err := NewSpool(t.TempDir(), func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestSpoolAtomicStateAndBootUnknown(t *testing.T) { + s := testSpool(t) + if _, err := s.Start("exec-1", 1, "session-1"); err != nil { + t.Fatal(err) + } + if _, err := s.Update("exec-1", "running", ""); err != nil { + t.Fatal(err) + } + report, err := s.MarkUnknownOnBoot() + if err != nil { + t.Fatal(err) + } + if len(report.Unknown) != 1 || report.Unknown[0] != "exec-1" { + t.Fatalf("recovery report = %+v", report) + } + state, err := s.Load("exec-1") + if err != nil { + t.Fatal(err) + } + if state.Status != "unknown" || !state.Unknown { + t.Fatalf("state = %+v", state) + } +} + +func TestSpoolQuarantinesCorruptStateAndNeverDeletesIt(t *testing.T) { + s := testSpool(t) + if err := os.MkdirAll(filepath.Join(s.Root(), "broken"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(s.Root(), "broken", "state.json"), []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + report, err := s.MarkUnknownOnBoot() + if err != nil { + t.Fatal(err) + } + if len(report.Quarantined) != 1 || len(report.Unknown) != 0 { + t.Fatalf("recovery report = %+v", report) + } + matches, err := filepath.Glob(filepath.Join(s.Root(), "broken", "state.json.corrupt-*")) + if err != nil || len(matches) != 1 { + t.Fatalf("quarantine files = %v, err=%v", matches, err) + } +} + +func TestSpoolTranscriptAndAssetAreDurableFiles(t *testing.T) { + s := testSpool(t) + if _, err := s.Start("exec-2", 1, "session-2"); err != nil { + t.Fatal(err) + } + if err := s.AppendTranscript("exec-2", []byte(`{"text":"hello","final":true}`)); err != nil { + t.Fatal(err) + } + path, n, hash, err := s.WriteAsset("exec-2", "recording.pcm", bytes.NewReader([]byte("pcm"))) + if err != nil { + t.Fatal(err) + } + if n != 3 || hash == "" || path == "" { + t.Fatalf("asset result path=%q bytes=%d hash=%q", path, n, hash) + } + if _, err := os.Stat(filepath.Join(s.Root(), "exec-2", "assets", "recording.pcm.part")); !os.IsNotExist(err) { + t.Fatalf("temporary asset still exists: %v", err) + } +} diff --git a/internal/agent/upload.go b/internal/agent/upload.go new file mode 100644 index 0000000..246b005 --- /dev/null +++ b/internal/agent/upload.go @@ -0,0 +1,154 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +// 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. +type UploadClient struct { + HTTPClient *http.Client + AllowedHosts map[string]struct{} + AllowInsecureHTTP bool + MaxResponseBodySize int64 + Now func() time.Time +} + +var ErrUploadGrantExpired = errors.New("upload grant is expired") + +type UploadResult struct { + StatusCode int + SizeBytes int64 + SHA256 string + ETag string +} + +func (c UploadClient) UploadFile(ctx context.Context, grant *agentv1.UploadGrant, path string) (result UploadResult, err error) { + if grant == nil { + return UploadResult{}, errors.New("upload grant is required") + } + if grant.TargetUrl == "" || grant.UploadId == "" || grant.ObjectKey == "" { + return UploadResult{}, errors.New("upload URL, ID and object key are required") + } + if grant.ExpiresAtUnixMs <= 0 { + return UploadResult{}, errors.New("upload grant expiry is required") + } + now := time.Now + if c.Now != nil { + now = c.Now + } + if !now().Before(time.UnixMilli(grant.ExpiresAtUnixMs)) { + return UploadResult{}, ErrUploadGrantExpired + } + parsed, err := url.Parse(grant.TargetUrl) + if err != nil || parsed.Host == "" { + return UploadResult{}, errors.New("upload URL is invalid") + } + if parsed.Scheme != "https" && !(c.AllowInsecureHTTP && parsed.Scheme == "http") { + return UploadResult{}, errors.New("upload URL must use HTTPS") + } + if len(c.AllowedHosts) > 0 { + if _, ok := c.AllowedHosts[strings.ToLower(parsed.Host)]; !ok { + return UploadResult{}, fmt.Errorf("upload host %q is not allowed", parsed.Host) + } + } + if err := ctx.Err(); err != nil { + return UploadResult{}, err + } + file, err := os.Open(path) + if err != nil { + return UploadResult{}, err + } + stat, err := file.Stat() + if err != nil { + _ = file.Close() + return UploadResult{}, err + } + if stat.IsDir() { + _ = file.Close() + return UploadResult{}, errors.New("upload path is a directory") + } + if grant.MaxBytes > 0 && stat.Size() > grant.MaxBytes { + _ = file.Close() + return UploadResult{}, fmt.Errorf("asset exceeds grant limit: %d > %d", stat.Size(), grant.MaxBytes) + } + 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 { + 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) + if err != nil { + return UploadResult{}, err + } + req.ContentLength = stat.Size() + for _, header := range grant.Headers { + if strings.EqualFold(header.Name, "host") || strings.EqualFold(header.Name, "content-length") { + return UploadResult{}, errors.New("upload grant contains a forbidden header") + } + req.Header.Set(header.Name, header.Value) + } + client := c.HTTPClient + if client == nil { + client = &http.Client{} + } + copyClient := *client + copyClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + resp, err := copyClient.Do(req) + if err != nil { + return UploadResult{}, err + } + defer resp.Body.Close() + maxResponse := c.MaxResponseBodySize + if maxResponse <= 0 { + maxResponse = 64 << 10 + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponse)) + return UploadResult{}, fmt.Errorf("upload returned HTTP %d", resp.StatusCode) + } + _, _ = 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 +} + +func digestFile(file *os.File) (string, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", err + } + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} diff --git a/internal/agent/upload_test.go b/internal/agent/upload_test.go new file mode 100644 index 0000000..b797ccb --- /dev/null +++ b/internal/agent/upload_test.go @@ -0,0 +1,87 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" +) + +func TestUploadClientUsesGrantAndVerifiesChecksum(t *testing.T) { + body := []byte("mock recording bytes") + digest := sha256.Sum256(body) + var received []byte + var receivedHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeader = r.Header.Get("x-upload-token") + received, _ = io.ReadAll(r.Body) + w.Header().Set("ETag", "etag-1") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + assetPath := filepath.Join(t.TempDir(), "recording.bin") + if err := os.WriteFile(assetPath, body, 0o600); err != nil { + t.Fatal(err) + } + grant := &agentv1.UploadGrant{UploadId: "upload-1", TargetUrl: server.URL, ObjectKey: "recording-1", ExpiresAtUnixMs: time.Unix(101, 0).UnixMilli(), Headers: []*agentv1.Header{{Name: "x-upload-token", Value: "mock-token"}}, RequiredChecksumSha256: hex.EncodeToString(digest[:]), MaxBytes: int64(len(body))} + result, err := (UploadClient{AllowInsecureHTTP: true, Now: func() time.Time { return time.Unix(100, 0) }, AllowedHosts: map[string]struct{}{strings.TrimPrefix(server.URL, "http://"): {}}}).UploadFile(context.Background(), grant, assetPath) + if err != nil { + t.Fatal(err) + } + if result.StatusCode != http.StatusOK || result.SHA256 != grant.RequiredChecksumSha256 || result.ETag != "etag-1" || string(received) != string(body) || receivedHeader != "mock-token" { + t.Fatalf("unexpected upload result: %+v body=%q header=%q", result, received, receivedHeader) + } + if retained, statErr := os.Stat(assetPath); statErr != nil || retained.Size() != int64(len(body)) { + t.Fatalf("source asset was not retained after upload: stat=%v info=%v", statErr, retained) + } +} + +func TestUploadClientFailsClosedForGrantMismatchAndHTTP(t *testing.T) { + assetPath := filepath.Join(t.TempDir(), "recording.bin") + if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil { + t.Fatal(err) + } + grant := &agentv1.UploadGrant{UploadId: "upload-2", TargetUrl: "http://127.0.0.1:1/upload", ObjectKey: "recording-2", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), RequiredChecksumSha256: strings.Repeat("a", 64), MaxBytes: 1024} + if _, err := (UploadClient{AllowInsecureHTTP: true}).UploadFile(context.Background(), grant, assetPath); err == nil { + t.Fatal("expected checksum mismatch") + } + grant.RequiredChecksumSha256 = "" + if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil { + t.Fatal("expected HTTP upload URL rejection") + } +} + +func TestUploadClientEnforcesSizeAndHost(t *testing.T) { + assetPath := filepath.Join(t.TempDir(), "recording.bin") + if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil { + t.Fatal(err) + } + grant := &agentv1.UploadGrant{UploadId: "upload-3", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-3", ExpiresAtUnixMs: time.Now().Add(time.Hour).UnixMilli(), MaxBytes: 1} + if _, err := (UploadClient{}).UploadFile(context.Background(), grant, assetPath); err == nil { + t.Fatal("expected size rejection") + } + grant.MaxBytes = 1024 + if _, err := (UploadClient{AllowedHosts: map[string]struct{}{"other.example.invalid": {}}}).UploadFile(context.Background(), grant, assetPath); err == nil { + t.Fatal("expected host rejection") + } +} + +func TestUploadClientRejectsExpiredGrant(t *testing.T) { + assetPath := filepath.Join(t.TempDir(), "recording.bin") + if err := os.WriteFile(assetPath, []byte("bytes"), 0o600); err != nil { + t.Fatal(err) + } + grant := &agentv1.UploadGrant{UploadId: "upload-expired", TargetUrl: "https://oss.example.invalid/upload", ObjectKey: "recording-expired", ExpiresAtUnixMs: time.Unix(100, 0).UnixMilli(), MaxBytes: 1024} + if _, err := (UploadClient{Now: func() time.Time { return time.Unix(100, 0) }}).UploadFile(context.Background(), grant, assetPath); err == nil { + t.Fatal("expected expired grant rejection") + } +} diff --git a/internal/ai/authorization.go b/internal/ai/authorization.go new file mode 100644 index 0000000..3e8093a --- /dev/null +++ b/internal/ai/authorization.go @@ -0,0 +1,69 @@ +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +type Authorization struct { + AuthorizationID string `json:"authorization_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + AgentVersionID string `json:"agent_version_id"` + ConfigSHA256 string `json:"config_sha256"` + Mode Mode `json:"mode"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Source string `json:"source"` + CredentialRefs map[string]string `json:"credential_refs"` + AllowedEgressPoolIDs []string `json:"allowed_egress_pool_ids"` + Revoked bool `json:"revoked"` + RevocationReason string `json:"revocation_reason"` +} + +func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, egressPoolID string, now time.Time) (Authorization, error) { + if err := contract.ValidateSourceSchema("ai-authorization.schema.json", raw); err != nil { + return Authorization{}, err + } + var authorization Authorization + 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") + } + if authorization.AgentVersionID != snapshot.AgentVersionID || authorization.ConfigSHA256 != snapshot.Digest || authorization.Mode != snapshot.Mode { + return Authorization{}, errors.New("AI authorization does not match immutable snapshot") + } + issuedAt, err := time.Parse(time.RFC3339, authorization.IssuedAt) + if err != nil { + return Authorization{}, fmt.Errorf("parse AI authorization issued_at: %w", err) + } + expiresAt, err := time.Parse(time.RFC3339, authorization.ExpiresAt) + if err != nil { + return Authorization{}, fmt.Errorf("parse AI authorization expires_at: %w", err) + } + if !issuedAt.Before(expiresAt) || now.Before(issuedAt) || !now.Before(expiresAt) { + return Authorization{}, errors.New("AI authorization is outside its validity window") + } + if egressPoolID != "" { + allowed := false + for _, value := range authorization.AllowedEgressPoolIDs { + if value == egressPoolID { + allowed = true + break + } + } + if !allowed { + return Authorization{}, errors.New("AI authorization does not allow this egress pool") + } + } + return authorization, nil +} diff --git a/internal/ai/authorization_test.go b/internal/ai/authorization_test.go new file mode 100644 index 0000000..e2461ea --- /dev/null +++ b/internal/ai/authorization_test.go @@ -0,0 +1,58 @@ +package ai + +import ( + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestValidateAuthorizationBindsSnapshotTenantAndEgress(t *testing.T) { + snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(snapshotRaw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + if err != nil { + t.Fatal(err) + } + authorization, err := ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if authorization.AuthorizationID == "" || authorization.Mode != ModeASROnly { + t.Fatalf("unexpected authorization: %+v", authorization) + } +} + +func TestValidateAuthorizationRejectsMismatchAndExpiry(t *testing.T) { + snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(snapshotRaw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + invalid, err := contracts.Read("examples/invalid-ai-authorization-revoked.json") + if err != nil { + t.Fatal(err) + } + 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") + if err != nil { + t.Fatal(err) + } + if _, err := ValidateAuthorization(valid, snapshot, "tenant-other", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { + t.Fatal("expected tenant binding rejection") + } + if _, err := ValidateAuthorization(valid, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 2, 0, 0, time.UTC)); err == nil { + t.Fatal("expected expired authorization rejection") + } +} diff --git a/internal/ai/mock_pipeline.go b/internal/ai/mock_pipeline.go new file mode 100644 index 0000000..a910824 --- /dev/null +++ b/internal/ai/mock_pipeline.go @@ -0,0 +1,124 @@ +package ai + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" +) + +// MockTurnInput is a test-only media boundary. TranscriptHint is explicit test +// input; the mock never claims to have recognized real audio. +type MockTurnInput struct { + Audio []byte + TranscriptHint string +} + +type MockTurnResult struct { + Mode Mode + Transcript string + ResponseText string + Audio []byte + Calls []string +} + +type mockAIConfig struct { + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + ASR struct { + ProviderRef string `json:"provider_ref"` + } `json:"asr"` + LLM struct { + ProviderRef string `json:"provider_ref"` + Model string `json:"model"` + } `json:"llm"` + TTS struct { + ProviderRef string `json:"provider_ref"` + Model string `json:"model"` + } `json:"tts"` +} + +// MockPipeline is a bounded provider adapter for the same callflow used by +// mixed and real modes. It never reaches a provider or accepts production +// credentials; only the adapter changes, not the business sequence. +type MockPipeline struct { + MaxAudioBytes int +} + +func (p MockPipeline) Synthesize(ctx context.Context, snapshot Snapshot, text string) ([]byte, error) { + result, err := p.Run(ctx, snapshot, MockTurnInput{Audio: []byte(text), TranscriptHint: text}) + if err != nil { + return nil, err + } + return result.Audio, nil +} + +func (p MockPipeline) RunTurn(ctx context.Context, snapshot Snapshot, pcm16 []byte) (TurnResult, error) { + result, err := p.Run(ctx, snapshot, MockTurnInput{Audio: pcm16}) + if err != nil { + return TurnResult{}, err + } + return TurnResult{Transcript: result.Transcript, Reply: result.ResponseText, AudioPCM16: result.Audio}, nil +} + +func (p MockPipeline) Run(ctx context.Context, snapshot Snapshot, input MockTurnInput) (MockTurnResult, error) { + if err := ctx.Err(); err != nil { + return MockTurnResult{}, err + } + if p.MaxAudioBytes > 0 && len(input.Audio) > p.MaxAudioBytes { + return MockTurnResult{}, fmt.Errorf("mock audio exceeds limit: %d > %d", len(input.Audio), p.MaxAudioBytes) + } + var config mockAIConfig + if err := json.Unmarshal(snapshot.Raw, &config); err != nil { + return MockTurnResult{}, fmt.Errorf("decode immutable AI snapshot: %w", err) + } + transcript := input.TranscriptHint + if transcript == "" { + digest := sha256.Sum256(input.Audio) + transcript = "mock transcript " + hex.EncodeToString(digest[:4]) + } + result := MockTurnResult{Mode: snapshot.Mode, Transcript: transcript, Calls: []string{"asr"}} + if snapshot.Mode == ModeASROnly { + return result, nil + } + if snapshot.Mode != ModeFullAI { + return MockTurnResult{}, fmt.Errorf("unsupported mock mode %q", snapshot.Mode) + } + if config.LLM.ProviderRef == "" || config.TTS.ProviderRef == "" || config.Prompt.Text == "" { + return MockTurnResult{}, fmt.Errorf("full-AI mock snapshot is missing provider or prompt parameters") + } + if err := ctx.Err(); err != nil { + return MockTurnResult{}, err + } + result.ResponseText = "mock response: " + transcript + result.Calls = append(result.Calls, "llm") + if err := ctx.Err(); err != nil { + return MockTurnResult{}, err + } + result.Audio = mockTonePCM16() + result.Calls = append(result.Calls, "tts") + return result, nil +} + +// mockTonePCM16 is a deterministic, non-silent fixture so the isolated SIP +// callee can advance its scripted conversation after the opening prompt. +func mockTonePCM16() []byte { + const ( + sampleRate = 16000 + samples = sampleRate / 5 + amplitude = int16(6000) + period = 36 + ) + pcm := make([]byte, samples*2) + for i := 0; i < samples; i++ { + value := amplitude + if (i/period)%2 == 1 { + value = -amplitude + } + binary.LittleEndian.PutUint16(pcm[i*2:i*2+2], uint16(value)) + } + return pcm +} diff --git a/internal/ai/mock_pipeline_test.go b/internal/ai/mock_pipeline_test.go new file mode 100644 index 0000000..482adbc --- /dev/null +++ b/internal/ai/mock_pipeline_test.go @@ -0,0 +1,76 @@ +package ai + +import ( + "context" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestMockPipelineASROnlyStopsAfterTranscript(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + result, err := (MockPipeline{MaxAudioBytes: 1024}).Run(context.Background(), snapshot, MockTurnInput{Audio: []byte{1, 2}, TranscriptHint: "客户文本"}) + if err != nil { + t.Fatal(err) + } + if result.Mode != ModeASROnly || result.Transcript != "客户文本" || result.ResponseText != "" || len(result.Audio) != 0 { + t.Fatalf("unexpected ASR-only result: %+v", result) + } + if len(result.Calls) != 1 || result.Calls[0] != "asr" { + t.Fatalf("unexpected provider calls: %v", result.Calls) + } +} + +func TestMockPipelineFullAIUsesAllStages(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-full-explicit.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeFullAI) + if err != nil { + t.Fatal(err) + } + result, err := (MockPipeline{MaxAudioBytes: 1024}).Run(context.Background(), snapshot, MockTurnInput{TranscriptHint: "你好"}) + if err != nil { + t.Fatal(err) + } + if result.ResponseText == "" || len(result.Audio) == 0 || len(result.Calls) != 3 { + t.Fatalf("unexpected full-AI result: %+v", result) + } + var nonSilent bool + for _, sample := range result.Audio { + if sample != 0 { + nonSilent = true + break + } + } + if !nonSilent { + t.Fatal("mock full-AI audio must be non-silent for isolated media tests") + } +} + +func TestMockPipelineHonorsCancellationAndBound(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := (MockPipeline{MaxAudioBytes: 1024}).Run(ctx, snapshot, MockTurnInput{}); err == nil { + t.Fatal("expected cancellation") + } + if _, err := (MockPipeline{MaxAudioBytes: 1}).Run(context.Background(), snapshot, MockTurnInput{Audio: []byte{1, 2}}); err == nil { + t.Fatal("expected bounded audio rejection") + } +} diff --git a/internal/ai/pipeline.go b/internal/ai/pipeline.go new file mode 100644 index 0000000..dea73d3 --- /dev/null +++ b/internal/ai/pipeline.go @@ -0,0 +1,12 @@ +package ai + +import "context" + +const InvalidCallMarker = "[INVALID_CALL]" + +// Pipeline is the single AI turn boundary used by every call mode. The call +// flow does not know whether the implementation is mock, mixed, or real. +type Pipeline interface { + Synthesize(ctx context.Context, snapshot Snapshot, text string) ([]byte, error) + RunTurn(ctx context.Context, snapshot Snapshot, pcm16 []byte) (TurnResult, error) +} diff --git a/internal/ai/provider_pipeline.go b/internal/ai/provider_pipeline.go new file mode 100644 index 0000000..67b2c8c --- /dev/null +++ b/internal/ai/provider_pipeline.go @@ -0,0 +1,442 @@ +package ai + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "git.ipao.vip/rogee/go-sip/internal/audio" + doubaospeech "github.com/GizClaw/doubao-speech-go" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/param" + "github.com/openai/openai-go/v3/shared" +) + +const ( + defaultBailianTTSModel = "qwen3-tts-flash" + defaultBailianTTSVoice = "Cherry" + defaultBailianLLMModel = "qwen-plus" + defaultMaxAudioBytes = 16 << 20 +) + +// ProviderPipelineConfig contains provider credentials and infrastructure endpoints. +// It deliberately contains no business parameters; those come from the immutable +// AI snapshot passed to RunTurn. +type ProviderPipelineConfig struct { + VolcAppID string + VolcAPIKey string + VolcWebsocketURL string + + BailianAPIKey string + BailianBaseURL string + BailianTTSVoice string + HTTPClient *http.Client + MaxAudioBytes int64 +} + +// LoadProviderPipelineConfigFromEnv reads only credential/endpoint variables. Values +// are never returned in errors or logs. +func LoadProviderPipelineConfigFromEnv() (ProviderPipelineConfig, error) { + cfg := ProviderPipelineConfig{ + VolcAppID: os.Getenv("VOLC_ASR_APP_NAME"), + VolcAPIKey: os.Getenv("VOLC_ASR_APP_KEY"), + VolcWebsocketURL: os.Getenv("VOLC_ASR_WSS_URL"), + BailianAPIKey: os.Getenv("BAILIAN_API_KEY"), + BailianBaseURL: os.Getenv("BAILIAN_BASE_URL"), + BailianTTSVoice: os.Getenv("BAILIAN_TTS_VOICE"), + HTTPClient: http.DefaultClient, + MaxAudioBytes: defaultMaxAudioBytes, + } + if cfg.BailianTTSVoice == "" { + cfg.BailianTTSVoice = defaultBailianTTSVoice + } + if cfg.VolcAppID == "" || cfg.VolcAPIKey == "" { + return ProviderPipelineConfig{}, errors.New("VOLC_ASR_APP_NAME and VOLC_ASR_APP_KEY are required") + } + return cfg, nil +} + +// ProviderPipeline performs one bounded AI turn. Full-AI runs ASR -> LLM -> TTS; +// ASR-only runs ASR and returns without invoking LLM or TTS. +type ProviderPipeline struct { + cfg ProviderPipelineConfig + recognizeFn func(context.Context, string, string, []byte) (string, error) +} + +func NewProviderPipeline(cfg ProviderPipelineConfig) (*ProviderPipeline, error) { + if cfg.VolcAppID == "" || cfg.VolcAPIKey == "" { + return nil, errors.New("Volcengine ASR credentials are required") + } + if cfg.BailianTTSVoice == "" { + cfg.BailianTTSVoice = defaultBailianTTSVoice + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = http.DefaultClient + } + if cfg.MaxAudioBytes <= 0 { + cfg.MaxAudioBytes = defaultMaxAudioBytes + } + return &ProviderPipeline{cfg: cfg}, nil +} + +type providerSnapshotConfig struct { + Mode Mode `json:"mode"` + Prompt struct { + Text string `json:"text"` + } `json:"prompt"` + ASR struct { + ProviderRef string `json:"provider_ref"` + Model string `json:"model"` + Language string `json:"language"` + TimeoutMS int `json:"timeout_ms"` + } `json:"asr"` + LLM struct { + ProviderRef string `json:"provider_ref"` + Model string `json:"model"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + TimeoutMS int `json:"timeout_ms"` + } `json:"llm"` + TTS struct { + ProviderRef string `json:"provider_ref"` + Model string `json:"model"` + Voice string `json:"voice"` + Speed float64 `json:"speed"` + Format json.RawMessage `json:"format"` + TimeoutMS int `json:"timeout_ms"` + } `json:"tts"` +} + +// TurnResult contains only bounded facts and audio bytes needed by the caller. +// Callers must persist a hash/length, not the transcript or prompt. +type TurnResult struct { + Transcript string + Reply string + AudioPCM16 []byte + InvalidCall bool + InvalidReason string +} + +// Synthesize turns a bounded reply into signed 16-bit little-endian 16kHz +// mono PCM using the immutable TTS section of the snapshot. +func (p *ProviderPipeline) Synthesize(ctx context.Context, snapshot Snapshot, text string) ([]byte, error) { + if snapshot.Mode == ModeASROnly { + return nil, errors.New("ASR-only mode does not use TTS") + } + if snapshot.Mode != ModeFullAI { + return nil, fmt.Errorf("unsupported AI mode %q", snapshot.Mode) + } + var cfg providerSnapshotConfig + if err := json.Unmarshal(snapshot.Raw, &cfg); err != nil { + return nil, fmt.Errorf("decode immutable AI snapshot: %w", err) + } + if cfg.TTS.ProviderRef == "" { + return nil, errors.New("AI snapshot TTS provider ref is required") + } + if err := p.requireBailian(); err != nil { + return nil, err + } + return p.synthesize(ctx, cfg.TTS.Model, cfg.TTS.Voice, text) +} + +// RunTurn executes one real AI turn from signed 16-bit little-endian 16kHz +// mono PCM. Full-AI continues through LLM/TTS; ASR-only returns after ASR. It +// is intentionally non-streaming at the provider boundary: the Asterisk media +// runtime can bound one utterance, then play returned PCM when present. +func (p *ProviderPipeline) RunTurn(ctx context.Context, snapshot Snapshot, pcm16 []byte) (TurnResult, error) { + if snapshot.Mode != ModeFullAI && snapshot.Mode != ModeASROnly { + return TurnResult{}, fmt.Errorf("unsupported AI mode %q", snapshot.Mode) + } + if len(pcm16) == 0 { + return TurnResult{}, errors.New("input PCM is empty") + } + var cfg providerSnapshotConfig + if err := json.Unmarshal(snapshot.Raw, &cfg); err != nil { + return TurnResult{}, fmt.Errorf("decode immutable AI snapshot: %w", err) + } + if cfg.ASR.ProviderRef == "" { + return TurnResult{}, errors.New("AI snapshot ASR provider ref is required") + } + if snapshot.Mode == ModeFullAI && (cfg.LLM.ProviderRef == "" || cfg.TTS.ProviderRef == "") { + return TurnResult{}, errors.New("full-AI snapshot LLM and TTS provider refs are required") + } + + asrCtx := ctx + if cfg.ASR.TimeoutMS > 0 { + var cancel context.CancelFunc + asrCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.ASR.TimeoutMS)*time.Millisecond) + defer cancel() + } + recognize := p.recognize + if p.recognizeFn != nil { + recognize = p.recognizeFn + } + transcript, err := recognize(asrCtx, cfg.ASR.Model, cfg.ASR.Language, pcm16) + if err != nil { + return TurnResult{}, fmt.Errorf("ASR failed: %w", err) + } + if strings.TrimSpace(transcript) == "" { + return TurnResult{}, errors.New("ASR returned empty transcript") + } + if snapshot.Mode == ModeASROnly { + return TurnResult{Transcript: transcript}, nil + } + if err := p.requireBailian(); err != nil { + return TurnResult{}, err + } + + llmCtx := ctx + if cfg.LLM.TimeoutMS > 0 { + var cancel context.CancelFunc + llmCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.LLM.TimeoutMS)*time.Millisecond) + defer cancel() + } + reply, err := p.complete(llmCtx, cfg.LLM.Model, cfg.LLM.Temperature, cfg.LLM.MaxTokens, cfg.Prompt.Text, transcript) + if err != nil { + return TurnResult{}, fmt.Errorf("LLM failed: %w", err) + } + if strings.TrimSpace(reply) == "" { + return TurnResult{}, errors.New("LLM returned empty reply") + } + + ttsCtx := ctx + if cfg.TTS.TimeoutMS > 0 { + var cancel context.CancelFunc + ttsCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TTS.TimeoutMS)*time.Millisecond) + defer cancel() + } + audio, err := p.synthesize(ttsCtx, cfg.TTS.Model, cfg.TTS.Voice, reply) + if err != nil { + return TurnResult{}, fmt.Errorf("TTS failed: %w", err) + } + return TurnResult{Transcript: transcript, Reply: reply, AudioPCM16: audio}, nil +} + +func (p *ProviderPipeline) requireBailian() error { + if p.cfg.BailianAPIKey == "" || p.cfg.BailianBaseURL == "" { + return errors.New("Bailian credentials and base URL are required for full-AI mode") + } + return nil +} + +func (p *ProviderPipeline) recognize(ctx context.Context, model, language string, pcm16 []byte) (string, error) { + client := doubaospeech.NewClient(p.cfg.VolcAppID, + doubaospeech.WithAPIKey(p.cfg.VolcAPIKey), + doubaospeech.WithWebSocketURL(p.cfg.VolcWebsocketURL), + ) + if p.cfg.VolcWebsocketURL == "" { + client = doubaospeech.NewClient(p.cfg.VolcAppID, doubaospeech.WithAPIKey(p.cfg.VolcAPIKey)) + } + lang := doubaospeech.LanguageZhCN + if language != "" { + lang = doubaospeech.Language(language) + } + request := &doubaospeech.ASRV2RequestConfig{ + ModelName: model, + ResultType: "single", + EnableITN: boolPtr(true), + EnablePunc: boolPtr(true), + EnableNonstream: boolPtr(true), + } + session, err := client.ASRV2.OpenStreamSession(ctx, &doubaospeech.ASRV2Config{ + Format: doubaospeech.FormatPCM, + SampleRate: doubaospeech.SampleRate16000, + Channel: 1, + Bits: 16, + Language: lang, + Request: request, + ResultType: "single", + }) + if err != nil { + return "", err + } + defer session.Close() + if err := session.SendAudio(ctx, pcm16, true); err != nil { + return "", err + } + var transcript string + for result, recvErr := range session.Recv() { + if recvErr != nil { + return "", recvErr + } + if result != nil && result.Text != "" { + transcript = result.Text + } + if result != nil && result.IsFinal { + break + } + } + return transcript, nil +} + +func (p *ProviderPipeline) complete(ctx context.Context, model string, temperature float64, maxTokens int, systemPrompt, transcript string) (string, error) { + if model == "" { + model = defaultBailianLLMModel + } + client := openai.NewClient( + option.WithAPIKey(p.cfg.BailianAPIKey), + option.WithBaseURL(strings.TrimRight(p.cfg.BailianBaseURL, "/")), + option.WithMaxRetries(0), + ) + messages := make([]openai.ChatCompletionMessageParamUnion, 0, 2) + if strings.TrimSpace(systemPrompt) != "" { + messages = append(messages, openai.SystemMessage(systemPrompt)) + } + messages = append(messages, openai.UserMessage(transcript)) + params := openai.ChatCompletionNewParams{ + Model: shared.ChatModel(model), + Messages: messages, + } + if maxTokens > 0 { + params.MaxTokens = param.NewOpt(int64(maxTokens)) + } + if temperature >= 0 { + params.Temperature = param.NewOpt(temperature) + } + result, err := client.Chat.Completions.New(ctx, params) + if err != nil { + return "", err + } + if len(result.Choices) == 0 { + return "", errors.New("LLM response has no choices") + } + return strings.TrimSpace(result.Choices[0].Message.Content), nil +} + +func (p *ProviderPipeline) synthesize(ctx context.Context, model, voice, text string) ([]byte, error) { + if model == "" { + model = defaultBailianTTSModel + } + if strings.TrimSpace(voice) == "" || strings.HasPrefix(voice, "env:") { + voice = p.cfg.BailianTTSVoice + } + if voice == "" { + voice = defaultBailianTTSVoice + } + base := p.cfg.BailianBaseURL + u, err := url.Parse(base) + if err != nil || u.Scheme == "" || u.Host == "" { + return nil, errors.New("invalid BAILIAN_BASE_URL") + } + generationURL := (&url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/api/v1/services/aigc/multimodal-generation/generation"}).String() + body, err := json.Marshal(map[string]any{ + "model": model, + "input": map[string]any{ + "text": text, + "voice": voice, + "language_type": "Chinese", + }, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, generationURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+p.cfg.BailianAPIKey) + req.Header.Set("Content-Type", "application/json") + resp, err := p.cfg.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("TTS generation HTTP %d", resp.StatusCode) + } + var envelope struct { + Output struct { + Audio struct { + URL string `json:"url"` + } `json:"audio"` + } `json:"output"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&envelope); err != nil { + return nil, err + } + if envelope.Output.Audio.URL == "" { + return nil, errors.New("TTS response has no audio URL") + } + audioReq, err := http.NewRequestWithContext(ctx, http.MethodGet, envelope.Output.Audio.URL, nil) + if err != nil { + return nil, err + } + audioResp, err := p.cfg.HTTPClient.Do(audioReq) + if err != nil { + return nil, err + } + defer audioResp.Body.Close() + if audioResp.StatusCode/100 != 2 { + return nil, fmt.Errorf("TTS audio URL HTTP %d", audioResp.StatusCode) + } + wav, err := io.ReadAll(io.LimitReader(audioResp.Body, p.cfg.MaxAudioBytes+1)) + if err != nil { + return nil, err + } + if int64(len(wav)) > p.cfg.MaxAudioBytes { + return nil, errors.New("TTS audio exceeds configured limit") + } + return decodeWAVToPCM16(wav) +} + +func boolPtr(value bool) *bool { return &value } + +func decodeWAVToPCM16(data []byte) ([]byte, error) { + if len(data) < 12 || string(data[:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + return nil, errors.New("TTS audio is not RIFF/WAVE") + } + var format, channels, sampleRate, bits int + var pcm []byte + for pos := 12; pos+8 <= len(data); { + id := string(data[pos : pos+4]) + size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) + pos += 8 + if size < 0 || pos+size > len(data) { + // DashScope's streaming WAV uses a 0x7fffffff placeholder for + // RIFF/data sizes. The response body is authoritative and bounded + // by MaxAudioBytes, so only the data chunk may consume the remainder. + if id != "data" { + return nil, errors.New("invalid WAV chunk size") + } + size = len(data) - pos + } + switch id { + case "fmt ": + if size < 16 { + return nil, errors.New("invalid WAV fmt chunk") + } + format = int(binary.LittleEndian.Uint16(data[pos : pos+2])) + channels = int(binary.LittleEndian.Uint16(data[pos+2 : pos+4])) + sampleRate = int(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) + bits = int(binary.LittleEndian.Uint16(data[pos+14 : pos+16])) + case "data": + pcm = append([]byte(nil), data[pos:pos+size]...) + } + pos += size + if size%2 == 1 { + pos++ + } + } + if format != 1 || channels != 1 || bits != 16 || sampleRate <= 0 || len(pcm) == 0 { + return nil, fmt.Errorf("unsupported WAV format=%d channels=%d rate=%d bits=%d", format, channels, sampleRate, bits) + } + if sampleRate == 16000 { + return pcm, nil + } + return resamplePCM16(pcm, sampleRate, 16000), nil +} + +func resamplePCM16(src []byte, sourceRate, targetRate int) []byte { + return audio.ResamplePCM16(src, sourceRate, targetRate) +} diff --git a/internal/ai/provider_pipeline_live_test.go b/internal/ai/provider_pipeline_live_test.go new file mode 100644 index 0000000..3947638 --- /dev/null +++ b/internal/ai/provider_pipeline_live_test.go @@ -0,0 +1,46 @@ +package ai + +import ( + "context" + "os" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestProviderFullAIChain(t *testing.T) { + if os.Getenv("AGENT_CALL_PROVIDER_SMOKE") != "1" { + t.Skip("set AGENT_CALL_PROVIDER_SMOKE=1 to authorize the provider smoke") + } + cfg, err := LoadProviderPipelineConfigFromEnv() + if err != nil { + t.Fatal(err) + } + pipeline, err := NewProviderPipeline(cfg) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Read("examples/agent-version-full-production-v1.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeFullAI) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + input, err := pipeline.Synthesize(ctx, snapshot, "你好,我想了解单节点服务。") + if err != nil { + t.Fatal(err) + } + result, err := pipeline.RunTurn(ctx, snapshot, input) + if err != nil { + t.Fatal(err) + } + if result.Transcript == "" || result.Reply == "" || len(result.AudioPCM16) == 0 { + t.Fatalf("incomplete provider chain transcript=%d reply=%d audio=%d", len([]rune(result.Transcript)), len([]rune(result.Reply)), len(result.AudioPCM16)) + } + t.Logf("provider full-AI chain passed transcript_chars=%d reply_chars=%d audio_bytes=%d", len([]rune(result.Transcript)), len([]rune(result.Reply)), len(result.AudioPCM16)) +} diff --git a/internal/ai/provider_pipeline_test.go b/internal/ai/provider_pipeline_test.go new file mode 100644 index 0000000..a0e1be8 --- /dev/null +++ b/internal/ai/provider_pipeline_test.go @@ -0,0 +1,54 @@ +package ai + +import ( + "context" + "strings" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestLoadProviderPipelineConfigAllowsASROnlyCredentials(t *testing.T) { + t.Setenv("VOLC_ASR_APP_NAME", "asr-app") + t.Setenv("VOLC_ASR_APP_KEY", "asr-key") + t.Setenv("VOLC_ASR_WSS_URL", "") + t.Setenv("BAILIAN_API_KEY", "") + t.Setenv("BAILIAN_BASE_URL", "") + t.Setenv("BAILIAN_TTS_VOICE", "") + if _, err := LoadProviderPipelineConfigFromEnv(); err != nil { + t.Fatalf("ASR-only provider config should not require Bailian credentials: %v", err) + } +} + +func TestProviderPipelineRunsASROnlyWithoutBailian(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + pipeline, err := NewProviderPipeline(ProviderPipelineConfig{VolcAppID: "asr-app", VolcAPIKey: "asr-key"}) + if err != nil { + t.Fatal(err) + } + pipeline.recognizeFn = func(context.Context, string, string, []byte) (string, error) { + return "recognized without Bailian", nil + } + result, err := pipeline.RunTurn(context.Background(), snapshot, []byte{1, 2}) + if err != nil { + t.Fatal(err) + } + if result.Transcript != "recognized without Bailian" || result.Reply != "" || len(result.AudioPCM16) != 0 { + t.Fatalf("unexpected ASR-only result: %+v", result) + } +} + +func TestProviderPipelineASROnlyDoesNotUseTTS(t *testing.T) { + pipeline := &ProviderPipeline{} + _, err := pipeline.Synthesize(context.Background(), Snapshot{Mode: ModeASROnly}, "opening") + if err == nil || !strings.Contains(err.Error(), "does not use TTS") { + t.Fatalf("Synthesize(ASR-only) error=%v", err) + } +} diff --git a/internal/ai/snapshot.go b/internal/ai/snapshot.go new file mode 100644 index 0000000..9f77f4c --- /dev/null +++ b/internal/ai/snapshot.go @@ -0,0 +1,114 @@ +// Package ai handles immutable Agent-version snapshots. The JSON Schema is +// loaded from the pinned upstream contract bundle; this package does not +// duplicate or loosen that schema. +package ai + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +type Mode string + +const ( + ModeFullAI Mode = "full_ai" + ModeASROnly Mode = "asr_only" +) + +type Snapshot struct { + TenantKey string + AgentVersionID string + Digest string + Raw []byte + Mode Mode +} + +func Validate(raw []byte) (Snapshot, error) { + if err := contract.ValidateSourceSchema("ai-config.schema.json", raw); err != nil { + return Snapshot{}, err + } + var value struct { + AgentVersionID string `json:"agent_version_id"` + Mode Mode `json:"mode"` + } + if err := json.Unmarshal(raw, &value); err != nil { + return Snapshot{}, err + } + mode := value.Mode + if mode == "" { + // Legacy immutable snapshots predate the explicit mode field and are + // full-AI snapshots because llm/prompt/tts are required by that branch. + mode = ModeFullAI + } + if mode != ModeFullAI && mode != ModeASROnly { + return Snapshot{}, fmt.Errorf("unsupported AI mode %q", mode) + } + digest := sha256.Sum256(raw) + return Snapshot{AgentVersionID: value.AgentVersionID, Digest: hex.EncodeToString(digest[:]), Raw: append([]byte(nil), raw...), Mode: mode}, nil +} + +func ValidateForMode(raw []byte, mode Mode) (Snapshot, error) { + if mode != ModeFullAI && mode != ModeASROnly { + return Snapshot{}, fmt.Errorf("unsupported AI mode %q", mode) + } + snapshot, err := Validate(raw) + if err != nil { + return Snapshot{}, err + } + if snapshot.Mode != mode { + return Snapshot{}, fmt.Errorf("AI config mode %q does not match requested mode %q", snapshot.Mode, mode) + } + return snapshot, nil +} + +func EnsureSameVersion(previous, next Snapshot) error { + if previous.AgentVersionID == "" || next.AgentVersionID == "" || previous.AgentVersionID != next.AgentVersionID { + return errors.New("agent version identity changed") + } + if previous.Digest != next.Digest { + return errors.New("immutable agent version content changed") + } + return nil +} + +type Cache struct { + mu sync.RWMutex + items map[string]Snapshot +} + +func NewCache() *Cache { return &Cache{items: make(map[string]Snapshot)} } + +func (c *Cache) Put(tenantKey string, snapshot Snapshot) error { + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return err + } + if snapshot.AgentVersionID == "" || snapshot.Digest == "" { + return errors.New("snapshot identity is required") + } + c.mu.Lock() + defer c.mu.Unlock() + key := tenantKey + "\x00" + snapshot.AgentVersionID + if old, ok := c.items[key]; ok { + if err := EnsureSameVersion(old, snapshot); err != nil { + return err + } + } + c.items[key] = snapshot + return nil +} + +func (c *Cache) Get(tenantKey, versionID string) (Snapshot, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + s, ok := c.items[tenantKey+"\x00"+versionID] + return s, ok +} + +func ContractSource() string { return contracts.SourceCommit } diff --git a/internal/ai/snapshot_test.go b/internal/ai/snapshot_test.go new file mode 100644 index 0000000..962e105 --- /dev/null +++ b/internal/ai/snapshot_test.go @@ -0,0 +1,65 @@ +package ai + +import ( + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestValidateUsesPinnedAIContract(t *testing.T) { + raw, err := contracts.Read("examples/agent-version.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeFullAI) + if err != nil { + t.Fatal(err) + } + if snapshot.AgentVersionID == "" || snapshot.Digest == "" || snapshot.Mode != ModeFullAI { + t.Fatalf("snapshot = %+v", snapshot) + } +} + +func TestValidateASROnlySnapshot(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ValidateForMode(raw, ModeASROnly) + if err != nil { + t.Fatal(err) + } + if snapshot.AgentVersionID == "" || snapshot.Digest == "" || snapshot.Mode != ModeASROnly { + t.Fatalf("snapshot = %+v", snapshot) + } +} + +func TestASROnlyRejectsFullAIFields(t *testing.T) { + raw, err := contracts.Read("examples/invalid-asr-only-with-llm.json") + if err != nil { + t.Fatal(err) + } + if _, err := ValidateForMode(raw, ModeASROnly); err == nil { + t.Fatal("expected ASR-only schema rejection") + } +} + +func TestCacheRejectsChangedImmutableContent(t *testing.T) { + raw, err := contracts.Read("examples/agent-version.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := Validate(raw) + if err != nil { + t.Fatal(err) + } + cache := NewCache() + if err := cache.Put("tenant-demo-key", snapshot); err != nil { + t.Fatal(err) + } + changed := snapshot + changed.Digest = "different" + if err := cache.Put("tenant-demo-key", changed); err == nil { + t.Fatal("expected immutable content rejection") + } +} diff --git a/internal/audio/pcm16.go b/internal/audio/pcm16.go new file mode 100644 index 0000000..77120a2 --- /dev/null +++ b/internal/audio/pcm16.go @@ -0,0 +1,35 @@ +// Package audio contains small, shared PCM16 transformations used by media and AI adapters. +package audio + +import "encoding/binary" + +// ResamplePCM16 linearly resamples mono signed little-endian PCM16. +// It preserves an empty input and returns a copy when the rates match. +func ResamplePCM16(src []byte, sourceRate, targetRate int) []byte { + if sourceRate <= 0 || targetRate <= 0 || len(src) < 2 || sourceRate == targetRate { + return append([]byte(nil), src...) + } + samples := len(src) / 2 + outSamples := samples * targetRate / sourceRate + if outSamples < 1 { + return nil + } + out := make([]byte, outSamples*2) + for i := 0; i < outSamples; i++ { + position := float64(i) * float64(sourceRate) / float64(targetRate) + left := int(position) + if left >= samples { + left = samples - 1 + } + right := left + 1 + if right >= samples { + right = left + } + frac := position - float64(left) + lv := int16(binary.LittleEndian.Uint16(src[left*2 : left*2+2])) + rv := int16(binary.LittleEndian.Uint16(src[right*2 : right*2+2])) + value := int16(float64(lv)*(1-frac) + float64(rv)*frac) + binary.LittleEndian.PutUint16(out[i*2:i*2+2], uint16(value)) + } + return out +} diff --git a/internal/callflow/capture.go b/internal/callflow/capture.go new file mode 100644 index 0000000..0cf2ea5 --- /dev/null +++ b/internal/callflow/capture.go @@ -0,0 +1,111 @@ +package callflow + +import ( + "context" + "encoding/binary" + "errors" + "time" +) + +// CaptureConfig mirrors the real Cell turn boundary: wait for speech, keep +// collecting until bounded duration or end-of-speech silence, then send one +// canonical PCM16 turn to the AI adapter. +type CaptureConfig struct { + FirstSpeechTimeout time.Duration + MaxDuration time.Duration + EndSilence time.Duration + VoiceThreshold int + MaxTurns int +} + +func captureTurn(ctx context.Context, session MediaSession, cfg CaptureConfig) ([]byte, error) { + if cfg.FirstSpeechTimeout <= 0 { + cfg.FirstSpeechTimeout = 5 * time.Second + } + if cfg.MaxDuration <= 0 { + cfg.MaxDuration = cfg.FirstSpeechTimeout + } + if cfg.EndSilence < 0 { + cfg.EndSilence = 0 + } + startedAt := time.Now() + firstDeadline := startedAt.Add(cfg.FirstSpeechTimeout) + maxDeadline := startedAt.Add(cfg.MaxDuration) + started := cfg.VoiceThreshold <= 0 + lastVoice := time.Time{} + var frames []byte + + for { + now := time.Now() + if !started && !now.Before(firstDeadline) { + return nil, errors.New("no speech detected before capture timeout") + } + if !maxDeadline.After(now) { + break + } + readDeadline := maxDeadline + if !started && firstDeadline.Before(readDeadline) { + readDeadline = firstDeadline + } + if started && cfg.EndSilence > 0 && !lastVoice.IsZero() { + silenceDeadline := lastVoice.Add(cfg.EndSilence) + if silenceDeadline.Before(readDeadline) { + readDeadline = silenceDeadline + } + } + readCtx, cancel := context.WithDeadline(ctx, readDeadline) + payload, err := session.ReadPayload(readCtx) + cancel() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) { + break + } + continue + } + if errors.Is(err, context.Canceled) && ctx.Err() == nil { + continue + } + return nil, err + } + if len(payload) == 0 { + continue + } + if cfg.VoiceThreshold > 0 { + if pcm16VoiceLevel(payload) >= cfg.VoiceThreshold { + started = true + lastVoice = time.Now() + } + if started { + frames = append(frames, payload...) + } + } else { + started = true + lastVoice = time.Now() + frames = append(frames, payload...) + } + if started && cfg.EndSilence > 0 && !lastVoice.IsZero() && !time.Now().Before(lastVoice.Add(cfg.EndSilence)) { + break + } + } + if len(frames) == 0 { + return nil, errors.New("captured audio is empty") + } + return frames, nil +} + +func pcm16VoiceLevel(pcm []byte) int { + if len(pcm) < 2 { + return 0 + } + var sum uint64 + count := len(pcm) / 2 + for i := 0; i < count; i++ { + value := int64(int16(binary.LittleEndian.Uint16(pcm[i*2 : i*2+2]))) + if value < 0 { + value = -value + } + sum += uint64(value) + } + return int(sum / uint64(count)) +} diff --git a/internal/callflow/capture_test.go b/internal/callflow/capture_test.go new file mode 100644 index 0000000..487b682 --- /dev/null +++ b/internal/callflow/capture_test.go @@ -0,0 +1,29 @@ +package callflow + +import ( + "context" + "testing" + "time" +) + +func TestCaptureTurnWaitsForSpeechAndStopsOnSilence(t *testing.T) { + silence := make([]byte, 640) + voice := make([]byte, 640) + for i := 0; i < len(voice); i += 2 { + voice[i] = 0xE8 + voice[i+1] = 0x03 // 1000, little-endian PCM16 + } + session := NewMemorySession(append(append(append([]byte{}, silence...), voice...), silence...)) + captured, err := captureTurn(context.Background(), session, CaptureConfig{ + FirstSpeechTimeout: 20 * time.Millisecond, + MaxDuration: 20 * time.Millisecond, + EndSilence: time.Millisecond, + VoiceThreshold: 100, + }) + if err != nil { + t.Fatal(err) + } + if len(captured) < len(voice) || len(captured) >= len(silence)+len(voice)+len(silence) { + t.Fatalf("captured=%d want speech and bounded trailing silence", len(captured)) + } +} diff --git a/internal/callflow/flow.go b/internal/callflow/flow.go new file mode 100644 index 0000000..29e6255 --- /dev/null +++ b/internal/callflow/flow.go @@ -0,0 +1,166 @@ +package callflow + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/media" +) + +// MediaSession is the only transport boundary of the shared call flow. +// Real mode uses Asterisk ExternalMedia/RTP; mock mode uses MemorySession; the +// sequencing is shared while ASR-only deliberately omits opening/reply TTS. +type MediaSession interface { + ReadPayload(context.Context) ([]byte, error) + SendPCM16(context.Context, []byte, int) error + Stats() media.RTPStats +} + +type Result struct { + Turn ai.TurnResult + Turns []ai.TurnResult + Inbound []byte + InboundTurns [][]byte + OutboundTurns [][]byte + RTP media.RTPStats +} + +func Execute(ctx context.Context, session MediaSession, pipeline ai.Pipeline, snapshot ai.Snapshot, opening string, turnWindow time.Duration) (Result, error) { + if turnWindow <= 0 { + turnWindow = 5 * time.Second + } + return ExecuteWithCapture(ctx, session, pipeline, snapshot, opening, CaptureConfig{ + FirstSpeechTimeout: turnWindow, + MaxDuration: turnWindow, + MaxTurns: 1, + }) +} + +func ExecuteWithCapture(ctx context.Context, session MediaSession, pipeline ai.Pipeline, snapshot ai.Snapshot, opening string, capture CaptureConfig) (Result, error) { + if session == nil || pipeline == nil { + return Result{}, errors.New("media session and AI pipeline are required") + } + if snapshot.Mode != ai.ModeFullAI && snapshot.Mode != ai.ModeASROnly { + return Result{}, fmt.Errorf("unsupported callflow AI mode %q", snapshot.Mode) + } + maxTurns := capture.MaxTurns + if maxTurns <= 0 { + maxTurns = 1 + } + result := Result{} + if snapshot.Mode == ai.ModeFullAI { + openingPCM, err := pipeline.Synthesize(ctx, snapshot, opening) + if err != nil { + return Result{}, err + } + result.OutboundTurns = append(result.OutboundTurns, clonePCM(openingPCM)) + if err := session.SendPCM16(ctx, openingPCM, 16000); err != nil { + return result, err + } + } + for turnIndex := 0; turnIndex < maxTurns; turnIndex++ { + inbound, err := captureTurn(ctx, session, capture) + if err != nil { + return result, fmt.Errorf("capturing RTP turn %d after opening/reply prompt: %w", turnIndex+1, err) + } + result.Inbound = inbound + result.InboundTurns = append(result.InboundTurns, clonePCM(inbound)) + if len(inbound) < 3200 { + return result, fmt.Errorf("captured audio turn %d is too short", turnIndex+1) + } + turn, err := pipeline.RunTurn(ctx, snapshot, inbound) + if err != nil { + return result, fmt.Errorf("run AI turn %d: %w", turnIndex+1, err) + } + turn.InvalidCall, turn.InvalidReason = invalidCallReason(turn.Transcript, turn.Reply) + result.Turn = turn + result.Turns = append(result.Turns, turn) + if turn.InvalidCall { + result.RTP = session.Stats() + return result, nil + } + if snapshot.Mode == ai.ModeASROnly { + result.RTP = session.Stats() + continue + } + if err := session.SendPCM16(ctx, turn.AudioPCM16, 16000); err != nil { + return result, fmt.Errorf("send AI reply turn %d: %w", turnIndex+1, err) + } + result.OutboundTurns = append(result.OutboundTurns, clonePCM(turn.AudioPCM16)) + result.RTP = session.Stats() + } + result.RTP = session.Stats() + return result, nil +} + +func clonePCM(pcm []byte) []byte { + return append([]byte(nil), pcm...) +} + +func invalidCallReason(transcript, reply string) (bool, string) { + if strings.Contains(reply, ai.InvalidCallMarker) { + return true, "llm_invalid_call_marker" + } + normalized := strings.NewReplacer(" ", "", " ", "", "。", "", ",", "", ",", "", ".", "").Replace(strings.TrimSpace(transcript)) + for _, marker := range []string{"打错", "不需要", "不用", "没兴趣", "不考虑", "不方便", "别打", "拒绝", "骚扰", "语音信箱", "自动语音", "请按键", "空号"} { + if strings.Contains(normalized, marker) { + return true, "transcript_invalid_intent" + } + } + return false, "" +} + +// MemorySession is a bounded transport adapter for mock/mixed-flow tests. It +// has no SIP semantics and never bypasses the shared call flow. +type MemorySession struct { + inbound [][]byte + index int + outbound []byte + stats media.RTPStats +} + +func NewMemorySession(pcm []byte) *MemorySession { + const frameBytes = 640 + frames := make([][]byte, 0, (len(pcm)+frameBytes-1)/frameBytes) + for offset := 0; offset < len(pcm); offset += frameBytes { + end := offset + frameBytes + if end > len(pcm) { + end = len(pcm) + } + frames = append(frames, append([]byte(nil), pcm[offset:end]...)) + } + return &MemorySession{inbound: frames} +} + +func (m *MemorySession) ReadPayload(ctx context.Context) ([]byte, error) { + if m.index < len(m.inbound) { + payload := m.inbound[m.index] + m.index++ + m.stats.ReceivedPackets++ + m.stats.ReceivedBytes += uint64(len(payload)) + return append([]byte(nil), payload...), nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + return nil, context.DeadlineExceeded + } +} + +func (m *MemorySession) SendPCM16(ctx context.Context, pcm []byte, _ int) error { + if err := ctx.Err(); err != nil { + return err + } + m.outbound = append(m.outbound, pcm...) + m.stats.SentBytes += uint64(len(pcm)) + m.stats.SentPackets += uint64((len(pcm) + 639) / 640) + return nil +} + +func (m *MemorySession) Stats() media.RTPStats { return m.stats } +func (m *MemorySession) OutboundPCM() []byte { return append([]byte(nil), m.outbound...) } diff --git a/internal/callflow/flow_test.go b/internal/callflow/flow_test.go new file mode 100644 index 0000000..62ba94f --- /dev/null +++ b/internal/callflow/flow_test.go @@ -0,0 +1,187 @@ +package callflow + +import ( + "context" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/media" +) + +func TestInitialMediaWaitIsBounded(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-full-explicit.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI) + if err != nil { + t.Fatal(err) + } + _, err = Execute(context.Background(), NewMemorySession(nil), ai.MockPipeline{}, snapshot, "测试开场", time.Millisecond) + if err == nil { + t.Fatal("expected bounded initial media wait to fail") + } +} + +func TestSharedFlowRunsThreeConversationTurns(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-full-explicit.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI) + if err != nil { + t.Fatal(err) + } + session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400), make([]byte, 6400), make([]byte, 6400)}} + result, err := ExecuteWithCapture(context.Background(), session, ai.MockPipeline{MaxAudioBytes: 16 << 20}, snapshot, "测试开场", CaptureConfig{ + FirstSpeechTimeout: time.Second, + MaxDuration: 2 * time.Millisecond, + MaxTurns: 3, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Turns) != 3 || len(result.InboundTurns) != 3 || len(result.OutboundTurns) != 4 { + t.Fatalf("expected opening plus three shared turns, got turns=%d inbound=%d outbound=%d", len(result.Turns), len(result.InboundTurns), len(result.OutboundTurns)) + } + if result.Turn.Transcript == "" || result.Turn.Reply == "" || result.RTP.ReceivedBytes != 19200 { + t.Fatalf("three-turn flow facts are incomplete: %+v", result) + } +} + +type scriptedTurnSession struct { + turns [][]byte + index int + served bool + stats media.RTPStats +} + +func (s *scriptedTurnSession) ReadPayload(ctx context.Context) ([]byte, error) { + if !s.served && s.index < len(s.turns) { + s.served = true + payload := append([]byte(nil), s.turns[s.index]...) + s.index++ + s.stats.ReceivedPackets++ + s.stats.ReceivedBytes += uint64(len(payload)) + return payload, nil + } + <-ctx.Done() + s.served = false + return nil, ctx.Err() +} + +func (s *scriptedTurnSession) SendPCM16(ctx context.Context, pcm []byte, _ int) error { + if err := ctx.Err(); err != nil { + return err + } + s.stats.SentPackets++ + s.stats.SentBytes += uint64(len(pcm)) + return nil +} + +func (s *scriptedTurnSession) Stats() media.RTPStats { return s.stats } + +type invalidCallPipeline struct{} + +func (invalidCallPipeline) Synthesize(context.Context, ai.Snapshot, string) ([]byte, error) { + return make([]byte, 6400), nil +} + +func (invalidCallPipeline) RunTurn(context.Context, ai.Snapshot, []byte) (ai.TurnResult, error) { + return ai.TurnResult{Transcript: "打错了", Reply: ai.InvalidCallMarker, AudioPCM16: make([]byte, 6400)}, nil +} + +func TestInvalidCallStopsBeforeReply(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-full-explicit.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI) + if err != nil { + t.Fatal(err) + } + session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}} + result, err := ExecuteWithCapture(context.Background(), session, invalidCallPipeline{}, snapshot, "开场", CaptureConfig{ + FirstSpeechTimeout: time.Second, + MaxDuration: 2 * time.Millisecond, + MaxTurns: 3, + }) + if err != nil { + t.Fatal(err) + } + if !result.Turn.InvalidCall || result.Turn.InvalidReason != "llm_invalid_call_marker" || len(result.Turns) != 1 { + t.Fatalf("invalid call was not classified: %+v", result) + } + if session.stats.SentPackets != 1 { + t.Fatalf("invalid call must not send a reply, sent packets=%d", session.stats.SentPackets) + } +} + +func TestASROnlySkipsOpeningLLMAndTTS(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(raw, ai.ModeASROnly) + if err != nil { + t.Fatal(err) + } + pipeline := &asrOnlyPipeline{} + session := &scriptedTurnSession{turns: [][]byte{make([]byte, 6400)}} + result, err := ExecuteWithCapture(context.Background(), session, pipeline, snapshot, "不得播放", CaptureConfig{ + FirstSpeechTimeout: time.Second, + MaxDuration: time.Millisecond, + MaxTurns: 1, + }) + if err != nil { + t.Fatal(err) + } + if pipeline.synthesizeCalls != 0 || pipeline.turnCalls != 1 { + t.Fatalf("ASR-only provider calls = synthesize:%d turn:%d", pipeline.synthesizeCalls, pipeline.turnCalls) + } + if result.Turn.Transcript != "asr-only transcript" || result.Turn.Reply != "" || len(result.OutboundTurns) != 0 { + t.Fatalf("unexpected ASR-only result: %+v", result) + } + if session.stats.SentPackets != 0 { + t.Fatalf("ASR-only flow must not send opening/reply audio, sent packets=%d", session.stats.SentPackets) + } +} + +type asrOnlyPipeline struct { + synthesizeCalls int + turnCalls int +} + +func (p *asrOnlyPipeline) Synthesize(context.Context, ai.Snapshot, string) ([]byte, error) { + p.synthesizeCalls++ + return nil, nil +} + +func (p *asrOnlyPipeline) RunTurn(context.Context, ai.Snapshot, []byte) (ai.TurnResult, error) { + p.turnCalls++ + return ai.TurnResult{Transcript: "asr-only transcript"}, nil +} + +func TestMockModeUsesTheSharedConversationFlow(t *testing.T) { + raw, err := contracts.Read("examples/agent-version-full-explicit.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(raw, ai.ModeFullAI) + if err != nil { + t.Fatal(err) + } + session := NewMemorySession(make([]byte, 6400)) + result, err := Execute(context.Background(), session, ai.MockPipeline{MaxAudioBytes: 16 << 20}, snapshot, "测试开场", 10*time.Millisecond) + if err != nil { + t.Fatal(err) + } + if result.Turn.Transcript == "" || result.Turn.Reply == "" || len(result.Turn.AudioPCM16) == 0 { + t.Fatalf("shared flow did not complete mock ASR/LLM/TTS: %+v", result.Turn) + } + if result.RTP.ReceivedBytes != 6400 || result.RTP.SentPackets == 0 || len(session.OutboundPCM()) == 0 { + t.Fatalf("shared media boundary was not exercised: %+v", result.RTP) + } +} diff --git a/internal/calllog/log.go b/internal/calllog/log.go new file mode 100644 index 0000000..664d0b3 --- /dev/null +++ b/internal/calllog/log.go @@ -0,0 +1,270 @@ +// Package calllog writes redacted, durable business facts for outbound calls. +// It never writes the original phone number, credentials, audio, prompts, or +// provider URLs. The phone reference is a keyed digest so one number can be +// correlated across tasks without making the log a contact database. +package calllog + +import ( + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +var phonePattern = regexp.MustCompile(`^[0-9]{3,32}$`) +var eventTypePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) + +// Identity is the only phone identity exposed to log records. +type Identity struct { + Ref string + Mask string +} + +// Event is the allow-listed input to Logger.Append. Phone is consumed to make +// a keyed identity and is never serialized. +type Event struct { + EventType string + Phone string + PhoneRef string + PhoneMask string + EventID string + OccurredAt time.Time + + TenantID string + TraceID string + ExecutionID string + TaskID string + TaskItemID string + TaskRevision int64 + AttemptID string + CallID string + AgentID string + CellID string + RoutePolicyID string + CallerProfileID string + TrunkID string + CallState string + AttemptState string + SIPStage string + SIPStatusCode int + SIPReason string + Status string + Result string + ReasonCode string + RecordingID string + RecordingState string + RecordingSize int64 + RecordingDuration int64 + RecordingSHA256 string + DurationMS int64 +} + +type record struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + OccurredAt string `json:"occurred_at"` + EventType string `json:"event_type"` + PhoneRef string `json:"phone_ref"` + PhoneMask string `json:"phone_mask"` + + TenantID string `json:"tenant_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + TaskID string `json:"task_id,omitempty"` + TaskItemID string `json:"task_item_id,omitempty"` + TaskRevision int64 `json:"task_revision,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + CallID string `json:"call_id,omitempty"` + AgentID string `json:"agent_id,omitempty"` + CellID string `json:"cell_id,omitempty"` + RoutePolicyID string `json:"route_policy_id,omitempty"` + CallerProfileID string `json:"caller_profile_id,omitempty"` + TrunkID string `json:"trunk_id,omitempty"` + CallState string `json:"call_state,omitempty"` + AttemptState string `json:"attempt_state,omitempty"` + SIPStage string `json:"sip_stage,omitempty"` + SIPStatusCode int `json:"sip_status_code,omitempty"` + SIPReason string `json:"sip_reason,omitempty"` + Status string `json:"status,omitempty"` + Result string `json:"result,omitempty"` + ReasonCode string `json:"reason_code,omitempty"` + RecordingID string `json:"recording_id,omitempty"` + RecordingState string `json:"recording_state,omitempty"` + RecordingSize int64 `json:"recording_size_bytes,omitempty"` + RecordingDuration int64 `json:"recording_duration_ms,omitempty"` + RecordingSHA256 string `json:"recording_sha256,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` +} + +// Logger appends one JSON object per line. A mutex and Sync keep concurrent +// call updates from interleaving and make a successful append durable enough +// for the Agent's file-backed recovery boundary. +type Logger struct { + path string + key []byte + now func() time.Time + mu sync.Mutex +} + +func New(path string, key []byte, now func() time.Time) (*Logger, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("call log path is required") + } + if len(key) < 16 { + return nil, errors.New("call log phone key must contain at least 16 bytes") + } + if now == nil { + now = time.Now + } + return &Logger{path: path, key: append([]byte(nil), key...), now: now}, nil +} + +func (l *Logger) Path() string { return l.path } + +// Identity returns the stable, redacted reference for a contract callee. +func (l *Logger) Identity(phone string) (Identity, error) { + if err := validatePhone(phone); err != nil { + return Identity{}, err + } + mac := hmac.New(sha256.New, l.key) + _, _ = mac.Write([]byte(phone)) + return Identity{Ref: "hmac-sha256:" + hex.EncodeToString(mac.Sum(nil)), Mask: maskPhone(phone)}, nil +} + +func (l *Logger) Append(event Event) error { + if l == nil { + return errors.New("call logger is nil") + } + if !eventTypePattern.MatchString(event.EventType) { + return errors.New("event type is invalid") + } + identity := Identity{Ref: event.PhoneRef, Mask: event.PhoneMask} + if event.Phone != "" { + computed, err := l.Identity(event.Phone) + if err != nil { + return err + } + if identity.Ref != "" && identity.Ref != computed.Ref { + return errors.New("phone reference does not match phone") + } + identity = computed + } + if err := validateIdentity(identity); err != nil { + return err + } + if event.SIPStatusCode != 0 && (event.SIPStatusCode < 100 || event.SIPStatusCode > 699) { + return errors.New("SIP status code is invalid") + } + if event.TaskRevision < 0 || event.DurationMS < 0 || event.RecordingSize < 0 || event.RecordingDuration < 0 { + return errors.New("negative business log value") + } + if len(event.RecordingSHA256) > 0 && (len(event.RecordingSHA256) != 64 || !isLowerHex(event.RecordingSHA256)) { + return errors.New("recording SHA-256 is invalid") + } + occurredAt := event.OccurredAt + if occurredAt.IsZero() { + occurredAt = l.now() + } + eventID := event.EventID + if eventID == "" { + var random [16]byte + if _, err := cryptorand.Read(random[:]); err != nil { + return fmt.Errorf("generate call log event ID: %w", err) + } + eventID = hex.EncodeToString(random[:]) + } + value := record{ + SchemaVersion: "1.0", EventID: eventID, OccurredAt: occurredAt.UTC().Format(time.RFC3339Nano), + EventType: event.EventType, PhoneRef: identity.Ref, PhoneMask: identity.Mask, + TenantID: event.TenantID, TraceID: event.TraceID, ExecutionID: event.ExecutionID, + TaskID: event.TaskID, TaskItemID: event.TaskItemID, TaskRevision: event.TaskRevision, + AttemptID: event.AttemptID, CallID: event.CallID, AgentID: event.AgentID, CellID: event.CellID, + RoutePolicyID: event.RoutePolicyID, CallerProfileID: event.CallerProfileID, TrunkID: event.TrunkID, + CallState: event.CallState, AttemptState: event.AttemptState, SIPStage: event.SIPStage, + SIPStatusCode: event.SIPStatusCode, SIPReason: event.SIPReason, Status: event.Status, + Result: event.Result, ReasonCode: event.ReasonCode, RecordingID: event.RecordingID, + RecordingState: event.RecordingState, RecordingSize: event.RecordingSize, + RecordingDuration: event.RecordingDuration, RecordingSHA256: event.RecordingSHA256, + DurationMS: event.DurationMS, + } + data, err := json.Marshal(value) + if err != nil { + return fmt.Errorf("encode call log event: %w", err) + } + + l.mu.Lock() + defer l.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil { + return fmt.Errorf("create call log directory: %w", err) + } + file, err := os.OpenFile(l.path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return fmt.Errorf("open call log: %w", err) + } + if err := file.Chmod(0o600); err != nil { + _ = file.Close() + return fmt.Errorf("protect call log: %w", err) + } + if _, err := file.Write(append(data, '\n')); err != nil { + _ = file.Close() + return fmt.Errorf("append call log: %w", err) + } + if err := file.Sync(); err != nil { + _ = file.Close() + return fmt.Errorf("sync call log: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close call log: %w", err) + } + return nil +} + +func validateIdentity(identity Identity) error { + if !strings.HasPrefix(identity.Ref, "hmac-sha256:") || len(identity.Ref) != len("hmac-sha256:")+64 || !isLowerHex(strings.TrimPrefix(identity.Ref, "hmac-sha256:")) { + return errors.New("redacted phone reference is invalid") + } + if len(identity.Mask) < 3 || strings.Contains(identity.Mask, " ") || strings.ContainsAny(identity.Mask, "\r\n") { + return errors.New("redacted phone mask is invalid") + } + for _, char := range identity.Mask { + if char != '*' && (char < '0' || char > '9') { + return errors.New("redacted phone mask is invalid") + } + } + if !strings.Contains(identity.Mask, "*") { + return errors.New("redacted phone mask must hide digits") + } + return nil +} + +func validatePhone(phone string) error { + if !phonePattern.MatchString(phone) { + return errors.New("phone must be the original 3-32 digit callee") + } + return nil +} + +func maskPhone(phone string) string { + if len(phone) <= 4 { + return strings.Repeat("*", len(phone)) + } + return strings.Repeat("*", len(phone)-4) + phone[len(phone)-4:] +} + +func isLowerHex(value string) bool { + for _, char := range value { + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { + return false + } + } + return true +} diff --git a/internal/calllog/log_test.go b/internal/calllog/log_test.go new file mode 100644 index 0000000..ffc5020 --- /dev/null +++ b/internal/calllog/log_test.go @@ -0,0 +1,130 @@ +package calllog + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestAppendCorrelatesPhoneWithoutWritingOriginal(t *testing.T) { + directory := t.TempDir() + logger, err := New(filepath.Join(directory, "logs", "calls.jsonl"), []byte("0123456789abcdef"), func() time.Time { + return time.Date(2026, 9, 19, 1, 2, 3, 4, time.UTC) + }) + if err != nil { + t.Fatal(err) + } + phone := "15003164745" + if err := logger.Append(Event{ + EventID: "event-1", EventType: "sip.status", Phone: phone, + ExecutionID: "exec-1", TaskID: "task-1", AttemptID: "attempt-1", CallID: "call-1", + RoutePolicyID: "route-sip-first", CallerProfileID: "caller-sip-first", TrunkID: "provider-primary", + SIPStage: "invite", SIPStatusCode: 183, Status: "ringing", ReasonCode: "provisional", + RecordingID: "recording-1", RecordingState: "pending", DurationMS: 120, + }); err != nil { + t.Fatal(err) + } + if err := logger.Append(Event{ + EventID: "event-2", EventType: "call.finished", Phone: phone, + ExecutionID: "exec-1", CallID: "call-1", Result: "no_answer", ReasonCode: "provider_480", + RecordingState: "failed", DurationMS: 3000, + }); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(logger.Path()) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, phone) { + t.Fatalf("call log contains original phone: %s", text) + } + lines := strings.Split(strings.TrimSpace(text), "\n") + if len(lines) != 2 { + t.Fatalf("got %d lines, want 2", len(lines)) + } + var first, second map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatal(err) + } + if first["phone_ref"] != second["phone_ref"] { + t.Fatalf("same phone must have one reference: %#v %#v", first["phone_ref"], second["phone_ref"]) + } + if first["phone_mask"] != "*******4745" { + t.Fatalf("unexpected phone mask: %v", first["phone_mask"]) + } + if first["trunk_id"] != "provider-primary" || first["sip_stage"] != "invite" || first["sip_status_code"] != float64(183) { + t.Fatalf("missing SIP correlation fields: %#v", first) + } + if first["recording_id"] != "recording-1" || second["result"] != "no_answer" { + t.Fatalf("missing recording/result fields: %#v %#v", first, second) + } + if mode := fileMode(t, logger.Path()); mode.Perm() != 0o600 { + t.Fatalf("log mode is %o, want 600", mode.Perm()) + } +} + +func TestAppendRejectsUnredactedOrInvalidIdentity(t *testing.T) { + logger, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("0123456789abcdef"), nil) + if err != nil { + t.Fatal(err) + } + for _, event := range []Event{ + {EventType: "call.status"}, + {EventType: "call.status", PhoneRef: "15003164745", PhoneMask: "15003164745"}, + {EventType: "call.status", PhoneRef: "hmac-sha256:bad", PhoneMask: "*******4745"}, + {EventType: "call.status", Phone: "15003164745", SIPStatusCode: 700}, + } { + if err := logger.Append(event); err == nil { + t.Fatalf("event %#v was accepted", event) + } + } +} + +func TestAppendSerializesConcurrentEvents(t *testing.T) { + logger, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("0123456789abcdef"), nil) + if err != nil { + t.Fatal(err) + } + var wait sync.WaitGroup + for index := 0; index < 32; index++ { + wait.Add(1) + go func(index int) { + defer wait.Done() + if err := logger.Append(Event{EventType: "call.status", Phone: "15830461047", EventID: "event-" + string(rune('a'+index))}); err != nil { + t.Errorf("append %d: %v", index, err) + } + }(index) + } + wait.Wait() + data, err := os.ReadFile(logger.Path()) + if err != nil { + t.Fatal(err) + } + if lines := strings.Count(strings.TrimSpace(string(data)), "\n") + 1; lines != 32 { + t.Fatalf("got %d serialized events, want 32", lines) + } +} + +func TestNewRequiresPhoneKey(t *testing.T) { + if _, err := New(filepath.Join(t.TempDir(), "calls.jsonl"), []byte("short"), nil); err == nil { + t.Fatal("short key was accepted") + } +} + +func fileMode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info.Mode() +} diff --git a/internal/callruntime/runtime.go b/internal/callruntime/runtime.go new file mode 100644 index 0000000..3ddf104 --- /dev/null +++ b/internal/callruntime/runtime.go @@ -0,0 +1,433 @@ +package callruntime + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/callflow" + "git.ipao.vip/rogee/go-sip/internal/media" + "github.com/CyCoreSystems/ari/v5" + "github.com/CyCoreSystems/ari/v5/client/native" +) + +const ( + defaultAnswerTimeout = 45 * time.Second + defaultTurnWindow = 5 * time.Second + defaultFirstSpeechWait = 20 * time.Second + defaultMaxTurnDuration = 12 * time.Second + defaultEndSilence = 900 * time.Millisecond + defaultVoiceThreshold = 350 + defaultConversationTurns = 3 + defaultCallDuration = 2 * time.Minute +) + +type Config struct { + ARIURL string + ARIWebsocketURL string + ARIApplication string + ARIUsername string + ARIPassword string + + Endpoint string + CallerID string + MediaBind string + MediaPort int + MediaFormat string + MediaSampleRate int + PayloadType uint8 + + RecordingDirectory string + AnswerTimeout time.Duration + TurnWindow time.Duration + FirstSpeechTimeout time.Duration + MaxTurnDuration time.Duration + EndSilence time.Duration + VoiceThreshold int + MaxTurns int + MaxCallDuration time.Duration + OpeningPrompt string + + Snapshot ai.Snapshot + Pipeline ai.Pipeline +} + +type RecordingFact struct { + Segment string + Path string + SHA256 string + Bytes int +} + +type Result struct { + ChannelID string + Transcript string + Reply string + InvalidCall bool + InvalidReason string + Turns []ai.TurnResult + InboundPath string + InboundSHA256 string + InboundBytes int + OutboundPath string + OutboundSHA256 string + OutboundBytes int + InboundRecordings []RecordingFact + OutboundRecordings []RecordingFact + RTP media.RTPStats +} + +func (c Config) validate() error { + if c.ARIApplication == "" || c.ARIURL == "" || c.ARIWebsocketURL == "" { + return errors.New("ARI URL, websocket URL and application are required") + } + if c.ARIPassword == "" || c.ARIUsername == "" { + return errors.New("ARI credentials are required") + } + if c.Endpoint == "" { + return errors.New("endpoint is required") + } + if c.MediaBind == "" || c.MediaPort < 1024 || c.MediaPort > 65535 { + return errors.New("valid media bind and port are required") + } + if err := media.ValidateFormat(media.Format(c.MediaFormat), c.PayloadType, c.MediaSampleRate); err != nil { + return err + } + if c.Pipeline == nil { + return errors.New("AI pipeline is required") + } + if c.AnswerTimeout <= 0 { + c.AnswerTimeout = defaultAnswerTimeout + } + if c.TurnWindow <= 0 { + c.TurnWindow = defaultTurnWindow + } + return nil +} + +func (c Config) normalized() Config { + if c.AnswerTimeout <= 0 { + c.AnswerTimeout = defaultAnswerTimeout + } + if c.TurnWindow <= 0 { + c.TurnWindow = defaultTurnWindow + } + if c.MediaFormat == "" { + c.MediaFormat = string(media.FormatSLIN16) + } + if c.MediaSampleRate <= 0 { + c.MediaSampleRate = 16000 + } + if c.FirstSpeechTimeout <= 0 { + c.FirstSpeechTimeout = defaultFirstSpeechWait + } + if c.MaxTurnDuration <= 0 { + c.MaxTurnDuration = defaultMaxTurnDuration + } + if c.EndSilence <= 0 { + c.EndSilence = defaultEndSilence + } + if c.VoiceThreshold <= 0 { + c.VoiceThreshold = defaultVoiceThreshold + } + if c.MaxTurns <= 0 { + c.MaxTurns = defaultConversationTurns + } + if c.MaxCallDuration <= 0 { + c.MaxCallDuration = defaultCallDuration + } + if c.OpeningPrompt == "" { + c.OpeningPrompt = "您好,请说出您想咨询的内容。" + } + return c +} + +func Run(ctx context.Context, cfg Config) (Result, error) { + cfg = cfg.normalized() + if err := cfg.validate(); err != nil { + return Result{}, err + } + runCtx, cancelRun := context.WithTimeout(ctx, cfg.MaxCallDuration) + defer cancelRun() + stream, err := media.ListenRTPWithFormat(fmt.Sprintf("%s:%d", cfg.MediaBind, cfg.MediaPort), cfg.PayloadType, media.Format(cfg.MediaFormat), cfg.MediaSampleRate) + if err != nil { + return Result{}, fmt.Errorf("bind external media: %w", err) + } + defer stream.Close() + + client, err := native.Connect(&native.Options{ + URL: cfg.ARIURL, + WebsocketURL: cfg.ARIWebsocketURL, + Application: cfg.ARIApplication, + Username: cfg.ARIUsername, + Password: cfg.ARIPassword, + SubscribeAll: true, + }) + if err != nil { + return Result{}, fmt.Errorf("connect ARI: %w", err) + } + defer client.Close() + + starts := client.Bus().Subscribe(nil, ari.Events.All) + defer starts.Cancel() + originate, err := client.Channel().Originate(nil, ari.OriginateRequest{ + Endpoint: cfg.Endpoint, + // ari.OriginateRequest.Timeout is specified in seconds. + Timeout: int(cfg.AnswerTimeout / time.Second), + CallerID: cfg.CallerID, + App: cfg.ARIApplication, + Formats: cfg.MediaFormat, + }) + if err != nil { + return Result{}, fmt.Errorf("originate %s: %w", cfg.Endpoint, err) + } + channelID := originate.ID() + channelKey := ari.NewKey(ari.ChannelKey, channelID) + var bridgeKey *ari.Key + var externalKey *ari.Key + defer func() { + if externalKey != nil { + _ = client.Channel().Hangup(externalKey, "normal") + } + if channelKey != nil { + _ = client.Channel().Hangup(channelKey, "normal") + } + if bridgeKey != nil { + _ = client.Bridge().Delete(bridgeKey) + } + }() + + if err := waitForStasisStart(runCtx, starts, channelID, cfg.AnswerTimeout); err != nil { + return Result{}, err + } + callCtx, cancelCall := context.WithCancel(runCtx) + defer cancelCall() + go watchChannelLifecycle(callCtx, starts, channelID, cancelCall) + if err := client.Channel().Answer(channelKey); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already") { + return Result{}, fmt.Errorf("answer channel: %w", err) + } + + bridgeID := "agent-call-" + channelID + bridge, err := client.Bridge().Create(ari.NewKey(ari.BridgeKey, bridgeID), "mixing", bridgeID) + if err != nil { + return Result{}, fmt.Errorf("create media bridge: %w", err) + } + bridgeKey = ari.NewKey(ari.BridgeKey, bridge.ID()) + if err := client.Bridge().AddChannel(bridgeKey, channelID); err != nil { + return Result{}, fmt.Errorf("add call channel to bridge: %w", err) + } + external, err := client.Channel().ExternalMedia(nil, ari.ExternalMediaOptions{ + App: cfg.ARIApplication, + ExternalHost: fmt.Sprintf("%s:%d", cfg.MediaBind, cfg.MediaPort), + Encapsulation: "rtp", + Transport: "udp", + ConnectionType: "client", + Format: cfg.MediaFormat, + Direction: "both", + }) + if err != nil { + return Result{}, fmt.Errorf("create external media channel: %w", err) + } + externalKey = ari.NewKey(ari.ChannelKey, external.ID()) + address, err := external.GetVariable("UNICASTRTP_LOCAL_ADDRESS") + if err != nil { + return Result{}, fmt.Errorf("read ExternalMedia RTP address: %w", err) + } + port, err := external.GetVariable("UNICASTRTP_LOCAL_PORT") + if err != nil { + return Result{}, fmt.Errorf("read ExternalMedia RTP port: %w", err) + } + if err := stream.SetPeer(net.JoinHostPort(strings.TrimSpace(address), strings.TrimSpace(port))); err != nil { + return Result{}, fmt.Errorf("configure ExternalMedia RTP peer: %w", err) + } + if err := client.Bridge().AddChannel(bridgeKey, external.ID()); err != nil { + return Result{}, fmt.Errorf("add external media to bridge: %w", err) + } + + flowResult, flowErr := callflow.ExecuteWithCapture(callCtx, stream, cfg.Pipeline, cfg.Snapshot, cfg.OpeningPrompt, callflow.CaptureConfig{ + FirstSpeechTimeout: cfg.FirstSpeechTimeout, + MaxDuration: cfg.MaxTurnDuration, + EndSilence: cfg.EndSilence, + VoiceThreshold: cfg.VoiceThreshold, + MaxTurns: cfg.MaxTurns, + }) + inboundRecordings, outboundRecordings, recordingErr := persistConversationRecordings(cfg.RecordingDirectory, channelID, flowResult) + if recordingErr != nil { + return Result{}, recordingErr + } + turn := flowResult.Turn + result := Result{ + ChannelID: channelID, + Transcript: turn.Transcript, + Reply: turn.Reply, + InvalidCall: turn.InvalidCall, + InvalidReason: turn.InvalidReason, + Turns: append([]ai.TurnResult(nil), flowResult.Turns...), + InboundRecordings: inboundRecordings, + OutboundRecordings: outboundRecordings, + RTP: flowResult.RTP, + } + if len(inboundRecordings) > 0 { + last := inboundRecordings[len(inboundRecordings)-1] + result.InboundPath, result.InboundBytes, result.InboundSHA256 = last.Path, last.Bytes, last.SHA256 + } + if len(outboundRecordings) > 0 { + last := outboundRecordings[len(outboundRecordings)-1] + result.OutboundPath, result.OutboundBytes, result.OutboundSHA256 = last.Path, last.Bytes, last.SHA256 + } + if flowErr != nil { + stats := stream.Stats() + return result, fmt.Errorf("execute call flow: %w (rtp rx_packets=%d rx_bytes=%d tx_packets=%d tx_bytes=%d)", flowErr, stats.ReceivedPackets, stats.ReceivedBytes, stats.SentPackets, stats.SentBytes) + } + return result, nil +} + +func watchChannelLifecycle(ctx context.Context, sub ari.Subscription, channelID string, cancel context.CancelFunc) { + for { + select { + case <-ctx.Done(): + return + case event, ok := <-sub.Events(): + if !ok || event == nil { + return + } + matched := false + for _, key := range event.Keys() { + if key != nil && key.ID == channelID { + matched = true + break + } + } + if !matched { + continue + } + switch event.GetType() { + case "ChannelHangupRequest", "ChannelDestroyed": + cancel() + return + } + } + } +} + +func waitForStasisStart(ctx context.Context, sub ari.Subscription, channelID string, timeout time.Duration) error { + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var recent []string + for { + select { + case <-waitCtx.Done(): + return fmt.Errorf("waiting for channel %s answer/StasisStart (recent_events=%s): %w", channelID, strings.Join(recent, ","), waitCtx.Err()) + case event, ok := <-sub.Events(): + if !ok { + return errors.New("ARI event subscription closed") + } + if event == nil { + continue + } + typ := event.GetType() + recent = append(recent, typ) + if len(recent) > 8 { + recent = recent[len(recent)-8:] + } + matched := false + for _, key := range event.Keys() { + if key != nil && key.ID == channelID { + matched = true + break + } + } + if !matched { + continue + } + if typ == "StasisStart" { + return nil + } + if typ == "ChannelStateChange" { + if state, ok := event.(*ari.ChannelStateChange); ok && strings.EqualFold(state.Channel.State, "Up") { + return nil + } + } + if typ == "ChannelHangupRequest" { + if hangup, ok := event.(*ari.ChannelHangupRequest); ok { + return fmt.Errorf("channel %s ended before StasisStart: %s cause=%d soft=%t state=%s", channelID, typ, hangup.Cause, hangup.Soft, hangup.Channel.State) + } + return fmt.Errorf("channel %s ended before StasisStart: %s", channelID, typ) + } + if typ == "ChannelDestroyed" { + return fmt.Errorf("channel %s ended before StasisStart: %s", channelID, typ) + } + } + } +} + +func persistConversationRecordings(directory, channelID string, flowResult callflow.Result) ([]RecordingFact, []RecordingFact, error) { + if directory == "" || (len(flowResult.InboundTurns) == 0 && len(flowResult.OutboundTurns) == 0) { + return nil, nil, nil + } + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, nil, err + } + stamp := time.Now().UTC().Format("20060102T150405.000000000Z") + name := strings.NewReplacer("/", "_", "\\", "_", ":", "_").Replace(channelID) + inbound := make([]RecordingFact, 0, len(flowResult.InboundTurns)) + for index, pcm := range flowResult.InboundTurns { + path := filepath.Join(directory, fmt.Sprintf("%s-%s-inbound-turn-%02d.wav", stamp, name, index+1)) + 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)}) + } + outbound := make([]RecordingFact, 0, len(flowResult.OutboundTurns)) + for index, pcm := range flowResult.OutboundTurns { + path := filepath.Join(directory, fmt.Sprintf("%s-%s-outbound-segment-%02d.wav", stamp, name, index)) + 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)}) + } + return inbound, outbound, nil +} + +func writeWAV(path string, pcm []byte, sampleRate int) error { + if len(pcm)%2 != 0 { + return errors.New("PCM16 has odd byte length") + } + dataSize := uint32(len(pcm)) + byteRate := uint32(sampleRate * 2) + blockAlign := uint16(2) + buf := make([]byte, 44+len(pcm)) + copy(buf[:4], "RIFF") + binary.LittleEndian.PutUint32(buf[4:8], 36+dataSize) + copy(buf[8:12], "WAVE") + copy(buf[12:16], "fmt ") + binary.LittleEndian.PutUint32(buf[16:20], 16) + binary.LittleEndian.PutUint16(buf[20:22], 1) + binary.LittleEndian.PutUint16(buf[22:24], 1) + binary.LittleEndian.PutUint32(buf[24:28], uint32(sampleRate)) + binary.LittleEndian.PutUint32(buf[28:32], byteRate) + binary.LittleEndian.PutUint16(buf[32:34], blockAlign) + binary.LittleEndian.PutUint16(buf[34:36], 16) + copy(buf[36:40], "data") + binary.LittleEndian.PutUint32(buf[40:44], dataSize) + copy(buf[44:], pcm) + return os.WriteFile(path, buf, 0o600) +} + +func fileSHA256(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/callwindow/window.go b/internal/callwindow/window.go new file mode 100644 index 0000000..481e44f --- /dev/null +++ b/internal/callwindow/window.go @@ -0,0 +1,33 @@ +// Package callwindow enforces the fixed legal window for SIP outbound dialing. +package callwindow + +import ( + "fmt" + "time" +) + +const ( + LocationName = "Asia/Shanghai" + OpenHour = 9 + CloseHour = 20 +) + +var shanghai = time.FixedZone(LocationName, 8*60*60) + +// Allowed reports whether SIP outbound dialing is permitted at now. The +// boundary is [09:00, 20:00) in Asia/Shanghai; the input's instant, not its +// presentation timezone, is authoritative. +func Allowed(now time.Time) bool { + local := now.In(shanghai) + minutes := local.Hour()*60 + local.Minute() + return minutes >= OpenHour*60 && minutes < CloseHour*60 +} + +// Check returns a stable, actionable error when outbound dialing is closed. +func Check(now time.Time) error { + local := now.In(shanghai) + if Allowed(now) { + return nil + } + return fmt.Errorf("SIP outbound dialing is closed at %s; allowed window is %02d:00-%02d:00 %s", local.Format("2006-01-02 15:04:05 -0700"), OpenHour, CloseHour, LocationName) +} diff --git a/internal/callwindow/window_test.go b/internal/callwindow/window_test.go new file mode 100644 index 0000000..84c98d9 --- /dev/null +++ b/internal/callwindow/window_test.go @@ -0,0 +1,49 @@ +package callwindow + +import ( + "strings" + "testing" + "time" +) + +func TestAllowedBoundariesInShanghai(t *testing.T) { + location := time.FixedZone("test", 8*60*60) + tests := []struct { + name string + at time.Time + want bool + }{ + {name: "before opening", at: time.Date(2026, 9, 20, 8, 59, 59, 0, location), want: false}, + {name: "opening", at: time.Date(2026, 9, 20, 9, 0, 0, 0, location), want: true}, + {name: "before closing", at: time.Date(2026, 9, 20, 19, 59, 59, 0, location), want: true}, + {name: "closing", at: time.Date(2026, 9, 20, 20, 0, 0, 0, location), want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Allowed(tt.at); got != tt.want { + t.Fatalf("Allowed(%s)=%v, want %v", tt.at, got, tt.want) + } + }) + } +} + +func TestAllowedConvertsUTCToShanghai(t *testing.T) { + if !Allowed(time.Date(2026, 9, 20, 1, 0, 0, 0, time.UTC)) { + t.Fatal("01:00 UTC should be 09:00 Asia/Shanghai and allowed") + } + if Allowed(time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)) { + t.Fatal("12:00 UTC should be 20:00 Asia/Shanghai and rejected") + } +} + +func TestCheckExplainsClosedWindow(t *testing.T) { + err := Check(time.Date(2026, 9, 20, 20, 0, 0, 0, time.FixedZone("test", 8*60*60))) + if err == nil { + t.Fatal("expected closed-window error") + } + for _, want := range []string{"09:00", "20:00", "Asia/Shanghai"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q does not contain %q", err, want) + } + } +} diff --git a/internal/config/agent_endpoints.go b/internal/config/agent_endpoints.go new file mode 100644 index 0000000..bdd7023 --- /dev/null +++ b/internal/config/agent_endpoints.go @@ -0,0 +1,65 @@ +package config + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" +) + +// AgentEndpoint is deployment-only Dispatcher configuration. It is not a SaaS +// command field and cannot be supplied by a tenant or a call task. +type AgentEndpoint struct { + AgentID string `json:"agent_id"` + CellID string `json:"cell_id"` + Address string `json:"address"` + ServerName string `json:"server_name"` +} + +// LoadAgentEndpoints reads the Dispatcher-owned endpoint inventory. Strict JSON +// decoding prevents silently accepting misspelled authorization or identity +// fields. +func LoadAgentEndpoints(path string) ([]AgentEndpoint, error) { + if strings.TrimSpace(path) == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read Agent endpoint inventory: %w", err) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var endpoints []AgentEndpoint + if err := decoder.Decode(&endpoints); err != nil { + return nil, fmt.Errorf("decode Agent endpoint inventory: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("Agent endpoint inventory contains trailing JSON") + } + return nil, fmt.Errorf("read Agent endpoint inventory trailer: %w", err) + } + if len(endpoints) == 0 { + return nil, errors.New("Agent endpoint inventory must not be empty") + } + seenAgents := make(map[string]struct{}, len(endpoints)) + seenCells := make(map[string]struct{}, len(endpoints)) + for index, endpoint := range endpoints { + if strings.TrimSpace(endpoint.AgentID) == "" || strings.TrimSpace(endpoint.CellID) == "" || strings.TrimSpace(endpoint.Address) == "" || strings.TrimSpace(endpoint.ServerName) == "" { + return nil, fmt.Errorf("Agent endpoint %d requires agent_id, cell_id, address, and server_name", index) + } + if _, exists := seenAgents[endpoint.AgentID]; exists { + return nil, fmt.Errorf("duplicate Agent ID %q", endpoint.AgentID) + } + if _, exists := seenCells[endpoint.CellID]; exists { + return nil, fmt.Errorf("duplicate Cell ID %q", endpoint.CellID) + } + seenAgents[endpoint.AgentID] = struct{}{} + seenCells[endpoint.CellID] = struct{}{} + } + return endpoints, nil +} diff --git a/internal/config/agent_endpoints_test.go b/internal/config/agent_endpoints_test.go new file mode 100644 index 0000000..6f5073c --- /dev/null +++ b/internal/config/agent_endpoints_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadAgentEndpointsValidatesStrictInventory(t *testing.T) { + path := filepath.Join(t.TempDir(), "agents.json") + valid := `[{"agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test"}]` + if err := os.WriteFile(path, []byte(valid), 0o600); err != nil { + t.Fatal(err) + } + endpoints, err := LoadAgentEndpoints(path) + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 || endpoints[0].AgentID != "agent-a" { + t.Fatalf("unexpected endpoints: %+v", endpoints) + } + + if err := os.WriteFile(path, []byte(`[{"agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test","role":"dispatcher"}]`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadAgentEndpoints(path); err == nil { + t.Fatal("expected unknown endpoint field to be rejected") + } + + if err := os.WriteFile(path, []byte(`[{ + "agent_id":"agent-a","cell_id":"cell-a","address":"127.0.0.1:19090","server_name":"agent.test" + },{ + "agent_id":"agent-a","cell_id":"cell-b","address":"127.0.0.1:19091","server_name":"agent.test" + }]`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadAgentEndpoints(path); err == nil { + t.Fatal("expected duplicate Agent ID to be rejected") + } +} + +func TestParseCertificateFingerprints(t *testing.T) { + fingerprint := "aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa:aa" + parsed, err := ParseCertificateFingerprints(fingerprint + "," + fingerprint) + if err != nil { + t.Fatal(err) + } + if len(parsed) != 1 { + t.Fatalf("unexpected fingerprint count: %d", len(parsed)) + } + if _, err := ParseCertificateFingerprints("not-a-fingerprint"); err == nil { + t.Fatal("expected invalid fingerprint to be rejected") + } +} + +func TestConfigRequiresDispatcherMTLSForEndpointInventory(t *testing.T) { + cfg := Config{Mode: "mock", DBPath: ":memory:", AgentEndpointsFile: "agents.json"} + if err := cfg.Validate("dispatcher"); err == nil { + t.Fatal("expected Dispatcher mTLS requirement") + } + cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile = "ca.pem", "dispatcher.pem", "dispatcher.key" + if err := cfg.Validate("dispatcher"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8d1dfbd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,233 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Mode string + DBPath string + SpoolRoot string + RabbitURL string + Exchange string + OutboxBatch int + AgentID string + DispatcherID string + Version string + ControlListen string + ControlToken string + CellID string + GRPCListen string + DispatcherGRPCListen string + DispatcherGRPCEndpoint string + DispatcherGRPCServerName string + DispatcherGRPCAllowedAgentIDs string + MTLSCAFile string + MTLSCertFile string + MTLSKeyFile string + MTLSServerName string + MTLSPeerCertificateFingerprints string + OSSRegion string + OSSEndpoint string + OSSBucket string + OSSAccessKeyID string + OSSAccessKeySecret string + OSSKeyPrefix string + OSSGrantTTL time.Duration + OSSMaxAssetBytes int64 + StaticArtifactPath string + AgentEndpointsFile string + CallLogPath string + CallLogPhoneKey string + ARIURL string + ARIWebsocketURL string + ARIApplication string + ARIUsername string + ARIPassword string + CallTarget string + CallTrunkID string + CallCallerID string + CallMediaBind string + CallMediaPort int + CallRecordingDirectory string + CallAISnapshotPath string + CallTenantID string + CallTenantKey string + CallTaskID string + CallTaskItemID string + CallExecutionID string +} + +func FromEnv() Config { + return Config{ + Mode: envOr("SIP_GO_AGENT_MODE", "mock"), + DBPath: envOr("DISPATCHER_DB", "dispatcher.db"), + SpoolRoot: envOr("AGENT_SPOOL", "./spool"), + RabbitURL: os.Getenv("RABBITMQ_URL"), + Exchange: envOr("RABBITMQ_EXCHANGE", "agent-call.commands.v1"), + OutboxBatch: 50, + 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"), + DispatcherGRPCEndpoint: os.Getenv("DISPATCHER_GRPC_ENDPOINT"), + DispatcherGRPCServerName: envOr("DISPATCHER_GRPC_SERVER_NAME", os.Getenv("MTLS_SERVER_NAME")), + DispatcherGRPCAllowedAgentIDs: os.Getenv("DISPATCHER_ALLOWED_AGENT_IDS"), + MTLSCAFile: os.Getenv("MTLS_CA_FILE"), + MTLSCertFile: os.Getenv("MTLS_CERT_FILE"), + 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)), + StaticArtifactPath: os.Getenv("AGENT_STATIC_ARTIFACT"), + AgentEndpointsFile: os.Getenv("DISPATCHER_AGENT_ENDPOINTS_FILE"), + CallLogPath: os.Getenv("AGENT_CALL_BUSINESS_LOG"), + CallLogPhoneKey: os.Getenv("AGENT_CALL_PHONE_LOG_KEY"), + ARIURL: envOr("ARI_URL", "http://127.0.0.1:8088/ari"), + ARIWebsocketURL: envOr("ARI_WS_URL", "ws://127.0.0.1:8088/ari/events"), + ARIApplication: envOr("ARI_APPLICATION", "agent-call"), + ARIUsername: os.Getenv("ARI_USERNAME"), + ARIPassword: os.Getenv("ARI_PASSWORD"), + CallTarget: os.Getenv("AGENT_CALL_TARGET"), + CallTrunkID: os.Getenv("AGENT_CALL_TRUNK_ID"), + CallCallerID: os.Getenv("AGENT_CALL_CALLER_ID"), + CallMediaBind: envOr("AGENT_CALL_MEDIA_BIND", "127.0.0.1"), + CallMediaPort: envInt("AGENT_CALL_MEDIA_PORT", 12000), + CallRecordingDirectory: envOr("AGENT_CALL_RECORDING_DIR", "./recordings"), + CallAISnapshotPath: os.Getenv("AGENT_CALL_AI_SNAPSHOT"), + CallTenantID: os.Getenv("AGENT_CALL_TENANT_ID"), + CallTenantKey: os.Getenv("AGENT_CALL_TENANT_KEY"), + CallTaskID: os.Getenv("AGENT_CALL_TASK_ID"), + CallTaskItemID: os.Getenv("AGENT_CALL_TASK_ITEM_ID"), + CallExecutionID: os.Getenv("AGENT_CALL_EXECUTION_ID"), + } +} + +func (c Config) Validate(role string) error { + if c.Mode != "mock" && c.Mode != "mixed" && c.Mode != "real" { + return fmt.Errorf("unsupported mode %q", c.Mode) + } + if role == "dispatcher" && strings.TrimSpace(c.DBPath) == "" { + return errors.New("dispatcher DB path is required") + } + if role == "agent" && strings.TrimSpace(c.SpoolRoot) == "" { + return errors.New("agent spool root is required") + } + if role == "agent" && strings.TrimSpace(c.CallLogPath) != "" && strings.TrimSpace(c.CallLogPhoneKey) == "" { + return errors.New("AGENT_CALL_PHONE_LOG_KEY is required when AGENT_CALL_BUSINESS_LOG is set") + } + if role == "agent" && strings.TrimSpace(c.CallLogPhoneKey) != "" && len([]byte(c.CallLogPhoneKey)) < 16 { + return errors.New("AGENT_CALL_PHONE_LOG_KEY must contain at least 16 bytes") + } + if role == "agent" && strings.TrimSpace(c.GRPCListen) != "" { + 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) == "" { + return fmt.Errorf("%s is required when AGENT_GRPC_LISTEN is enabled", name) + } + } + } + if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.GRPCListen) == "" { + return errors.New("real agent mode requires AGENT_GRPC_LISTEN") + } + if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.StaticArtifactPath) == "" { + return errors.New("real agent mode requires AGENT_STATIC_ARTIFACT") + } + if role == "agent" && strings.TrimSpace(c.MTLSPeerCertificateFingerprints) != "" { + if _, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints); err != nil { + 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) == "" { + return fmt.Errorf("%s is required when DISPATCHER_GRPC_ENDPOINT is enabled", name) + } + } + } + if role == "dispatcher" && strings.TrimSpace(c.DispatcherGRPCListen) != "" { + 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) == "" { + 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} { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s is required when Dispatcher gRPC is enabled", name) + } + } + if strings.TrimSpace(c.DispatcherGRPCAllowedAgentIDs) == "" { + return errors.New("DISPATCHER_ALLOWED_AGENT_IDS is required when Dispatcher gRPC is enabled") + } + fingerprints, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints) + if err != nil { + return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) + } + if len(fingerprints) == 0 { + return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled") + } + } + if role == "dispatcher" && strings.TrimSpace(c.AgentEndpointsFile) != "" { + 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) == "" { + return fmt.Errorf("%s is required when DISPATCHER_AGENT_ENDPOINTS_FILE is enabled", name) + } + } + } + if c.Mode == "real" && strings.TrimSpace(c.RabbitURL) == "" && role == "dispatcher" { + return errors.New("real dispatcher mode requires RABBITMQ_URL") + } + return nil +} + +func envOr(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func envOrSecret(valueKey, fileKey string) string { + if value := strings.TrimSpace(os.Getenv(valueKey)); value != "" { + return value + } + path := strings.TrimSpace(os.Getenv(fileKey)) + if path == "" { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func envInt(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..008f6f4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,85 @@ +package config + +import "testing" + +func TestConfigRejectsRealDispatcherWithoutBroker(t *testing.T) { + c := FromEnv() + c.Mode, c.RabbitURL, c.DBPath = "real", "", ":memory:" + if err := c.Validate("dispatcher"); err == nil { + t.Fatal("expected real-mode broker requirement") + } +} + +func TestConfigAcceptsMockAgent(t *testing.T) { + c := Config{Mode: "mock", SpoolRoot: t.TempDir()} + if err := c.Validate("agent"); err != nil { + t.Fatal(err) + } +} + +func TestConfigLoadsUnifiedDispatcherGRPCSettings(t *testing.T) { + t.Setenv("DISPATCHER_GRPC_LISTEN", "127.0.0.1:19443") + t.Setenv("DISPATCHER_GRPC_ENDPOINT", "dispatcher.test:19443") + t.Setenv("DISPATCHER_GRPC_SERVER_NAME", "dispatcher.test") + t.Setenv("DISPATCHER_ALLOWED_AGENT_IDS", "agent-cell-a,agent-cell-b") + c := FromEnv() + if c.DispatcherGRPCListen != "127.0.0.1:19443" || c.DispatcherGRPCEndpoint != "dispatcher.test:19443" || c.DispatcherGRPCServerName != "dispatcher.test" || c.DispatcherGRPCAllowedAgentIDs != "agent-cell-a,agent-cell-b" { + t.Fatalf("unified Dispatcher gRPC settings were not loaded: %+v", c) + } +} + +func TestConfigRequiresPhoneLogPair(t *testing.T) { + c := Config{Mode: "mock", SpoolRoot: t.TempDir(), CallLogPath: "calls.jsonl"} + if err := c.Validate("agent"); err == nil { + t.Fatal("expected phone log key requirement") + } + c.CallLogPhoneKey = "0123456789abcdef" + if err := c.Validate("agent"); err != nil { + t.Fatal(err) + } + c.CallLogPath = "" + if err := c.Validate("agent"); err != nil { + t.Fatalf("key-only configuration should use spool fallback: %v", err) + } + c.CallLogPhoneKey = "short" + if err := c.Validate("agent"); err == nil { + t.Fatal("expected phone log key length requirement") + } +} + +func TestConfigRequiresAgentTLSFilesWhenRPCIsEnabled(t *testing.T) { + c := Config{Mode: "mock", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090"} + if err := c.Validate("agent"); err == nil { + t.Fatal("expected mTLS file requirement") + } + c.MTLSCAFile, c.MTLSCertFile, c.MTLSKeyFile = "ca.pem", "agent.pem", "agent.key" + if err := c.Validate("agent"); err != nil { + t.Fatal(err) + } + c.Mode, c.GRPCListen = "real", "" + if err := c.Validate("agent"); err == nil { + t.Fatal("expected real agent listener requirement") + } +} + +func TestConfigRequiresStaticArtifactForRealAgent(t *testing.T) { + c := Config{Mode: "real", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090", MTLSCAFile: "ca.pem", MTLSCertFile: "agent.pem", MTLSKeyFile: "agent.key"} + if err := c.Validate("agent"); err == nil { + t.Fatal("expected static artifact requirement") + } + c.StaticArtifactPath = "artifact.json" + if err := c.Validate("agent"); err != nil { + 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/fingerprints.go b/internal/config/fingerprints.go new file mode 100644 index 0000000..b6ac622 --- /dev/null +++ b/internal/config/fingerprints.go @@ -0,0 +1,28 @@ +package config + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// ParseCertificateFingerprints parses a deployment-owned comma-separated SHA-256 +// leaf fingerprint allowlist. Colons are accepted for operator convenience but +// normalized away before comparison. +func ParseCertificateFingerprints(raw string) (map[string]struct{}, error) { + result := make(map[string]struct{}) + for _, item := range strings.Split(raw, ",") { + fingerprint := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(item), ":", "")) + if fingerprint == "" { + continue + } + if len(fingerprint) != 64 { + return nil, fmt.Errorf("mTLS peer certificate fingerprint must be 32 bytes: %q", item) + } + if _, err := hex.DecodeString(fingerprint); err != nil { + return nil, fmt.Errorf("invalid mTLS peer certificate fingerprint %q: %w", item, err) + } + result[fingerprint] = struct{}{} + } + return result, nil +} diff --git a/internal/contract/contract.go b/internal/contract/contract.go new file mode 100644 index 0000000..f60001d --- /dev/null +++ b/internal/contract/contract.go @@ -0,0 +1,171 @@ +package contract + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "time" + "unicode/utf8" + + "git.ipao.vip/rogee/go-sip/contracts" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +type CommandEnvelope 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"` +} + +type ExecutePayload struct { + ExecutionID string `json:"execution_id"` + TaskID string `json:"task_id"` + TaskItemID string `json:"task_item_id"` + TaskRevision int64 `json:"task_revision"` + Callee string `json:"callee"` + RoutePolicyID string `json:"route_policy_id"` + CallerProfileID string `json:"caller_profile_id"` + AgentVersionID string `json:"agent_version_id"` + Variables map[string]any `json:"variables"` + RingTimeoutMS int64 `json:"ring_timeout_ms"` + MaxCallDurationMS int64 `json:"max_call_duration_ms"` +} + +type EventEnvelope struct { + SchemaVersion string `json:"schema_version"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + TraceID string `json:"trace_id"` + OccurredAt string `json:"occurred_at"` + AggregateType string `json:"aggregate_type"` + AggregateID string `json:"aggregate_id"` + AggregateVersion int64 `json:"aggregate_version"` + Payload map[string]any `json:"payload"` +} + +var ErrInvalidTenantKey = errors.New("invalid tenant_key") + +// ValidateJSON applies the imported JSON Schema. It intentionally validates +// the source contract instead of maintaining a second hand-written schema. +func ValidateJSON(raw []byte) error { + return ValidateSourceSchema("mq.schema.json", raw) +} + +// ValidateEvent applies the event-specific payload contract in addition to the +// generic MQ envelope contract. +func ValidateEvent(raw []byte) error { + return ValidateSourceSchema("event-payloads.schema.json", raw) +} + +func ValidateSourceSchema(schemaName string, raw []byte) error { + var value any + if err := json.Unmarshal(raw, &value); 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) + if err != nil { + return fmt.Errorf("compile %s: %w", schemaName, err) + } + if err := schema.Validate(value); err != nil { + return fmt.Errorf("%s validation: %w", schemaName, err) + } + return nil +} + +func DecodeExecute(raw []byte) (CommandEnvelope, ExecutePayload, error) { + if err := ValidateJSON(raw); 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) + } + if err := ValidateTenantKey(envelope.TenantKey); err != nil { + return CommandEnvelope{}, ExecutePayload{}, err + } + var payload ExecutePayload + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode call.execute payload: %w", err) + } + return envelope, payload, nil +} + +func ValidateTenantKey(key string) error { + if key == "" || !utf8.ValidString(key) { + return fmt.Errorf("%w: must be non-empty valid UTF-8", ErrInvalidTenantKey) + } + if len([]byte(key)) > 224 { + return fmt.Errorf("%w: %d UTF-8 bytes exceeds 224-byte routing budget", ErrInvalidTenantKey, len([]byte(key))) + } + return nil +} + +func NotAfterExpired(raw string, now time.Time) (bool, error) { + deadline, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return false, fmt.Errorf("parse not_after: %w", err) + } + return !now.Before(deadline), nil +} + +func CloneJSON(raw []byte) json.RawMessage { + return bytes.Clone(raw) +} + +type EventBuilder struct { + TenantID string + TenantKey string + TraceID string + EventType string + Aggregate string + AggregateID string + Version int64 + Payload map[string]any +} + +func (b EventBuilder) Marshal(now time.Time, eventID string) ([]byte, error) { + if err := ValidateTenantKey(b.TenantKey); err != nil { + return nil, err + } + if b.Version < 1 { + return nil, errors.New("aggregate version must be positive") + } + e := EventEnvelope{ + SchemaVersion: "1.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, + } + raw, err := json.Marshal(e) + if err != nil { + return nil, err + } + if err := ValidateJSON(raw); err != nil { + return nil, err + } + if err := ValidateEvent(raw); err != nil { + return nil, err + } + return raw, nil +} diff --git a/internal/contract/contract_test.go b/internal/contract/contract_test.go new file mode 100644 index 0000000..7aa776e --- /dev/null +++ b/internal/contract/contract_test.go @@ -0,0 +1,130 @@ +package contract + +import ( + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestDecodeExecuteValidatesImportedSchema(t *testing.T) { + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + envelope, payload, err := DecodeExecute(raw) + if err != nil { + t.Fatal(err) + } + if envelope.TenantKey != "tenant-demo-key" || payload.ExecutionID == "" { + t.Fatalf("unexpected decoded command: %+v %+v", envelope, payload) + } +} + +func TestValidateTenantKeyPreservesRawValueAndBudget(t *testing.T) { + for _, key := range []string{"客户-A", " tenant /raw "} { + if err := ValidateTenantKey(key); err != nil { + t.Fatalf("tenant key %q: %v", key, err) + } + } + if err := ValidateTenantKey(strings.Repeat("x", 225)); err == nil { + t.Fatal("expected routing budget rejection") + } +} + +func TestEventFixtures(t *testing.T) { + positive := []string{ + "examples/event-command-result.json", + "examples/event-call-status.json", + "examples/event-transcript-updated.json", + "examples/event-call-finished.json", + "examples/event-recording-ready.json", + "examples/event-recording-failed.json", + "examples/event-transcript-failed.json", + "examples/event-contact-opt-out.json", + } + for _, name := range positive { + raw, err := contracts.Read(name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := ValidateEvent(raw); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + invalid, err := contracts.Read("examples/invalid-event-unknown-type.json") + if err != nil { + t.Fatal(err) + } + if err := ValidateEvent(invalid); err == nil { + t.Fatal("expected invalid transcript alias to be rejected") + } +} + +func TestProjectOwnedW01Fixtures(t *testing.T) { + valid := []string{ + "examples/ai-authorization.json", + "examples/oss-upload-grant.json", + "examples/static-cell-artifact.json", + "p1-development-profile.json", + } + for _, name := range valid { + raw, err := contracts.Read(name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if err := ValidateSourceSchema(schemaNameForFixture(name), raw); err != nil { + t.Fatalf("%s: %v", name, err) + } + } + invalid := []struct { + name string + schema string + }{ + {"examples/invalid-ai-authorization-revoked.json", "ai-authorization.schema.json"}, + {"examples/invalid-oss-upload-http.json", "oss-upload.schema.json"}, + } + for _, tc := range invalid { + raw, err := contracts.Read(tc.name) + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + if err := ValidateSourceSchema(tc.schema, raw); err == nil { + t.Fatalf("%s: expected rejection", tc.name) + } + } +} + +func schemaNameForFixture(name string) string { + switch name { + case "examples/ai-authorization.json": + return "ai-authorization.schema.json" + case "examples/oss-upload-grant.json": + return "oss-upload.schema.json" + case "examples/static-cell-artifact.json": + return "static-cell-artifact.schema.json" + case "p1-development-profile.json": + return "p1-development-profile.schema.json" + default: + return "" + } +} + +func TestEventBuilder(t *testing.T) { + raw, err := (EventBuilder{ + TenantID: "tenant-1", TenantKey: "tenant-1", TraceID: "trace-1", + EventType: "command.result", Aggregate: "command", AggregateID: "cmd-1", Version: 1, + Payload: map[string]any{ + "command_id": "cmd-1", "command_type": "call.execute", + "status": "accepted", "reason_code": "accepted", + "execution_id": "execution-1", + }, + }).Marshal(time.Unix(0, 0), "event-1") + if err != nil { + t.Fatal(err) + } + if err := ValidateEvent(raw); err != nil { + t.Fatal(err) + } +} diff --git a/internal/contract/schema_source_test.go b/internal/contract/schema_source_test.go new file mode 100644 index 0000000..e0a6335 --- /dev/null +++ b/internal/contract/schema_source_test.go @@ -0,0 +1,24 @@ +package contract + +import ( + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestContractBundleIsReadable(t *testing.T) { + for _, name := range []string{ + "mq.schema.json", "event-payloads.schema.json", "ai-config.schema.json", + "ai-authorization.schema.json", "oss-upload.schema.json", + "static-cell-artifact.schema.json", "p1-development-profile.schema.json", + "executor.openapi.yaml", "saas.openapi.yaml", "cell-agent.openapi.yaml", + } { + data, err := contracts.Read(name) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if len(data) == 0 { + t.Fatalf("%s is empty", name) + } + } +} diff --git a/internal/contract/static_artifact.go b/internal/contract/static_artifact.go new file mode 100644 index 0000000..d8c9f60 --- /dev/null +++ b/internal/contract/static_artifact.go @@ -0,0 +1,158 @@ +package contract + +import ( + "encoding/json" + "fmt" +) + +// StaticCellArtifact is the management-approved, immutable Cell/SIP hand-off +// artifact. Its JSON shape is owned by static-cell-artifact.schema.json; this +// type only provides a typed boundary after schema validation. +type StaticCellArtifact struct { + ArtifactID string `json:"artifact_id"` + SourceRelease string `json:"source_release"` + SourceDigest string `json:"source_digest"` + ApprovalReference string `json:"approval_reference"` + CellID string `json:"cell_id"` + Revision uint64 `json:"revision"` + ConfigSHA256 string `json:"config_sha256"` + Mode string `json:"mode"` + AllowedTargets []string `json:"allowed_targets"` + Trunks []StaticTrunk `json:"trunks"` + ARI *StaticARI `json:"ari,omitempty"` + MediaProfiles map[string]StaticMediaProfile `json:"media_profiles,omitempty"` + Media *StaticMedia `json:"media,omitempty"` + Recording *StaticRecording `json:"recording,omitempty"` + LoadEvidence *StaticLoadEvidence `json:"load_evidence"` +} + +type StaticTrunk struct { + TrunkID string `json:"trunk_id"` + ProviderID string `json:"provider_id"` + EgressPoolID string `json:"egress_pool_id"` + Codec string `json:"codec"` + CallerProfileIDs []string `json:"caller_profile_ids"` + DialPrefix string `json:"dial_prefix"` + Enabled bool `json:"enabled"` + SIPEndpointRef string `json:"sip_endpoint_ref"` + CredentialRef *string `json:"credential_ref"` + MediaProfileID string `json:"media_profile_id,omitempty"` +} + +type StaticARI struct { + BaseURL string `json:"base_url"` + WebsocketURL string `json:"websocket_url"` + Application string `json:"application"` + CredentialRef string `json:"credential_ref"` +} + +type StaticMedia struct { + BindAddress string `json:"bind_address"` + Port int `json:"port"` + Format string `json:"format"` + SampleRateHz int `json:"sample_rate_hz"` + Channels int `json:"channels"` + PayloadType int `json:"payload_type"` +} + +type StaticMediaProfile struct { + Format string `json:"format"` + SampleRateHz int `json:"sample_rate_hz"` + Channels int `json:"channels"` + PayloadType int `json:"payload_type"` +} + +type StaticRecording struct { + Enabled bool `json:"enabled"` + Format string `json:"format"` + Directory string `json:"directory"` + MaxBytes int64 `json:"max_bytes"` +} + +type StaticLoadEvidence struct { + AsteriskConfigSHA256 string `json:"asterisk_config_sha256"` + LoadedAt string `json:"loaded_at"` + Status string `json:"status"` +} + +// StaticArtifactExpectation contains deployment-local binding constraints. +// Empty string/slice values leave the corresponding optional check disabled; +// the source contract remains mandatory and is always validated first. +type StaticArtifactExpectation struct { + CellID string + Mode string + SourceRelease string + SourceDigest string + ConfigSHA256 string + MinimumRevision uint64 + AllowedEgressPoolIDs []string + RequiredTrunkIDs []string +} + +// ValidateStaticArtifact validates the imported artifact schema and then +// applies the local Cell hand-off bindings. It deliberately does not claim +// that Asterisk has loaded the artifact: load_evidence.status is explicitly +// "not-yet-loaded" in the contract until an independent load check exists. +func ValidateStaticArtifact(raw []byte, expected StaticArtifactExpectation) (StaticCellArtifact, error) { + if err := ValidateSourceSchema("static-cell-artifact.schema.json", raw); err != nil { + return StaticCellArtifact{}, err + } + + var artifact StaticCellArtifact + if err := json.Unmarshal(raw, &artifact); err != nil { + return StaticCellArtifact{}, fmt.Errorf("decode static Cell artifact: %w", err) + } + if expected.CellID != "" && artifact.CellID != expected.CellID { + return StaticCellArtifact{}, fmt.Errorf("static artifact cell binding mismatch: got %q, want %q", artifact.CellID, expected.CellID) + } + if expected.Mode != "" && artifact.Mode != expected.Mode { + return StaticCellArtifact{}, fmt.Errorf("static artifact mode mismatch: got %q, want %q", artifact.Mode, expected.Mode) + } + if expected.SourceRelease != "" && artifact.SourceRelease != expected.SourceRelease { + return StaticCellArtifact{}, fmt.Errorf("static artifact source release mismatch: got %q, want %q", artifact.SourceRelease, expected.SourceRelease) + } + if expected.SourceDigest != "" && artifact.SourceDigest != expected.SourceDigest { + return StaticCellArtifact{}, fmt.Errorf("static artifact source digest mismatch: got %q, want %q", artifact.SourceDigest, expected.SourceDigest) + } + if expected.ConfigSHA256 != "" && artifact.ConfigSHA256 != expected.ConfigSHA256 { + return StaticCellArtifact{}, fmt.Errorf("static artifact config digest mismatch: got %q, want %q", artifact.ConfigSHA256, expected.ConfigSHA256) + } + if expected.MinimumRevision != 0 && artifact.Revision < expected.MinimumRevision { + return StaticCellArtifact{}, fmt.Errorf("static artifact revision %d is older than required %d", artifact.Revision, expected.MinimumRevision) + } + + allowedEgress := make(map[string]struct{}, len(expected.AllowedEgressPoolIDs)) + for _, egressPoolID := range expected.AllowedEgressPoolIDs { + allowedEgress[egressPoolID] = struct{}{} + } + requiredTrunks := make(map[string]struct{}, len(expected.RequiredTrunkIDs)) + for _, trunkID := range expected.RequiredTrunkIDs { + requiredTrunks[trunkID] = struct{}{} + } + seenTrunks := make(map[string]struct{}, len(artifact.Trunks)) + for _, trunk := range artifact.Trunks { + if _, duplicate := seenTrunks[trunk.TrunkID]; duplicate { + return StaticCellArtifact{}, fmt.Errorf("static artifact contains duplicate trunk_id %q", trunk.TrunkID) + } + seenTrunks[trunk.TrunkID] = struct{}{} + if len(allowedEgress) != 0 { + if _, ok := allowedEgress[trunk.EgressPoolID]; !ok { + return StaticCellArtifact{}, fmt.Errorf("static artifact trunk %q uses disallowed egress pool %q", trunk.TrunkID, trunk.EgressPoolID) + } + } + if _, required := requiredTrunks[trunk.TrunkID]; required && !trunk.Enabled { + return StaticCellArtifact{}, fmt.Errorf("required static artifact trunk %q is disabled", trunk.TrunkID) + } + if trunk.MediaProfileID != "" { + if _, ok := artifact.MediaProfiles[trunk.MediaProfileID]; !ok { + return StaticCellArtifact{}, fmt.Errorf("static artifact trunk %q references unknown media profile %q", trunk.TrunkID, trunk.MediaProfileID) + } + } + } + for trunkID := range requiredTrunks { + if _, present := seenTrunks[trunkID]; !present { + return StaticCellArtifact{}, fmt.Errorf("required static artifact trunk %q is missing", trunkID) + } + } + return artifact, nil +} diff --git a/internal/contract/static_artifact_test.go b/internal/contract/static_artifact_test.go new file mode 100644 index 0000000..9e69f10 --- /dev/null +++ b/internal/contract/static_artifact_test.go @@ -0,0 +1,139 @@ +package contract + +import ( + "encoding/json" + "testing" + + "git.ipao.vip/rogee/go-sip/contracts" +) + +func TestValidateStaticArtifactBindsCellAndTrunks(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + + artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{ + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + MinimumRevision: 1, + AllowedEgressPoolIDs: []string{"egress-mock"}, + RequiredTrunkIDs: []string{"trunk-mock"}, + }) + if err != nil { + t.Fatalf("valid artifact rejected: %v", err) + } + if artifact.CellID != "cell-a" || artifact.Revision != 1 || len(artifact.Trunks) != 1 { + t.Fatalf("unexpected artifact: %+v", artifact) + } +} + +func TestValidateRealStaticArtifact(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json") + if err != nil { + t.Fatal(err) + } + artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{ + CellID: "cell-single", + Mode: "real", + SourceRelease: "asterisk-22.10.1-native-v1", + SourceDigest: "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + ConfigSHA256: "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", + AllowedEgressPoolIDs: []string{"egress-single"}, + RequiredTrunkIDs: []string{"provider-second"}, + }) + if err != nil { + t.Fatalf("valid real artifact rejected: %v", err) + } + if artifact.ARI == nil || artifact.Media == nil || artifact.Recording == nil { + t.Fatalf("real artifact lost runtime sections: %+v", artifact) + } + if artifact.Media.Format != "alaw" || artifact.Media.SampleRateHz != 8000 || artifact.Media.PayloadType != 8 || artifact.Recording.Format != "wav" { + t.Fatalf("unexpected real media/recording contract: %+v %+v", artifact.Media, artifact.Recording) + } +} + +func TestValidateStaticArtifactRejectsBindingViolations(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + base := func() StaticArtifactExpectation { + return StaticArtifactExpectation{ + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + AllowedEgressPoolIDs: []string{"egress-mock"}, + } + } + + tests := []struct { + name string + expected StaticArtifactExpectation + mutate func(*StaticCellArtifact) + }{ + {name: "wrong cell", expected: func() StaticArtifactExpectation { e := base(); e.CellID = "cell-b"; return e }()}, + {name: "wrong source digest", expected: func() StaticArtifactExpectation { + e := base() + e.SourceDigest = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + return e + }()}, + {name: "old revision", expected: func() StaticArtifactExpectation { e := base(); e.MinimumRevision = 2; return e }()}, + {name: "disallowed egress", expected: func() StaticArtifactExpectation { + e := base() + e.AllowedEgressPoolIDs = []string{"egress-other"} + return e + }()}, + {name: "missing required trunk", expected: func() StaticArtifactExpectation { + e := base() + e.RequiredTrunkIDs = []string{"trunk-required"} + return e + }()}, + {name: "disabled required trunk", expected: func() StaticArtifactExpectation { e := base(); e.RequiredTrunkIDs = []string{"trunk-mock"}; return e }(), mutate: func(a *StaticCellArtifact) { a.Trunks[0].Enabled = false }}, + {name: "duplicate trunk", expected: base(), mutate: func(a *StaticCellArtifact) { a.Trunks = append(a.Trunks, a.Trunks[0]) }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := raw + if test.mutate != nil { + var artifact StaticCellArtifact + if err := json.Unmarshal(raw, &artifact); err != nil { + t.Fatal(err) + } + test.mutate(&artifact) + candidate, err = json.Marshal(artifact) + if err != nil { + t.Fatal(err) + } + } + if _, err := ValidateStaticArtifact(candidate, test.expected); err == nil { + t.Fatal("expected static artifact validation to fail") + } + }) + } +} + +func TestValidateStaticArtifactAlwaysChecksSourceSchema(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + value["unexpected"] = true + candidate, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if _, err := ValidateStaticArtifact(candidate, StaticArtifactExpectation{}); err == nil { + t.Fatal("expected schema validation failure") + } +} diff --git a/internal/control/doc.go b/internal/control/doc.go new file mode 100644 index 0000000..39c61a0 --- /dev/null +++ b/internal/control/doc.go @@ -0,0 +1,3 @@ +// 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 new file mode 100644 index 0000000..e1ee64b --- /dev/null +++ b/internal/control/http.go @@ -0,0 +1,199 @@ +// 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 new file mode 100644 index 0000000..15d5a23 --- /dev/null +++ b/internal/control/http_test.go @@ -0,0 +1,81 @@ +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.go b/internal/dispatcher/agent.go new file mode 100644 index 0000000..1d58dff --- /dev/null +++ b/internal/dispatcher/agent.go @@ -0,0 +1,201 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/protobuf/proto" +) + +var ErrRemoteResultUnknown = errors.New("remote Agent result is unknown; reconciliation required") + +type AgentSession struct { + AgentID string + CellID string + BootID string + DispatcherEpoch string + SessionGeneration uint64 +} + +type DispatchResult struct { + Permit *agentv1.ExecutionPermit + Receipt *agentv1.OperationReceipt + State agentv1.ExecutionState + Unknown bool + Snapshot *agentv1.ExecutionSnapshot +} + +// AgentCoordinator owns session binding and the no-retry-after-unknown rule. +// Quotas and reservations remain in Dispatcher SQLite; this type only turns a +// durable reservation into a fenced Agent permit and execution request. +type AgentCoordinator struct { + mu sync.Mutex + now func() time.Time + clients map[string]agentv1.AgentControlServiceClient + sessions map[string]AgentSession +} + +func NewAgentCoordinator(now func() time.Time) *AgentCoordinator { + if now == nil { + now = time.Now + } + return &AgentCoordinator{now: now, clients: make(map[string]agentv1.AgentControlServiceClient), sessions: make(map[string]AgentSession)} +} + +func (c *AgentCoordinator) Register(agentID string, client agentv1.AgentControlServiceClient) error { + if agentID == "" || client == nil { + return errors.New("agent ID and client are required") + } + c.mu.Lock() + defer c.mu.Unlock() + c.clients[agentID] = client + return nil +} + +// Probe performs the pre-activation R01 status read. The response is limited +// to deployment status; Activate must still bind the returned boot ID before +// any session-authorized operation is attempted. +func (c *AgentCoordinator) Probe(ctx context.Context, agentID, cellID string) (*agentv1.AgentStatus, error) { + if agentID == "" || cellID == "" { + return nil, errors.New("agent ID and cell ID are required") + } + client, err := c.client(agentID) + if err != nil { + return nil, err + } + operationID := "status:" + agentID + response, err := client.GetAgentStatus(ctx, &agentv1.GetAgentStatusRequest{ + Meta: &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", + RequestId: operationID + ":request", + TraceId: operationID, + OperationId: operationID, + AgentId: agentID, + CellId: cellID, + }, + Target: &agentv1.AgentBinding{AgentId: agentID, CellId: cellID}, + }) + if err != nil { + return nil, err + } + if response == nil || response.Status == nil { + return nil, errors.New("Agent status response is empty") + } + if response.Status.AgentId != agentID || response.Status.CellId != cellID || response.Status.BootId == "" { + return nil, errors.New("Agent status identity or boot ID is invalid") + } + return response.Status, nil +} + +func (c *AgentCoordinator) Activate(ctx context.Context, agentID, cellID, bootID, epoch string, generation uint64) (AgentSession, error) { + client, err := c.client(agentID) + if err != nil { + return AgentSession{}, err + } + if cellID == "" || bootID == "" || epoch == "" { + return AgentSession{}, errors.New("cell, boot and dispatcher epoch are required") + } + operationID := fmt.Sprintf("activate:%s:%s", agentID, bootID) + meta := &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, DispatcherEpoch: epoch, AgentId: agentID, CellId: cellID, BootId: bootID} + response, err := client.ActivateAgent(ctx, &agentv1.ActivateAgentRequest{Meta: meta, Binding: &agentv1.AgentBinding{AgentId: agentID, CellId: cellID, ExpectedBootId: bootID, DispatcherEpoch: epoch, SessionGeneration: generation}, ActivationOperationId: operationID}) + if err != nil { + return AgentSession{}, err + } + if response.Session == nil || response.State != agentv1.ActivationState_ACTIVATION_STATE_ACTIVE { + return AgentSession{}, errors.New("Agent activation was not active") + } + session := AgentSession{AgentID: agentID, CellID: cellID, BootID: bootID, DispatcherEpoch: epoch, SessionGeneration: response.Session.SessionGeneration} + c.mu.Lock() + c.sessions[agentID] = session + c.mu.Unlock() + return session, nil +} + +func (c *AgentCoordinator) ExecuteRaw(ctx context.Context, agentID string, binding *agentv1.ExecutionBinding, raw []byte, reservationID, configSHA256 string) (DispatchResult, error) { + if binding == nil || binding.ExecutionId == "" || reservationID == "" { + return DispatchResult{}, errors.New("execution binding and reservation are required") + } + client, session, err := c.clientAndSession(agentID) + if err != nil { + return DispatchResult{}, err + } + permitRequest := &agentv1.GetExecutionPermitRequest{Meta: c.meta(session, "permit:"+binding.ExecutionId, "permit:"+binding.ExecutionId), Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), ResourceReservationId: reservationID, ExpectedTaskRevision: binding.TaskRevision, ConfigSha256: configSHA256} + permitResponse, err := client.GetExecutionPermit(ctx, permitRequest) + if err != nil { + return c.reconcileUnknown(ctx, client, session, binding, err) + } + if permitResponse != nil && permitResponse.Permit == nil && permitResponse.Receipt != nil && permitResponse.Receipt.Result == agentv1.ResultCode_RESULT_CODE_APPLIED { + // A durable receipt without the permit body is an incomplete read, not a + // reason to originate. Re-read the same reservation under a new read key. + permitRequest.Meta = c.meta(session, "permit-reconcile:"+binding.ExecutionId, "permit-reconcile:"+binding.ExecutionId) + permitResponse, err = client.GetExecutionPermit(ctx, permitRequest) + if err != nil { + return c.reconcileUnknown(ctx, client, session, binding, err) + } + } + if permitResponse == nil || permitResponse.Permit == nil || permitResponse.Receipt == nil || permitResponse.Receipt.Result == agentv1.ResultCode_RESULT_CODE_REJECTED || permitResponse.Receipt.Result == agentv1.ResultCode_RESULT_CODE_CONFLICT { + if permitResponse == nil { + return DispatchResult{}, fmt.Errorf("Agent did not grant execution permit: empty response") + } + return DispatchResult{}, fmt.Errorf("Agent did not grant execution permit: result=%s failure=%v permit=%v", permitResponse.Receipt.GetResult().String(), permitResponse.Receipt.GetFailure(), permitResponse.Permit) + } + executeMeta := c.meta(session, "execute:"+binding.ExecutionId, "execute:"+binding.ExecutionId) + executeResponse, err := client.Execute(ctx, &agentv1.ExecuteRequest{Meta: executeMeta, Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), CallExecuteJson: append([]byte(nil), raw...), ConfigSha256: configSHA256, PermitId: permitResponse.Permit.PermitId}) + if err != nil { + return c.reconcileUnknown(ctx, client, session, binding, err) + } + if executeResponse == nil || executeResponse.Receipt == nil { + return c.reconcileUnknown(ctx, client, session, binding, ErrRemoteResultUnknown) + } + return DispatchResult{Permit: permitResponse.Permit, Receipt: executeResponse.Receipt, State: executeResponse.State}, nil +} + +func (c *AgentCoordinator) Control(ctx context.Context, agentID string, binding *agentv1.ExecutionBinding, action agentv1.ControlAction, policy agentv1.ActiveCallPolicy) (*agentv1.ApplyTaskControlResponse, error) { + client, session, err := c.clientAndSession(agentID) + if err != nil { + return nil, err + } + meta := c.meta(session, fmt.Sprintf("control:%s:%d", binding.ExecutionId, binding.TaskRevision), fmt.Sprintf("control:%s:%d", binding.ExecutionId, binding.TaskRevision)) + return client.ApplyTaskControl(ctx, &agentv1.ApplyTaskControlRequest{Meta: meta, Binding: proto.Clone(binding).(*agentv1.ExecutionBinding), Action: action, ActiveCallPolicy: policy, ExpectedTaskRevision: binding.TaskRevision}) +} + +func (c *AgentCoordinator) client(agentID string) (agentv1.AgentControlServiceClient, error) { + c.mu.Lock() + defer c.mu.Unlock() + client := c.clients[agentID] + if client == nil { + return nil, fmt.Errorf("Agent %q is not registered", agentID) + } + return client, nil +} + +func (c *AgentCoordinator) clientAndSession(agentID string) (agentv1.AgentControlServiceClient, AgentSession, error) { + client, err := c.client(agentID) + if err != nil { + return nil, AgentSession{}, err + } + c.mu.Lock() + session, ok := c.sessions[agentID] + c.mu.Unlock() + if !ok { + return nil, AgentSession{}, fmt.Errorf("Agent %q is not activated", agentID) + } + return client, session, nil +} + +func (c *AgentCoordinator) meta(session AgentSession, operationID, idempotencyKey string) *agentv1.RequestMeta { + return &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + ":request", TraceId: operationID, OperationId: operationID, IdempotencyKey: idempotencyKey, DispatcherEpoch: session.DispatcherEpoch, AgentId: session.AgentID, CellId: session.CellID, BootId: session.BootID, SessionGeneration: session.SessionGeneration} +} + +func (c *AgentCoordinator) reconcileUnknown(ctx context.Context, client agentv1.AgentControlServiceClient, session AgentSession, binding *agentv1.ExecutionBinding, cause error) (DispatchResult, error) { + queryMeta := c.meta(session, "query:"+binding.ExecutionId, "query:"+binding.ExecutionId) + response, err := client.QueryExecution(ctx, &agentv1.QueryExecutionRequest{Meta: queryMeta, Binding: proto.Clone(binding).(*agentv1.ExecutionBinding)}) + if err == nil && response != nil && response.Snapshot != nil { + return DispatchResult{Unknown: true, Snapshot: response.Snapshot}, fmt.Errorf("%w: %v", ErrRemoteResultUnknown, cause) + } + return DispatchResult{Unknown: true}, fmt.Errorf("%w: %v", ErrRemoteResultUnknown, cause) +} diff --git a/internal/dispatcher/agent_test.go b/internal/dispatcher/agent_test.go new file mode 100644 index 0000000..e5ae550 --- /dev/null +++ b/internal/dispatcher/agent_test.go @@ -0,0 +1,114 @@ +package dispatcher + +import ( + "context" + "net" + "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" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestAgentCoordinatorProbesBeforeActivation(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := rpcserver.NewServer(rpcserver.ServerOptions{ + Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) }, + Status: &agentv1.AgentStatus{ + AgentId: "agent-1", + CellId: "cell-1", + BootId: "boot-probed", + ProtocolVersion: "agent.v1", + }, + }) + grpcServer := grpc.NewServer() + agentv1.RegisterAgentControlServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + defer grpcServer.Stop() + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + coordinator := NewAgentCoordinator(func() time.Time { return time.Unix(100, 0) }) + if err := coordinator.Register("agent-1", agentv1.NewAgentControlServiceClient(conn)); err != nil { + t.Fatal(err) + } + status, err := coordinator.Probe(context.Background(), "agent-1", "cell-1") + if err != nil { + t.Fatal(err) + } + if status.BootId != "boot-probed" || status.SessionActive { + t.Fatalf("unexpected pre-activation status: %+v", status) + } + first, err := coordinator.Activate(context.Background(), "agent-1", "cell-1", status.BootId, "epoch-1", 0) + if err != nil { + t.Fatal(err) + } + second, err := coordinator.Activate(context.Background(), "agent-1", "cell-1", status.BootId, "epoch-2", 0) + if err != nil { + t.Fatal(err) + } + if first.SessionGeneration != 1 || second.SessionGeneration != 2 { + t.Fatalf("unexpected generations: first=%d second=%d", first.SessionGeneration, second.SessionGeneration) + } +} + +func TestAgentCoordinatorActivatesExecutesAndControlsWithoutRetryingOriginate(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + server := rpcserver.NewServer(rpcserver.ServerOptions{Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) }}) + grpcServer := grpc.NewServer() + agentv1.RegisterAgentControlServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + defer grpcServer.Stop() + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + coordinator := NewAgentCoordinator(func() time.Time { return time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) }) + if err := coordinator.Register("agent-1", agentv1.NewAgentControlServiceClient(conn)); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-1", "cell-1", "boot-1", "epoch-1", 1); err != nil { + t.Fatal(err) + } + + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + t.Fatal(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, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} + first, err := coordinator.ExecuteRaw(context.Background(), "agent-1", binding, raw, "reservation-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + if first.Permit == nil || first.Receipt == nil || first.Unknown { + t.Fatalf("unexpected dispatch result: %+v", first) + } + second, err := coordinator.ExecuteRaw(context.Background(), "agent-1", binding, raw, "reservation-1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + if second.Permit == nil || second.Receipt == nil || second.Unknown { + t.Fatalf("unexpected replay result: %+v", second) + } + control, err := coordinator.Control(context.Background(), "agent-1", binding, agentv1.ControlAction_CONTROL_ACTION_PAUSE, agentv1.ActiveCallPolicy_ACTIVE_CALL_POLICY_DRAIN) + if err != nil { + t.Fatal(err) + } + if control.Receipt == nil || control.State != agentv1.ExecutionState_EXECUTION_STATE_PERMIT_GRANTED { + t.Fatalf("unexpected control result: %+v", control) + } +} diff --git a/internal/dispatcher/consumer.go b/internal/dispatcher/consumer.go new file mode 100644 index 0000000..ff80678 --- /dev/null +++ b/internal/dispatcher/consumer.go @@ -0,0 +1,40 @@ +package dispatcher + +import ( + "context" + "errors" + "strings" + + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +func (d *Dispatcher) ConsumeTenant(ctx context.Context, broker *mq.Broker, tenantKey string) error { + if broker == nil { + return errors.New("broker is required") + } + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return err + } + queue, err := broker.DeclareTenantQueue(tenantKey) + if err != nil { + return err + } + return broker.Consume(ctx, queue, func(ctx context.Context, routingKey string, body []byte) error { + if _, err := d.AcceptCommand(body, routingKey); err != nil { + if isPermanentCommandError(err) { + return mq.Permanent(err) + } + return err + } + return nil + }) +} + +func isPermanentCommandError(err error) bool { + if errors.Is(err, contract.ErrInvalidTenantKey) { + return true + } + message := err.Error() + return strings.Contains(message, "schema validation") || strings.Contains(message, "decode json") || strings.Contains(message, "routing mismatch") +} diff --git a/internal/dispatcher/consumer_test.go b/internal/dispatcher/consumer_test.go new file mode 100644 index 0000000..4a71a93 --- /dev/null +++ b/internal/dispatcher/consumer_test.go @@ -0,0 +1,24 @@ +package dispatcher + +import ( + "errors" + "testing" + + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/mq" +) + +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") + } + if isPermanentCommandError(errors.New("sqlite busy")) { + t.Fatal("storage failure must be retried") + } + if !mq.IsPermanent(mq.Permanent(contract.ErrInvalidTenantKey)) { + t.Fatal("permanent wrapper was not recognized") + } +} diff --git a/internal/dispatcher/dispatcher.go b/internal/dispatcher/dispatcher.go new file mode 100644 index 0000000..2885b87 --- /dev/null +++ b/internal/dispatcher/dispatcher.go @@ -0,0 +1,208 @@ +package dispatcher + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + "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/mq" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +type Dispatcher struct { + store *store.Store + publisher mq.Publisher + now func() time.Time +} + +func New(s *store.Store, publisher mq.Publisher, now func() time.Time) (*Dispatcher, error) { + if s == nil { + return nil, errors.New("store is required") + } + if now == nil { + now = time.Now + } + return &Dispatcher{store: s, publisher: publisher, now: now}, nil +} + +func (d *Dispatcher) Store() *store.Store { return d.store } + +func (d *Dispatcher) AcceptCommand(raw []byte, routingKey string) (store.IngestResult, error) { + return d.store.IngestCommand(raw, routingKey) +} + +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 + } + published := 0 + for _, record := range records { + 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) + } + if err := d.store.MarkOutboxPublished(record.ID); err != nil { + return published, fmt.Errorf("mark outbox %s published: %w", record.EventID, err) + } + published++ + } + return published, nil +} + +func (d *Dispatcher) ReserveTask(tenantKey, reservationID string, scopes []string) (store.Task, error) { + task, err := d.store.NextTask(tenantKey) + if err != nil { + return store.Task{}, err + } + 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 +} + +// ExecuteReserved is the local integration seam between the durable +// 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) { + if coordinator == nil || task.ExecutionID == "" || reservationID == "" { + return DispatchResult{}, errors.New("coordinator, reserved task and reservation are required") + } + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + return DispatchResult{}, err + } + if envelope.TenantID != task.TenantID || envelope.TenantKey != task.TenantKey || + payload.ExecutionID != task.ExecutionID || payload.TaskID != task.TaskID || + payload.TaskItemID != task.TaskItemID || payload.TaskRevision != task.TaskRevision || + payload.AgentVersionID != task.AgentVersionID || payload.RoutePolicyID != task.RoutePolicyID || + payload.CallerProfileID != task.CallerProfileID { + if finalizeErr := d.store.FinalizeReservation(reservationID, task.ExecutionID, false); finalizeErr != nil { + return DispatchResult{}, fmt.Errorf("execution binding mismatch; finalize reservation: %w", finalizeErr) + } + return DispatchResult{}, errors.New("reserved task does not match call.execute binding") + } + binding := &agentv1.ExecutionBinding{ + TenantId: envelope.TenantID, + TenantKey: envelope.TenantKey, + ExecutionId: payload.ExecutionID, + TaskId: payload.TaskID, + TaskItemId: payload.TaskItemID, + TaskRevision: payload.TaskRevision, + AgentVersionId: payload.AgentVersionID, + RoutePolicyId: payload.RoutePolicyID, + CallerProfileId: payload.CallerProfileID, + } + 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 { + return result, fmt.Errorf("%w; finalize reservation: %v", err, finalizeErr) + } + return result, err + } + if err := d.store.MarkTaskRunning(task.ExecutionID); err != nil { + result.Unknown = true + return result, fmt.Errorf("remote execution applied but task state could not be marked running: %w", err) + } + return result, nil +} + +func (d *Dispatcher) ApplyControl(executionID string, expectedRevision int64, action string) error { + return d.store.ApplyControl(executionID, expectedRevision, action) +} + +type FairScheduler struct { + mu sync.Mutex + tenants []string + cursor int +} + +func NewFairScheduler(tenants []string) *FairScheduler { + copyTenants := append([]string(nil), tenants...) + return &FairScheduler{tenants: copyTenants} +} + +func NewFairSchedulerFromStore(st *store.Store, scope string, tenants []string) (*FairScheduler, error) { + if st == nil { + return nil, errors.New("store is required") + } + scheduler := NewFairScheduler(tenants) + cursor, err := st.LoadSchedulerCursor(scope, tenants) + if err != nil { + return nil, err + } + scheduler.RestoreCursor(cursor) + return scheduler, nil +} + +// NextTenantDurable persists the next cursor before returning a tenant. A +// restart therefore resumes the bounded rotation instead of resetting to the +// first active tenant. +func (s *FairScheduler) NextTenantDurable(st *store.Store, scope string) (string, bool, error) { + if st == nil { + return "", false, errors.New("store is required") + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.tenants) == 0 { + return "", false, nil + } + index := s.cursor % len(s.tenants) + tenant := s.tenants[index] + next := (index + 1) % len(s.tenants) + if err := st.SaveSchedulerCursor(scope, s.tenants, next); err != nil { + return "", false, err + } + s.cursor = next + return tenant, true, nil +} + +// NextTenant returns the next tenant in a bounded round-robin cycle. The +// caller performs the durable task/lease check; no unbounded in-memory FIFO is +// used and a failed tenant does not consume another tenant's turn. +func (s *FairScheduler) NextTenant() (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.tenants) == 0 { + return "", false + } + tenant := s.tenants[s.cursor%len(s.tenants)] + s.cursor = (s.cursor + 1) % len(s.tenants) + return tenant, true +} + +func (s *FairScheduler) Snapshot() (tenants []string, cursor int) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.tenants...), s.cursor +} + +// RestoreCursor is used only during restart recovery after the durable +// scheduler has reconstructed the active tenant set. +func (s *FairScheduler) RestoreCursor(cursor int) { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.tenants) == 0 { + s.cursor = 0 + return + } + if cursor < 0 { + cursor = 0 + } + s.cursor = cursor % len(s.tenants) +} + +func IsNoTask(err error) bool { return errors.Is(err, sql.ErrNoRows) } diff --git a/internal/dispatcher/dispatcher_test.go b/internal/dispatcher/dispatcher_test.go new file mode 100644 index 0000000..c50bf9b --- /dev/null +++ b/internal/dispatcher/dispatcher_test.go @@ -0,0 +1,313 @@ +package dispatcher + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "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" + _ "modernc.org/sqlite" +) + +type fakePublisher struct { + exchanges []string + keys []string + bodies [][]byte + err error +} + +func (p *fakePublisher) Publish(_ context.Context, exchange, key string, body []byte) error { + if p.err != nil { + return p.err + } + p.exchanges = append(p.exchanges, exchange) + p.keys = append(p.keys, key) + p.bodies = append(p.bodies, append([]byte(nil), body...)) + return nil +} + +func TestFlushOutboxPublishesAfterDurableIngest(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + 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") + if err != nil { + t.Fatal(err) + } + if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); 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" { + t.Fatalf("exchange=%q", pub.exchanges[0]) + } +} + +func TestOutboxClaimRecoveryPublishesAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "dispatcher.db") + first, err := store.Open(path) + if 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") + if err != nil { + t.Fatal(err) + } + if _, err := firstDispatcher.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + t.Fatal(err) + } + claimed, err := first.ClaimOutbox(1) + if err != nil || len(claimed) != 1 { + t.Fatalf("claimed=%d err=%v", len(claimed), err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + second, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }) + publisher := &fakePublisher{} + secondDispatcher, err := New(second, publisher, time.Now) + if err != nil { + t.Fatal(err) + } + published, err := secondDispatcher.FlushOutbox(t.Context(), 1) + if err != nil || published != 1 || len(publisher.bodies) != 1 { + t.Fatalf("published=%d err=%v bodies=%d", published, err, len(publisher.bodies)) + } + var status string + if err := second.DB().QueryRow(`SELECT status FROM outbox WHERE event_id = ?`, claimed[0].EventID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "published" { + t.Fatalf("status=%q, want published", status) + } +} + +func TestOutboxProcessCrashRecovery(t *testing.T) { + const ( + helperEnv = "SIP_GO_AGENT_OUTBOX_CRASH_HELPER" + dbEnv = "SIP_GO_AGENT_OUTBOX_CRASH_DB" + exitCode = 97 + ) + if os.Getenv(helperEnv) == "1" { + path := os.Getenv(dbEnv) + st, err := store.Open(path) + if 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") + 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 { + fmt.Fprintln(os.Stderr, err) + os.Exit(5) + } + claimed, err := st.ClaimOutbox(1) + if err != nil || len(claimed) != 1 { + fmt.Fprintf(os.Stderr, "claimed=%d err=%v\\n", len(claimed), err) + os.Exit(6) + } + // Simulate a process dying after the durable claim and before publish. + os.Exit(exitCode) + } + + path := filepath.Join(t.TempDir(), "dispatcher.db") + cmd := exec.Command(os.Args[0], "-test.run=^TestOutboxProcessCrashRecovery$") + cmd.Env = append(os.Environ(), helperEnv+"=1", dbEnv+"="+path) + output, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + if err == nil || !errors.As(err, &exitErr) || exitErr.ExitCode() != exitCode { + t.Fatalf("crash helper err=%v output=%s", err, output) + } + + st, err := store.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + publisher := &fakePublisher{} + d, err := New(st, publisher, time.Now) + if err != nil { + t.Fatal(err) + } + published, err := d.FlushOutbox(t.Context(), 1) + if err != nil || published != 1 || len(publisher.bodies) != 1 { + t.Fatalf("published=%d err=%v bodies=%d", published, err, len(publisher.bodies)) + } +} + +func TestFlushOutboxMarksRetryOnPublishFailure(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + 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") + if err != nil { + t.Fatal(err) + } + if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + t.Fatal(err) + } + if _, err := d.FlushOutbox(context.Background(), 10); err == nil { + t.Fatal("expected publish failure") + } + rows, err := st.DB().Query(`SELECT status FROM outbox`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + if !rows.Next() { + t.Fatal("missing outbox row") + } + var status string + if err := rows.Scan(&status); err != nil { + t.Fatal(err) + } + if status != "retry" { + t.Fatalf("status=%q, want retry", status) + } +} + +func TestExecuteReservedBindsQuotaAndAgentExecution(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-1"} { + if err := st.SetQuota(scope, 1); err != nil { + t.Fatal(err) + } + } + now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) + d, err := New(st, nil, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + if _, err := d.AcceptCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + t.Fatal(err) + } + task, err := d.ReserveTask("tenant-demo-key", "reservation-integration", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}) + if err != nil { + t.Fatal(err) + } + coordinator := NewAgentCoordinator(func() time.Time { return now }) + client := startMockAgent(t, &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1"}) + if err := coordinator.Register("agent-1", client); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-1", "cell-1", "boot-1", "epoch-1", 1); err != nil { + t.Fatal(err) + } + result, err := d.ExecuteReserved(context.Background(), coordinator, "agent-1", task, raw, "reservation-integration", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + if result.Permit == nil || result.Receipt == nil || result.Unknown { + t.Fatalf("unexpected result: %+v", result) + } + var taskStatus string + if err := st.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = ?`, task.ExecutionID).Scan(&taskStatus); err != nil { + t.Fatal(err) + } + if taskStatus != "running" { + t.Fatalf("task status=%q, want running", taskStatus) + } +} + +func TestFairSchedulerRoundRobinAndRestore(t *testing.T) { + s := NewFairScheduler([]string{"a", "b", "c"}) + for i, want := range []string{"a", "b", "c", "a"} { + got, ok := s.NextTenant() + if !ok || got != want { + t.Fatalf("turn %d = %q/%v, want %q", i, got, ok, want) + } + } + s.RestoreCursor(2) + got, _ := s.NextTenant() + if got != "c" { + t.Fatalf("restored cursor = %q, want c", got) + } +} + +func TestFairSchedulerPersistsCursorAcrossRestart(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "dispatcher.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + first, err := NewFairSchedulerFromStore(st, "tenant-rotation", []string{"a", "b", "c"}) + if err != nil { + t.Fatal(err) + } + for i, want := range []string{"a", "b"} { + got, ok, err := first.NextTenantDurable(st, "tenant-rotation") + if err != nil || !ok || got != want { + t.Fatalf("turn %d = %q/%v err=%v, want %q", i, got, ok, err, want) + } + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + st, err = store.Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + second, err := NewFairSchedulerFromStore(st, "tenant-rotation", []string{"a", "b", "c"}) + if err != nil { + t.Fatal(err) + } + got, ok, err := second.NextTenantDurable(st, "tenant-rotation") + if err != nil || !ok || got != "c" { + t.Fatalf("restart turn = %q/%v err=%v, want c", got, ok, err) + } +} diff --git a/internal/dispatcher/lease.go b/internal/dispatcher/lease.go new file mode 100644 index 0000000..d0bad9e --- /dev/null +++ b/internal/dispatcher/lease.go @@ -0,0 +1,72 @@ +package dispatcher + +import ( + "context" + "fmt" + "sync" + "time" + + "git.ipao.vip/rogee/go-sip/internal/store" +) + +const activeLeaseScope = "dispatcher" + +type LeaseGuard struct { + store *store.Store + leaseID string + scope string + holder string + ttl time.Duration + stop chan struct{} + done chan struct{} + lost chan error + once sync.Once +} + +func StartLease(ctx context.Context, s *store.Store, leaseID, scope, holder string, ttl time.Duration) (*LeaseGuard, error) { + if ctx == nil { + return nil, fmt.Errorf("lease context is required") + } + lease, err := s.AcquireLease(leaseID, scope, holder, ttl) + if err != nil { + return nil, err + } + guard := &LeaseGuard{store: s, leaseID: lease.LeaseID, scope: scope, holder: holder, ttl: ttl, stop: make(chan struct{}), done: make(chan struct{}), lost: make(chan error, 1)} + go guard.renew(ctx) + return guard, nil +} + +func (g *LeaseGuard) renew(ctx context.Context) { + defer close(g.done) + ticker := time.NewTicker(g.ttl / 3) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-g.stop: + return + case <-ticker.C: + if _, err := g.store.AcquireLease(g.leaseID, g.scope, g.holder, g.ttl); err != nil { + select { + case g.lost <- err: + default: + } + return + } + } + } +} + +func (g *LeaseGuard) Lost() <-chan error { return g.lost } + +func (g *LeaseGuard) Stop() error { + if g == nil { + return nil + } + g.once.Do(func() { + close(g.stop) + <-g.done + }) + return g.store.ReleaseLease(g.leaseID) +} diff --git a/internal/dispatcher/local_flow_test.go b/internal/dispatcher/local_flow_test.go new file mode 100644 index 0000000..27154c3 --- /dev/null +++ b/internal/dispatcher/local_flow_test.go @@ -0,0 +1,107 @@ +package dispatcher + +import ( + "context" + "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/agent" + "git.ipao.vip/rogee/go-sip/internal/ai" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/health" + "git.ipao.vip/rogee/go-sip/internal/store" +) + +func TestLocalContractBackedFlowEvidence(t *testing.T) { + now := time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC) + + artifactRaw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + if _, err := contract.ValidateStaticArtifact(artifactRaw, contract.StaticArtifactExpectation{ + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + AllowedEgressPoolIDs: []string{"egress-mock"}, + RequiredTrunkIDs: []string{"trunk-mock"}, + }); err != nil { + t.Fatal(err) + } + + snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(snapshotRaw, ai.ModeASROnly) + if err != nil { + t.Fatal(err) + } + authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + if err != nil { + t.Fatal(err) + } + if _, err := ai.ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", now); err != nil { + t.Fatal(err) + } + + sample := (health.Sampler{Now: func() time.Time { return now }}).Sample(context.Background(), t.TempDir()) + if sample.SampleFresh || sample.MissingReason == "" { + t.Fatalf("resource sample must remain explicitly partial/unknown: %+v", sample) + } + + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-a"} { + if err := st.SetQuota(scope, 1); err != nil { + t.Fatal(err) + } + } + d, err := New(st, nil, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + callRaw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + if _, err := d.AcceptCommand(callRaw, "agent-call.tenant.tenant-demo-key.call.execute"); err != nil { + t.Fatal(err) + } + task, err := d.ReserveTask("tenant-demo-key", "reservation-local-flow", []string{"tenant:tenant-demo-key", "global", "cell:cell-a"}) + if err != nil { + t.Fatal(err) + } + coordinator := NewAgentCoordinator(func() time.Time { return now }) + status := agentStatusForLocalFlow() + client := startMockAgent(t, &status) + if err := coordinator.Register("agent-a", client); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-a", "cell-a", "boot-a", "epoch-a", 1); err != nil { + t.Fatal(err) + } + result, err := d.ExecuteReserved(context.Background(), coordinator, "agent-a", task, callRaw, "reservation-local-flow", snapshot.Digest) + if err != nil || result.Unknown || result.Permit == nil || result.Receipt == nil { + t.Fatalf("local execution result=%+v err=%v", result, 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) + } + if err := contract.ValidateEvent(event); err != nil { + t.Fatal(err) + } +} + +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 new file mode 100644 index 0000000..b8c1e17 --- /dev/null +++ b/internal/dispatcher/mq_integration_test.go @@ -0,0 +1,97 @@ +//go:build integration + +package dispatcher + +import ( + "context" + "os" + "path/filepath" + "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" +) + +func TestLocalDispatcherConsumesCommandIntoSQLiteAndPublishesOutbox(t *testing.T) { + url := os.Getenv("RABBITMQ_URL") + if url == "" { + t.Skip("RABBITMQ_URL is not configured") + } + broker, err := mq.OpenWithPrefetch(url, "", 1) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := broker.Close(); err != nil { + t.Error(err) + } + }) + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + d, err := New(st, broker, time.Now) + if err != nil { + t.Fatal(err) + } + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + const tenantKey = "tenant-demo-key" + routingKey := "agent-call.tenant." + tenantKey + ".call.execute" + if _, err := broker.DeclareTenantQueue(tenantKey); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second) + defer cancel() + consumeDone := make(chan error, 1) + go func() { consumeDone <- d.ConsumeTenant(ctx, broker, tenantKey) }() + if err := broker.Publish(ctx, mq.DefaultExchange, routingKey, raw); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + var taskCount, outboxCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM tasks`).Scan(&taskCount); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox`).Scan(&outboxCount); err != nil { + t.Fatal(err) + } + if taskCount == 1 && outboxCount >= 1 { + break + } + if time.Now().After(deadline) { + t.Fatalf("command was not persisted: tasks=%d outbox=%d", taskCount, outboxCount) + } + time.Sleep(50 * time.Millisecond) + } + published, err := d.FlushOutbox(ctx, 1) + if err != nil || published != 1 { + t.Fatalf("published=%d err=%v", published, err) + } + cancel() + select { + case <-consumeDone: + case <-time.After(3 * time.Second): + t.Fatal("tenant consumer did not stop") + } + var taskStatus, outboxStatus string + if err := st.DB().QueryRow(`SELECT status FROM tasks LIMIT 1`).Scan(&taskStatus); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT status FROM outbox LIMIT 1`).Scan(&outboxStatus); err != nil { + t.Fatal(err) + } + if taskStatus != "accepted" || outboxStatus != "published" { + t.Fatalf("unexpected persisted statuses: task=%q outbox=%q", taskStatus, outboxStatus) + } +} diff --git a/internal/dispatcher/two_cell_test.go b/internal/dispatcher/two_cell_test.go new file mode 100644 index 0000000..e92b440 --- /dev/null +++ b/internal/dispatcher/two_cell_test.go @@ -0,0 +1,97 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "net" + "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" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/test/bufconn" +) + +func TestTwoMockCellsKeepAgentSessionsAndPermitsSeparate(t *testing.T) { + coordinator := NewAgentCoordinator(func() time.Time { return time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) }) + clientA := startMockAgent(t, &agentv1.AgentStatus{AgentId: "agent-a", CellId: "cell-a"}) + clientB := startMockAgent(t, &agentv1.AgentStatus{AgentId: "agent-b", CellId: "cell-b"}) + if err := coordinator.Register("agent-a", clientA); err != nil { + t.Fatal(err) + } + if err := coordinator.Register("agent-b", clientB); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-a", "cell-a", "boot-a", "epoch-1", 1); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Activate(context.Background(), "agent-b", "cell-b", "boot-b", "epoch-1", 1); err != nil { + t.Fatal(err) + } + + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + rawA := mutateExecution(t, raw, "execution-a", "task-a", "item-a") + rawB := mutateExecution(t, raw, "execution-b", "task-b", "item-b") + bindingA := executionBinding(t, rawA) + bindingB := executionBinding(t, rawB) + resultA, err := coordinator.ExecuteRaw(context.Background(), "agent-a", bindingA, rawA, "reservation-a", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + if err != nil { + t.Fatal(err) + } + resultB, err := coordinator.ExecuteRaw(context.Background(), "agent-b", bindingB, rawB, "reservation-b", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + if err != nil { + t.Fatal(err) + } + if resultA.Permit == nil || resultB.Permit == nil || resultA.Permit.PermitId == resultB.Permit.PermitId { + t.Fatalf("permits are not cell-scoped: A=%v B=%v", resultA.Permit, resultB.Permit) + } +} + +func startMockAgent(t *testing.T, status *agentv1.AgentStatus) agentv1.AgentControlServiceClient { + t.Helper() + listener := bufconn.Listen(1024 * 1024) + server := rpcserver.NewServer(rpcserver.ServerOptions{Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) }, Status: status}) + grpcServer := grpc.NewServer() + agentv1.RegisterAgentControlServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + t.Cleanup(func() { grpcServer.Stop() }) + conn, err := grpc.NewClient("passthrough:///mock-agent", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + return agentv1.NewAgentControlServiceClient(conn) +} + +func mutateExecution(t *testing.T, raw []byte, executionID, taskID, itemID string) []byte { + t.Helper() + var document map[string]any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatal(err) + } + payload := document["payload"].(map[string]any) + payload["execution_id"] = executionID + payload["task_id"] = taskID + payload["task_item_id"] = itemID + encoded, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + return encoded +} + +func executionBinding(t *testing.T, raw []byte) *agentv1.ExecutionBinding { + t.Helper() + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + t.Fatal(err) + } + return &agentv1.ExecutionBinding{TenantId: envelope.TenantID, TenantKey: envelope.TenantKey, ExecutionId: payload.ExecutionID, TaskId: payload.TaskID, TaskItemId: payload.TaskItemID, TaskRevision: payload.TaskRevision, AgentVersionId: payload.AgentVersionID, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID} +} diff --git a/internal/health/sample.go b/internal/health/sample.go new file mode 100644 index 0000000..85e3547 --- /dev/null +++ b/internal/health/sample.go @@ -0,0 +1,80 @@ +package health + +import ( + "context" + "os" + "strings" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/disk" + "github.com/shirou/gopsutil/v4/mem" + "github.com/shirou/gopsutil/v4/process" +) + +// Sampler uses gopsutil for host/process observations. Unsupported or +// unconfigured dimensions stay explicitly unknown instead of being reported as +// zero capacity. +type Sampler struct { + Now func() time.Time +} + +func (s Sampler) Sample(ctx context.Context, spoolRoot string) *agentv1.ResourceSample { + now := time.Now + if s.Now != nil { + now = s.Now + } + sample := &agentv1.ResourceSample{ + ObservedAtUnixMs: now().UnixMilli(), + SampleFresh: true, + } + var missing []string + + if values, err := cpu.PercentWithContext(ctx, 0, false); err != nil || len(values) == 0 { + missing = append(missing, "cpu") + } else { + sample.CpuUsedRatio = values[0] / 100 + } + if value, err := mem.VirtualMemoryWithContext(ctx); err != nil { + missing = append(missing, "memory") + } else { + sample.MemoryAvailableBytes = int64(value.Available) + } + if spoolRoot == "" { + missing = append(missing, "spool") + } else if value, err := disk.UsageWithContext(ctx, spoolRoot); err != nil { + missing = append(missing, "spool") + } else { + sample.SpoolUsedBytes = int64(value.Used) + sample.SpoolCapacityBytes = int64(value.Total) + } + proc, err := process.NewProcess(int32(os.Getpid())) + if err != nil { + missing = append(missing, "fd") + } else if limits, limitErr := proc.RlimitUsageWithContext(ctx, true); limitErr != nil { + missing = append(missing, "fd") + } else { + found := false + for _, limit := range limits { + if limit.Resource == process.RLIMIT_NOFILE { + sample.FdUsed = int64(limit.Used) + sample.FdLimit = int64(limit.Soft) + found = true + break + } + } + if !found { + missing = append(missing, "fd") + } + } + + // Media-port and AI-provider quotas are owned by the Cell/Dispatcher, not + // this host sampler. Keep them unknown until those sources are connected. + missing = append(missing, "media_ports", "ai_quota") + if len(missing) != 0 { + sample.SampleFresh = false + sample.MissingReason = strings.Join(missing, ",") + } + return sample +} diff --git a/internal/health/sample_test.go b/internal/health/sample_test.go new file mode 100644 index 0000000..458bc17 --- /dev/null +++ b/internal/health/sample_test.go @@ -0,0 +1,36 @@ +package health + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestSampleReportsHostDataAndUnconfiguredDimensionsAsUnknown(t *testing.T) { + sample := (Sampler{Now: func() time.Time { return time.Unix(100, 0) }}).Sample(context.Background(), t.TempDir()) + if sample.ObservedAtUnixMs != 100000 { + t.Fatalf("observed_at=%d", sample.ObservedAtUnixMs) + } + if sample.MemoryAvailableBytes <= 0 || sample.SpoolCapacityBytes <= 0 || sample.FdLimit <= 0 { + t.Fatalf("expected host observations: %+v", sample) + } + if sample.SampleFresh { + t.Fatal("expected sample to remain unknown until media/AI dimensions are connected") + } + if !strings.Contains(sample.MissingReason, "media_ports") || !strings.Contains(sample.MissingReason, "ai_quota") { + t.Fatalf("missing reason=%q", sample.MissingReason) + } +} + +func TestSampleFailsClosedForCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + sample := (Sampler{}).Sample(ctx, t.TempDir()) + if sample.SampleFresh { + t.Fatal("canceled sample must not be fresh") + } + if sample.MissingReason == "" { + t.Fatal("canceled sample must explain missing data") + } +} diff --git a/internal/media/rtp.go b/internal/media/rtp.go new file mode 100644 index 0000000..5f88a9c --- /dev/null +++ b/internal/media/rtp.go @@ -0,0 +1,56 @@ +package media + +import ( + "errors" + "fmt" + + "github.com/pion/rtp" +) + +// PacketGuard is a thin policy adapter around Pion's RTP parser. It does not +// implement RTP/RTCP framing; the mature library owns wire parsing. +type PacketGuard struct { + MaxPacketBytes int + MaxPayloadBytes int + AllowedSSRC uint32 + RequireSSRC bool + AllowedPayloadTypes map[uint8]struct{} +} + +type Packet struct { + PayloadType uint8 + SequenceNumber uint16 + Timestamp uint32 + SSRC uint32 + Marker bool + Payload []byte +} + +func (g PacketGuard) Parse(raw []byte) (Packet, error) { + if len(raw) == 0 { + return Packet{}, errors.New("empty RTP packet") + } + if g.MaxPacketBytes > 0 && len(raw) > g.MaxPacketBytes { + return Packet{}, fmt.Errorf("RTP packet exceeds limit: %d > %d", len(raw), g.MaxPacketBytes) + } + var parsed rtp.Packet + if err := parsed.Unmarshal(raw); err != nil { + return Packet{}, fmt.Errorf("parse RTP packet: %w", err) + } + if len(g.AllowedPayloadTypes) > 0 { + if _, ok := g.AllowedPayloadTypes[parsed.PayloadType]; !ok { + return Packet{}, fmt.Errorf("RTP payload type %d is not allowed", parsed.PayloadType) + } + } + if g.RequireSSRC && parsed.SSRC != g.AllowedSSRC { + return Packet{}, fmt.Errorf("RTP SSRC %d is not allowed", parsed.SSRC) + } + if g.MaxPayloadBytes > 0 && len(parsed.Payload) > g.MaxPayloadBytes { + return Packet{}, fmt.Errorf("RTP payload exceeds limit: %d > %d", len(parsed.Payload), g.MaxPayloadBytes) + } + return Packet{ + PayloadType: parsed.PayloadType, SequenceNumber: parsed.SequenceNumber, + Timestamp: parsed.Timestamp, SSRC: parsed.SSRC, Marker: parsed.Marker, + Payload: append([]byte(nil), parsed.Payload...), + }, nil +} diff --git a/internal/media/rtp_test.go b/internal/media/rtp_test.go new file mode 100644 index 0000000..3a6b5c9 --- /dev/null +++ b/internal/media/rtp_test.go @@ -0,0 +1,144 @@ +package media + +import ( + "context" + "net" + "testing" + "time" + + "github.com/pion/rtp" +) + +func TestPacketGuardUsesPionAndAppliesCellPolicy(t *testing.T) { + packet := &rtp.Packet{Header: rtp.Header{Version: 2, PayloadType: 8, SequenceNumber: 42, Timestamp: 160, SSRC: 99, Marker: true}, Payload: []byte{1, 2, 3, 4}} + raw, err := packet.Marshal() + if err != nil { + t.Fatal(err) + } + parsed, err := (PacketGuard{MaxPacketBytes: 1500, MaxPayloadBytes: 100, AllowedSSRC: 99, RequireSSRC: true, AllowedPayloadTypes: map[uint8]struct{}{8: {}}}).Parse(raw) + if err != nil { + t.Fatal(err) + } + if parsed.PayloadType != 8 || parsed.SequenceNumber != 42 || parsed.Timestamp != 160 || parsed.SSRC != 99 || !parsed.Marker || string(parsed.Payload) != string(packet.Payload) { + t.Fatalf("unexpected parsed packet: %+v", parsed) + } +} + +func TestPacketGuardPreservesPCMAPayloadByteForByte(t *testing.T) { + payload := make([]byte, 160) + for index := range payload { + payload[index] = byte((index*37 + 11) % 256) + } + packet := &rtp.Packet{Header: rtp.Header{Version: 2, PayloadType: 8, SequenceNumber: 700, Timestamp: 112000, SSRC: 0xA1B2C3D4}, Payload: payload} + raw, err := packet.Marshal() + if err != nil { + t.Fatal(err) + } + parsed, err := (PacketGuard{MaxPacketBytes: 1500, MaxPayloadBytes: 160, AllowedSSRC: 0xA1B2C3D4, RequireSSRC: true, AllowedPayloadTypes: map[uint8]struct{}{8: {}}}).Parse(raw) + if err != nil { + t.Fatal(err) + } + if parsed.PayloadType != 8 || parsed.SequenceNumber != packet.SequenceNumber || parsed.Timestamp != packet.Timestamp || parsed.SSRC != packet.SSRC { + t.Fatalf("unexpected PCMA RTP header: %+v", parsed) + } + if string(parsed.Payload) != string(payload) { + t.Fatalf("PCMA payload changed: got %x want %x", parsed.Payload, payload) + } +} + +func TestRTPStreamSendsAfterExplicitPeerBeforeInbound(t *testing.T) { + receiver, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + defer receiver.Close() + stream, err := ListenRTP("127.0.0.1:0", 118) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if err := stream.SetPeer(receiver.LocalAddr().String()); err != nil { + t.Fatal(err) + } + pcm := make([]byte, pcm16FrameSamples*2) + if err := stream.SendPCM16(context.Background(), pcm, 16000); err != nil { + t.Fatal(err) + } + if err := receiver.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + buf := make([]byte, maxRTPPacketBytes) + n, _, err := receiver.ReadFromUDP(buf) + if err != nil { + t.Fatal(err) + } + var packet rtp.Packet + if err := packet.Unmarshal(buf[:n]); err != nil { + t.Fatal(err) + } + if packet.PayloadType != 118 || len(packet.Payload) != len(pcm) { + t.Fatalf("unexpected packet: payload_type=%d payload_bytes=%d", packet.PayloadType, len(packet.Payload)) + } +} + +func TestALAWProfileNormalizesWireAudioToCanonicalPCM16(t *testing.T) { + receiver, err := ListenRTPWithFormat("127.0.0.1:0", 8, FormatALAW, 8000) + if err != nil { + t.Fatal(err) + } + defer receiver.Close() + sender, err := ListenRTPWithFormat("127.0.0.1:0", 8, FormatALAW, 8000) + if err != nil { + t.Fatal(err) + } + defer sender.Close() + if err := sender.SetPeer(receiver.LocalAddr().String()); err != nil { + t.Fatal(err) + } + pcm := make([]byte, pcm16FrameSamples*2) + if err := sender.SendPCM16(context.Background(), pcm, 16000); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + decoded, err := receiver.ReadPayload(ctx) + if err != nil { + t.Fatal(err) + } + if len(decoded) != len(pcm) { + t.Fatalf("canonical PCM16 bytes=%d want=%d", len(decoded), len(pcm)) + } +} + +func TestPacketGuardRejectsMalformedOrUnauthorizedMedia(t *testing.T) { + guard := PacketGuard{MaxPacketBytes: 12, MaxPayloadBytes: 2, AllowedSSRC: 99, RequireSSRC: true, AllowedPayloadTypes: map[uint8]struct{}{8: {}}} + if _, err := guard.Parse([]byte{0x80}); err == nil { + t.Fatal("expected malformed RTP rejection") + } + packet := &rtp.Packet{Header: rtp.Header{Version: 2, PayloadType: 0, SequenceNumber: 1, Timestamp: 1, SSRC: 99}, Payload: []byte{1}} + raw, err := packet.Marshal() + if err != nil { + t.Fatal(err) + } + if _, err := guard.Parse(raw); err == nil { + t.Fatal("expected payload type rejection") + } + packet.PayloadType = 8 + packet.SSRC = 100 + raw, err = packet.Marshal() + if err != nil { + t.Fatal(err) + } + if _, err := guard.Parse(raw); err == nil { + t.Fatal("expected SSRC rejection") + } + packet.SSRC = 99 + packet.Payload = []byte{1, 2, 3} + raw, err = packet.Marshal() + if err != nil { + t.Fatal(err) + } + if _, err := guard.Parse(raw); err == nil { + t.Fatal("expected payload limit rejection") + } +} diff --git a/internal/media/stream.go b/internal/media/stream.go new file mode 100644 index 0000000..990f3ec --- /dev/null +++ b/internal/media/stream.go @@ -0,0 +1,243 @@ +package media + +import ( + "context" + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "net" + "time" + + "git.ipao.vip/rogee/go-sip/internal/audio" + "github.com/pion/rtp/v2" + "github.com/zaf/g711" +) + +const ( + maxRTPPacketBytes = 2048 + pcm16FrameSamples = 320 // 20 ms at 16 kHz, the canonical AI frame size +) + +// Format is the wire codec used by an Asterisk ExternalMedia channel. The +// CallFlow always consumes and produces canonical mono PCM16/16 kHz. +type Format string + +const ( + FormatSLIN16 Format = "slin16" + FormatALAW Format = "alaw" +) + +// RTPStream is a bounded UDP/RTP adapter for an Asterisk ExternalMedia channel. +// Pion owns RTP framing; this package only binds the project media contract and +// exposes signed-linear PCM16 payloads to the AI runtime. +type RTPStream struct { + conn *net.UDPConn + peer *net.UDPAddr + payloadType uint8 + sequence uint16 + timestamp uint32 + ssrc uint32 + format Format + wireSampleRate int + wireFrameBytes int + receivedPackets uint64 + receivedBytes uint64 + sentPackets uint64 + sentBytes uint64 +} + +type RTPStats struct { + ReceivedPackets uint64 + ReceivedBytes uint64 + SentPackets uint64 + SentBytes uint64 +} + +func ListenRTP(address string, payloadType uint8) (*RTPStream, error) { + return ListenRTPWithFormat(address, payloadType, FormatSLIN16, 16000) +} + +func ValidateFormat(format Format, payloadType uint8, sampleRate int) error { + return validateFormat(format, payloadType, sampleRate) +} + +func validateFormat(format Format, payloadType uint8, sampleRate int) error { + switch format { + case FormatSLIN16: + if sampleRate != 16000 || payloadType < 96 || payloadType > 127 { + return fmt.Errorf("invalid slin16 RTP profile: sample_rate=%d payload_type=%d", sampleRate, payloadType) + } + case FormatALAW: + if sampleRate != 8000 || payloadType != 8 { + return fmt.Errorf("invalid alaw RTP profile: sample_rate=%d payload_type=%d", sampleRate, payloadType) + } + default: + return fmt.Errorf("unsupported RTP format %q", format) + } + return nil +} + +func ListenRTPWithFormat(address string, payloadType uint8, format Format, sampleRate int) (*RTPStream, error) { + if err := validateFormat(format, payloadType, sampleRate); err != nil { + return nil, err + } + addr, err := net.ResolveUDPAddr("udp", address) + if err != nil { + return nil, err + } + conn, err := net.ListenUDP("udp", addr) + if err != nil { + return nil, err + } + var ssrcBytes [4]byte + if _, err := rand.Read(ssrcBytes[:]); err != nil { + _ = conn.Close() + return nil, err + } + return &RTPStream{ + conn: conn, + payloadType: payloadType, + sequence: 1, + ssrc: binary.BigEndian.Uint32(ssrcBytes[:]), + format: format, + wireSampleRate: sampleRate, + wireFrameBytes: sampleRate / 50 * 2, + }, nil +} + +func (s *RTPStream) LocalAddr() net.Addr { return s.conn.LocalAddr() } + +func (s *RTPStream) Close() error { + if s == nil || s.conn == nil { + return nil + } + return s.conn.Close() +} + +// SetPeer configures the Asterisk ExternalMedia RTP destination before the +// first inbound packet arrives. A received packet may still refine it to the +// actual source address used by the connected RTP socket. +func (s *RTPStream) SetPeer(address string) error { + if s == nil || s.conn == nil { + return errors.New("RTP stream is closed") + } + peer, err := net.ResolveUDPAddr("udp", address) + if err != nil { + return err + } + s.peer = peer + return nil +} + +// ReadPayload reads one RTP packet, validates version/payload type and remembers +// the Asterisk peer for subsequent outbound audio. +func (s *RTPStream) ReadPayload(ctx context.Context) ([]byte, error) { + if s == nil || s.conn == nil { + return nil, errors.New("RTP stream is closed") + } + buf := make([]byte, maxRTPPacketBytes) + for { + deadline := time.Now().Add(250 * time.Millisecond) + if err := s.conn.SetReadDeadline(deadline); err != nil { + return nil, err + } + n, peer, err := s.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + continue + } + } + return nil, err + } + var packet rtp.Packet + if err := packet.Unmarshal(buf[:n]); err != nil { + continue + } + if packet.Version != 2 || packet.PayloadType != s.payloadType { + continue + } + s.peer = peer + s.receivedPackets++ + s.receivedBytes += uint64(len(packet.Payload)) + payload := append([]byte(nil), packet.Payload...) + if s.format == FormatALAW { + payload = audio.ResamplePCM16(g711.DecodeAlaw(payload), s.wireSampleRate, 16000) + } + return payload, nil + } +} + +func (s *RTPStream) Stats() RTPStats { + if s == nil { + return RTPStats{} + } + return RTPStats{ReceivedPackets: s.receivedPackets, ReceivedBytes: s.receivedBytes, SentPackets: s.sentPackets, SentBytes: s.sentBytes} +} + +// SendPCM16 sends signed-linear mono PCM16 in 20 ms RTP frames. The peer may +// be configured from Asterisk's UNICASTRTP_LOCAL_* variables before capture. +func (s *RTPStream) SendPCM16(ctx context.Context, pcm []byte, sampleRate int) error { + if s == nil || s.conn == nil { + return errors.New("RTP stream is closed") + } + if s.peer == nil { + return errors.New("RTP peer is unknown; configure the ExternalMedia peer first") + } + if sampleRate != 16000 { + return fmt.Errorf("unsupported PCM sample rate %d", sampleRate) + } + wirePCM := audio.ResamplePCM16(pcm, sampleRate, s.wireSampleRate) + for offset := 0; offset < len(wirePCM); { + end := offset + s.wireFrameBytes + if s.format == FormatALAW { + end = offset + s.wireSampleRate/50*2 + } + if end > len(wirePCM) { + end = len(wirePCM) + } + if end-offset < 2 { + break + } + frame := wirePCM[offset:end] + payload := frame + if s.format == FormatALAW { + payload = g711.EncodeAlaw(frame) + } + packet := &rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: s.payloadType, + SequenceNumber: s.sequence, + Timestamp: s.timestamp, + SSRC: s.ssrc, + }, + Payload: append([]byte(nil), payload...), + } + encoded, err := packet.Marshal() + if err != nil { + return err + } + if err := s.conn.SetWriteDeadline(time.Now().Add(1 * time.Second)); err != nil { + return err + } + if _, err := s.conn.WriteToUDP(encoded, s.peer); err != nil { + return err + } + s.sequence++ + s.timestamp += uint32(len(frame) / 2) + s.sentPackets++ + s.sentBytes += uint64(len(payload)) + offset = end + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + return nil +} diff --git a/internal/mq/amqp.go b/internal/mq/amqp.go new file mode 100644 index 0000000..b62448e --- /dev/null +++ b/internal/mq/amqp.go @@ -0,0 +1,246 @@ +// Package mq contains the RabbitMQ adapter. Business state remains in the +// Dispatcher store; this package only declares topology and transports bytes. +package mq + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + + "git.ipao.vip/rogee/go-sip/internal/tenant" + amqp "github.com/rabbitmq/amqp091-go" +) + +var consumerSequence atomic.Uint64 + +const ( + DefaultExchange = tenant.CommandExchange + EventExchange = tenant.EventExchange + DeadLetterExchange = "agent-call.dead-letter.v1" + DefaultPrefetch = 1 +) + +type Publisher interface { + Publish(context.Context, string, string, []byte) error +} + +type permanentError struct{ err error } + +func (e permanentError) Error() string { return e.err.Error() } +func (e permanentError) Unwrap() error { return e.err } + +func Permanent(err error) error { + if err == nil { + return nil + } + return permanentError{err: err} +} + +func IsPermanent(err error) bool { + var target permanentError + return errors.As(err, &target) +} + +type Broker struct { + conn *amqp.Connection + channel *amqp.Channel + exchange string + eventExchange string + prefetch int + mu sync.Mutex +} + +func Open(url, exchange string) (*Broker, error) { + return OpenWithPrefetch(url, exchange, DefaultPrefetch) +} + +func OpenWithPrefetch(url, exchange 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) + } + conn, err := amqp.Dial(url) + if err != nil { + return nil, fmt.Errorf("dial rabbitmq: %w", err) + } + channel, err := conn.Channel() + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("open rabbitmq channel: %w", err) + } + if err := channel.ExchangeDeclare(exchange, "direct", true, false, false, false, nil); err != nil { + _ = channel.Close() + _ = conn.Close() + return nil, fmt.Errorf("declare command exchange: %w", 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) + } + 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 +} + +func (b *Broker) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.channel != nil { + _ = b.channel.Close() + } + if b.conn != nil { + return b.conn.Close() + } + return nil +} + +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") + } + b.mu.Lock() + defer b.mu.Unlock() + if b.channel == nil { + return errors.New("rabbitmq channel is closed") + } + if exchange != b.exchange && exchange != b.eventExchange { + return fmt.Errorf("unsupported publish exchange %q", exchange) + } + confirmation, err := b.channel.PublishWithDeferredConfirmWithContext(ctx, exchange, routingKey, false, false, amqp.Publishing{ + ContentType: "application/json", + DeliveryMode: amqp.Persistent, + Body: body, + }) + if err != nil { + return err + } + if confirmation == nil { + return errors.New("rabbitmq publisher confirmation unavailable") + } + acked, err := confirmation.WaitContext(ctx) + if err != nil { + return err + } + if !acked { + return errors.New("rabbitmq publisher was negatively acknowledged") + } + return nil +} + +func (b *Broker) DeclareTenantQueue(tenantKey string) (string, error) { + queue, err := tenant.CommandQueue(tenantKey) + if err != nil { + return "", err + } + b.mu.Lock() + defer b.mu.Unlock() + 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 + } + queueArgs := amqp.Table{ + "x-dead-letter-exchange": DeadLetterExchange, + "x-dead-letter-routing-key": routingKey, + } + if _, err := b.channel.QueueDeclare(queue, true, false, false, false, queueArgs); err != nil { + return "", fmt.Errorf("declare tenant queue: %w", err) + } + 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 { + 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) + } + return queue, nil +} + +type MessageHandler func(context.Context, string, []byte) error + +// Consume ACKs only after the handler returns nil. A transient handler error +// requeues; malformed or unauthorized messages can be rejected by the caller +// with Permanent, which RabbitMQ dead-letters through the tenant queue policy. +func (b *Broker) Consume(ctx context.Context, queue string, handler MessageHandler) error { + if queue == "" || handler == nil { + return errors.New("queue and handler are required") + } + b.mu.Lock() + if b.channel == nil { + b.mu.Unlock() + return errors.New("rabbitmq channel is closed") + } + prefetch := b.prefetch + if prefetch <= 0 { + prefetch = DefaultPrefetch + } + if err := b.channel.Qos(prefetch, 0, false); err != nil { + b.mu.Unlock() + return fmt.Errorf("set tenant prefetch: %w", err) + } + consumerTag := fmt.Sprintf("sip-go-agent-%d", consumerSequence.Add(1)) + deliveries, err := b.channel.Consume(queue, consumerTag, false, false, false, false, nil) + b.mu.Unlock() + if err != nil { + return fmt.Errorf("consume tenant queue: %w", err) + } + defer func() { + b.mu.Lock() + if b.channel != nil { + _ = b.channel.Cancel(consumerTag, false) + } + b.mu.Unlock() + }() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case d, ok := <-deliveries: + if !ok { + return errors.New("rabbitmq delivery channel closed") + } + if err := handler(ctx, d.RoutingKey, d.Body); err != nil { + if IsPermanent(err) { + if rejectErr := d.Reject(false); rejectErr != nil { + return fmt.Errorf("permanent handler error %v; reject: %w", err, rejectErr) + } + continue + } + if nackErr := d.Nack(false, true); nackErr != nil { + return fmt.Errorf("handler error %v; nack: %w", err, nackErr) + } + continue + } + if err := d.Ack(false); err != nil { + return fmt.Errorf("ack delivery: %w", err) + } + } + } +} diff --git a/internal/mq/amqp_test.go b/internal/mq/amqp_test.go new file mode 100644 index 0000000..4b43762 --- /dev/null +++ b/internal/mq/amqp_test.go @@ -0,0 +1,13 @@ +package mq + +import ( + "strings" + "testing" +) + +func TestOpenWithPrefetchRejectsZero(t *testing.T) { + _, err := OpenWithPrefetch("amqp://unused", "", 0) + if err == nil || !strings.Contains(err.Error(), "prefetch") { + t.Fatalf("error=%v, want prefetch validation", err) + } +} diff --git a/internal/mq/doc.go b/internal/mq/doc.go new file mode 100644 index 0000000..546a017 --- /dev/null +++ b/internal/mq/doc.go @@ -0,0 +1,3 @@ +// Package mq is the narrow RabbitMQ transport boundary used by Dispatcher. +// It confirms durable publishes and does not own business state or implement an alternate HTTP callback path. +package mq diff --git a/internal/mq/integration_test.go b/internal/mq/integration_test.go new file mode 100644 index 0000000..72c48e6 --- /dev/null +++ b/internal/mq/integration_test.go @@ -0,0 +1,119 @@ +//go:build integration + +package mq + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/tenant" +) + +func TestLocalRabbitMQConfirmAckAndDeadLetter(t *testing.T) { + url := os.Getenv("RABBITMQ_URL") + if url == "" { + t.Skip("RABBITMQ_URL is not configured") + } + broker, err := OpenWithPrefetch(url, "", 1) + if err != nil { + t.Fatal(err) + } + defer broker.Close() + + tenantKey := fmt.Sprintf("integration-%d", time.Now().UnixNano()) + queue, err := broker.DeclareTenantQueue(tenantKey) + if err != nil { + t.Fatal(err) + } + routingKey, err := tenant.CommandRoutingKey(tenantKey) + if err != nil { + t.Fatal(err) + } + deadLetterQueue, err := tenant.DeadLetterQueue(tenantKey) + if err != nil { + t.Fatal(err) + } + body := []byte(`{"command_id":"integration-command"}`) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + acked := make(chan struct{}, 1) + consumeDone := make(chan error, 1) + go func() { + consumeDone <- broker.Consume(ctx, queue, func(_ context.Context, gotRoutingKey string, gotBody []byte) error { + if gotRoutingKey != routingKey || string(gotBody) != string(body) { + return fmt.Errorf("delivery mismatch: key=%q body=%q", gotRoutingKey, gotBody) + } + acked <- struct{}{} + return nil + }) + }() + if err := broker.Publish(ctx, DefaultExchange, routingKey, body); err != nil { + t.Fatal(err) + } + select { + case <-acked: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + cancel() + select { + case <-consumeDone: + case <-time.After(3 * time.Second): + t.Fatal("consumer did not stop") + } + + permanentCtx, permanentCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer permanentCancel() + permanentSeen := make(chan struct{}, 1) + permanentDone := make(chan error, 1) + go func() { + permanentDone <- broker.Consume(permanentCtx, queue, func(_ context.Context, _ string, _ []byte) error { + permanentSeen <- struct{}{} + return Permanent(errors.New("synthetic permanent command error")) + }) + }() + if err := broker.Publish(permanentCtx, DefaultExchange, routingKey, body); err != nil { + t.Fatal(err) + } + select { + case <-permanentSeen: + case <-permanentCtx.Done(): + t.Fatal(permanentCtx.Err()) + } + permanentCancel() + select { + case <-permanentDone: + case <-time.After(3 * time.Second): + t.Fatal("permanent consumer did not stop") + } + + dlqCtx, dlqCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer dlqCancel() + 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) + } + deadLettered <- struct{}{} + return nil + }) + }() + select { + case <-deadLettered: + dlqCancel() + case <-dlqCtx.Done(): + t.Fatal(dlqCtx.Err()) + } + select { + case <-dlqDone: + case <-time.After(3 * time.Second): + t.Fatal("dead-letter consumer did not stop") + } +} diff --git a/internal/oss/aliyun.go b/internal/oss/aliyun.go new file mode 100644 index 0000000..218df7e --- /dev/null +++ b/internal/oss/aliyun.go @@ -0,0 +1,154 @@ +package oss + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + aliyunoss "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" + "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" +) + +const defaultGrantTTL = 15 * time.Minute + +// Config is Dispatcher-owned OSS configuration. Access keys never leave the +// Dispatcher; Agents receive only presigned upload grants. +type Config struct { + Endpoint string + Region string + Bucket string + AccessKeyID string + AccessKeySecret string + KeyPrefix string + GrantTTL time.Duration + MaxAssetBytes int64 +} + +func (c Config) Validate() error { + if strings.TrimSpace(c.Endpoint) == "" || strings.TrimSpace(c.Region) == "" || strings.TrimSpace(c.Bucket) == "" { + return errors.New("OSS endpoint, region and bucket are required") + } + if strings.TrimSpace(c.AccessKeyID) == "" || strings.TrimSpace(c.AccessKeySecret) == "" { + return errors.New("OSS access key ID and secret are required") + } + endpoint := c.Endpoint + if !strings.Contains(endpoint, "://") { + endpoint = "https://" + endpoint + } + parsed, err := url.Parse(endpoint) + 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.MaxAssetBytes <= 0 { + return errors.New("OSS max asset bytes must be positive") + } + return nil +} + +// Client signs and verifies object operations using Alibaba's official OSS +// SDK. It is intentionally only constructed in the Dispatcher process. +type Client struct { + config Config + client *aliyunoss.Client +} + +func NewClient(config Config) (*Client, error) { + if config.GrantTTL == 0 { + config.GrantTTL = defaultGrantTTL + } + if err := config.Validate(); err != nil { + return nil, err + } + endpoint := config.Endpoint + if !strings.Contains(endpoint, "://") { + endpoint = "https://" + endpoint + } + sdkConfig := aliyunoss.LoadDefaultConfig(). + WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)). + WithRegion(config.Region). + WithEndpoint(endpoint). + WithSignatureVersion(aliyunoss.SignatureVersionV4) + return &Client{config: config, client: aliyunoss.NewClient(sdkConfig)}, nil +} + +func (c *Client) Config() Config { return c.config } + +func (c *Client) Grant(ctx context.Context, uploadID, objectKey, checksum string, maxBytes int64, now time.Time) (*agentv1.UploadGrant, error) { + if c == nil || c.client == nil { + return nil, errors.New("OSS client is not configured") + } + if uploadID == "" || objectKey == "" || checksum == "" { + return nil, errors.New("upload ID, object key and checksum are required") + } + if maxBytes <= 0 || maxBytes > c.config.MaxAssetBytes { + maxBytes = c.config.MaxAssetBytes + } + if now.IsZero() { + now = time.Now() + } + expiresAt := now.Add(c.config.GrantTTL) + request := &aliyunoss.PutObjectRequest{ + Bucket: aliyunoss.Ptr(c.config.Bucket), + Key: aliyunoss.Ptr(objectKey), + Metadata: map[string]string{ + "sha256": checksum, + }, + } + presigned, err := c.client.Presign(ctx, request, aliyunoss.PresignExpiration(expiresAt)) + if err != nil { + return nil, fmt.Errorf("presign OSS PUT: %w", err) + } + headers := make([]*agentv1.Header, 0, len(presigned.SignedHeaders)) + for name, value := range presigned.SignedHeaders { + headers = append(headers, &agentv1.Header{Name: name, Value: value}) + } + return &agentv1.UploadGrant{ + UploadId: uploadID, + TargetUrl: presigned.URL, + Headers: headers, + ExpiresAtUnixMs: presigned.Expiration.UnixMilli(), + ObjectKey: objectKey, + RequiredChecksumSha256: checksum, + 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 new file mode 100644 index 0000000..9a9c712 --- /dev/null +++ b/internal/oss/aliyun_integration_test.go @@ -0,0 +1,75 @@ +package oss_test + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "strings" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/internal/agent" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" +) + +func TestAlibabaOSSGrantPutHeadIntegration(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") + } + config := ossclient.Config{ + Endpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"), + Region: os.Getenv("DISPATCHER_OSS_REGION"), + Bucket: os.Getenv("DISPATCHER_OSS_BUCKET"), + AccessKeyID: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_ID"), + AccessKeySecret: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_SECRET"), + KeyPrefix: "agent-call/integration-tests", + GrantTTL: 15 * time.Minute, + MaxAssetBytes: 1 << 20, + } + client, err := ossclient.NewClient(config) + if err != nil { + t.Fatal(err) + } + payload := []byte(strings.Repeat("agent-call-oss-integration\n", 1024)) + digest := sha256.Sum256(payload) + checksum := hex.EncodeToString(digest[:]) + objectKey := "agent-call/integration-tests/" + checksum + ".txt" + grant, err := client.Grant(context.Background(), "integration-"+checksum[:16], objectKey, checksum, int64(len(payload)), time.Now()) + if err != nil { + t.Fatal(err) + } + file, err := os.CreateTemp(t.TempDir(), "oss-upload-*.txt") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write(payload); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + uploader := agent.UploadClient{Now: time.Now, AllowedHosts: map[string]struct{}{}} + result, err := uploader.UploadFile(context.Background(), grant, file.Name()) + if err != nil { + t.Fatalf("upload failed without exposing the presigned URL: %s", redactError(err, config.AccessKeyID)) + } + 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 { + if err == nil { + return "" + } + message := err.Error() + if accessKeyID != "" { + message = strings.ReplaceAll(message, accessKeyID, "") + } + return message +} diff --git a/internal/rpc/ai_authorization_test.go b/internal/rpc/ai_authorization_test.go new file mode 100644 index 0000000..d4d1278 --- /dev/null +++ b/internal/rpc/ai_authorization_test.go @@ -0,0 +1,123 @@ +package rpc + +import ( + "context" + "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/ai" + "git.ipao.vip/rogee/go-sip/internal/contract" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) { + snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(snapshotRaw, ai.ModeASROnly) + if err != nil { + t.Fatal(err) + } + authorizationRaw, err := contracts.Read("examples/ai-authorization.json") + if err != nil { + t.Fatal(err) + } + server := NewServer(ServerOptions{ + Now: func() time.Time { return time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC) }, + AISnapshotRaw: snapshotRaw, + AIAuthorizationRaw: authorizationRaw, + AIEgressPoolID: "egress-mock", + }) + activateTestServer(t, server) + + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + _, payload, err := contract.DecodeExecute(raw) + if err != nil { + t.Fatal(err) + } + binding := &agentv1.ExecutionBinding{ + TenantId: "tenant-1", + TenantKey: "tenant-demo-key", + ExecutionId: payload.ExecutionID, + TaskId: payload.TaskID, + TaskItemId: payload.TaskItemID, + TaskRevision: payload.TaskRevision, + AgentVersionId: snapshot.AgentVersionID, + RoutePolicyId: payload.RoutePolicyID, + CallerProfileId: payload.CallerProfileID, + } + response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{ + Meta: testMeta("permit-ai", "permit-ai-key", 1), + Binding: binding, + ResourceReservationId: "reservation-ai", + ExpectedTaskRevision: payload.TaskRevision, + ConfigSha256: snapshot.Digest, + }) + if err != nil { + t.Fatal(err) + } + if response.Permit == nil || response.Receipt == nil || response.Receipt.Result != agentv1.ResultCode_RESULT_CODE_APPLIED { + t.Fatalf("unexpected authorized permit response: %+v", response) + } + + badDigest := &agentv1.GetExecutionPermitRequest{ + Meta: testMeta("permit-ai-bad-digest", "permit-ai-bad-digest-key", 1), + Binding: binding, + ResourceReservationId: "reservation-ai-2", + ExpectedTaskRevision: payload.TaskRevision, + ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + if _, err := server.GetExecutionPermit(context.Background(), badDigest); status.Code(err) != codes.FailedPrecondition { + t.Fatalf("bad digest error=%v, code=%s", err, status.Code(err)) + } +} + +func TestExecutionPermitRejectsRevokedAIAuthorization(t *testing.T) { + snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") + if err != nil { + t.Fatal(err) + } + snapshot, err := ai.ValidateForMode(snapshotRaw, ai.ModeASROnly) + if err != nil { + t.Fatal(err) + } + authorizationRaw, err := contracts.Read("examples/invalid-ai-authorization-revoked.json") + if err != nil { + t.Fatal(err) + } + server := NewServer(ServerOptions{ + Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 30, 0, time.UTC) }, + AISnapshotRaw: snapshotRaw, + AIAuthorizationRaw: authorizationRaw, + AIEgressPoolID: "egress-mock", + }) + activateTestServer(t, server) + response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{ + Meta: testMeta("permit-revoked", "permit-revoked-key", 1), + Binding: &agentv1.ExecutionBinding{TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-revoked", AgentVersionId: snapshot.AgentVersionID}, + ResourceReservationId: "reservation-revoked", + ConfigSha256: snapshot.Digest, + }) + if err == nil || status.Code(err) != codes.PermissionDenied || response != nil { + t.Fatalf("revoked authorization response=%+v err=%v code=%s", response, err, status.Code(err)) + } +} + +func activateTestServer(t *testing.T, server *Server) { + t.Helper() + _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: testMeta("activate-ai", "", 0), + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-ai", + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/rpc/calllog_test.go b/internal/rpc/calllog_test.go new file mode 100644 index 0000000..1eadb1f --- /dev/null +++ b/internal/rpc/calllog_test.go @@ -0,0 +1,100 @@ +package rpc + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "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" +) + +func TestServerWritesPhoneCorrelatedCallBusinessLog(t *testing.T) { + now := time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC) + logger, err := calllog.New(filepath.Join(t.TempDir(), "business", "calls.jsonl"), []byte("0123456789abcdef"), func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + server := NewServer(ServerOptions{Now: func() time.Time { return now }, CallLogger: logger}) + meta := testMeta("activate-log", "", 0) + if _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: meta, + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-log", + }); err != nil { + t.Fatal(err) + } + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + t.Fatal(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, RoutePolicyId: payload.RoutePolicyID, CallerProfileId: payload.CallerProfileID, + } + if _, err := server.Execute(context.Background(), &agentv1.ExecuteRequest{ + Meta: testMeta("execute-log", "execute-log-key", 1), Binding: binding, CallExecuteJson: raw, + ConfigSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); err != nil { + t.Fatal(err) + } + statusPayload := []byte(`{"call_id":"call-1","execution_id":"execution-1","call_state":"answered","attempt_id":"attempt-1","attempt_state":"active","route_policy_id":"route-sip-first","caller_profile_id":"caller-sip-first","trunk_id":"provider-primary","cell_id":"cell-1","sip_stage":"invite","sip_status_code":183}`) + if _, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{ + Meta: testMeta("fact-status", "fact-status-key", 1), Fact: &agentv1.ExecutionFact{ + FactId: "fact-status", ContentSha256: "digest-status", Binding: binding, + Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS, PayloadJson: statusPayload, + }, + }); err != nil { + t.Fatal(err) + } + finishedPayload := []byte(`{"call_id":"call-1","execution_id":"execution-1","outcome":"no_answer","duration_ms":3000,"reason_code":"provider_480","asset_state":"failed","attempt_summary":[{"attempt_id":"attempt-1","state":"ended","trunk_id":"provider-primary","cell_id":"cell-1","reason_code":"provider_480"}],"recording_id":"recording-1","recording_state":"failed"}`) + if _, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{ + Meta: testMeta("fact-finished", "fact-finished-key", 1), Fact: &agentv1.ExecutionFact{ + FactId: "fact-finished", ContentSha256: "digest-finished", Binding: binding, + Kind: agentv1.FactKind_FACT_KIND_CALL_FINISHED, PayloadJson: finishedPayload, + }, + }); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(logger.Path()) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Contains(text, payload.Callee) { + t.Fatalf("business log contains original phone: %s", text) + } + lines := strings.Split(strings.TrimSpace(text), "\n") + if len(lines) != 4 { + t.Fatalf("got %d business log lines, want prepared/status/finished/attempt: %s", len(lines), text) + } + var records []map[string]any + for _, line := range lines { + var record map[string]any + if err := json.Unmarshal([]byte(line), &record); err != nil { + t.Fatal(err) + } + records = append(records, record) + } + if records[0]["event_type"] != "execution.prepared" || records[1]["sip_status_code"] != float64(183) { + t.Fatalf("prepared or SIP status record missing: %#v", records) + } + if records[2]["recording_id"] != "recording-1" || records[2]["result"] != "no_answer" || records[3]["trunk_id"] != "provider-primary" { + t.Fatalf("finished or attempt record missing: %#v", records) + } + if records[0]["phone_ref"] != records[1]["phone_ref"] || records[1]["phone_ref"] != records[2]["phone_ref"] { + t.Fatalf("phone correlation changed across events: %#v", records) + } +} diff --git a/internal/rpc/client.go b/internal/rpc/client.go new file mode 100644 index 0000000..2f429bf --- /dev/null +++ b/internal/rpc/client.go @@ -0,0 +1,92 @@ +package rpc + +import ( + "context" + "crypto/tls" + "fmt" + "os" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +// Client is a thin generated-stub wrapper. Business retries and reconciliation +// remain at the caller; this type never retries an originate automatically. +type Client struct { + Conn *grpc.ClientConn + Agent agentv1.AgentControlServiceClient +} + +func Dial(endpoint string, tlsConfig *tls.Config) (*Client, error) { + if endpoint == "" { + return nil, fmt.Errorf("gRPC endpoint is required") + } + if tlsConfig == nil { + return nil, fmt.Errorf("mTLS configuration is required") + } + conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + if err != nil { + return nil, fmt.Errorf("dial gRPC endpoint: %w", err) + } + return &Client{Conn: conn, Agent: agentv1.NewAgentControlServiceClient(conn)}, nil +} + +func DialFromFiles(endpoint, caFile, certFile, keyFile, serverName string) (*Client, error) { + caPEM, err := readFile(caFile, "CA") + if err != nil { + return nil, err + } + certPEM, err := readFile(certFile, "certificate") + if err != nil { + return nil, err + } + keyPEM, err := readFile(keyFile, "key") + if err != nil { + return nil, err + } + tlsConfig, err := NewClientTLSConfig(caPEM, certPEM, keyPEM, serverName) + if err != nil { + return nil, err + } + return Dial(endpoint, tlsConfig) +} + +func readFile(path, label string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("%s file is required", label) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s file: %w", label, err) + } + return data, nil +} + +func (c *Client) ReportExecutionEvent(ctx context.Context, request *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + if c == nil || c.Agent == nil { + return nil, fmt.Errorf("AgentControl client is not initialized") + } + return c.Agent.ReportExecutionEvent(ctx, request) +} + +func (c *Client) RequestUpload(ctx context.Context, request *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) { + if c == nil || c.Agent == nil { + return nil, fmt.Errorf("AgentControl client is not initialized") + } + return c.Agent.RequestUpload(ctx, request) +} + +func (c *Client) CompleteUpload(ctx context.Context, request *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) { + if c == nil || c.Agent == nil { + return nil, fmt.Errorf("AgentControl client is not initialized") + } + return c.Agent.CompleteUpload(ctx, request) +} + +func (c *Client) Close() error { + if c == nil || c.Conn == nil { + return nil + } + return c.Conn.Close() +} diff --git a/internal/rpc/dispatcher_events.go b/internal/rpc/dispatcher_events.go new file mode 100644 index 0000000..441eed6 --- /dev/null +++ b/internal/rpc/dispatcher_events.go @@ -0,0 +1,184 @@ +package rpc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/store" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" +) + +type DispatcherEventServerOptions struct { + RequirePeer bool + PeerCertificateFingerprints map[string]struct{} + AllowedAgentIDs map[string]struct{} + Now func() time.Time +} + +// DispatcherEventServer owns the Dispatcher side of R11. It persists the +// received fact and the derived authoritative MQ event through the same +// SQLite transaction; it never accepts an Agent-chosen aggregate version. +type DispatcherEventServer struct { + store *store.Store + now func() time.Time + requirePeer bool + peerCertificateFingerprints map[string]struct{} + allowedAgentIDs map[string]struct{} +} + +func NewDispatcherEventServer(st *store.Store, options DispatcherEventServerOptions) (*DispatcherEventServer, error) { + if st == nil { + return nil, errors.New("Dispatcher event server requires store") + } + now := options.Now + if now == nil { + now = time.Now + } + return &DispatcherEventServer{ + store: st, now: now, requirePeer: options.RequirePeer, + peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints), + allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs), + }, nil +} + +func (s *DispatcherEventServer) ReportExecutionEvent(ctx context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + if req == nil || req.Meta == nil || req.Fact == nil || req.Fact.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata, fact and binding are required") + } + if err := validateDispatcherPeer(ctx, req.Meta, s.requirePeer, s.peerCertificateFingerprints, s.allowedAgentIDs); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + fact := req.Fact + binding := fact.Binding + if fact.FactId == "" || fact.ContentSha256 == "" || fact.SourceBootId == "" || fact.ObservedAtUnixMs <= 0 || len(fact.PayloadJson) == 0 { + return nil, status.Error(codes.InvalidArgument, "fact ID, content digest, source boot, observed time and payload are required") + } + if binding.TenantId == "" || binding.TenantKey == "" || binding.ExecutionId == "" { + return nil, status.Error(codes.InvalidArgument, "fact tenant and execution binding are required") + } + if err := contract.ValidateTenantKey(binding.TenantKey); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + if req.Meta.AgentId == "" || req.Meta.CellId == "" || req.Meta.BootId == "" { + return nil, status.Error(codes.InvalidArgument, "Agent, Cell and boot identity are required") + } + payload := make(map[string]any) + if err := json.Unmarshal(fact.PayloadJson, &payload); err != nil || payload == nil { + return nil, status.Error(codes.InvalidArgument, "fact payload must be a JSON object") + } + + eventType, aggregateType, aggregateID, eventPayload, err := deriveFactEvent(fact.Kind, binding, payload) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + eventID := "" + var eventBuilder store.FactEventBuilder + if eventType != "" { + eventID = "execution-fact-" + fact.FactId + eventBuilder = func(aggregateVersion int64) ([]byte, error) { + return (contract.EventBuilder{ + TenantID: binding.TenantId, TenantKey: binding.TenantKey, TraceID: req.Meta.TraceId, + EventType: eventType, Aggregate: aggregateType, AggregateID: aggregateID, + Version: aggregateVersion, Payload: eventPayload, + }).Marshal(s.now(), eventID) + } + } + bindingJSON, err := protojson.Marshal(binding) + if err != nil { + return nil, status.Errorf(codes.Internal, "encode fact binding: %v", err) + } + record := store.ExecutionFactRecord{ + FactID: fact.FactId, TenantID: binding.TenantId, TenantKey: binding.TenantKey, + ExecutionID: binding.ExecutionId, ContentSHA256: fact.ContentSha256, Kind: int32(fact.Kind), + BindingJSON: bindingJSON, PayloadJSON: append([]byte(nil), fact.PayloadJson...), + ObservedAt: time.UnixMilli(fact.ObservedAtUnixMs), SourceBootID: fact.SourceBootId, + SourceSequence: fact.SourceSequence, EventID: eventID, EventType: eventType, + AggregateType: aggregateType, AggregateID: aggregateID, + } + routingKey := "" + if eventType != "" { + routingKey = "agent-call." + eventType + } + result, err := s.store.RecordExecutionFact(record, tenant.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 + } + return nil, status.Errorf(codes.Internal, "persist execution fact: %v", err) + } + message := "fact accepted and event persisted" + if result.Duplicate { + message = "duplicate fact" + } + return &agentv1.ReportExecutionEventResponse{Receipt: dispatcherReceipt(req.Meta, s.now(), agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, message, fact.FactId, fact.ContentSha256)}, nil +} + +func deriveFactEvent(kind agentv1.FactKind, binding *agentv1.ExecutionBinding, payload map[string]any) (string, string, string, map[string]any, error) { + var eventType, aggregateType string + switch kind { + case agentv1.FactKind_FACT_KIND_EXECUTION_ACCEPTED: + eventType, aggregateType = "command.result", "command" + case agentv1.FactKind_FACT_KIND_CALL_STATUS: + eventType, aggregateType = "call.status", "call" + case agentv1.FactKind_FACT_KIND_CALL_FINISHED: + eventType, aggregateType = "call.finished", "call" + case agentv1.FactKind_FACT_KIND_TRANSCRIPT_UPDATED: + eventType, aggregateType = "transcript.updated", "transcript_segment" + case agentv1.FactKind_FACT_KIND_TRANSCRIPT_FAILED: + eventType, aggregateType = "transcript.failed", "transcript" + case agentv1.FactKind_FACT_KIND_CONTACT_OPT_OUT: + eventType, aggregateType = "contact.opt_out", "call" + case agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS: + return "", "execution_fact", binding.ExecutionId, payload, nil + default: + return "", "", "", nil, fmt.Errorf("unsupported fact kind %s", kind.String()) + } + + aggregateID := firstPayloadString(payload, "call_id", "segment_id", "command_id", "execution_id") + if aggregateID == "" { + aggregateID = binding.CallId + } + if aggregateID == "" { + aggregateID = binding.ExecutionId + } + if aggregateID == "" { + return "", "", "", nil, errors.New("fact payload or binding must provide aggregate ID") + } + return eventType, aggregateType, aggregateID, payload, nil +} + +func firstPayloadString(payload map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := payload[key].(string); ok && value != "" { + return value + } + } + return "" +} + +func dispatcherReceipt(meta *agentv1.RequestMeta, now time.Time, result agentv1.ResultCode, failure agentv1.FailureCode, detail, factID, digest string) *agentv1.OperationReceipt { + receipt := &agentv1.OperationReceipt{ + Meta: &agentv1.ResponseMeta{ + ProtocolVersion: meta.ProtocolVersion, RequestId: meta.RequestId, TraceId: meta.TraceId, + OperationId: meta.OperationId, ObservedAtUnixMs: now.UnixMilli(), + DispatcherEpoch: meta.DispatcherEpoch, AgentId: meta.AgentId, CellId: meta.CellId, + BootId: meta.BootId, SessionGeneration: meta.SessionGeneration, + }, + Result: result, FactId: factID, ContentSha256: digest, AcceptedAtUnixMs: now.UnixMilli(), + } + if failure != agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED { + receipt.Failure = &agentv1.Failure{Code: failure, Detail: detail} + } + return receipt +} diff --git a/internal/rpc/dispatcher_events_test.go b/internal/rpc/dispatcher_events_test.go new file mode 100644 index 0000000..58fb567 --- /dev/null +++ b/internal/rpc/dispatcher_events_test.go @@ -0,0 +1,144 @@ +package rpc + +import ( + "context" + "encoding/json" + "path/filepath" + "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" + "google.golang.org/protobuf/proto" +) + +func TestDispatcherServerReportsFactAndEmitsOneAuthoritativeEvent(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + 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 { + t.Fatal(err) + } + uploads, err := NewDispatcherUploadServer(st, nil, func() time.Time { return clock }, false) + if err == nil || uploads != nil { + t.Fatal("expected upload handler to reject nil OSS client") + } + server := NewDispatcherServer(nil, events) + payload := eventPayload(t, "examples/event-call-status.json") + var payloadObject map[string]any + if err := json.Unmarshal(payload, &payloadObject); err != nil { + t.Fatal(err) + } + payloadObject["call_version"] = 999 + payload, err = json.Marshal(payloadObject) + if err != nil { + t.Fatal(err) + } + meta := &agentv1.RequestMeta{ + ProtocolVersion: "agent.v1", RequestId: "request-fact-1", TraceId: "trace-fact-1", + OperationId: "operation-fact-1", IdempotencyKey: "idempotency-fact-1", AgentId: "agent-cell-a", + CellId: "cell-a", BootId: "boot-a", + } + binding := &agentv1.ExecutionBinding{ + TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-1", + TaskId: "task-1", TaskItemId: "item-1", TaskRevision: 1, CallId: "call-1", AttemptId: "attempt-1", + } + fact := &agentv1.ExecutionFact{ + FactId: "fact-1", ContentSha256: "sha256-fact-1", Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS, + Binding: binding, PayloadJson: payload, ObservedAtUnixMs: clock.UnixMilli(), SourceBootId: "boot-a", SourceSequence: 1, + } + request := &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: fact} + response, err := server.ReportExecutionEvent(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if response.Receipt == nil || response.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { + t.Fatalf("unexpected receipt: %+v", response) + } + var body []byte + if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&body); err != nil { + t.Fatal(err) + } + var eventEnvelope map[string]any + if err := json.Unmarshal(body, &eventEnvelope); err != nil { + t.Fatal(err) + } + if version, ok := eventEnvelope["aggregate_version"].(float64); !ok || version != 1 { + t.Fatalf("Dispatcher did not allocate aggregate version 1: %#v", eventEnvelope["aggregate_version"]) + } + var facts, outbox int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM execution_facts`).Scan(&facts); err != nil { + t.Fatal(err) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&outbox); err != nil { + t.Fatal(err) + } + if facts != 1 || outbox != 1 { + t.Fatalf("facts=%d outbox=%d, want 1/1", facts, outbox) + } + + duplicate, err := server.ReportExecutionEvent(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if duplicate.Receipt == nil || duplicate.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { + t.Fatalf("duplicate was not accepted: %+v", duplicate) + } + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "execution-fact-fact-1").Scan(&outbox); err != nil { + t.Fatal(err) + } + if outbox != 1 { + t.Fatalf("duplicate created %d outbox rows", outbox) + } + + secondFact := proto.Clone(fact).(*agentv1.ExecutionFact) + secondFact.FactId = "fact-2" + secondFact.ContentSha256 = "sha256-fact-2" + secondFact.SourceSequence = 2 + second, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: secondFact}) + if err != nil { + t.Fatal(err) + } + if second.Receipt == nil || second.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED { + t.Fatalf("second fact was not accepted: %+v", second) + } + if err := st.DB().QueryRow(`SELECT body FROM outbox WHERE event_id = ?`, "execution-fact-fact-2").Scan(&body); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &eventEnvelope); err != nil { + t.Fatal(err) + } + if version, ok := eventEnvelope["aggregate_version"].(float64); !ok || version != 2 { + t.Fatalf("Dispatcher did not allocate aggregate version 2: %#v", eventEnvelope["aggregate_version"]) + } + + conflictFact := proto.Clone(fact).(*agentv1.ExecutionFact) + conflictFact.ContentSha256 = "sha256-fact-conflict" + conflict, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: meta, Fact: conflictFact}) + if err != nil { + t.Fatal(err) + } + if conflict.Receipt == nil || conflict.Receipt.Result != agentv1.ResultCode_RESULT_CODE_CONFLICT { + t.Fatalf("digest conflict was not rejected: %+v", conflict) + } +} + +func eventPayload(t *testing.T, name string) []byte { + t.Helper() + raw, err := contracts.Read(name) + if err != nil { + t.Fatal(err) + } + var event struct { + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &event); err != nil { + t.Fatal(err) + } + return event.Payload +} diff --git a/internal/rpc/dispatcher_server.go b/internal/rpc/dispatcher_server.go new file mode 100644 index 0000000..e17c4d1 --- /dev/null +++ b/internal/rpc/dispatcher_server.go @@ -0,0 +1,83 @@ +package rpc + +import ( + "context" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +// DispatcherServer is the single Dispatcher-side AgentControl gRPC service. +// Upload RPCs and Agent-reported facts share this listener; the service is not +// an upload-only endpoint. +type dispatcherEventReporter interface { + ReportExecutionEvent(context.Context, *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) +} + +type DispatcherServer struct { + agentv1.UnimplementedAgentControlServiceServer + uploads *DispatcherUploadServer + events dispatcherEventReporter +} + +func NewDispatcherServer(uploads *DispatcherUploadServer, eventHandlers ...dispatcherEventReporter) *DispatcherServer { + var events dispatcherEventReporter + if len(eventHandlers) > 0 { + events = eventHandlers[0] + } + return &DispatcherServer{uploads: uploads, events: events} +} + +func (s *DispatcherServer) RequestUpload(ctx context.Context, req *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) { + if s.uploads == nil { + return nil, status.Error(codes.Unimplemented, "Dispatcher upload handler is not configured") + } + return s.uploads.RequestUpload(ctx, req) +} + +func (s *DispatcherServer) CompleteUpload(ctx context.Context, req *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) { + if s.uploads == nil { + return nil, status.Error(codes.Unimplemented, "Dispatcher upload handler is not configured") + } + return s.uploads.CompleteUpload(ctx, req) +} + +func (s *DispatcherServer) ReportExecutionEvent(ctx context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + if s.events == nil { + return nil, status.Error(codes.Unimplemented, "Dispatcher execution-event receiver is not configured") + } + return s.events.ReportExecutionEvent(ctx, req) +} + +func validateDispatcherPeer(ctx context.Context, meta *agentv1.RequestMeta, requirePeer bool, fingerprints, allowedAgentIDs map[string]struct{}) error { + if meta == nil { + return status.Error(codes.InvalidArgument, "request metadata is required") + } + if len(allowedAgentIDs) > 0 { + if _, ok := allowedAgentIDs[meta.AgentId]; !ok { + return status.Error(codes.PermissionDenied, "Agent identity is not allowed for Dispatcher RPCs") + } + } + if !requirePeer { + return nil + } + p, ok := peer.FromContext(ctx) + if !ok || p.AuthInfo == nil { + return status.Error(codes.Unauthenticated, "verified mTLS peer is required") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.VerifiedChains) == 0 || len(tlsInfo.State.VerifiedChains[0]) == 0 { + return status.Error(codes.Unauthenticated, "verified mTLS peer is required") + } + if len(fingerprints) == 0 { + return status.Error(codes.PermissionDenied, "Dispatcher gRPC mTLS peer allowlist is not configured") + } + fingerprint := CertificateFingerprint(tlsInfo.State.VerifiedChains[0][0]) + if _, allowed := fingerprints[fingerprint]; !allowed { + return status.Error(codes.PermissionDenied, "mTLS certificate is not allowed for Dispatcher RPCs") + } + return nil +} diff --git a/internal/rpc/dispatcher_upload.go b/internal/rpc/dispatcher_upload.go new file mode 100644 index 0000000..72deb32 --- /dev/null +++ b/internal/rpc/dispatcher_upload.go @@ -0,0 +1,296 @@ +package rpc + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "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" + "git.ipao.vip/rogee/go-sip/internal/tenant" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// DispatcherUploadServer is the Dispatcher-owned upload boundary. It keeps +// credentials and durable upload state on the Dispatcher and gives Agents only +// short-lived presigned PUT grants. +type DispatcherUploadServer struct { + agentv1.UnimplementedAgentControlServiceServer + + store *store.Store + oss *ossclient.Client + now func() time.Time + requirePeer bool + maxAssetBytes int64 + peerCertificateFingerprints map[string]struct{} + allowedAgentIDs map[string]struct{} +} + +type DispatcherUploadOptions struct { + RequirePeer bool + PeerCertificateFingerprints map[string]struct{} + AllowedAgentIDs map[string]struct{} +} + +func NewDispatcherUploadServer(st *store.Store, client *ossclient.Client, now func() time.Time, requirePeer bool) (*DispatcherUploadServer, error) { + return NewDispatcherUploadServerWithOptions(st, client, now, DispatcherUploadOptions{RequirePeer: requirePeer}) +} + +func NewDispatcherUploadServerWithOptions(st *store.Store, client *ossclient.Client, now func() time.Time, options DispatcherUploadOptions) (*DispatcherUploadServer, error) { + if st == nil { + return nil, errors.New("upload server requires Dispatcher store") + } + if client == nil { + return nil, errors.New("upload server requires OSS client") + } + if now == nil { + now = time.Now + } + return &DispatcherUploadServer{ + store: st, oss: client, now: now, requirePeer: options.RequirePeer, + maxAssetBytes: client.Config().MaxAssetBytes, + peerCertificateFingerprints: cloneStringSet(options.PeerCertificateFingerprints), + allowedAgentIDs: cloneStringSet(options.AllowedAgentIDs), + }, nil +} + +func (s *DispatcherUploadServer) RequestUpload(ctx context.Context, req *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) { + if err := s.validateRequest(ctx, req.GetMeta(), req.GetBinding(), req.GetAsset(), req.GetUploadId()); err != nil { + return nil, err + } + if req.Asset.SizeBytes <= 0 || req.Asset.ChecksumSha256 == "" { + return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "asset size and SHA-256 are required", false), nil + } + if s.maxAssetBytes > 0 && req.Asset.SizeBytes > s.maxAssetBytes { + return s.requestUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_RESOURCE_EXHAUSTED, "asset exceeds Dispatcher OSS limit", false), nil + } + + record, err := s.store.LoadUpload(req.UploadId) + if err == nil { + binding, asset, grant, 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" + } + 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) + } + + 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) + if err != nil { + return nil, status.Errorf(codes.Internal, "encode upload binding: %v", err) + } + assetRaw, err := proto.Marshal(req.Asset) + if err != nil { + return nil, status.Errorf(codes.Internal, "encode upload asset: %v", err) + } + grantRaw, err := proto.Marshal(grant) + if err != nil { + return nil, status.Errorf(codes.Internal, "encode upload grant: %v", err) + } + if err := s.store.InsertUpload(store.UploadRecord{ + UploadID: req.UploadId, + Binding: bindingRaw, + Asset: assetRaw, + Grant: grantRaw, + ObjectKey: objectKey, + 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) + } + return &agentv1.RequestUploadResponse{ + Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, "upload grant issued", false), + Grant: grant, + State: agentv1.UploadState_UPLOAD_STATE_REQUESTED, + }, nil +} + +func (s *DispatcherUploadServer) CompleteUpload(ctx context.Context, req *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) { + if err := s.validateRequest(ctx, req.GetMeta(), req.GetBinding(), req.GetAsset(), req.GetUploadId()); err != nil { + return nil, err + } + if req.UploadedSizeBytes <= 0 || req.UploadedChecksumSha256 == "" { + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded size and SHA-256 are required", false), nil + } + record, err := s.store.LoadUpload(req.UploadId) + if errors.Is(err, sql.ErrNoRows) { + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "upload not found", false), nil + } + if err != nil { + return nil, status.Errorf(codes.Internal, "load upload: %v", err) + } + binding, asset, grant, err := decodeUploadRecord(record) + if err != nil { + return nil, status.Errorf(codes.Internal, "decode durable upload %q: %v", req.UploadId, err) + } + if !proto.Equal(binding, req.Binding) || !proto.Equal(asset, req.Asset) { + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload completion binding does not match request", false), nil + } + if asset.SizeBytes != req.UploadedSizeBytes || !strings.EqualFold(asset.ChecksumSha256, req.UploadedChecksumSha256) || req.UploadedSizeBytes > grant.MaxBytes { + return s.completeUploadFailure(req.Meta, agentv1.FailureCode_FAILURE_CODE_INVALID_ARGUMENT, "uploaded asset does not match grant", false), nil + } + if record.State == "completed" { + if record.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 + } + 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 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 + } + 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) + if err != nil { + return nil, status.Errorf(codes.Internal, "build verified recording event: %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) + } + 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 +} + +func (s *DispatcherUploadServer) validateRequest(ctx context.Context, meta *agentv1.RequestMeta, binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor, uploadID string) error { + if meta == nil || binding == nil || asset == nil { + return status.Error(codes.InvalidArgument, "request metadata, binding and asset are required") + } + if meta.AgentId == "" || meta.CellId == "" || meta.OperationId == "" || meta.IdempotencyKey == "" { + return status.Error(codes.InvalidArgument, "agent, Cell, operation and idempotency metadata are required") + } + if binding.ExecutionId == "" || binding.TaskId == "" || binding.TenantId == "" || binding.TenantKey == "" { + return status.Error(codes.InvalidArgument, "complete execution binding is required") + } + if err := contract.ValidateTenantKey(binding.TenantKey); err != nil { + return status.Errorf(codes.InvalidArgument, "invalid tenant key: %v", err) + } + if asset.AssetId == "" || uploadID == "" { + return status.Error(codes.InvalidArgument, "asset ID and upload ID are required") + } + return validateDispatcherPeer(ctx, meta, s.requirePeer, s.peerCertificateFingerprints, s.allowedAgentIDs) +} + +func cloneStringSet(source map[string]struct{}) map[string]struct{} { + if len(source) == 0 { + return nil + } + result := make(map[string]struct{}, len(source)) + for value := range source { + result[value] = struct{}{} + } + return result +} + +func (s *DispatcherUploadServer) objectKey(binding *agentv1.ExecutionBinding, asset *agentv1.AssetDescriptor) string { + prefix := strings.Trim(s.oss.Config().KeyPrefix, "/") + input := binding.TenantKey + "\x00" + binding.ExecutionId + "\x00" + asset.AssetId + digest := sha256.Sum256([]byte(input)) + name := hex.EncodeToString(digest[:]) + if prefix == "" { + return name + } + return prefix + "/" + name +} + +func decodeUploadRecord(record store.UploadRecord) (*agentv1.ExecutionBinding, *agentv1.AssetDescriptor, *agentv1.UploadGrant, error) { + binding := &agentv1.ExecutionBinding{} + asset := &agentv1.AssetDescriptor{} + grant := &agentv1.UploadGrant{} + if err := proto.Unmarshal(record.Binding, binding); err != nil { + return nil, nil, nil, fmt.Errorf("binding: %w", err) + } + if err := proto.Unmarshal(record.Asset, asset); err != nil { + return nil, nil, nil, fmt.Errorf("asset: %w", err) + } + if err := proto.Unmarshal(record.Grant, grant); err != nil { + return nil, nil, nil, fmt.Errorf("grant: %w", err) + } + 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} +} + +func (s *DispatcherUploadServer) receipt(meta *agentv1.RequestMeta, result agentv1.ResultCode, detail string, retryable bool) *agentv1.OperationReceipt { + receipt := &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: result, AcceptedAtUnixMs: s.now().UnixMilli()} + if detail != "" { + receipt.Failure = &agentv1.Failure{Code: agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, Detail: detail, Retryable: retryable} + } + return receipt +} + +func (s *DispatcherUploadServer) requestUploadFailure(meta *agentv1.RequestMeta, code agentv1.FailureCode, detail string, retryable bool) *agentv1.RequestUploadResponse { + return &agentv1.RequestUploadResponse{Receipt: &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: agentv1.ResultCode_RESULT_CODE_REJECTED, Failure: &agentv1.Failure{Code: code, Detail: detail, Retryable: retryable}, AcceptedAtUnixMs: s.now().UnixMilli()}, State: agentv1.UploadState_UPLOAD_STATE_FAILED} +} + +func (s *DispatcherUploadServer) completeUploadFailure(meta *agentv1.RequestMeta, code agentv1.FailureCode, detail string, retryable bool) *agentv1.CompleteUploadResponse { + return &agentv1.CompleteUploadResponse{Receipt: &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: agentv1.ResultCode_RESULT_CODE_REJECTED, Failure: &agentv1.Failure{Code: code, Detail: detail, Retryable: retryable}, AcceptedAtUnixMs: s.now().UnixMilli()}, State: agentv1.UploadState_UPLOAD_STATE_FAILED} +} diff --git a/internal/rpc/dispatcher_upload_integration_test.go b/internal/rpc/dispatcher_upload_integration_test.go new file mode 100644 index 0000000..b914a20 --- /dev/null +++ b/internal/rpc/dispatcher_upload_integration_test.go @@ -0,0 +1,128 @@ +package rpc + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "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/agent" + ossclient "git.ipao.vip/rogee/go-sip/internal/oss" + "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/protobuf/proto" +) + +func TestAlibabaOSSDispatcherUploadDurableIntegration(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") + } + client, err := ossclient.NewClient(ossclient.Config{ + Endpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"), + Region: os.Getenv("DISPATCHER_OSS_REGION"), + Bucket: os.Getenv("DISPATCHER_OSS_BUCKET"), + AccessKeyID: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_ID"), + AccessKeySecret: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_SECRET"), + KeyPrefix: "agent-call/rpc-integration-tests", + GrantTTL: 15 * time.Minute, + MaxAssetBytes: 1 << 20, + }) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "recording.wav") + payload := []byte(strings.Repeat("dispatcher-upload-integration\n", 1024)) + if sourcePath := os.Getenv("OSS_INTEGRATION_RECORDING_PATH"); sourcePath != "" { + var readErr error + payload, readErr = os.ReadFile(sourcePath) + if readErr != nil { + t.Fatal(readErr) + } + if len(payload) == 0 { + t.Fatal("OSS_INTEGRATION_RECORDING_PATH is empty") + } + } + if err := os.WriteFile(path, payload, 0o600); err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(payload) + checksum := hex.EncodeToString(digest[:]) + binding := &agentv1.ExecutionBinding{TenantId: "tenant-integration", TenantKey: "tenant-integration", ExecutionId: "exec-oss-integration", TaskId: "task-oss-integration", TaskItemId: "item-oss-integration", TaskRevision: 1, CallId: "call-oss-integration", AttemptId: "attempt-oss-integration"} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-integration", CallId: binding.CallId, ExecutionId: binding.ExecutionId, Format: "wav", SizeBytes: int64(len(payload)), ChecksumSha256: checksum, Channels: 1, SampleRateHz: 16000, DurationMs: 1000} + requestMeta := &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "request-oss-integration", TraceId: "trace-oss-integration", OperationId: "operation-oss-integration", IdempotencyKey: "idempotency-oss-integration", AgentId: "agent-integration", CellId: "cell-integration"} + st, err := store.Open(filepath.Join(t.TempDir(), "dispatcher.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + clock := time.Now() + server, err := NewDispatcherUploadServer(st, client, func() time.Time { return clock }, false) + if err != nil { + t.Fatal(err) + } + uploadID := "upload-oss-integration" + grantResponse, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: requestMeta, Binding: binding, Asset: asset, UploadId: uploadID}) + if err != nil { + t.Fatal(err) + } + if grantResponse.Receipt.Result != agentv1.ResultCode_RESULT_CODE_ACCEPTED || grantResponse.Grant == nil { + t.Fatalf("grant rejected: %+v", grantResponse.Receipt) + } + clock = clock.Add(16 * time.Minute) + retryMeta := proto.Clone(requestMeta).(*agentv1.RequestMeta) + retryMeta.RequestId = "request-oss-integration-retry" + retryMeta.TraceId = "trace-oss-integration-retry" + retryMeta.OperationId = "operation-oss-integration-retry" + retryMeta.IdempotencyKey = "idempotency-oss-integration-retry" + reissuedResponse, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{Meta: retryMeta, Binding: binding, Asset: asset, UploadId: uploadID}) + if err != nil { + t.Fatal(err) + } + if reissuedResponse.Grant.ExpiresAtUnixMs <= grantResponse.Grant.ExpiresAtUnixMs { + t.Fatal("explicit post-expiry request did not receive a new grant") + } + grantResponse = reissuedResponse + parsed, err := url.Parse(grantResponse.Grant.TargetUrl) + if err != nil { + t.Fatal(err) + } + uploader := agent.UploadClient{Now: time.Now, AllowedHosts: map[string]struct{}{strings.ToLower(parsed.Host): {}}} + result, err := uploader.UploadFile(context.Background(), grantResponse.Grant, path) + 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) + } + var outboxCount int + if err := st.DB().QueryRow(`SELECT COUNT(*) FROM outbox WHERE event_id = ?`, "recording-ready-"+uploadID).Scan(&outboxCount); err != nil { + t.Fatal(err) + } + if outboxCount != 1 { + t.Fatalf("recording.ready outbox 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) + } +} diff --git a/internal/rpc/server.go b/internal/rpc/server.go new file mode 100644 index 0000000..a52dc49 --- /dev/null +++ b/internal/rpc/server.go @@ -0,0 +1,1095 @@ +package rpc + +import ( + "context" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + 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/calllog" + "git.ipao.vip/rogee/go-sip/internal/callwindow" + "git.ipao.vip/rogee/go-sip/internal/contract" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// ServerOptions contains deployment-safe identity, immutable contract +// artifacts, and mock policy inputs. Production credentials are supplied to +// grpc.Server via TLS credentials; no certificate or secret is stored here. +type ServerOptions struct { + Mode string + Status *agentv1.AgentStatus + UploadPolicy *agentv1.UploadPolicy + ConfigReferences []*agentv1.ConfigReference + StaticArtifactRaw []byte + StaticArtifactExpected contract.StaticArtifactExpectation + AISnapshotRaw []byte + AIAuthorizationRaw []byte + AIEgressPoolID string + Now func() time.Time + RequirePeerCertificate bool + PeerAgentIDs map[string]string + PeerCertificateFingerprints map[string]struct{} + StatePath string + CallLogger *calllog.Logger +} + +// Server is the local Unary gRPC state boundary. It owns session/fencing and +// durable-operation semantics for the RPC layer; Dispatcher SQLite remains the +// business source of truth for quotas and task state. +type Server struct { + agentv1.UnimplementedAgentControlServiceServer + + mode string + now func() time.Time + status *agentv1.AgentStatus + uploadPolicy *agentv1.UploadPolicy + configReferences []*agentv1.ConfigReference + staticArtifact contract.StaticCellArtifact + staticArtifactEnabled bool + staticArtifactError error + aiSnapshot ai.Snapshot + aiAuthorizationRaw []byte + aiEgressPoolID string + aiConfigError error + requirePeerCertificate bool + peerAgentIDs map[string]string + peerCertificateFingerprints map[string]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 +} + +type operationRecord struct { + digest string + receipt *agentv1.OperationReceipt +} + +type admissionRecord struct { + state agentv1.AdmissionState + generation uint64 +} + +type executionRecord struct { + binding *agentv1.ExecutionBinding + state agentv1.ExecutionState + taskRevision int64 + callState string + controlAction agentv1.ControlAction + permit *agentv1.ExecutionPermit + unknown bool + phone calllog.Identity +} + +type uploadRecord struct { + binding *agentv1.ExecutionBinding + asset *agentv1.AssetDescriptor + state agentv1.UploadState + grant *agentv1.UploadGrant + completed bool +} + +// NewServer constructs a handler suitable for registration with a gRPC server. +func NewServer(options ServerOptions) *Server { + mode := options.Mode + if mode == "" { + mode = "mock" + } + now := options.Now + if now == nil { + now = time.Now + } + statusValue := &agentv1.AgentStatus{} + if options.Status != nil { + statusValue = proto.Clone(options.Status).(*agentv1.AgentStatus) + } + if statusValue.AdmissionState == agentv1.AdmissionState_ADMISSION_STATE_UNSPECIFIED { + statusValue.AdmissionState = agentv1.AdmissionState_ADMISSION_STATE_CLOSED + } + uploadPolicy := &agentv1.UploadPolicy{} + if options.UploadPolicy != nil { + uploadPolicy = proto.Clone(options.UploadPolicy).(*agentv1.UploadPolicy) + } + var staticArtifact contract.StaticCellArtifact + var staticArtifactError error + staticArtifactEnabled := len(options.StaticArtifactRaw) != 0 + if staticArtifactEnabled { + staticArtifact, staticArtifactError = contract.ValidateStaticArtifact(options.StaticArtifactRaw, options.StaticArtifactExpected) + } + var aiSnapshot ai.Snapshot + var aiConfigError error + aiConfigured := len(options.AISnapshotRaw) != 0 || len(options.AIAuthorizationRaw) != 0 + if aiConfigured { + if len(options.AISnapshotRaw) == 0 || len(options.AIAuthorizationRaw) == 0 || options.AIEgressPoolID == "" { + aiConfigError = errors.New("AI snapshot, authorization and egress pool are required together") + } else { + aiSnapshot, aiConfigError = ai.Validate(options.AISnapshotRaw) + } + } + return &Server{ + mode: mode, + now: now, + status: statusValue, + uploadPolicy: uploadPolicy, + configReferences: cloneConfigReferences(options.ConfigReferences), + staticArtifact: staticArtifact, + staticArtifactEnabled: staticArtifactEnabled, + staticArtifactError: staticArtifactError, + aiSnapshot: aiSnapshot, + aiAuthorizationRaw: append([]byte(nil), options.AIAuthorizationRaw...), + aiEgressPoolID: options.AIEgressPoolID, + aiConfigError: aiConfigError, + requirePeerCertificate: options.RequirePeerCertificate, + peerAgentIDs: cloneStringMap(options.PeerAgentIDs), + peerCertificateFingerprints: cloneSet(options.PeerCertificateFingerprints), + callLogger: options.CallLogger, + sessions: NewSessionRegistry(options.StatePath), + operations: make(map[string]operationRecord), + admissions: make(map[string]admissionRecord), + executions: make(map[string]*executionRecord), + facts: make(map[string]string), + uploads: make(map[string]uploadRecord), + } +} + +func (s *Server) validateAIExecution(binding *agentv1.ExecutionBinding, configSHA256 string) error { + if s.aiConfigError == nil && len(s.aiAuthorizationRaw) == 0 { + return nil + } + if s.aiConfigError != nil { + return status.Errorf(codes.FailedPrecondition, "AI authorization configuration is invalid: %v", s.aiConfigError) + } + if binding == nil { + return status.Error(codes.InvalidArgument, "execution binding is required") + } + if configSHA256 == "" || configSHA256 != s.aiSnapshot.Digest { + return status.Error(codes.FailedPrecondition, "AI config digest does not match the authorized snapshot") + } + if binding.AgentVersionId != s.aiSnapshot.AgentVersionID { + return status.Error(codes.FailedPrecondition, "AI agent version does not match the authorized snapshot") + } + if _, err := ai.ValidateAuthorization(s.aiAuthorizationRaw, s.aiSnapshot, binding.TenantId, binding.TenantKey, s.aiEgressPoolID, s.now()); err != nil { + return status.Errorf(codes.PermissionDenied, "AI authorization rejected: %v", err) + } + return nil +} + +// SessionRegistry keeps the newest Dispatcher-approved binding for each Agent. +// A newer generation fences all older requests; it does not release unknown +// work from an older boot. +type SessionRegistry struct { + mu sync.Mutex + sessions map[string]sessionRecord + generations map[string]uint64 + statePath string + loadErr error +} + +type sessionRecord struct { + binding *agentv1.AgentBinding + activationOperationID string + digest string + session *agentv1.Session +} + +func NewSessionRegistry(statePath string) *SessionRegistry { + registry := &SessionRegistry{sessions: make(map[string]sessionRecord), generations: make(map[string]uint64), statePath: statePath} + if statePath != "" { + registry.loadErr = registry.load() + } + return registry +} + +type sessionJournal struct { + Generations map[string]uint64 `json:"generations"` +} + +func (r *SessionRegistry) load() error { + data, err := os.ReadFile(r.statePath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var journal sessionJournal + if err := json.Unmarshal(data, &journal); err != nil { + return err + } + for agentID, generation := range journal.Generations { + if agentID != "" && generation > 0 { + r.generations[agentID] = generation + } + } + return nil +} + +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 +} + +func (r *SessionRegistry) Activate(binding *agentv1.AgentBinding, activationOperationID, digest string, now time.Time) (*agentv1.Session, bool, error) { + if binding == nil || binding.AgentId == "" || binding.CellId == "" || binding.DispatcherEpoch == "" || activationOperationID == "" { + return nil, false, status.Error(codes.InvalidArgument, "agent, cell, epoch and activation operation are required") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.loadErr != nil { + return nil, false, status.Errorf(codes.Internal, "load session journal: %v", r.loadErr) + } + if existing, ok := r.sessions[binding.AgentId]; ok { + if existing.activationOperationID == activationOperationID && existing.digest == digest { + return cloneSession(existing.session), true, nil + } + if binding.SessionGeneration == 0 { + binding = proto.Clone(binding).(*agentv1.AgentBinding) + binding.SessionGeneration = existing.binding.SessionGeneration + 1 + } + if binding.SessionGeneration <= existing.binding.SessionGeneration { + return nil, false, status.Error(codes.Aborted, "session generation is fenced") + } + } + if binding.SessionGeneration == 0 { + binding = proto.Clone(binding).(*agentv1.AgentBinding) + binding.SessionGeneration = 1 + } + if previous, ok := r.generations[binding.AgentId]; ok && binding.SessionGeneration <= previous { + return nil, false, status.Error(codes.Aborted, "persisted session generation is fenced") + } + credential := make([]byte, 32) + if _, err := cryptorand.Read(credential); err != nil { + return nil, false, status.Errorf(codes.Internal, "create session credential: %v", err) + } + session := &agentv1.Session{ + DispatcherEpoch: binding.DispatcherEpoch, + SessionGeneration: binding.SessionGeneration, + ExpiresAtUnixMs: now.Add(10 * time.Minute).UnixMilli(), + SessionCredential: credential, + } + previous, hadPrevious := r.sessions[binding.AgentId] + r.sessions[binding.AgentId] = sessionRecord{ + binding: proto.Clone(binding).(*agentv1.AgentBinding), + activationOperationID: activationOperationID, + digest: digest, + session: cloneSession(session), + } + r.generations[binding.AgentId] = binding.SessionGeneration + if err := r.persistLocked(); err != nil { + if hadPrevious { + r.sessions[binding.AgentId] = previous + } else { + delete(r.sessions, binding.AgentId) + } + return nil, false, status.Errorf(codes.Internal, "persist session journal: %v", err) + } + return session, false, nil +} + +func (r *SessionRegistry) Authorize(meta *agentv1.RequestMeta, now time.Time) error { + if meta == nil || meta.AgentId == "" || meta.CellId == "" || meta.BootId == "" || meta.DispatcherEpoch == "" || meta.SessionGeneration == 0 { + return status.Error(codes.InvalidArgument, "complete session metadata is required") + } + r.mu.Lock() + defer r.mu.Unlock() + existing, ok := r.sessions[meta.AgentId] + if !ok { + return status.Error(codes.Unauthenticated, "agent session is not active") + } + if existing.binding.CellId != meta.CellId || existing.binding.ExpectedBootId != meta.BootId || existing.binding.DispatcherEpoch != meta.DispatcherEpoch || existing.binding.SessionGeneration != meta.SessionGeneration { + return status.Error(codes.Aborted, "agent session is fenced") + } + if existing.session.ExpiresAtUnixMs <= now.UnixMilli() { + return status.Error(codes.Unauthenticated, "agent session expired") + } + return nil +} + +func (s *Server) GetAgentStatus(ctx context.Context, req *agentv1.GetAgentStatusRequest) (*agentv1.GetAgentStatusResponse, error) { + if req == nil || req.Meta == nil || req.Meta.AgentId == "" || req.Meta.CellId == "" { + return nil, status.Error(codes.InvalidArgument, "status metadata with agent and cell is required") + } + if err := s.checkConfiguredIdentity(req.Meta.AgentId, req.Meta.CellId); err != nil { + return nil, err + } + if req.Target != nil { + if req.Target.AgentId != "" && req.Target.AgentId != req.Meta.AgentId { + return nil, status.Error(codes.PermissionDenied, "target agent does not match authenticated agent") + } + if req.Target.CellId != "" && req.Target.CellId != req.Meta.CellId { + return nil, status.Error(codes.PermissionDenied, "target Cell does not match authenticated Cell") + } + } + if err := s.checkPeer(ctx, req.Meta.AgentId); err != nil { + return nil, err + } + preActivation := req.Meta.BootId == "" && req.Meta.DispatcherEpoch == "" && req.Meta.SessionGeneration == 0 + if preActivation { + if req.Target != nil && (req.Target.ExpectedBootId != "" || req.Target.DispatcherEpoch != "" || req.Target.SessionGeneration != 0) { + return nil, status.Error(codes.InvalidArgument, "pre-activation status cannot include session binding") + } + } else if err := s.sessions.Authorize(req.Meta, s.now()); err != nil { + return nil, err + } + result := proto.Clone(s.status).(*agentv1.AgentStatus) + result.SessionActive = !preActivation + result.MtlsAuthenticated = s.peerIsAuthenticated(ctx) + return &agentv1.GetAgentStatusResponse{Meta: s.responseMeta(req.Meta), Status: result}, nil +} + +func (s *Server) ActivateAgent(ctx context.Context, req *agentv1.ActivateAgentRequest) (*agentv1.ActivateAgentResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "activation metadata and binding are required") + } + if req.Meta.AgentId != req.Binding.AgentId || req.Meta.CellId != req.Binding.CellId || req.Meta.BootId == "" || req.Meta.DispatcherEpoch == "" { + return nil, status.Error(codes.InvalidArgument, "activation identity is inconsistent") + } + if err := s.checkConfiguredIdentity(req.Binding.AgentId, req.Binding.CellId); err != nil { + return nil, err + } + if err := s.checkPeer(ctx, req.Binding.AgentId); err != nil { + return nil, err + } + if s.staticArtifactEnabled { + if s.staticArtifactError != nil { + return nil, status.Errorf(codes.FailedPrecondition, "static Cell artifact is invalid: %v", s.staticArtifactError) + } + if req.Binding.CellId != s.staticArtifact.CellID { + return nil, status.Error(codes.FailedPrecondition, "activation Cell does not match static artifact") + } + } + binding := proto.Clone(req.Binding).(*agentv1.AgentBinding) + if binding.ExpectedBootId == "" { + binding.ExpectedBootId = req.Meta.BootId + } + if binding.ExpectedBootId != req.Meta.BootId { + return nil, status.Error(codes.Aborted, "activation boot identity is fenced") + } + if req.ActivationOperationId == "" { + return nil, status.Error(codes.InvalidArgument, "activation operation is required") + } + digest := messageDigest(req) + session, replay, err := s.sessions.Activate(binding, req.ActivationOperationId, digest, s.now()) + if err != nil { + return nil, err + } + state := agentv1.ActivationState_ACTIVATION_STATE_ACTIVE + if replay { + state = agentv1.ActivationState_ACTIVATION_STATE_ACTIVE + } + return &agentv1.ActivateAgentResponse{Meta: s.responseMeta(req.Meta), State: state, Session: session}, nil +} + +func (s *Server) GetBootstrap(ctx context.Context, req *agentv1.GetBootstrapRequest) (*agentv1.GetBootstrapResponse, error) { + if req == nil || req.Meta == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata is required") + } + if err := s.checkPeer(ctx, req.Meta.AgentId); err != nil { + return nil, err + } + if err := s.sessions.Authorize(req.Meta, s.now()); err != nil { + return nil, err + } + return &agentv1.GetBootstrapResponse{ + Meta: s.responseMeta(req.Meta), + State: agentv1.ActivationState_ACTIVATION_STATE_ACTIVE, + RuntimeConfigs: cloneConfigReferences(s.configReferences), + UploadPolicy: proto.Clone(s.uploadPolicy).(*agentv1.UploadPolicy), + }, nil +} + +func (s *Server) SetAdmissionState(ctx context.Context, req *agentv1.SetAdmissionStateRequest) (*agentv1.SetAdmissionStateResponse, error) { + if req == nil || req.Meta == nil || req.Target == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and target are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + if req.State == agentv1.AdmissionState_ADMISSION_STATE_UNSPECIFIED { + return nil, status.Error(codes.InvalidArgument, "admission state is required") + } + key := req.Target.AgentId + s.mu.Lock() + defer s.mu.Unlock() + current := s.admissions[key] + if current.generation != req.ExpectedAdmissionGeneration { + return &agentv1.SetAdmissionStateResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "admission generation conflict", false)}, nil + } + current.generation++ + current.state = req.State + s.admissions[key] = current + return &agentv1.SetAdmissionStateResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_APPLIED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false), AppliedAdmissionGeneration: current.generation}, nil +} + +func (s *Server) Execute(ctx context.Context, req *agentv1.ExecuteRequest) (*agentv1.ExecuteResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and execution binding are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + if req.Binding.ExecutionId == "" || req.Binding.TaskId == "" || req.Binding.TaskItemId == "" || req.Binding.TenantKey == "" || len(req.CallExecuteJson) == 0 { + return nil, status.Error(codes.InvalidArgument, "execution binding and command bytes are required") + } + envelope, payload, err := contract.DecodeExecute(req.CallExecuteJson) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "call.execute contract: %v", err) + } + if envelope.TenantKey != req.Binding.TenantKey || payload.ExecutionID != req.Binding.ExecutionId || payload.TaskID != req.Binding.TaskId || payload.TaskItemID != req.Binding.TaskItemId || payload.AgentVersionID != req.Binding.AgentVersionId { + return nil, status.Error(codes.Aborted, "execution binding does not match command") + } + if err := s.validateAIExecution(req.Binding, req.ConfigSha256); err != nil { + return nil, err + } + phoneIdentity := calllog.Identity{} + if s.callLogger != nil { + phoneIdentity, err = s.callLogger.Identity(payload.Callee) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "callee cannot be logged: %v", err) + } + } + digest := messageDigest(req) + if receipt, conflict := s.replayOperation(req.Meta, digest); receipt != nil || conflict != nil { + if conflict != nil { + return &agentv1.ExecuteResponse{Receipt: conflict}, nil + } + return &agentv1.ExecuteResponse{Receipt: receipt, State: agentv1.ExecutionState_EXECUTION_STATE_PREPARED}, nil + } + if s.mode != "mock" { + if err := callwindow.Check(s.now()); err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + } + if s.callLogger != nil { + if err := s.callLogger.Append(calllog.Event{ + EventID: "execution:" + payload.ExecutionID + ":prepared", EventType: "execution.prepared", Phone: payload.Callee, + TenantID: envelope.TenantID, TraceID: envelope.TraceID, ExecutionID: payload.ExecutionID, + TaskID: payload.TaskID, TaskItemID: payload.TaskItemID, TaskRevision: payload.TaskRevision, + AgentID: req.Meta.AgentId, CellID: req.Meta.CellId, RoutePolicyID: payload.RoutePolicyID, + CallerProfileID: payload.CallerProfileID, Status: "accepted", Result: "prepared", ReasonCode: "accepted", + }); err != nil { + 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} + 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() + return &agentv1.ExecuteResponse{Receipt: receipt, State: state}, nil +} + +func (s *Server) GetExecutionPermit(ctx context.Context, req *agentv1.GetExecutionPermitRequest) (*agentv1.GetExecutionPermitResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and execution binding are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + if req.Binding.ExecutionId == "" || req.ResourceReservationId == "" { + return nil, status.Error(codes.InvalidArgument, "execution and reservation are required") + } + if err := s.validateAIExecution(req.Binding, req.ConfigSha256); err != nil { + return nil, err + } + if s.mode != "mock" { + if err := callwindow.Check(s.now()); err != nil { + return nil, status.Error(codes.FailedPrecondition, err.Error()) + } + } + 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() + 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 + } + 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() + return &agentv1.GetExecutionPermitResponse{Receipt: receipt, Permit: permit}, nil +} + +func (s *Server) ApplyTaskControl(ctx context.Context, req *agentv1.ApplyTaskControlRequest) (*agentv1.ApplyTaskControlResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and execution binding are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + 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") + } + s.mu.Lock() + defer s.mu.Unlock() + 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 + } + 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 + } + 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 + } + execution.taskRevision++ + execution.controlAction = req.Action + if req.Action == agentv1.ControlAction_CONTROL_ACTION_STOP { + execution.state = agentv1.ExecutionState_EXECUTION_STATE_TERMINAL + execution.callState = "stopped" + } else if req.Action == agentv1.ControlAction_CONTROL_ACTION_PAUSE { + execution.callState = "paused" + } 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 +} + +func (s *Server) QueryExecution(ctx context.Context, req *agentv1.QueryExecutionRequest) (*agentv1.QueryExecutionResponse, error) { + if req == nil || req.Meta == nil || req.Binding == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and execution binding are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + s.mu.Lock() + execution := s.executions[req.Binding.ExecutionId] + if execution == nil { + s.mu.Unlock() + return &agentv1.QueryExecutionResponse{Meta: s.responseMeta(req.Meta), Failure: s.failure(agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "execution not found", false)}, nil + } + snapshot := &agentv1.ExecutionSnapshot{Binding: proto.Clone(execution.binding).(*agentv1.ExecutionBinding), State: execution.state, CallState: execution.callState, AttemptId: execution.binding.AttemptId, ObservedAtUnixMs: s.now().UnixMilli(), Unknown: execution.unknown} + s.mu.Unlock() + return &agentv1.QueryExecutionResponse{Meta: s.responseMeta(req.Meta), Snapshot: snapshot}, nil +} + +func (s *Server) ReportExecutionEvent(ctx context.Context, req *agentv1.ReportExecutionEventRequest) (*agentv1.ReportExecutionEventResponse, error) { + if req == nil || req.Meta == nil || req.Fact == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata and fact are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + if req.Fact.FactId == "" || req.Fact.ContentSha256 == "" { + return nil, status.Error(codes.InvalidArgument, "fact ID and content digest are required") + } + s.mu.Lock() + if previous, ok := s.facts[req.Fact.FactId]; ok { + s.mu.Unlock() + if previous != req.Fact.ContentSha256 { + return &agentv1.ReportExecutionEventResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "fact digest conflict", false)}, nil + } + return &agentv1.ReportExecutionEventResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "duplicate fact", false)}, nil + } + execution := executionForFact(s.executions, req.Fact) + if s.callLogger != nil && execution != nil && execution.phone.Ref != "" { + for _, event := range callLogEvents(req.Fact, execution, req.Meta, s.now()) { + if err := s.callLogger.Append(event); err != nil { + s.mu.Unlock() + return nil, status.Errorf(codes.Internal, "write call business log: %v", err) + } + } + } + s.facts[req.Fact.FactId] = req.Fact.ContentSha256 + s.mu.Unlock() + return &agentv1.ReportExecutionEventResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false)}, nil +} + +type callFactPayload struct { + CallID string `json:"call_id"` + ExecutionID string `json:"execution_id"` + TaskID string `json:"task_id"` + TaskItemID string `json:"task_item_id"` + CallState string `json:"call_state"` + AttemptID string `json:"attempt_id"` + AttemptState string `json:"attempt_state"` + RoutePolicyID string `json:"route_policy_id"` + CallerProfileID string `json:"caller_profile_id"` + TrunkID string `json:"trunk_id"` + CellID string `json:"cell_id"` + ReasonCode string `json:"reason_code"` + Outcome string `json:"outcome"` + Result string `json:"result"` + Status string `json:"status"` + DurationMS int64 `json:"duration_ms"` + AssetState string `json:"asset_state"` + RecordingID string `json:"recording_id"` + RecordingState string `json:"recording_state"` + SizeBytes int64 `json:"size_bytes"` + ChecksumSHA256 string `json:"checksum_sha256"` + SHA256 string `json:"sha256"` + SIPStage string `json:"sip_stage"` + SIPStatusCode int `json:"sip_status_code"` + StatusCode int `json:"status_code"` + SIPReason string `json:"sip_reason"` + Stage string `json:"stage"` + AttemptSummary []struct { + AttemptID string `json:"attempt_id"` + State string `json:"state"` + TrunkID string `json:"trunk_id"` + CellID string `json:"cell_id"` + ReasonCode string `json:"reason_code"` + } `json:"attempt_summary"` +} + +func executionForFact(executions map[string]*executionRecord, fact *agentv1.ExecutionFact) *executionRecord { + if fact == nil || fact.Binding == nil || fact.Binding.ExecutionId == "" { + return nil + } + return executions[fact.Binding.ExecutionId] +} + +func callLogEvents(fact *agentv1.ExecutionFact, execution *executionRecord, meta *agentv1.RequestMeta, now time.Time) []calllog.Event { + if fact == nil || execution == nil { + return nil + } + payload := callFactPayload{} + _ = json.Unmarshal(fact.PayloadJson, &payload) + binding := execution.binding + if fact.Binding != nil { + binding = fact.Binding + } + event := calllog.Event{ + EventID: fact.FactId, EventType: factKindEventType(fact.Kind), OccurredAt: now, + PhoneRef: execution.phone.Ref, PhoneMask: execution.phone.Mask, + AttemptID: payload.AttemptID, CallID: payload.CallID, Status: payload.Status, + Result: payload.Result, ReasonCode: payload.ReasonCode, DurationMS: payload.DurationMS, + SIPStage: payload.SIPStage, SIPStatusCode: payload.SIPStatusCode, SIPReason: payload.SIPReason, + RecordingID: payload.RecordingID, RecordingState: payload.RecordingState, + RecordingSize: payload.SizeBytes, RecordingSHA256: firstNonEmpty(payload.ChecksumSHA256, payload.SHA256), + } + if meta != nil { + event.TraceID = meta.TraceId + event.AgentID = meta.AgentId + event.CellID = meta.CellId + } + if event.SIPStatusCode == 0 { + event.SIPStatusCode = payload.StatusCode + } + if event.RecordingState == "" { + event.RecordingState = payload.AssetState + } + if event.RecordingState == "" && payload.Stage != "" { + event.RecordingState = payload.Stage + } + if event.Result == "" { + event.Result = payload.Outcome + } + if event.Status == "" { + event.Status = payload.CallState + } + if event.Status == "" { + event.Status = payload.Outcome + } + if binding != nil { + event.TenantID = binding.TenantId + event.ExecutionID = binding.ExecutionId + event.TaskID = binding.TaskId + event.TaskItemID = binding.TaskItemId + event.TaskRevision = binding.TaskRevision + event.CallID = firstNonEmpty(event.CallID, binding.CallId) + event.AttemptID = firstNonEmpty(event.AttemptID, binding.AttemptId) + event.RoutePolicyID = firstNonEmpty(payload.RoutePolicyID, binding.RoutePolicyId) + event.CallerProfileID = firstNonEmpty(payload.CallerProfileID, binding.CallerProfileId) + } + if event.CallID == "" { + event.CallID = payload.CallID + } + if event.ExecutionID == "" { + event.ExecutionID = payload.ExecutionID + } + if event.TaskID == "" { + event.TaskID = payload.TaskID + } + if event.TaskItemID == "" { + event.TaskItemID = payload.TaskItemID + } + event.TrunkID = payload.TrunkID + if payload.CellID != "" { + event.CellID = payload.CellID + } + result := []calllog.Event{event} + for index, attempt := range payload.AttemptSummary { + if attempt.AttemptID == "" { + continue + } + result = append(result, calllog.Event{ + EventID: fact.FactId + ":attempt:" + attempt.AttemptID, EventType: "call.attempt", OccurredAt: now, + PhoneRef: execution.phone.Ref, PhoneMask: execution.phone.Mask, TenantID: event.TenantID, + ExecutionID: event.ExecutionID, TaskID: event.TaskID, TaskItemID: event.TaskItemID, + TaskRevision: event.TaskRevision, AttemptID: attempt.AttemptID, CallID: event.CallID, + RoutePolicyID: event.RoutePolicyID, CallerProfileID: event.CallerProfileID, + TrunkID: attempt.TrunkID, CellID: attempt.CellID, AttemptState: attempt.State, + Status: attempt.State, ReasonCode: attempt.ReasonCode, Result: fmt.Sprintf("attempt_%d", index+1), + }) + } + return result +} + +func factKindEventType(kind agentv1.FactKind) string { + switch kind { + case agentv1.FactKind_FACT_KIND_EXECUTION_ACCEPTED: + return "execution.accepted" + case agentv1.FactKind_FACT_KIND_CALL_STATUS: + return "call.status" + case agentv1.FactKind_FACT_KIND_CALL_FINISHED: + return "call.finished" + case agentv1.FactKind_FACT_KIND_TRANSCRIPT_UPDATED: + return "transcript.updated" + case agentv1.FactKind_FACT_KIND_TRANSCRIPT_FAILED: + return "transcript.failed" + case agentv1.FactKind_FACT_KIND_CONTACT_OPT_OUT: + return "contact.opt_out" + case agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS: + return "recording.progress" + default: + return "execution.fact" + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func (s *Server) RequestUpload(ctx context.Context, req *agentv1.RequestUploadRequest) (*agentv1.RequestUploadResponse, error) { + if s.mode != "mock" { + return nil, status.Error(codes.Unimplemented, "mock upload path is disabled outside mock mode") + } + if req == nil || req.Meta == nil || req.Binding == nil || req.Asset == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata, binding and asset are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + if req.UploadId == "" { + return nil, status.Error(codes.InvalidArgument, "upload ID is required") + } + if !s.uploadPolicy.Enabled { + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION, "uploads are disabled", false), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.uploads[req.UploadId]; ok { + if !proto.Equal(existing.binding, req.Binding) || !proto.Equal(existing.asset, req.Asset) { + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload ID is bound to a different execution or asset", false), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil + } + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "duplicate upload request", false), Grant: proto.Clone(existing.grant).(*agentv1.UploadGrant), State: existing.state}, nil + } + grant := &agentv1.UploadGrant{UploadId: req.UploadId, TargetUrl: "https://oss.mock.invalid/upload/" + req.UploadId, ExpiresAtUnixMs: s.now().Add(5 * time.Minute).UnixMilli(), ObjectKey: req.Asset.AssetId, RequiredChecksumSha256: req.Asset.ChecksumSha256, MaxBytes: s.uploadPolicy.MaxAssetBytes} + if grant.MaxBytes == 0 { + grant.MaxBytes = req.Asset.SizeBytes + } + s.uploads[req.UploadId] = uploadRecord{binding: proto.Clone(req.Binding).(*agentv1.ExecutionBinding), asset: proto.Clone(req.Asset).(*agentv1.AssetDescriptor), state: agentv1.UploadState_UPLOAD_STATE_REQUESTED, grant: grant} + return &agentv1.RequestUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_ACCEPTED, agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED, "", false), Grant: proto.Clone(grant).(*agentv1.UploadGrant), State: agentv1.UploadState_UPLOAD_STATE_REQUESTED}, nil +} + +func (s *Server) CompleteUpload(ctx context.Context, req *agentv1.CompleteUploadRequest) (*agentv1.CompleteUploadResponse, error) { + if s.mode != "mock" { + return nil, status.Error(codes.Unimplemented, "mock upload path is disabled outside mock mode") + } + if req == nil || req.Meta == nil || req.Binding == nil || req.Asset == nil { + return nil, status.Error(codes.InvalidArgument, "request metadata, binding and asset are required") + } + if err := s.authorize(ctx, req.Meta); err != nil { + return nil, err + } + if err := requireIdempotency(req.Meta); err != nil { + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + upload, ok := s.uploads[req.UploadId] + if !ok { + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_REJECTED, agentv1.FailureCode_FAILURE_CODE_NOT_FOUND, "upload not found", false), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil + } + if !proto.Equal(upload.binding, req.Binding) || !proto.Equal(upload.asset, req.Asset) { + return &agentv1.CompleteUploadResponse{Receipt: s.receipt(req.Meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "upload completion binding does not match request", false), State: agentv1.UploadState_UPLOAD_STATE_FAILED}, nil + } + if upload.asset.ChecksumSha256 != req.UploadedChecksumSha256 || upload.asset.SizeBytes != req.UploadedSizeBytes || req.UploadedSizeBytes > upload.grant.MaxBytes { + 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 + } + 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 + } + 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 +} + +func requireIdempotency(meta *agentv1.RequestMeta) error { + if meta == nil || meta.OperationId == "" || meta.IdempotencyKey == "" { + return status.Error(codes.InvalidArgument, "operation and idempotency key are required") + } + return nil +} + +func (s *Server) authorize(ctx context.Context, meta *agentv1.RequestMeta) error { + if meta == nil { + return status.Error(codes.InvalidArgument, "request metadata is required") + } + if err := s.checkPeer(ctx, meta.AgentId); err != nil { + return err + } + return s.sessions.Authorize(meta, s.now()) +} + +func (s *Server) checkConfiguredIdentity(agentID, cellID string) error { + if s.status.AgentId != "" && s.status.AgentId != agentID { + return status.Error(codes.PermissionDenied, "request Agent identity is not bound to this endpoint") + } + if s.status.CellId != "" && s.status.CellId != cellID { + return status.Error(codes.PermissionDenied, "request Cell identity is not bound to this endpoint") + } + return nil +} + +func (s *Server) checkPeer(ctx context.Context, agentID string) error { + if !s.requirePeerCertificate && len(s.peerAgentIDs) == 0 && len(s.peerCertificateFingerprints) == 0 { + return nil + } + p, ok := peer.FromContext(ctx) + if !ok { + return status.Error(codes.Unauthenticated, "mTLS peer is missing") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.VerifiedChains) == 0 || len(tlsInfo.State.VerifiedChains[0]) == 0 { + return status.Error(codes.Unauthenticated, "verified mTLS peer is required") + } + fingerprint := CertificateFingerprint(tlsInfo.State.VerifiedChains[0][0]) + if len(s.peerAgentIDs) == 0 && len(s.peerCertificateFingerprints) == 0 { + // The shared Agent certificate authenticates the certificate group. The + // Dispatcher-approved session binding still authorizes the individual + // agent/cell/boot tuple; no self-reported identity is trusted here. + return nil + } + if len(s.peerCertificateFingerprints) > 0 { + if _, allowed := s.peerCertificateFingerprints[fingerprint]; !allowed { + return status.Error(codes.PermissionDenied, "mTLS certificate is not in the endpoint allowlist") + } + } + if len(s.peerAgentIDs) > 0 { + if expected := s.peerAgentIDs[fingerprint]; expected == "" || expected != agentID { + return status.Error(codes.PermissionDenied, "mTLS certificate is not bound to this agent") + } + } + return nil +} + +func (s *Server) peerIsAuthenticated(ctx context.Context) bool { + p, ok := peer.FromContext(ctx) + if !ok { + return false + } + _, ok = p.AuthInfo.(credentials.TLSInfo) + return ok +} + +func (s *Server) responseMeta(meta *agentv1.RequestMeta) *agentv1.ResponseMeta { + if meta == nil { + return nil + } + 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} +} + +func (s *Server) receipt(meta *agentv1.RequestMeta, result agentv1.ResultCode, code agentv1.FailureCode, detail string, retryable bool) *agentv1.OperationReceipt { + receipt := &agentv1.OperationReceipt{Meta: s.responseMeta(meta), Result: result, AcceptedAtUnixMs: s.now().UnixMilli()} + if code != agentv1.FailureCode_FAILURE_CODE_UNSPECIFIED { + receipt.Failure = s.failure(code, detail, retryable) + } + return receipt +} + +func (s *Server) failure(code agentv1.FailureCode, detail string, retryable bool) *agentv1.Failure { + return &agentv1.Failure{Code: code, Detail: detail, Retryable: retryable} +} + +func (s *Server) operationKey(meta *agentv1.RequestMeta) string { + if meta == nil || meta.IdempotencyKey == "" { + return "" + } + return meta.AgentId + "\x00" + meta.OperationId + "\x00" + meta.IdempotencyKey +} + +func (s *Server) replayOperation(meta *agentv1.RequestMeta, digest string) (*agentv1.OperationReceipt, *agentv1.OperationReceipt) { + key := s.operationKey(meta) + if key == "" { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + record, ok := s.operations[key] + if !ok { + return nil, nil + } + if record.digest != digest { + return nil, s.receipt(meta, agentv1.ResultCode_RESULT_CODE_CONFLICT, agentv1.FailureCode_FAILURE_CODE_ABORTED, "idempotency key content conflict", false) + } + return proto.Clone(record.receipt).(*agentv1.OperationReceipt), nil +} + +func messageDigest(message proto.Message) string { + encoded, err := proto.Marshal(message) + if err != nil { + return "marshal-error" + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]) +} + +func randomToken() string { + value := make([]byte, 16) + if _, err := cryptorand.Read(value); err != nil { + return "unavailable" + } + return hex.EncodeToString(value) +} + +func cloneSession(value *agentv1.Session) *agentv1.Session { + if value == nil { + return nil + } + return proto.Clone(value).(*agentv1.Session) +} + +func cloneConfigReferences(values []*agentv1.ConfigReference) []*agentv1.ConfigReference { + result := make([]*agentv1.ConfigReference, 0, len(values)) + for _, value := range values { + if value != nil { + result = append(result, proto.Clone(value).(*agentv1.ConfigReference)) + } + } + return result +} + +func cloneStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + result := make(map[string]string, len(values)) + for key, value := range values { + result[key] = value + } + return result +} + +func cloneSet(values map[string]struct{}) map[string]struct{} { + if values == nil { + return nil + } + result := make(map[string]struct{}, len(values)) + for key := range values { + result[key] = struct{}{} + } + return result +} + +var _ agentv1.AgentControlServiceServer = (*Server)(nil) +var _ = grpc.SupportPackageIsVersion9 diff --git a/internal/rpc/server_test.go b/internal/rpc/server_test.go new file mode 100644 index 0000000..9479384 --- /dev/null +++ b/internal/rpc/server_test.go @@ -0,0 +1,258 @@ +package rpc + +import ( + "context" + "crypto/tls" + "crypto/x509" + "net" + "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" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/proto" + "reflect" +) + +func TestPeerCertificateAllowlist(t *testing.T) { + _, allowedCert, _ := testCertificate(t, nil, nil, false, []string{"dispatcher.local"}, nil) + fingerprint := CertificateFingerprint(allowedCert) + server := NewServer(ServerOptions{RequirePeerCertificate: true, PeerCertificateFingerprints: map[string]struct{}{fingerprint: {}}}) + allowedContext := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{allowedCert}}}}}) + if err := server.checkPeer(allowedContext, "agent-1"); err != nil { + t.Fatal(err) + } + _, rejectedCert, _ := testCertificate(t, nil, nil, false, []string{"other-dispatcher.local"}, nil) + rejectedContext := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{rejectedCert}}}}}) + if err := server.checkPeer(rejectedContext, "agent-1"); status.Code(err) != codes.PermissionDenied { + t.Fatalf("got %v, want PermissionDenied", err) + } +} + +func TestGetAgentStatusSupportsPreActivationProbe(t *testing.T) { + now := time.Unix(100, 0) + server := NewServer(ServerOptions{ + Now: func() time.Time { return now }, + Status: &agentv1.AgentStatus{ + AgentId: "agent-1", + CellId: "cell-1", + BootId: "boot-current", + SoftwareVersion: "test", + ProtocolVersion: "agent.v1", + AdmissionState: agentv1.AdmissionState_ADMISSION_STATE_CLOSED, + }, + }) + response, err := server.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{ + Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "probe-request", TraceId: "probe-trace", OperationId: "probe-operation", AgentId: "agent-1", CellId: "cell-1"}, + Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}, + }) + require.NoError(t, err) + require.Equal(t, "boot-current", response.Status.BootId) + require.Equal(t, false, response.Status.SessionActive) + require.Equal(t, false, response.Status.MtlsAuthenticated) + + active := activatedServer(now, t) + activeResponse, err := active.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{Meta: testMeta("status-active", "status-active-key", 1), Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}}) + require.NoError(t, err) + require.Equal(t, true, activeResponse.Status.SessionActive) +} + +func TestAgentIdentityIsBoundToConfiguredEndpoint(t *testing.T) { + server := NewServer(ServerOptions{Status: &agentv1.AgentStatus{AgentId: "agent-1", CellId: "cell-1"}}) + _, err := server.GetAgentStatus(context.Background(), &agentv1.GetAgentStatusRequest{ + Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "wrong-probe", TraceId: "wrong-probe", OperationId: "wrong-probe", AgentId: "agent-2", CellId: "cell-2"}, + Target: &agentv1.AgentBinding{AgentId: "agent-2", CellId: "cell-2"}, + }) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + + _, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "wrong-activate", TraceId: "wrong-activate", OperationId: "wrong-activate", AgentId: "agent-2", CellId: "cell-2", BootId: "boot-2", DispatcherEpoch: "epoch-2"}, + Binding: &agentv1.AgentBinding{AgentId: "agent-2", CellId: "cell-2", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 1}, + ActivationOperationId: "wrong-activate", + }) + require.Equal(t, codes.PermissionDenied, status.Code(err)) +} + +func TestSessionGenerationFencesOlderRequests(t *testing.T) { + now := time.Unix(100, 0) + server := NewServer(ServerOptions{Now: func() time.Time { return now }}) + firstMeta := testMeta("activate-1", "", 0) + firstMeta.OperationId = "activate-1" + _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: firstMeta, + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-1", + }) + require.NoError(t, err) + secondMeta := testMeta("activate-2", "", 0) + secondMeta.BootId = "boot-2" + secondMeta.OperationId = "activate-2" + _, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: secondMeta, + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 2}, + ActivationOperationId: "activate-2", + }) + require.NoError(t, err) + + _, err = server.GetBootstrap(context.Background(), &agentv1.GetBootstrapRequest{Meta: testMeta("old", "read-old", 1)}) + require.Error(t, err) + require.Equal(t, codes.Aborted, status.Code(err)) + fresh := testMeta("fresh", "read-fresh", 2) + fresh.BootId = "boot-2" + fresh.DispatcherEpoch = "epoch-2" + _, err = server.GetBootstrap(context.Background(), &agentv1.GetBootstrapRequest{Meta: fresh}) + require.NoError(t, err) +} + +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") + require.NoError(t, err) + envelope, payload, err := contract.DecodeExecute(raw) + require.NoError(t, err) + meta := testMeta("execute-1", "execute-key", 1) + req := &agentv1.ExecuteRequest{Meta: meta, 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"} + first, err := server.Execute(context.Background(), req) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, first.Receipt.Result) + replay, err := server.Execute(context.Background(), req) + require.NoError(t, err) + require.Equal(t, first.Receipt.Meta.OperationId, replay.Receipt.Meta.OperationId) + conflictReq := proto.Clone(req).(*agentv1.ExecuteRequest) + conflictReq.ConfigSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + conflict, err := server.Execute(context.Background(), conflictReq) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result) +} + +func TestRealModeRejectsExecutionOutsideCallWindow(t *testing.T) { + server := NewServer(ServerOptions{ + Mode: "mixed", + Now: func() time.Time { return time.Date(2026, 9, 18, 12, 0, 0, 0, time.UTC) }, + }) + activateTestServer(t, server) + response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{ + Meta: testMeta("permit-window", "permit-window-key", 1), + Binding: &agentv1.ExecutionBinding{ExecutionId: "execution-window"}, + ResourceReservationId: "reservation-window", + }) + if response != nil || status.Code(err) != codes.FailedPrecondition { + t.Fatalf("response=%+v err=%v code=%s", response, err, status.Code(err)) + } +} + +func TestRealModeRejectsMockUploadDataPlane(t *testing.T) { + server := NewServer(ServerOptions{Mode: "real"}) + response, err := server.RequestUpload(context.Background(), &agentv1.RequestUploadRequest{}) + if response != nil || status.Code(err) != codes.Unimplemented { + t.Fatalf("response=%+v err=%v code=%s", response, err, status.Code(err)) + } +} + +func TestAdmissionAndControlCAS(t *testing.T) { + server := activatedServer(time.Date(2026, 9, 18, 1, 0, 0, 0, time.UTC), t) + meta := testMeta("admission-1", "admission-key", 1) + admission, err := server.SetAdmissionState(context.Background(), &agentv1.SetAdmissionStateRequest{Meta: meta, Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}, State: agentv1.AdmissionState_ADMISSION_STATE_OPEN}) + require.NoError(t, err) + require.Equal(t, uint64(1), admission.AppliedAdmissionGeneration) + conflict, err := server.SetAdmissionState(context.Background(), &agentv1.SetAdmissionStateRequest{Meta: testMeta("admission-2", "admission-key-2", 1), Target: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1"}, State: agentv1.AdmissionState_ADMISSION_STATE_CLOSED}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result) + + raw, err := contracts.Read("examples/call.execute.json") + require.NoError(t, err) + envelope, payload, err := contract.DecodeExecute(raw) + require.NoError(t, err) + executeMeta := testMeta("execute-control", "execute-control-key", 1) + _, 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}) + 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}) + 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}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_REJECTED, resumed.Receipt.Result) +} + +func TestFactDeduplication(t *testing.T) { + server := activatedServer(time.Unix(100, 0), t) + fact := &agentv1.ExecutionFact{FactId: "fact-1", ContentSha256: "digest-a", Binding: &agentv1.ExecutionBinding{ExecutionId: "execution-1"}, Kind: agentv1.FactKind_FACT_KIND_CALL_STATUS} + first, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-1", "fact-key-1", 1), Fact: fact}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, first.Receipt.Result) + replay, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-2", "fact-key-2", 1), Fact: fact}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_ACCEPTED, replay.Receipt.Result) + fact.ContentSha256 = "digest-b" + conflict, err := server.ReportExecutionEvent(context.Background(), &agentv1.ReportExecutionEventRequest{Meta: testMeta("fact-3", "fact-key-3", 1), Fact: fact}) + require.NoError(t, err) + require.Equal(t, agentv1.ResultCode_RESULT_CODE_CONFLICT, conflict.Receipt.Result) +} + +func TestGeneratedUnaryServiceWiring(t *testing.T) { + server := NewServer(ServerOptions{Now: func() time.Time { return time.Unix(100, 0) }}) + listener := bufconn.Listen(1024 * 1024) + grpcServer := grpc.NewServer() + agentv1.RegisterAgentControlServiceServer(grpcServer, server) + go func() { _ = grpcServer.Serve(listener) }() + defer grpcServer.Stop() + conn, err := grpc.NewClient("passthrough:///bufnet", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + defer conn.Close() + client := agentv1.NewAgentControlServiceClient(conn) + meta := testMeta("activate-rpc", "", 0) + response, err := client.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: meta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, ActivationOperationId: "activate-rpc"}) + require.NoError(t, err) + require.Equal(t, agentv1.ActivationState_ACTIVATION_STATE_ACTIVE, response.State) +} + +func activatedServer(now time.Time, t *testing.T) *Server { + t.Helper() + server := NewServer(ServerOptions{Now: func() time.Time { return now }, UploadPolicy: &agentv1.UploadPolicy{Enabled: true, MaxAssetBytes: 16 << 20}}) + meta := testMeta("activate", "", 0) + _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{Meta: meta, Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, ActivationOperationId: "activate"}) + require.NoError(t, err) + return server +} + +type testAssertions struct{} + +var require testAssertions + +func (testAssertions) NoError(t *testing.T, err error, _ ...any) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func (testAssertions) Error(t *testing.T, err error, _ ...any) { + t.Helper() + if err == nil { + t.Fatal("expected error") + } +} + +func (testAssertions) Equal(t *testing.T, expected, actual any, _ ...any) { + t.Helper() + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("expected %#v, got %#v", expected, actual) + } +} + +func testMeta(operationID, idempotencyKey string, generation uint64) *agentv1.RequestMeta { + return &agentv1.RequestMeta{ProtocolVersion: "agent.v1", RequestId: operationID + "-request", TraceId: "trace-1", OperationId: operationID, IdempotencyKey: idempotencyKey, DispatcherEpoch: "epoch-1", AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1", SessionGeneration: generation} +} diff --git a/internal/rpc/session_test.go b/internal/rpc/session_test.go new file mode 100644 index 0000000..105a59b --- /dev/null +++ b/internal/rpc/session_test.go @@ -0,0 +1,27 @@ +package rpc + +import ( + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestSessionRegistryPersistsGenerationAcrossRestart(t *testing.T) { + path := t.TempDir() + "/rpc-session.json" + first := NewSessionRegistry(path) + _, _, err := first.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 3}, "activate-1", "digest-1", time.Unix(100, 0)) + if err != nil { + t.Fatal(err) + } + second := NewSessionRegistry(path) + _, _, err = second.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 2}, "activate-2", "digest-2", time.Unix(100, 0)) + if status.Code(err) != codes.Aborted { + t.Fatalf("error = %v, want persisted generation fence", err) + } + if _, _, err := second.Activate(&agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-2", DispatcherEpoch: "epoch-2", SessionGeneration: 4}, "activate-3", "digest-3", time.Unix(100, 0)); err != nil { + t.Fatal(err) + } +} diff --git a/internal/rpc/static_artifact_test.go b/internal/rpc/static_artifact_test.go new file mode 100644 index 0000000..a695543 --- /dev/null +++ b/internal/rpc/static_artifact_test.go @@ -0,0 +1,68 @@ +package rpc + +import ( + "context" + "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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestActivationValidatesStaticCellArtifact(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + server := NewServer(ServerOptions{ + Now: func() time.Time { return time.Unix(100, 0) }, + StaticArtifactRaw: raw, + StaticArtifactExpected: contract.StaticArtifactExpectation{ + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + AllowedEgressPoolIDs: []string{"egress-mock"}, + RequiredTrunkIDs: []string{"trunk-mock"}, + }, + }) + meta := testMeta("activate-static", "", 0) + meta.CellId = "cell-a" + response, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: meta, + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-a", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-static", + }) + if err != nil { + t.Fatal(err) + } + if response.State != agentv1.ActivationState_ACTIVATION_STATE_ACTIVE { + t.Fatalf("activation state=%s", response.State) + } +} + +func TestActivationRejectsInvalidStaticCellArtifact(t *testing.T) { + raw, err := contracts.Read("examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + server := NewServer(ServerOptions{ + Now: func() time.Time { return time.Unix(100, 0) }, + StaticArtifactRaw: raw, + StaticArtifactExpected: contract.StaticArtifactExpectation{CellID: "cell-b", Mode: "mock"}, + }) + meta := testMeta("activate-invalid-static", "", 0) + meta.CellId = "cell-a" + _, err = server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: meta, + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-a", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-invalid-static", + }) + if err == nil || status.Code(err) != codes.FailedPrecondition { + t.Fatalf("invalid artifact error=%v code=%s", err, status.Code(err)) + } +} diff --git a/internal/rpc/tls.go b/internal/rpc/tls.go new file mode 100644 index 0000000..9c4b3f1 --- /dev/null +++ b/internal/rpc/tls.go @@ -0,0 +1,99 @@ +package rpc + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "fmt" + "os" +) + +// NewServerTLSConfig builds the mTLS configuration used by a Dispatcher or +// Agent listener. ServerName is intentionally not used to disable verification; +// callers still verify the peer certificate against the supplied CA. +// LoadServerTLSConfig reads deployment-provided certificate files without +// exposing their contents to logs or repository state. +func LoadServerTLSConfig(caFile, certFile, keyFile string) (*tls.Config, error) { + caPEM, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + certPEM, err := os.ReadFile(certFile) + if err != nil { + return nil, fmt.Errorf("read certificate file: %w", err) + } + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + return nil, fmt.Errorf("read key file: %w", err) + } + return NewServerTLSConfig(caPEM, certPEM, keyPEM) +} + +func NewServerTLSConfig(caPEM, certPEM, keyPEM []byte) (*tls.Config, error) { + pool, err := certPool(caPEM) + if err != nil { + return nil, err + } + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("load server certificate: %w", err) + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + ClientCAs: pool, + ClientAuth: tls.RequireAndVerifyClientCert, + NextProtos: []string{"h2"}, + VerifyConnection: func(state tls.ConnectionState) error { + if len(state.VerifiedChains) == 0 || len(state.VerifiedChains[0]) == 0 { + return fmt.Errorf("verified peer certificate is required") + } + leaf := state.VerifiedChains[0][0] + if len(leaf.DNSNames) == 0 && len(leaf.URIs) == 0 && len(leaf.IPAddresses) == 0 { + return fmt.Errorf("peer certificate has no SAN") + } + return nil + }, + }, nil +} + +// NewClientTLSConfig builds a peer-verifying mTLS client configuration. +func NewClientTLSConfig(caPEM, certPEM, keyPEM []byte, serverName string) (*tls.Config, error) { + pool, err := certPool(caPEM) + if err != nil { + return nil, err + } + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, fmt.Errorf("load client certificate: %w", err) + } + if serverName == "" { + return nil, fmt.Errorf("server name is required for mTLS peer verification") + } + return &tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{certificate}, + RootCAs: pool, + ServerName: serverName, + NextProtos: []string{"h2"}, + }, nil +} + +func certPool(pemBytes []byte) (*x509.CertPool, error) { + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemBytes) { + return nil, fmt.Errorf("CA bundle contains no certificates") + } + return pool, nil +} + +// CertificateFingerprint returns a stable identifier for a verified leaf. It +// is suitable for an allow-list lookup, not for logging certificate contents. +func CertificateFingerprint(cert *x509.Certificate) string { + if cert == nil { + return "" + } + digest := sha256.Sum256(cert.Raw) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/rpc/tls_test.go b/internal/rpc/tls_test.go new file mode 100644 index 0000000..3a706d6 --- /dev/null +++ b/internal/rpc/tls_test.go @@ -0,0 +1,261 @@ +package rpc + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "testing" + "time" +) + +func TestTLSConfigsRequireVerifiedSANPeer(t *testing.T) { + caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil) + serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil) + clientPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"dispatcher.local"}, nil) + + serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM) + if err != nil { + t.Fatal(err) + } + if serverConfig.MinVersion != 0x0304 { + t.Fatalf("MinVersion = %v, want TLS 1.3", serverConfig.MinVersion) + } + if serverConfig.ClientAuth != 4 { + t.Fatalf("ClientAuth = %v, want RequireAndVerifyClientCert", serverConfig.ClientAuth) + } + clientConfig, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, "agent.local") + if err != nil { + t.Fatal(err) + } + if clientConfig.ServerName != "agent.local" || clientConfig.RootCAs == nil { + t.Fatalf("client config does not verify the configured server name") + } + if _, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, ""); err == nil { + t.Fatal("expected empty server name to be rejected") + } + if CertificateFingerprint(caCert) == "" { + t.Fatal("expected certificate fingerprint") + } +} + +func TestTLSConfigsCompleteMutualHandshake(t *testing.T) { + caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil) + serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil) + clientPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"dispatcher.local"}, nil) + serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM) + if err != nil { + t.Fatal(err) + } + clientConfig, err := NewClientTLSConfig(caPEM.certPEM, clientPEM.certPEM, clientPEM.keyPEM, "agent.local") + if err != nil { + t.Fatal(err) + } + listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer conn.Close() + serverDone <- conn.(*tls.Conn).Handshake() + }() + client, err := tls.Dial("tcp", listener.Addr().String(), clientConfig) + if err != nil { + t.Fatal(err) + } + if err := client.Handshake(); err != nil { + t.Fatal(err) + } + _ = client.Close() + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} + +func TestTLSConfigsRejectMissingOrUntrustedClient(t *testing.T) { + caPEM, caCert, caKey := testCertificate(t, nil, nil, true, nil, nil) + serverPEM, _, _ := testCertificate(t, caCert, caKey, false, []string{"agent.local"}, nil) + serverConfig, err := NewServerTLSConfig(caPEM.certPEM, serverPEM.certPEM, serverPEM.keyPEM) + if err != nil { + t.Fatal(err) + } + _, rogueCACert, rogueCAKey := testCertificate(t, nil, nil, true, nil, nil) + rogueClientPEM, _, _ := testCertificate(t, rogueCACert, rogueCAKey, false, []string{"dispatcher.local"}, nil) + trustedServerPool, err := certPool(caPEM.certPEM) + if err != nil { + t.Fatal(err) + } + clients := map[string]*tls.Config{ + "missing client certificate": { + MinVersion: tls.VersionTLS13, + RootCAs: trustedServerPool, + ServerName: "agent.local", + NextProtos: []string{"h2"}, + }, + "untrusted client certificate": func() *tls.Config { + clientConfig, configErr := NewClientTLSConfig(caPEM.certPEM, rogueClientPEM.certPEM, rogueClientPEM.keyPEM, "agent.local") + if configErr != nil { + t.Fatal(configErr) + } + return clientConfig + }(), + } + for name, clientConfig := range clients { + t.Run(name, func(t *testing.T) { + listener, listenErr := tls.Listen("tcp", "127.0.0.1:0", serverConfig) + if listenErr != nil { + t.Fatal(listenErr) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer conn.Close() + serverDone <- conn.(*tls.Conn).Handshake() + }() + client, dialErr := tls.Dial("tcp", listener.Addr().String(), clientConfig) + if client != nil { + _ = client.Close() + } + _ = dialErr + if serverErr := <-serverDone; serverErr == nil { + t.Fatal("expected server handshake to reject the client") + } + }) + } +} + +func TestTLSRotationRejectsPreviousClientCA(t *testing.T) { + caOnePEM, caOneCert, caOneKey := testCertificate(t, nil, nil, true, nil, nil) + serverOnePEM, _, _ := testCertificate(t, caOneCert, caOneKey, false, []string{"agent.local"}, nil) + clientOnePEM, _, _ := testCertificate(t, caOneCert, caOneKey, false, []string{"dispatcher.local"}, nil) + serverOneConfig, err := NewServerTLSConfig(caOnePEM.certPEM, serverOnePEM.certPEM, serverOnePEM.keyPEM) + if err != nil { + t.Fatal(err) + } + clientOneConfig, err := NewClientTLSConfig(caOnePEM.certPEM, clientOnePEM.certPEM, clientOnePEM.keyPEM, "agent.local") + if err != nil { + t.Fatal(err) + } + if err := runTLSHandshake(t, serverOneConfig, clientOneConfig); err != nil { + t.Fatal(err) + } + + caTwoPEM, caTwoCert, caTwoKey := testCertificate(t, nil, nil, true, nil, nil) + serverTwoPEM, _, _ := testCertificate(t, caTwoCert, caTwoKey, false, []string{"agent.local"}, nil) + clientTwoPEM, _, _ := testCertificate(t, caTwoCert, caTwoKey, false, []string{"dispatcher.local"}, nil) + serverTwoConfig, err := NewServerTLSConfig(caTwoPEM.certPEM, serverTwoPEM.certPEM, serverTwoPEM.keyPEM) + if err != nil { + t.Fatal(err) + } + clientTwoConfig, err := NewClientTLSConfig(caTwoPEM.certPEM, clientTwoPEM.certPEM, clientTwoPEM.keyPEM, "agent.local") + if err != nil { + t.Fatal(err) + } + if err := runTLSHandshake(t, serverTwoConfig, clientOneConfig); err == nil { + t.Fatal("expected previous client CA to be rejected after rotation") + } + if err := runTLSHandshake(t, serverTwoConfig, clientTwoConfig); err != nil { + t.Fatal(err) + } +} + +func TestDialRequiresTLSConfig(t *testing.T) { + if _, err := Dial("bufnet", nil); err == nil { + t.Fatal("expected TLS configuration requirement") + } +} + +func runTLSHandshake(t *testing.T, serverConfig, clientConfig *tls.Config) error { + t.Helper() + listener, err := tls.Listen("tcp", "127.0.0.1:0", serverConfig) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + serverDone := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer conn.Close() + serverDone <- conn.(*tls.Conn).Handshake() + }() + client, clientErr := tls.Dial("tcp", listener.Addr().String(), clientConfig) + if client != nil { + _ = client.Close() + } + serverErr := <-serverDone + if clientErr != nil { + return clientErr + } + return serverErr +} + +func testCertificate(t *testing.T, parent *x509.Certificate, parentKey *rsa.PrivateKey, isCA bool, dnsNames []string, ips []net.IP) (pemBundle, *x509.Certificate, *rsa.PrivateKey) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120)) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "sip-go-agent-test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + BasicConstraintsValid: true, + IsCA: isCA, + DNSNames: dnsNames, + IPAddresses: ips, + KeyUsage: x509.KeyUsageDigitalSignature, + } + if isCA { + template.KeyUsage |= x509.KeyUsageCertSign + } + if !isCA { + template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + } + if parent == nil { + parent = template + parentKey = key + } + der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return pemBundle{ + certPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + keyPEM: pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), + }, cert, key +} + +type pemBundle struct { + certPEM []byte + keyPEM []byte +} diff --git a/internal/rpc/upload_test.go b/internal/rpc/upload_test.go new file mode 100644 index 0000000..a4d7c47 --- /dev/null +++ b/internal/rpc/upload_test.go @@ -0,0 +1,71 @@ +package rpc + +import ( + "context" + "strings" + "testing" + "time" + + agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +func TestUploadBindingAndExpiryGuards(t *testing.T) { + now := time.Unix(100, 0) + server := NewServer(ServerOptions{ + Now: func() time.Time { return now }, + UploadPolicy: &agentv1.UploadPolicy{Enabled: true, MaxAssetBytes: 1024}, + }) + _, err := server.ActivateAgent(context.Background(), &agentv1.ActivateAgentRequest{ + Meta: testMeta("activate-upload", "", 0), + Binding: &agentv1.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1}, + ActivationOperationId: "activate-upload", + SessionExpiresAtUnixMs: now.Add(time.Hour).UnixMilli(), + }) + if err != nil { + t.Fatal(err) + } + binding := &agentv1.ExecutionBinding{TenantId: "tenant-1", TenantKey: "tenant-demo-key", ExecutionId: "execution-upload", TaskId: "task-upload", TaskItemId: "item-upload", TaskRevision: 1} + asset := &agentv1.AssetDescriptor{Kind: agentv1.AssetKind_ASSET_KIND_RECORDING, AssetId: "recording-upload", ExecutionId: binding.ExecutionId, Format: "wav", SizeBytes: 4, ChecksumSha256: strings.Repeat("a", 64), Channels: 1, SampleRateHz: 16000, DurationMs: 1} + request := &agentv1.RequestUploadRequest{Meta: testMeta("upload-request", "upload-request-key", 1), Binding: binding, Asset: asset, UploadId: "upload-binding"} + created, err := server.RequestUpload(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if created.Grant == nil || created.Grant.ExpiresAtUnixMs <= now.UnixMilli() { + t.Fatalf("invalid grant: %+v", created.Grant) + } + + mismatched := proto.Clone(request).(*agentv1.RequestUploadRequest) + mismatched.Meta = testMeta("upload-conflict", "upload-conflict-key", 1) + mismatched.Binding = proto.Clone(binding).(*agentv1.ExecutionBinding) + mismatched.Binding.ExecutionId = "other-execution" + conflict, err := server.RequestUpload(context.Background(), mismatched) + if err != nil { + t.Fatal(err) + } + if conflict.Receipt == nil || conflict.Receipt.Result != agentv1.ResultCode_RESULT_CODE_CONFLICT { + t.Fatalf("unexpected upload conflict: %+v", conflict) + } + + now = now.Add(6 * time.Minute) + completed, err := server.CompleteUpload(context.Background(), &agentv1.CompleteUploadRequest{ + Meta: testMeta("upload-complete-expired", "upload-complete-expired-key", 1), + Binding: binding, + Asset: asset, + UploadId: request.UploadId, + UploadedSizeBytes: asset.SizeBytes, + UploadedChecksumSha256: asset.ChecksumSha256, + }) + if err != nil { + t.Fatal(err) + } + if completed.Receipt == nil || completed.Receipt.Result != agentv1.ResultCode_RESULT_CODE_REJECTED || completed.Receipt.Failure == nil || completed.Receipt.Failure.Code != agentv1.FailureCode_FAILURE_CODE_FAILED_PRECONDITION { + t.Fatalf("unexpected expired completion: %+v", completed) + } + if status.Code(err) != codes.OK { + t.Fatalf("unexpected status code: %s", status.Code(err)) + } +} diff --git a/internal/store/doc.go b/internal/store/doc.go new file mode 100644 index 0000000..283264b --- /dev/null +++ b/internal/store/doc.go @@ -0,0 +1,4 @@ +// Package store owns the Dispatcher SQLite authority: inbox, tasks, quota +// reservations, controls, and the transactional event outbox. It is not used +// by Agent processes; Agent execution state belongs in files. +package store diff --git a/internal/store/facts.go b/internal/store/facts.go new file mode 100644 index 0000000..26672e0 --- /dev/null +++ b/internal/store/facts.go @@ -0,0 +1,126 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +var ErrFactConflict = errors.New("execution fact content conflict") + +type ExecutionFactRecord struct { + FactID string + TenantID string + TenantKey string + ExecutionID string + ContentSHA256 string + Kind int32 + BindingJSON []byte + PayloadJSON []byte + ObservedAt time.Time + SourceBootID string + SourceSequence uint64 + EventID string + EventType string + AggregateType string + AggregateID string + AggregateVersion int64 +} + +type ExecutionFactResult struct { + Duplicate bool + EventID string +} + +type FactEventBuilder func(aggregateVersion int64) ([]byte, error) + +// RecordExecutionFact persists an Agent fact and, when eventBuilder is set, +// the authoritative MQ event in one SQLite transaction. Aggregate versions are +// allocated by Dispatcher from durable state; the Agent cannot select them. A +// duplicate fact with the same digest is accepted without creating a second +// outbox row; reuse with a different digest is rejected. +func (s *Store) RecordExecutionFact(record ExecutionFactRecord, eventExchange, routingKey string, eventBuilder FactEventBuilder) (ExecutionFactResult, error) { + if record.FactID == "" || record.TenantID == "" || record.TenantKey == "" || record.ExecutionID == "" || record.ContentSHA256 == "" { + return ExecutionFactResult{}, errors.New("fact identity and tenant/execution binding are required") + } + if len(record.BindingJSON) == 0 || len(record.PayloadJSON) == 0 { + return ExecutionFactResult{}, errors.New("fact binding and payload are required") + } + if record.ObservedAt.IsZero() { + return ExecutionFactResult{}, errors.New("fact observed time is required") + } + if eventBuilder != nil && record.EventID == "" { + return ExecutionFactResult{}, errors.New("event ID is required for outbox event") + } + if eventBuilder != nil && (eventExchange == "" || routingKey == "") { + return ExecutionFactResult{}, errors.New("event exchange and routing key are required for outbox event") + } + + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return ExecutionFactResult{}, err + } + defer tx.Rollback() + + var existingDigest, existingEventID string + err = tx.QueryRow(`SELECT content_sha256, event_id FROM execution_facts WHERE fact_id = ?`, record.FactID).Scan(&existingDigest, &existingEventID) + if err == nil { + if existingDigest != record.ContentSHA256 { + return ExecutionFactResult{}, fmt.Errorf("%w: %s", ErrFactConflict, record.FactID) + } + if err := tx.Commit(); err != nil { + return ExecutionFactResult{}, err + } + return ExecutionFactResult{Duplicate: true, EventID: existingEventID}, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return ExecutionFactResult{}, err + } + + var eventBody []byte + if eventBuilder != nil { + var latestVersion sql.NullInt64 + if err := tx.QueryRow(`SELECT MAX(aggregate_version) FROM execution_facts WHERE aggregate_type = ? AND aggregate_id = ?`, record.AggregateType, record.AggregateID).Scan(&latestVersion); err != nil { + return ExecutionFactResult{}, err + } + record.AggregateVersion = latestVersion.Int64 + 1 + if record.AggregateVersion < 1 { + record.AggregateVersion = 1 + } + var buildErr error + eventBody, buildErr = eventBuilder(record.AggregateVersion) + if buildErr != nil { + return ExecutionFactResult{}, buildErr + } + if len(eventBody) == 0 { + return ExecutionFactResult{}, errors.New("event builder returned an empty body") + } + } else if record.AggregateVersion < 1 { + record.AggregateVersion = 1 + } + now := s.now().UTC().Format(time.RFC3339Nano) + if _, err := tx.Exec(`INSERT INTO execution_facts( + fact_id, tenant_id, tenant_key, execution_id, content_sha256, kind, + binding_json, payload_json, observed_at, source_boot_id, source_sequence, + event_id, event_type, aggregate_type, aggregate_id, aggregate_version, received_at + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + record.FactID, record.TenantID, record.TenantKey, record.ExecutionID, record.ContentSHA256, + record.Kind, record.BindingJSON, record.PayloadJSON, record.ObservedAt.UTC().Format(time.RFC3339Nano), + record.SourceBootID, record.SourceSequence, record.EventID, record.EventType, + record.AggregateType, record.AggregateID, record.AggregateVersion, now); err != nil { + return ExecutionFactResult{}, err + } + if eventBuilder != nil { + if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`, + record.EventID, record.TenantKey, eventExchange, routingKey, eventBody, now); err != nil { + return ExecutionFactResult{}, err + } + } + if err := tx.Commit(); err != nil { + return ExecutionFactResult{}, err + } + return ExecutionFactResult{EventID: record.EventID}, nil +} diff --git a/internal/store/lease_test.go b/internal/store/lease_test.go new file mode 100644 index 0000000..53ace66 --- /dev/null +++ b/internal/store/lease_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "errors" + "path/filepath" + "testing" + "time" +) + +func TestSingleActiveLeaseAndExpiry(t *testing.T) { + now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) + var current = now + s, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer s.Close() + s.now = func() time.Time { return current } + if _, err := s.AcquireLease("lease-a", "dispatcher", "holder-a", time.Minute); err != nil { + t.Fatal(err) + } + if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); !errors.Is(err, ErrLeaseHeld) { + t.Fatalf("second holder error = %v", err) + } + current = current.Add(2 * time.Minute) + if _, err := s.AcquireLease("lease-b", "dispatcher", "holder-b", time.Minute); err != nil { + t.Fatal(err) + } + if err := s.ReleaseLease("lease-b"); err != nil { + t.Fatal(err) + } +} + +func TestRecoverOutboxRequeuesClaimedRows(t *testing.T) { + s := testStore(t) + if _, err := s.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`); err != nil { + t.Fatal(err) + } + if err := s.RecoverOutbox(); err != nil { + t.Fatal(err) + } + var status, lastError string + if err := s.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'e1'`).Scan(&status, &lastError); err != nil { + t.Fatal(err) + } + if status != "retry" || lastError != "recovered_after_restart" { + t.Fatalf("status=%q error=%q", status, lastError) + } +} + +func TestOutboxRecoverySurvivesSQLiteReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "dispatcher.db") + first, err := Open(path) + if err != nil { + t.Fatal(err) + } + firstClosed := false + t.Cleanup(func() { + if !firstClosed { + if err := first.Close(); err != nil { + t.Error(err) + } + } + }) + result, err := first.DB().Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES('restart-e1', 'tenant', 'agent-call.events.v1', 'rk', '{}', 'dispatching', '2026-09-18T00:00:00Z')`) + if err != nil { + t.Fatal(err) + } + if rows, err := result.RowsAffected(); err != nil || rows != 1 { + t.Fatalf("inserted rows=%d err=%v, want 1", rows, err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + firstClosed = true + + second, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }) + var status, lastError string + if err := second.DB().QueryRow(`SELECT status, last_error FROM outbox WHERE event_id = 'restart-e1'`).Scan(&status, &lastError); err != nil { + t.Fatal(err) + } + if status != "retry" || lastError != "recovered_after_restart" { + t.Fatalf("status=%q error=%q after reopen", status, lastError) + } +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..3421f3d --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,101 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS inbox ( + command_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + command_type TEXT NOT NULL, + body_hash TEXT NOT NULL, + body BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('received', 'persisted', 'rejected')), + received_at TEXT NOT NULL, + persisted_at TEXT +); + +CREATE TABLE IF NOT EXISTS tasks ( + execution_id TEXT PRIMARY KEY, + tenant_key TEXT NOT NULL, + tenant_id TEXT NOT NULL, + task_id TEXT NOT NULL, + task_item_id TEXT NOT NULL, + task_revision INTEGER NOT NULL, + trace_id TEXT NOT NULL, + callee TEXT NOT NULL, + route_policy_id TEXT NOT NULL, + caller_profile_id TEXT NOT NULL, + agent_version_id TEXT NOT NULL, + variables BLOB NOT NULL, + ring_timeout_ms INTEGER NOT NULL, + max_call_duration_ms INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('accepted', 'reserved', 'running', 'draining', 'paused', 'stopped', 'finished', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (tenant_key, task_id, task_item_id, task_revision) +); +CREATE INDEX IF NOT EXISTS tasks_pending_idx ON tasks (status, created_at); +CREATE INDEX IF NOT EXISTS tasks_tenant_idx ON tasks (tenant_key, status, created_at); + +CREATE TABLE IF NOT EXISTS quotas ( + scope TEXT PRIMARY KEY, + limit_value INTEGER NOT NULL CHECK (limit_value >= 0), + reserved_value INTEGER NOT NULL DEFAULT 0 CHECK (reserved_value >= 0), + unknown_value INTEGER NOT NULL DEFAULT 0 CHECK (unknown_value >= 0), + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS reservations ( + reservation_id TEXT PRIMARY KEY, + execution_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + scopes BLOB NOT NULL, + state TEXT NOT NULL CHECK (state IN ('held', 'released', 'unknown')), + created_at TEXT NOT NULL, + released_at TEXT, + UNIQUE (execution_id) +); +CREATE INDEX IF NOT EXISTS reservations_tenant_idx ON reservations (tenant_key, state); + +CREATE TABLE IF NOT EXISTS outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + tenant_key TEXT NOT NULL, + exchange TEXT NOT NULL, + routing_key TEXT NOT NULL, + body BLOB NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'dispatching', 'published', 'retry')), + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL, + published_at TEXT +); +CREATE INDEX IF NOT EXISTS outbox_pending_idx ON outbox (status, id); + +CREATE TABLE IF NOT EXISTS controls ( + execution_id TEXT PRIMARY KEY, + task_revision INTEGER NOT NULL, + action TEXT NOT NULL CHECK (action IN ('pause', 'resume', 'drain', 'stop', 'hangup')), + active_call_policy TEXT, + reason TEXT, + idempotency_key TEXT, + applied INTEGER NOT NULL DEFAULT 0, + requested_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS replays ( + idempotency_key TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + source_command_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (idempotency_key, source_command_id) +); + +CREATE TABLE IF NOT EXISTS leases ( + lease_id TEXT PRIMARY KEY, + scope TEXT NOT NULL, + holder_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'expired', 'released')) +); +CREATE INDEX IF NOT EXISTS leases_scope_idx ON leases (scope, state, expires_at); diff --git a/internal/store/migrations/002_scheduler_state.sql b/internal/store/migrations/002_scheduler_state.sql new file mode 100644 index 0000000..1d6e86b --- /dev/null +++ b/internal/store/migrations/002_scheduler_state.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS scheduler_state ( + scope TEXT PRIMARY KEY, + tenants_json BLOB NOT NULL, + cursor INTEGER NOT NULL CHECK (cursor >= 0), + updated_at TEXT NOT NULL +); diff --git a/internal/store/migrations/003_uploads.sql b/internal/store/migrations/003_uploads.sql new file mode 100644 index 0000000..3c802f3 --- /dev/null +++ b/internal/store/migrations/003_uploads.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS uploads ( + upload_id TEXT PRIMARY KEY, + binding BLOB NOT NULL, + asset BLOB NOT NULL, + grant BLOB NOT NULL, + object_key TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('granted', 'completed', 'failed')), + oss_id TEXT, + created_at TEXT NOT NULL, + completed_at TEXT +); +CREATE INDEX IF NOT EXISTS uploads_state_idx ON uploads (state, created_at); diff --git a/internal/store/migrations/004_execution_facts.sql b/internal/store/migrations/004_execution_facts.sql new file mode 100644 index 0000000..00d1e13 --- /dev/null +++ b/internal/store/migrations/004_execution_facts.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS execution_facts ( + fact_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tenant_key TEXT NOT NULL, + execution_id TEXT NOT NULL, + content_sha256 TEXT NOT NULL, + kind INTEGER NOT NULL, + binding_json BLOB NOT NULL, + payload_json BLOB NOT NULL, + observed_at TEXT NOT NULL, + source_boot_id TEXT NOT NULL, + source_sequence INTEGER NOT NULL, + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + aggregate_version INTEGER NOT NULL, + received_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS execution_facts_execution_idx ON execution_facts (execution_id, observed_at); +CREATE INDEX IF NOT EXISTS execution_facts_event_idx ON execution_facts (event_id); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c25912f --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,817 @@ +package store + +import ( + "crypto/sha256" + "database/sql" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + iofs "io/fs" + "sort" + "strings" + "sync" + "time" + + "git.ipao.vip/rogee/go-sip/internal/contract" + "git.ipao.vip/rogee/go-sip/internal/tenant" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrations embed.FS + +var ( + ErrDuplicateCommand = errors.New("duplicate command") + ErrCommandConflict = errors.New("command id reused with different body") + ErrNoCapacity = errors.New("quota capacity unavailable") + ErrCASConflict = errors.New("control revision conflict") + ErrLeaseHeld = errors.New("active lease held by another dispatcher") +) + +type Store struct { + db *sql.DB + now func() time.Time + mu sync.Mutex +} + +func Open(path string) (*Store, error) { + if path == "" { + path = "file:dispatcher.db?_pragma=busy_timeout(5000)" + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + store := &Store{db: db, now: time.Now} + if err := store.migrate(); err != nil { + _ = db.Close() + return nil, err + } + if err := store.RecoverOutbox(); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +func New(db *sql.DB, now func() time.Time) (*Store, error) { + if db == nil { + return nil, errors.New("nil database") + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if now == nil { + now = time.Now + } + store := &Store{db: db, now: now} + if err := store.migrate(); err != nil { + return nil, err + } + if err := store.RecoverOutbox(); err != nil { + return nil, err + } + return store, nil +} + +func (s *Store) migrate() error { + files, err := iofs.Glob(migrations, "migrations/*.sql") + if err != nil { + return err + } + sort.Strings(files) + for _, file := range files { + data, err := migrations.ReadFile(file) + if err != nil { + return err + } + if _, err := s.db.Exec(string(data)); err != nil { + return fmt.Errorf("migrate sqlite %s: %w", file, err) + } + } + return nil +} + +func (s *Store) RecoverOutbox() error { + _, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = 'recovered_after_restart' WHERE status = 'dispatching'`) + return err +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) DB() *sql.DB { return s.db } + +func (s *Store) LoadSchedulerCursor(scope string, tenants []string) (int, error) { + if scope == "" { + return 0, errors.New("scheduler scope is required") + } + encoded, err := json.Marshal(tenants) + if err != nil { + return 0, err + } + s.mu.Lock() + defer s.mu.Unlock() + var cursor int + err = s.db.QueryRow(`SELECT cursor FROM scheduler_state WHERE scope = ?`, scope).Scan(&cursor) + if errors.Is(err, sql.ErrNoRows) { + _, err = s.db.Exec(`INSERT INTO scheduler_state(scope, tenants_json, cursor, updated_at) VALUES(?, ?, 0, ?)`, scope, encoded, s.now().UTC().Format(time.RFC3339Nano)) + return 0, err + } + if err != nil { + return 0, err + } + if len(tenants) == 0 { + cursor = 0 + } else { + cursor %= len(tenants) + if cursor < 0 { + cursor += len(tenants) + } + } + _, err = s.db.Exec(`UPDATE scheduler_state SET tenants_json = ?, cursor = ?, updated_at = ? WHERE scope = ?`, encoded, cursor, s.now().UTC().Format(time.RFC3339Nano), scope) + return cursor, err +} + +func (s *Store) SaveSchedulerCursor(scope string, tenants []string, cursor int) error { + if scope == "" { + return errors.New("scheduler scope is required") + } + if len(tenants) == 0 { + cursor = 0 + } else { + cursor %= len(tenants) + if cursor < 0 { + cursor += len(tenants) + } + } + encoded, err := json.Marshal(tenants) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + _, err = s.db.Exec(`INSERT INTO scheduler_state(scope, tenants_json, cursor, updated_at) VALUES(?, ?, ?, ?) + ON CONFLICT(scope) DO UPDATE SET tenants_json = excluded.tenants_json, cursor = excluded.cursor, updated_at = excluded.updated_at`, + scope, encoded, cursor, s.now().UTC().Format(time.RFC3339Nano)) + return err +} + +type IngestResult struct { + CommandID string + ExecutionID string + Duplicate bool + PersistedAt time.Time +} + +func (s *Store) IngestCommand(raw []byte, routingKey string) (IngestResult, error) { + envelope, payload, err := contract.DecodeExecute(raw) + if err != nil { + return IngestResult{}, err + } + if routingKey != "" { + if err := verifyRouting(envelope.TenantKey, routingKey); err != nil { + return IngestResult{}, err + } + } + 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[:]) + + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return IngestResult{}, fmt.Errorf("begin ingest: %w", err) + } + defer tx.Rollback() + + var existingHash, status string + err = tx.QueryRow(`SELECT body_hash, status FROM inbox WHERE command_id = ?`, envelope.CommandID).Scan(&existingHash, &status) + switch { + case err == nil: + if existingHash != bodyHash { + return IngestResult{}, fmt.Errorf("%w: %s", ErrCommandConflict, envelope.CommandID) + } + return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil + case !errors.Is(err, sql.ErrNoRows): + return IngestResult{}, fmt.Errorf("lookup inbox: %w", err) + } + + if _, err := tx.Exec(`INSERT INTO inbox(command_id, tenant_id, tenant_key, command_type, body_hash, body, status, received_at) + VALUES(?, ?, ?, ?, ?, ?, 'received', ?)`, envelope.CommandID, envelope.TenantID, envelope.TenantKey, envelope.CommandType, bodyHash, raw, now.Format(time.RFC3339Nano)); err != nil { + return IngestResult{}, fmt.Errorf("persist inbox: %w", err) + } + + 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) + } + if err := tx.Commit(); err != nil { + return IngestResult{}, fmt.Errorf("commit duplicate command: %w", err) + } + return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, Duplicate: true, PersistedAt: now}, nil + } + + event, err := (contract.EventBuilder{ + TenantID: envelope.TenantID, TenantKey: envelope.TenantKey, TraceID: envelope.TraceID, + EventType: "command.result", Aggregate: "command", AggregateID: envelope.CommandID, Version: 1, + Payload: map[string]any{ + "command_id": envelope.CommandID, "command_type": envelope.CommandType, + "execution_id": payload.ExecutionID, + "status": "accepted", "reason_code": "accepted", + "requested_task_revision": payload.TaskRevision, + }, + }).Marshal(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 { + return IngestResult{}, fmt.Errorf("persist outbox: %w", err) + } + if _, err := tx.Exec(`UPDATE inbox SET status = 'persisted', persisted_at = ? WHERE command_id = ?`, now.Format(time.RFC3339Nano), envelope.CommandID); err != nil { + return IngestResult{}, fmt.Errorf("mark inbox persisted: %w", err) + } + if err := tx.Commit(); err != nil { + return IngestResult{}, fmt.Errorf("commit ingest: %w", err) + } + return IngestResult{CommandID: envelope.CommandID, ExecutionID: payload.ExecutionID, PersistedAt: now}, nil +} + +func verifyRouting(tenantKey, routingKey string) error { + want, err := tenant.CommandRoutingKey(tenantKey) + if err != nil { + return err + } + if want != routingKey { + return fmt.Errorf("tenant routing mismatch: expected %q got %q", want, routingKey) + } + return nil +} + +type OutboxRecord struct { + ID int64 + EventID string + TenantKey string + Exchange string + RoutingKey string + Body []byte + Attempts int +} + +func (s *Store) ClaimOutbox(limit int) ([]OutboxRecord, error) { + if limit <= 0 { + return nil, errors.New("outbox limit must be positive") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + rows, err := tx.Query(`SELECT id, event_id, tenant_key, exchange, routing_key, body, attempts + FROM outbox WHERE status IN ('pending', 'retry') ORDER BY id LIMIT ?`, limit) + if err != nil { + return nil, err + } + var records []OutboxRecord + for rows.Next() { + var r OutboxRecord + if err := rows.Scan(&r.ID, &r.EventID, &r.TenantKey, &r.Exchange, &r.RoutingKey, &r.Body, &r.Attempts); err != nil { + rows.Close() + return nil, err + } + records = append(records, r) + } + if err := rows.Close(); err != nil { + return nil, err + } + for _, r := range records { + if _, err := tx.Exec(`UPDATE outbox SET status = 'dispatching', attempts = attempts + 1 WHERE id = ?`, r.ID); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return records, nil +} + +func (s *Store) MarkOutboxPublished(id int64) error { + 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 +} + +func (s *Store) MarkOutboxRetry(id int64, cause error) error { + message := "retry" + if cause != nil { + message = cause.Error() + } + _, err := s.db.Exec(`UPDATE outbox SET status = 'retry', last_error = ? WHERE id = ?`, message, id) + return err +} + +type Task struct { + ExecutionID string + TenantKey string + TenantID string + TaskID string + TaskItemID string + TaskRevision int64 + TraceID string + Callee string + RoutePolicyID string + CallerProfileID string + AgentVersionID string + Variables map[string]any + RingTimeoutMS int64 + MaxCallDurationMS int64 + Status string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (s *Store) FindTask(tenantID, taskID string) (Task, error) { + if tenantID == "" || taskID == "" { + return Task{}, errors.New("tenant id and task id are required") + } + s.mu.Lock() + defer s.mu.Unlock() + row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, + trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables, + ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at + FROM tasks WHERE tenant_id = ? AND task_id = ? ORDER BY task_revision DESC LIMIT 1`, tenantID, taskID) + return scanTask(row) +} + +func (s *Store) NextTask(tenantKey string) (Task, error) { + if strings.TrimSpace(tenantKey) == "" { + return Task{}, errors.New("tenant key is required") + } + s.mu.Lock() + defer s.mu.Unlock() + row := s.db.QueryRow(`SELECT execution_id, tenant_key, tenant_id, task_id, task_item_id, task_revision, + trace_id, callee, route_policy_id, caller_profile_id, agent_version_id, variables, + ring_timeout_ms, max_call_duration_ms, status, created_at, updated_at + FROM tasks WHERE tenant_key = ? AND status = 'accepted' ORDER BY created_at, execution_id LIMIT 1`, tenantKey) + return scanTask(row) +} + +func (s *Store) 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") + } + s.mu.Lock() + defer s.mu.Unlock() + result, err := s.db.Exec(`UPDATE tasks SET status = 'running', updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, s.now().UTC().Format(time.RFC3339Nano), executionID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return ErrCASConflict + } + return nil +} + +// FinalizeReservation releases a failed remote attempt and updates its task in +// the same SQLite transaction. Unknown attempts stay counted in unknown_value; +// only a failure proven before remote submission is requeued as accepted. +func (s *Store) FinalizeReservation(reservationID, executionID string, unknown bool) error { + if reservationID == "" || executionID == "" { + return errors.New("reservation and execution ids are required") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var state, storedExecutionID string + var scopesJSON []byte + if err := tx.QueryRow(`SELECT state, execution_id, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &storedExecutionID, &scopesJSON); err != nil { + return err + } + if storedExecutionID != executionID { + return fmt.Errorf("reservation execution mismatch: got %q, want %q", storedExecutionID, executionID) + } + if state != "held" { + return nil + } + var scopes []string + if err := json.Unmarshal(scopesJSON, &scopes); err != nil { + return fmt.Errorf("decode reservation scopes: %w", err) + } + if len(scopes) == 0 { + return errors.New("quota scopes are required to finalize a reservation") + } + now := s.now().UTC().Format(time.RFC3339Nano) + for _, scope := range scopes { + var result sql.Result + if unknown { + result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope) + } else { + result, err = tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope) + } + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return ErrCASConflict + } + } + newState := "released" + newTaskStatus := "accepted" + if unknown { + newState = "unknown" + newTaskStatus = "unknown" + } + if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ? AND state = 'held'`, newState, now, reservationID); err != nil { + return err + } + result, err := tx.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE execution_id = ? AND status = 'reserved'`, newTaskStatus, now, executionID) + if err != nil { + return err + } + count, err := result.RowsAffected() + if err != nil { + return err + } + if count != 1 { + return ErrCASConflict + } + return tx.Commit() +} + +type CommandRecord struct { + CommandID string + TenantID string + TenantKey string + CommandType string + Status string + Body []byte + ReceivedAt time.Time + PersistedAt *time.Time +} + +func (s *Store) GetCommand(tenantID, commandID string) (CommandRecord, error) { + if tenantID == "" || commandID == "" { + return CommandRecord{}, errors.New("tenant id and command id are required") + } + var record CommandRecord + var received, persisted sql.NullString + if err := s.db.QueryRow(`SELECT command_id, tenant_id, tenant_key, command_type, status, body, received_at, persisted_at + FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, commandID).Scan(&record.CommandID, &record.TenantID, &record.TenantKey, &record.CommandType, &record.Status, &record.Body, &received, &persisted); err != nil { + return CommandRecord{}, err + } + var err error + record.ReceivedAt, err = time.Parse(time.RFC3339Nano, received.String) + if err != nil { + return CommandRecord{}, err + } + if persisted.Valid { + value, err := time.Parse(time.RFC3339Nano, persisted.String) + if err != nil { + return CommandRecord{}, err + } + record.PersistedAt = &value + } + return record, nil +} + +func (s *Store) ReplayCommand(idempotencyKey, tenantID, sourceCommandID, reason string) error { + if idempotencyKey == "" || tenantID == "" || sourceCommandID == "" || strings.TrimSpace(reason) == "" { + return errors.New("replay identity and reason are required") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var tenantKey string + var body []byte + if err := tx.QueryRow(`SELECT tenant_key, body FROM inbox WHERE tenant_id = ? AND command_id = ?`, tenantID, sourceCommandID).Scan(&tenantKey, &body); err != nil { + return err + } + var existing string + if err := tx.QueryRow(`SELECT idempotency_key FROM replays WHERE idempotency_key = ?`, idempotencyKey).Scan(&existing); err == nil { + return tx.Commit() + } else if !errors.Is(err, sql.ErrNoRows) { + return err + } + now := s.now().UTC().Format(time.RFC3339Nano) + if _, err := tx.Exec(`INSERT INTO replays(idempotency_key, tenant_id, source_command_id, tenant_key, reason, created_at) VALUES(?, ?, ?, ?, ?, ?)`, idempotencyKey, tenantID, sourceCommandID, tenantKey, reason, now); err != nil { + return err + } + routingKey := "agent-call.tenant." + tenantKey + ".call.execute" + eventID := "replay-" + idempotencyKey + if _, err := tx.Exec(`INSERT INTO outbox(event_id, tenant_key, exchange, routing_key, body, status, created_at) VALUES(?, ?, ?, ?, ?, 'pending', ?)`, eventID, tenantKey, tenant.CommandExchange, routingKey, body, now); err != nil { + return err + } + return tx.Commit() +} + +func scanTask(row *sql.Row) (Task, error) { + var t Task + var variables []byte + var created, updated string + if err := row.Scan(&t.ExecutionID, &t.TenantKey, &t.TenantID, &t.TaskID, &t.TaskItemID, &t.TaskRevision, + &t.TraceID, &t.Callee, &t.RoutePolicyID, &t.CallerProfileID, &t.AgentVersionID, &variables, + &t.RingTimeoutMS, &t.MaxCallDurationMS, &t.Status, &created, &updated); err != nil { + return Task{}, err + } + if err := json.Unmarshal(variables, &t.Variables); err != nil { + return Task{}, fmt.Errorf("decode task variables: %w", err) + } + var err error + t.CreatedAt, err = time.Parse(time.RFC3339Nano, created) + if err != nil { + return Task{}, err + } + t.UpdatedAt, err = time.Parse(time.RFC3339Nano, updated) + if err != nil { + return Task{}, err + } + return t, nil +} + +func (s *Store) SetQuota(scope string, limit int64) error { + if scope == "" || limit < 0 { + return errors.New("invalid quota") + } + _, err := s.db.Exec(`INSERT INTO quotas(scope, limit_value, updated_at) VALUES(?, ?, ?) + ON CONFLICT(scope) DO UPDATE SET limit_value = excluded.limit_value, updated_at = excluded.updated_at`, scope, limit, s.now().UTC().Format(time.RFC3339Nano)) + return err +} + +func (s *Store) Reserve(reservationID, executionID, tenantKey string, scopes []string) error { + if reservationID == "" || executionID == "" || tenantKey == "" || len(scopes) == 0 { + return errors.New("reservation identity and scopes are required") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + now := s.now().UTC().Format(time.RFC3339Nano) + scopesJSON, err := json.Marshal(scopes) + if err != nil { + return err + } + for _, scope := range scopes { + var limit, reserved, unknown int64 + if err := tx.QueryRow(`SELECT limit_value, reserved_value, unknown_value FROM quotas WHERE scope = ?`, scope).Scan(&limit, &reserved, &unknown); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: quota %s is not configured", ErrNoCapacity, scope) + } + return err + } + if reserved+unknown >= limit { + return fmt.Errorf("%w: %s", ErrNoCapacity, scope) + } + } + if _, err := tx.Exec(`INSERT INTO reservations(reservation_id, execution_id, tenant_key, scopes, state, created_at) VALUES(?, ?, ?, ?, 'held', ?)`, reservationID, executionID, tenantKey, scopesJSON, now); err != nil { + return err + } + for _, scope := range scopes { + if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value + 1, updated_at = ? WHERE scope = ?`, now, scope); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + return nil +} + +func (s *Store) ReleaseReservation(reservationID string, unknown bool) error { + if reservationID == "" { + return errors.New("reservation id is required") + } + return s.releaseReservation(reservationID, unknown, nil) +} + +func (s *Store) ReleaseReservationWithScopes(reservationID string, scopes []string, unknown bool) error { + return s.releaseReservation(reservationID, unknown, scopes) +} + +func (s *Store) releaseReservation(reservationID string, unknown bool, scopes []string) error { + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var state string + var scopesJSON []byte + if err := tx.QueryRow(`SELECT state, scopes FROM reservations WHERE reservation_id = ?`, reservationID).Scan(&state, &scopesJSON); err != nil { + return err + } + if state != "held" { + return nil + } + if len(scopes) == 0 { + if err := json.Unmarshal(scopesJSON, &scopes); err != nil { + return fmt.Errorf("decode reservation scopes: %w", err) + } + } + if len(scopes) == 0 { + return errors.New("quota scopes are required to release a reservation") + } + now := s.now().UTC().Format(time.RFC3339Nano) + for _, scope := range scopes { + if unknown { + if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, unknown_value = unknown_value + 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil { + return err + } + } else if _, err := tx.Exec(`UPDATE quotas SET reserved_value = reserved_value - 1, updated_at = ? WHERE scope = ? AND reserved_value > 0`, now, scope); err != nil { + return err + } + } + newState := "released" + if unknown { + newState = "unknown" + } + if _, err := tx.Exec(`UPDATE reservations SET state = ?, released_at = ? WHERE reservation_id = ?`, newState, now, reservationID); err != nil { + return err + } + return tx.Commit() +} + +type Lease struct { + LeaseID string + Scope string + HolderID string + ExpiresAt time.Time + State string +} + +func (s *Store) AcquireLease(leaseID, scope, holderID string, ttl time.Duration) (Lease, error) { + if leaseID == "" || scope == "" || holderID == "" || ttl <= 0 { + return Lease{}, errors.New("lease id, scope, holder, and positive ttl are required") + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return Lease{}, err + } + defer tx.Rollback() + now := s.now().UTC() + var existingID, existingHolder, existingExpiry string + err = tx.QueryRow(`SELECT lease_id, holder_id, expires_at FROM leases WHERE scope = ? AND state = 'active' LIMIT 1`, scope).Scan(&existingID, &existingHolder, &existingExpiry) + if err == nil { + expires, parseErr := time.Parse(time.RFC3339Nano, existingExpiry) + if parseErr != nil { + return Lease{}, parseErr + } + if expires.After(now) && existingHolder != holderID { + return Lease{}, fmt.Errorf("%w: scope %s", ErrLeaseHeld, scope) + } + if expires.After(now) && existingHolder == holderID && existingID != leaseID { + return Lease{}, fmt.Errorf("%w: holder already owns scope %s", ErrLeaseHeld, scope) + } + if !expires.After(now) { + if _, err := tx.Exec(`UPDATE leases SET state = 'expired' WHERE lease_id = ?`, existingID); err != nil { + return Lease{}, err + } + } + } else if !errors.Is(err, sql.ErrNoRows) { + return Lease{}, err + } + expires := now.Add(ttl) + if _, err := tx.Exec(`INSERT INTO leases(lease_id, scope, holder_id, expires_at, state) VALUES(?, ?, ?, ?, 'active') + ON CONFLICT(lease_id) DO UPDATE SET scope = excluded.scope, holder_id = excluded.holder_id, expires_at = excluded.expires_at, state = 'active'`, leaseID, scope, holderID, expires.Format(time.RFC3339Nano)); err != nil { + return Lease{}, err + } + if err := tx.Commit(); err != nil { + return Lease{}, err + } + return Lease{LeaseID: leaseID, Scope: scope, HolderID: holderID, ExpiresAt: expires, State: "active"}, nil +} + +func (s *Store) ReleaseLease(leaseID string) error { + if leaseID == "" { + return errors.New("lease id is required") + } + _, err := s.db.Exec(`UPDATE leases SET state = 'released' WHERE lease_id = ? AND state = 'active'`, leaseID) + return err +} + +func (s *Store) ApplyControl(executionID string, expectedRevision int64, action string) error { + return s.ApplyControlDetailed(executionID, expectedRevision, action, "", "", "") +} + +func (s *Store) ApplyControlDetailed(executionID string, expectedRevision int64, action, activeCallPolicy, reason, idempotencyKey string) error { + if executionID == "" || expectedRevision < 1 { + return errors.New("execution id and expected revision are required") + } + if action != "pause" && action != "resume" && action != "drain" && action != "stop" && action != "hangup" { + return fmt.Errorf("unsupported control action %q", action) + } + s.mu.Lock() + defer s.mu.Unlock() + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + var status string + if err := tx.QueryRow(`SELECT status FROM tasks WHERE execution_id = ? AND task_revision = ?`, executionID, expectedRevision).Scan(&status); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrCASConflict + } + return err + } + if status == "stopped" && action != "stop" { + return fmt.Errorf("%w: stopped execution cannot resume", ErrCASConflict) + } + if _, err := tx.Exec(`INSERT INTO controls(execution_id, task_revision, action, active_call_policy, reason, idempotency_key, applied, requested_at) + VALUES(?, ?, ?, ?, ?, ?, 0, ?) + ON CONFLICT(execution_id) DO UPDATE SET task_revision = excluded.task_revision, action = excluded.action, active_call_policy = excluded.active_call_policy, reason = excluded.reason, idempotency_key = excluded.idempotency_key, applied = 0, requested_at = excluded.requested_at`, executionID, expectedRevision, action, activeCallPolicy, reason, idempotencyKey, s.now().UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + newStatus := status + switch action { + case "pause": + newStatus = "paused" + case "resume": + if status != "paused" { + return fmt.Errorf("%w: execution is %s", ErrCASConflict, status) + } + newStatus = "accepted" + case "drain": + newStatus = "draining" + case "stop", "hangup": + newStatus = "stopped" + } + if _, err := tx.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE execution_id = ? AND task_revision = ?`, newStatus, s.now().UTC().Format(time.RFC3339Nano), executionID, expectedRevision); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..6cedf7f --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,185 @@ +package store + +import ( + "database/sql" + "errors" + "testing" + "time" + + "git.ipao.vip/rogee/go-sip/contracts" + "git.ipao.vip/rogee/go-sip/internal/tenant" + _ "modernc.org/sqlite" +) + +func testStore(t *testing.T) *Store { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC) + s, err := New(db, func() time.Time { return now }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestIngestIsDurableAndIdempotent(t *testing.T) { + s := testStore(t) + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + result, err := s.IngestCommand(raw, "agent-call.tenant.tenant-demo-key.call.execute") + 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) + if err != nil { + t.Fatal(err) + } + if !second.Duplicate { + t.Fatal("second command was not idempotent") + } + outbox, err := s.ClaimOutbox(10) + if err != nil { + t.Fatal(err) + } + if len(outbox) != 1 { + t.Fatalf("outbox rows = %d, want 1", len(outbox)) + } +} + +func TestIngestRejectsRoutingMismatch(t *testing.T) { + s := testStore(t) + raw, err := contracts.Read("examples/call.execute.json") + if err != nil { + t.Fatal(err) + } + if _, err := s.IngestCommand(raw, "agent-call.tenant.other.call.execute"); err == nil { + t.Fatal("expected routing mismatch") + } +} + +func TestQuotaIsAtomicAndUnknownIsNotReleased(t *testing.T) { + s := testStore(t) + for _, scope := range []string{"tenant:tenant-demo-key", "global", "cell:cell-1"} { + if err := s.SetQuota(scope, 1); err != nil { + t.Fatal(err) + } + } + if err := s.Reserve("r1", "e1", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); err != nil { + t.Fatal(err) + } + if err := s.Reserve("r2", "e2", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) { + t.Fatalf("reserve error = %v, want ErrNoCapacity", err) + } + if err := s.ReleaseReservationWithScopes("r1", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}, true); err != nil { + t.Fatal(err) + } + if err := s.Reserve("r3", "e3", "tenant-demo-key", []string{"tenant:tenant-demo-key", "global", "cell:cell-1"}); !errors.Is(err, ErrNoCapacity) { + t.Fatalf("unknown reservation released capacity: %v", err) + } +} + +func TestFinalizeReservationRequeuesBeforeRemoteSubmission(t *testing.T) { + s := testStore(t) + if err := s.SetQuota("global", 1); err != nil { + t.Fatal(err) + } + raw, err := 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) + } + 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) + } + var taskStatus string + if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil { + t.Fatal(err) + } + if taskStatus != "accepted" { + t.Fatalf("task status=%q, want accepted", taskStatus) + } + var reserved, unknown int64 + if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil { + t.Fatal(err) + } + if reserved != 0 || unknown != 0 { + t.Fatalf("quota reserved=%d unknown=%d, want 0/0", reserved, unknown) + } +} + +func TestFinalizeReservationKeepsUnknownCounted(t *testing.T) { + s := testStore(t) + if err := s.SetQuota("global", 1); err != nil { + t.Fatal(err) + } + raw, err := 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) + } + 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) + } + var taskStatus string + if err := s.DB().QueryRow(`SELECT status FROM tasks WHERE execution_id = 'exec_demo_001'`).Scan(&taskStatus); err != nil { + t.Fatal(err) + } + if taskStatus != "unknown" { + t.Fatalf("task status=%q, want unknown", taskStatus) + } + var reserved, unknown int64 + if err := s.DB().QueryRow(`SELECT reserved_value, unknown_value FROM quotas WHERE scope = 'global'`).Scan(&reserved, &unknown); err != nil { + t.Fatal(err) + } + if reserved != 0 || unknown != 1 { + t.Fatalf("quota reserved=%d unknown=%d, want 0/1", reserved, unknown) + } +} + +func TestControlCASAndStopBarrier(t *testing.T) { + s := testStore(t) + raw, err := 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) + } + if err := s.ApplyControl("exec_demo_001", 1, "pause"); err != nil { + t.Fatal(err) + } + if err := s.ApplyControl("exec_demo_001", 99, "resume"); !errors.Is(err, ErrCASConflict) { + t.Fatalf("revision mismatch error = %v", err) + } + if err := s.ApplyControl("exec_demo_001", 1, "stop"); err != nil { + t.Fatal(err) + } + if err := s.ApplyControl("exec_demo_001", 1, "resume"); !errors.Is(err, ErrCASConflict) { + t.Fatalf("stopped resume error = %v", err) + } +} diff --git a/internal/store/uploads.go b/internal/store/uploads.go new file mode 100644 index 0000000..328bfd5 --- /dev/null +++ b/internal/store/uploads.go @@ -0,0 +1,169 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "time" +) + +type UploadRecord struct { + UploadID string + Binding []byte + Asset []byte + Grant []byte + ObjectKey string + State string + OSSID string + CreatedAt time.Time + CompletedAt *time.Time +} + +func (s *Store) LoadUpload(uploadID string) (UploadRecord, error) { + if uploadID == "" { + return UploadRecord{}, errors.New("upload ID is required") + } + s.mu.Lock() + defer s.mu.Unlock() + var record UploadRecord + var createdAt string + var completedAt sql.NullString + 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, + ) + 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) + if err != nil { + return UploadRecord{}, fmt.Errorf("parse upload created_at: %w", err) + } + record.CreatedAt = parsed + if completedAt.Valid && completedAt.String != "" { + parsed, err := time.Parse(time.RFC3339Nano, completedAt.String) + if err != nil { + return UploadRecord{}, fmt.Errorf("parse upload completed_at: %w", err) + } + record.CompletedAt = &parsed + } + return record, nil +} + +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.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)) + 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") + } + 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 +} diff --git a/internal/tenant/routing.go b/internal/tenant/routing.go new file mode 100644 index 0000000..4a862a5 --- /dev/null +++ b/internal/tenant/routing.go @@ -0,0 +1,58 @@ +package tenant + +import ( + "fmt" + + "git.ipao.vip/rogee/go-sip/internal/contract" +) + +const ( + CommandExchange = "agent-call.commands.v1" + EventExchange = "agent-call.events.v1" + CommandQueuePrefix = "agent-call.executor." + CommandQueueSuffix = ".v1" + CommandRoutingPrefix = "agent-call.tenant." + CommandRoutingSuffix = ".call.execute" + DeadLetterQueueSuffix = ".dlq.v1" + maxAMQPNameBytes = 255 +) + +func CommandQueue(tenantKey string) (string, error) { + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return "", err + } + return boundedQueueName(tenantKey, CommandQueueSuffix) +} + +func DeadLetterQueue(tenantKey string) (string, error) { + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return "", err + } + return boundedQueueName(tenantKey, DeadLetterQueueSuffix) +} + +func boundedQueueName(tenantKey, suffix string) (string, error) { + queue := CommandQueuePrefix + tenantKey + suffix + if len([]byte(queue)) > maxAMQPNameBytes { + return "", fmt.Errorf("AMQP queue name exceeds %d UTF-8 bytes", maxAMQPNameBytes) + } + return queue, nil +} + +func CommandRoutingKey(tenantKey string) (string, error) { + if err := contract.ValidateTenantKey(tenantKey); err != nil { + return "", err + } + return CommandRoutingPrefix + tenantKey + CommandRoutingSuffix, nil +} + +func VerifyCommandRouting(tenantKey, routingKey string) error { + expected, err := CommandRoutingKey(tenantKey) + if err != nil { + return err + } + if expected != routingKey { + return fmt.Errorf("routing key does not match tenant_key: expected %q got %q", expected, routingKey) + } + return nil +} diff --git a/internal/tenant/routing_test.go b/internal/tenant/routing_test.go new file mode 100644 index 0000000..12d2e7b --- /dev/null +++ b/internal/tenant/routing_test.go @@ -0,0 +1,31 @@ +package tenant + +import "testing" + +func TestCommandRoutingPreservesTenantKey(t *testing.T) { + got, err := CommandRoutingKey("租户-A") + if err != nil { + t.Fatal(err) + } + want := "agent-call.tenant.租户-A.call.execute" + if got != want { + t.Fatalf("routing key = %q, want %q", got, want) + } + if err := VerifyCommandRouting("租户-A", got); err != nil { + t.Fatal(err) + } + queue, err := CommandQueue("租户-A") + if err != nil { + t.Fatal(err) + } + if queue != "agent-call.executor.租户-A.v1" { + t.Fatalf("queue = %q", queue) + } + deadLetterQueue, err := DeadLetterQueue("租户-A") + if err != nil { + t.Fatal(err) + } + if deadLetterQueue != "agent-call.executor.租户-A.dlq.v1" { + t.Fatalf("dead-letter queue = %q", deadLetterQueue) + } +} diff --git a/proto/ERRORS.md b/proto/ERRORS.md new file mode 100644 index 0000000..d03dd18 --- /dev/null +++ b/proto/ERRORS.md @@ -0,0 +1,84 @@ +# W02 error, idempotency, and fencing contract + +This document is part of the `agent.v1` project-owned baseline. It does not +change the SaaS/MQ contract. + +## Transport and domain errors + +Handlers return normal gRPC status codes and, when a response message exists, +put the stable `FailureCode` in `Failure.code` as well: + +| `FailureCode` | gRPC status | Retry rule | +| --- | --- | --- | +| `INVALID_ARGUMENT` | `InvalidArgument` | Fix the request; never retry unchanged | +| `UNAUTHENTICATED` | `Unauthenticated` | Re-establish mTLS/session; do not replay business work | +| `PERMISSION_DENIED` | `PermissionDenied` | Stop; require a new authorization | +| `FAILED_PRECONDITION` | `FailedPrecondition` | Refresh state/barrier, then use the original operation ID only if allowed | +| `ABORTED` | `Aborted` | Re-read the CAS revision; do not assume the operation applied | +| `RESOURCE_EXHAUSTED` | `ResourceExhausted` | Wait for durable quota/resource release | +| `UNAVAILABLE` | `Unavailable` | Reconnect and reconcile the original operation before any retry | +| `DEADLINE_EXCEEDED` | `DeadlineExceeded` | Result is unknown unless a durable receipt exists | +| `NOT_FOUND` | `NotFound` | Do not create a substitute execution | +| `ALREADY_EXISTS` | `AlreadyExists` | Read the existing operation/result; do not create a second one | + +A transport success is not an application receipt. `RESULT_CODE_ACCEPTED` means +that the receiving side durably recorded the request; `APPLIED` requires the +specified state transition and barrier evidence. + +## Idempotency keys + +`RequestMeta.idempotency_key` is required for mutating methods. The key is +scoped by `(agent_id, operation_id, idempotency_key)` and the durable operation +record also stores the request content digest. Reusing a key with different +content returns `ABORTED`/`RESULT_CODE_CONFLICT`; it never overwrites the first +request. + +- `ActivateAgent`: `activation_operation_id` is the idempotency key. A repeated + identical request returns the same session generation and credential metadata. +- `SetAdmissionState`: `barrier_id` plus the expected admission generation is + persisted. A replay cannot move the generation twice. +- `Execute`: the execution binding and permit ID are the deduplication identity. + An unknown result is reconciled with `QueryExecution`; it is never retried as + a new originate. +- `GetExecutionPermit`: the reservation, binding, expected revision and + idempotency key are persisted. A permit is not issued after the reservation is + released or fenced. +- `ApplyTaskControl`: `(execution_id, expected_task_revision, action, + idempotency_key)` is CAS-checked. `STOP` is terminal; `PAUSE` may be resumed + only by a new authorized request. +- `ReportExecutionEvent`: `(fact_id, content_sha256)` is the durable fact key. + An identical duplicate returns the original receipt; a digest mismatch is a + conflict. +- `RequestUpload`/`CompleteUpload`: `upload_id` and the asset checksum are + retained. Completion is returned only after the OSS/SaaS verification state + is known; a local PUT success is not `recording.ready`. + +Read-only methods may be retried, but callers still preserve the original +request and trace identity: `GetAgentStatus`, `GetBootstrap`, and +`QueryExecution`. + +## Fencing and session rules + +1. The Dispatcher creates a new opaque `dispatcher_epoch` when its active + instance changes. The Agent accepts mutations only for the active epoch. +2. The Agent identity is the mTLS certificate plus the Dispatcher-approved + `(agent_id, cell_id, boot_id, session_generation)` binding. Self-reported + identity or endpoint values are not authorization. +3. A newer boot or session generation fences older requests. The old request + returns `UNAUTHENTICATED` or `ABORTED` and cannot release an unknown lease. +4. A permit contains the dispatcher epoch, session generation, reservation and + `fencing_token`. The Agent checks all of them immediately before originate. +5. Admission close/drain is a prerequisite barrier. `SetAdmissionState` and + `ApplyTaskControl` are applied only when their expected generation/revision + matches durable state. +6. When the result of a mutation is unknown, the caller first queries the + original operation/execution and records `UNKNOWN` if evidence is absent. + No new execution ID or new permit is invented for recovery. + +## Upload boundary + +The Agent receives a restricted `UploadGrant` and uploads directly to the +approved OSS target. The Dispatcher never receives audio bytes. The Agent +reports only asset metadata/checksum through `CompleteUpload`; the Dispatcher +coordinates the SaaS completion/verification and publishes the resulting OSS ID +through the existing MQ event path. diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 0000000..5c50c6b --- /dev/null +++ b/proto/README.md @@ -0,0 +1,32 @@ +# W02 Unary gRPC contract + +This directory is the project-owned W02 protocol baseline, created from the +confirmed R01–R03/R05/R07–R13 responsibilities in `docs/通信与事件数据交互_v0.1.md` +and the crash/authorization rules in `docs/G0开发准备与契约冻结提案_v0.1.md`. + +- Transport is Unary gRPC over mTLS; no internal MQ, bidirectional audio stream, +or HTTP call-execution callback is introduced. +- The protocol carries control, authorization, facts, metadata and restricted +upload grants. It never carries recording/audio bytes. +- `call_execute_json` and `payload_json` preserve the approved external JSON +bytes; this Proto does not create a second external SaaS Schema. +- `accepted` is durable receipt only; `applied`, terminal state and verified +asset facts require later evidence. +- IDs, epochs, boot/session generations, operation IDs and explicit idempotency +keys are retained for idempotency, fencing and unknown-result reconciliation. +- `ERRORS.md` is the companion error/CAS/fencing contract; it defines when a +response is only accepted and when a caller must reconcile instead of retrying. + +Generation is deterministic with the pinned local tools: + +```sh +buf lint +buf breaking --against '.git#branch=HEAD' +buf generate +``` + +The protocol is versioned by the `agent.v1` package and the `protocol_version` +metadata. Field numbers are never reused. Production mTLS credentials and +endpoints are deployment inputs, not repository contents. `ERRORS.md` and the +Proto are released together; a field or semantic change requires a new +compatibility review. diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto new file mode 100644 index 0000000..383c860 --- /dev/null +++ b/proto/agent/v1/agent.proto @@ -0,0 +1,450 @@ +syntax = "proto3"; + +package agent.v1; + +option go_package = "git.ipao.vip/rogee/go-sip/gen/agent/v1;agentv1"; + +// AgentControl is the project-owned Unary gRPC boundary between the single +// active Dispatcher and a Cell Agent. It carries metadata and facts, never +// audio bytes or an internal message-bus replacement. +service AgentControlService { + rpc GetAgentStatus(GetAgentStatusRequest) returns (GetAgentStatusResponse); + rpc ActivateAgent(ActivateAgentRequest) returns (ActivateAgentResponse); + rpc GetBootstrap(GetBootstrapRequest) returns (GetBootstrapResponse); + rpc SetAdmissionState(SetAdmissionStateRequest) returns (SetAdmissionStateResponse); + rpc Execute(ExecuteRequest) returns (ExecuteResponse); + rpc GetExecutionPermit(GetExecutionPermitRequest) returns (GetExecutionPermitResponse); + rpc ApplyTaskControl(ApplyTaskControlRequest) returns (ApplyTaskControlResponse); + rpc QueryExecution(QueryExecutionRequest) returns (QueryExecutionResponse); + rpc ReportExecutionEvent(ReportExecutionEventRequest) returns (ReportExecutionEventResponse); + rpc RequestUpload(RequestUploadRequest) returns (RequestUploadResponse); + rpc CompleteUpload(CompleteUploadRequest) returns (CompleteUploadResponse); +} + +enum ResultCode { + RESULT_CODE_UNSPECIFIED = 0; + RESULT_CODE_ACCEPTED = 1; + RESULT_CODE_APPLIED = 2; + RESULT_CODE_REJECTED = 3; + RESULT_CODE_UNKNOWN = 4; + RESULT_CODE_CONFLICT = 5; +} + +enum FailureCode { + FAILURE_CODE_UNSPECIFIED = 0; + FAILURE_CODE_INVALID_ARGUMENT = 1; + FAILURE_CODE_UNAUTHENTICATED = 2; + FAILURE_CODE_PERMISSION_DENIED = 3; + FAILURE_CODE_FAILED_PRECONDITION = 4; + FAILURE_CODE_ABORTED = 5; + FAILURE_CODE_RESOURCE_EXHAUSTED = 6; + FAILURE_CODE_UNAVAILABLE = 7; + FAILURE_CODE_DEADLINE_EXCEEDED = 8; + FAILURE_CODE_NOT_FOUND = 9; + FAILURE_CODE_ALREADY_EXISTS = 10; +} + +enum ActivationState { + ACTIVATION_STATE_UNSPECIFIED = 0; + ACTIVATION_STATE_PENDING = 1; + ACTIVATION_STATE_ACTIVE = 2; + ACTIVATION_STATE_CONFLICT = 3; + ACTIVATION_STATE_REVOKED = 4; +} + +enum AdmissionState { + ADMISSION_STATE_UNSPECIFIED = 0; + ADMISSION_STATE_OPEN = 1; + ADMISSION_STATE_CLOSED = 2; + ADMISSION_STATE_DRAINING = 3; + ADMISSION_STATE_QUARANTINED = 4; +} + +enum ControlAction { + CONTROL_ACTION_UNSPECIFIED = 0; + CONTROL_ACTION_PAUSE = 1; + CONTROL_ACTION_RESUME = 2; + CONTROL_ACTION_STOP = 3; +} + +enum ActiveCallPolicy { + ACTIVE_CALL_POLICY_UNSPECIFIED = 0; + ACTIVE_CALL_POLICY_DRAIN = 1; + ACTIVE_CALL_POLICY_HANGUP = 2; +} + +enum ExecutionState { + EXECUTION_STATE_UNSPECIFIED = 0; + EXECUTION_STATE_PREPARED = 1; + EXECUTION_STATE_PERMIT_GRANTED = 2; + EXECUTION_STATE_DISPATCHING = 3; + EXECUTION_STATE_OBSERVED = 4; + EXECUTION_STATE_UNKNOWN = 5; + EXECUTION_STATE_TERMINAL = 6; +} + +enum AssetKind { + ASSET_KIND_UNSPECIFIED = 0; + ASSET_KIND_RECORDING = 1; + ASSET_KIND_TRANSCRIPT = 2; +} + +enum UploadState { + UPLOAD_STATE_UNSPECIFIED = 0; + UPLOAD_STATE_REQUESTED = 1; + UPLOAD_STATE_UPLOADING = 2; + UPLOAD_STATE_COMPLETED = 3; + UPLOAD_STATE_FAILED = 4; + UPLOAD_STATE_EXPIRED = 5; +} + +enum FactKind { + FACT_KIND_UNSPECIFIED = 0; + FACT_KIND_EXECUTION_ACCEPTED = 1; + FACT_KIND_CALL_STATUS = 2; + FACT_KIND_CALL_FINISHED = 3; + FACT_KIND_TRANSCRIPT_UPDATED = 4; + FACT_KIND_TRANSCRIPT_FAILED = 5; + FACT_KIND_CONTACT_OPT_OUT = 6; + FACT_KIND_RECORDING_PROGRESS = 7; +} + +message RequestMeta { + string protocol_version = 1; + string request_id = 2; + string trace_id = 3; + string operation_id = 4; + int64 deadline_unix_ms = 5; + string dispatcher_epoch = 6; + string agent_id = 7; + string cell_id = 8; + string boot_id = 9; + uint64 session_generation = 10; + string idempotency_key = 11; +} + +message ResponseMeta { + string protocol_version = 1; + string request_id = 2; + string trace_id = 3; + string operation_id = 4; + int64 observed_at_unix_ms = 5; + string dispatcher_epoch = 6; + string agent_id = 7; + string cell_id = 8; + string boot_id = 9; + uint64 session_generation = 10; +} + +message Failure { + FailureCode code = 1; + bool retryable = 2; + string detail = 3; + string field = 4; +} + +message OperationReceipt { + ResponseMeta meta = 1; + ResultCode result = 2; + Failure failure = 3; + string fact_id = 4; + string content_sha256 = 5; + int64 accepted_at_unix_ms = 6; +} + +message AgentBinding { + string agent_id = 1; + string cell_id = 2; + string expected_boot_id = 3; + string dispatcher_epoch = 4; + uint64 session_generation = 5; + string endpoint_id = 6; +} + +message Capability { + string name = 1; + string version = 2; + string value = 3; +} + +message ResourceSample { + int64 observed_at_unix_ms = 1; + double cpu_used_ratio = 2; + int64 memory_available_bytes = 3; + int64 fd_used = 4; + int64 fd_limit = 5; + int64 spool_used_bytes = 6; + int64 spool_capacity_bytes = 7; + int64 media_ports_used = 8; + int64 media_ports_capacity = 9; + bool sample_fresh = 10; + string missing_reason = 11; +} + +message AppliedConfig { + string kind = 1; + string revision = 2; + string config_sha256 = 3; + string state = 4; + int64 observed_at_unix_ms = 5; +} + +message AgentStatus { + string agent_id = 1; + string cell_id = 2; + string boot_id = 3; + string software_version = 4; + string protocol_version = 5; + string asterisk_version = 6; + AdmissionState admission_state = 7; + repeated Capability capabilities = 8; + ResourceSample resources = 9; + repeated AppliedConfig applied_configs = 10; + bool mtls_authenticated = 11; + bool session_active = 12; + string status_reason = 13; +} + +message Session { + string dispatcher_epoch = 1; + uint64 session_generation = 2; + int64 expires_at_unix_ms = 3; + bytes session_credential = 4; +} + +message ConfigReference { + string kind = 1; + string version = 2; + string sha256 = 3; + string source = 4; +} + +message UploadPolicy { + bool enabled = 1; + int64 max_asset_bytes = 2; + int64 min_retention_ms = 3; + repeated string allowed_hosts = 4; +} + +message ExecutionBinding { + string tenant_id = 1; + string tenant_key = 2; + string execution_id = 3; + string task_id = 4; + string task_item_id = 5; + int64 task_revision = 6; + string call_id = 7; + string attempt_id = 8; + string agent_version_id = 9; + string route_policy_id = 10; + string caller_profile_id = 11; +} + +message AssetDescriptor { + AssetKind kind = 1; + string asset_id = 2; + string call_id = 3; + string execution_id = 4; + string format = 5; + int64 size_bytes = 6; + string checksum_sha256 = 7; + int32 channels = 8; + int32 sample_rate_hz = 9; + int64 duration_ms = 10; +} + +message Header { + string name = 1; + string value = 2; +} + +message GetAgentStatusRequest { + RequestMeta meta = 1; + AgentBinding target = 2; +} + +message GetAgentStatusResponse { + ResponseMeta meta = 1; + AgentStatus status = 2; + Failure failure = 3; +} + +message ActivateAgentRequest { + RequestMeta meta = 1; + AgentBinding binding = 2; + string activation_operation_id = 3; + bytes session_nonce = 4; + int64 session_expires_at_unix_ms = 5; +} + +message ActivateAgentResponse { + ResponseMeta meta = 1; + ActivationState state = 2; + Session session = 3; + Failure failure = 4; +} + +message GetBootstrapRequest { + RequestMeta meta = 1; + string agent_id = 2; + string cell_id = 3; + string boot_id = 4; + uint64 session_generation = 5; +} + +message GetBootstrapResponse { + ResponseMeta meta = 1; + ActivationState state = 2; + repeated ConfigReference runtime_configs = 3; + UploadPolicy upload_policy = 4; + Failure failure = 5; +} + +message SetAdmissionStateRequest { + RequestMeta meta = 1; + AgentBinding target = 2; + AdmissionState state = 3; + string barrier_id = 4; + uint64 expected_admission_generation = 5; + string reason = 6; +} + +message SetAdmissionStateResponse { + OperationReceipt receipt = 1; + uint64 applied_admission_generation = 2; +} + +message ExecuteRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; + bytes call_execute_json = 3; + string config_sha256 = 4; + uint64 admission_generation = 5; + string resource_reservation_id = 6; + string permit_id = 7; +} + +message ExecuteResponse { + OperationReceipt receipt = 1; + ExecutionState state = 2; +} + +message GetExecutionPermitRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; + string resource_reservation_id = 3; + int64 expected_task_revision = 4; + uint64 admission_generation = 5; + string config_sha256 = 6; +} + +message ExecutionPermit { + string permit_id = 1; + string resource_reservation_id = 2; + int64 issued_at_unix_ms = 3; + int64 expires_at_unix_ms = 4; + string dispatcher_epoch = 5; + uint64 session_generation = 6; + string fencing_token = 7; + string config_sha256 = 8; +} + +message GetExecutionPermitResponse { + OperationReceipt receipt = 1; + ExecutionPermit permit = 2; +} + +message ApplyTaskControlRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; + ControlAction action = 3; + ActiveCallPolicy active_call_policy = 4; + int64 expected_task_revision = 5; + string reason = 6; +} + +message ApplyTaskControlResponse { + OperationReceipt receipt = 1; + int64 applied_task_revision = 2; + ExecutionState state = 3; +} + +message QueryExecutionRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; +} + +message ExecutionSnapshot { + ExecutionBinding binding = 1; + ExecutionState state = 2; + string call_state = 3; + string attempt_id = 4; + string reason_code = 5; + int64 observed_at_unix_ms = 6; + bool unknown = 7; + repeated AssetDescriptor assets = 8; +} + +message QueryExecutionResponse { + ResponseMeta meta = 1; + ExecutionSnapshot snapshot = 2; + Failure failure = 3; +} + +message ExecutionFact { + string fact_id = 1; + string content_sha256 = 2; + ExecutionBinding binding = 3; + FactKind kind = 4; + int64 observed_at_unix_ms = 5; + string source_boot_id = 6; + uint64 source_sequence = 7; + bytes payload_json = 8; +} + +message ReportExecutionEventRequest { + RequestMeta meta = 1; + ExecutionFact fact = 2; +} + +message ReportExecutionEventResponse { + OperationReceipt receipt = 1; +} + +message RequestUploadRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; + AssetDescriptor asset = 3; + string upload_id = 4; +} + +message UploadGrant { + string upload_id = 1; + string target_url = 2; + repeated Header headers = 3; + int64 expires_at_unix_ms = 4; + string object_key = 5; + string required_checksum_sha256 = 6; + int64 max_bytes = 7; +} + +message RequestUploadResponse { + OperationReceipt receipt = 1; + UploadGrant grant = 2; + UploadState state = 3; +} + +message CompleteUploadRequest { + RequestMeta meta = 1; + ExecutionBinding binding = 2; + AssetDescriptor asset = 3; + string upload_id = 4; + int64 uploaded_size_bytes = 5; + string uploaded_checksum_sha256 = 6; +} + +message CompleteUploadResponse { + OperationReceipt receipt = 1; + UploadState state = 2; + string oss_id = 3; +} diff --git a/proto/manifest.json b/proto/manifest.json new file mode 100644 index 0000000..fe622c9 --- /dev/null +++ b/proto/manifest.json @@ -0,0 +1,51 @@ +{ + "package": "agent.v1", + "service": "AgentControlService", + "status": "project-owned-development-baseline", + "external_authority": false, + "generator": { + "tool": "buf", + "configuration": "buf.gen.yaml", + "plugins": [ + "protoc-gen-go", + "protoc-gen-go-grpc" + ] + }, + "files": [ + { + "path": "buf.gen.yaml", + "bytes": 181, + "sha256": "00fa0ef6ae59e067dc005e9995ada15d92bbd60aec7e4c10b3b36463da30745f" + }, + { + "path": "buf.yaml", + "bytes": 100, + "sha256": "62e59b0299c930c1eb5324c8a139232dd59109c4f8dbac577bc7ca0f8627d96b" + }, + { + "path": "proto/ERRORS.md", + "bytes": 4706, + "sha256": "0fb4ae7a41c3e7127645e393ee175b660f2aba9f9d27b8c8c033f1f6c334d163" + }, + { + "path": "proto/README.md", + "bytes": 1579, + "sha256": "d2754ceb47fd54bdfc4a05c5b5be818fe984399748b502f7ea8707fddf56c7dd" + }, + { + "path": "proto/agent/v1/agent.proto", + "bytes": 10681, + "sha256": "083d17eb761566e533d91d63cb118da056de498ab4187663de0294704c93e7fa" + }, + { + "path": "gen/agent/v1/agent.pb.go", + "bytes": 144947, + "sha256": "01ce926ed3d3e90888719d361ffad2e6da84bb2bc9df7b06f0bbab3ec901a9df" + }, + { + "path": "gen/agent/v1/agent_grpc.pb.go", + "bytes": 23035, + "sha256": "e87a0114e6d7d1d3d0801a7bba302e76945e3d244ec67cf56249eef0e838617d" + } + ] +} diff --git a/scripts/acceptance-local.sh b/scripts/acceptance-local.sh new file mode 100755 index 0000000..57c412c --- /dev/null +++ b/scripts/acceptance-local.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +./scripts/check-contracts.sh +go mod verify +go test -race ./... +go vet ./... +go build -trimpath -buildvcs=false -o dist/sip-go-agent ./cmd/sip-go-agent + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +./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 + +if ./dist/sip-go-agent dispatcher --mode real --db "$tmp/real.db" >/dev/null 2>&1; then + echo "real mode unexpectedly started without broker credentials" >&2 + exit 1 +fi + +echo "local P1 acceptance passed for the current single-node/Cell/tenant scope; external production gates are intentionally deferred to phase two" diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..357b949 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +OUT_INPUT=${1:-"$ROOT/dist/release"} +OUT=$(realpath -m -- "$OUT_INPUT") +VERSION=${RELEASE_VERSION:-local-development} + +case "$OUT" in + "$ROOT"/*) ;; + *) + echo "release output must stay below project root: $OUT" >&2 + exit 1 + ;; +esac + +rm -rf -- "$OUT" +mkdir -p -- "$OUT" + +cd -- "$ROOT" +go mod verify +go build -trimpath -buildvcs=false -o "$OUT/sip-go-agent" ./cmd/sip-go-agent +cp -- go.mod go.sum "$OUT/" +( + cd -- "$OUT" + sha256sum sip-go-agent go.mod go.sum > SHA256SUMS +) + +source_ref=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || printf 'unavailable') +source_dirty=false +if [[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all -- . 2>/dev/null)" ]]; then + source_dirty=true +fi + +go_version=$(go version) +python3 - "$OUT/manifest.json" "$VERSION" "$source_ref" "$source_dirty" "$go_version" "$OUT" <<'PY' +import hashlib +import json +import pathlib +import sys + +manifest_path, version, source_ref, source_dirty, go_version, out = sys.argv[1:] +root = pathlib.Path(out) +def sha256(name): + return hashlib.sha256((root / name).read_bytes()).hexdigest() + +manifest = { + "manifest_version": 1, + "scope": "local-development", + "version": version, + "source_ref": source_ref, + "source_dirty": source_dirty == "true", + "go_version": go_version, + "binary": {"path": "sip-go-agent", "sha256": sha256("sip-go-agent")}, + "module_files": { + "go.mod": sha256("go.mod"), + "go.sum": sha256("go.sum"), + }, + "security": { + "credentials_embedded": False, + "production_approval": False, + }, +} +pathlib.Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") +PY + +printf 'release manifest: %s\n' "$OUT/manifest.json" diff --git a/scripts/check-contracts.sh b/scripts/check-contracts.sh new file mode 100755 index 0000000..86594cd --- /dev/null +++ b/scripts/check-contracts.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +manifest="$root/contracts/upstream/manifest.txt" +[ -f "$manifest" ] || { echo "missing contract manifest" >&2; exit 1; } +bundle=$(sed -n 's/^active_bundle=//p' "$manifest") +[ -n "$bundle" ] || { echo "missing active contract bundle" >&2; exit 1; } +[ -d "$root/contracts/upstream/$bundle" ] || { echo "missing pinned contract directory: $bundle" >&2; exit 1; } + +cd "$root" +grep '^sha256=' "$manifest" | sed 's/^sha256=//' | sha256sum -c - +go test ./contracts ./internal/contract ./internal/ai diff --git a/scripts/check-proto.sh b/scripts/check-proto.sh new file mode 100755 index 0000000..42f7c15 --- /dev/null +++ b/scripts/check-proto.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +buf lint +buf build --error-format=json >/dev/null +buf generate +go test ./gen/agent/v1 + +python3 - <<'PY' +from hashlib import sha256 +from pathlib import Path +import json + +root = Path('.') +manifest = json.loads((root / 'proto/manifest.json').read_text()) +for entry in manifest.get('files', []): + path = root / entry['path'] + if not path.is_file(): + raise SystemExit(f'missing Proto manifest file: {entry["path"]}') + digest = sha256(path.read_bytes()).hexdigest() + if digest != entry['sha256']: + raise SystemExit(f'Proto manifest hash mismatch: {entry["path"]}') +print(f'proto manifest verified: {len(manifest.get("files", []))} files') +PY diff --git a/scripts/mq-integration-local.sh b/scripts/mq-integration-local.sh new file mode 100755 index 0000000..9b1c7cb --- /dev/null +++ b/scripts/mq-integration-local.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +cd -- "$ROOT" +IMAGE=${RABBITMQ_IMAGE:-rabbitmq:4.1-management-alpine} +NAME="sip-go-agent-rabbit-poc-${$}" +PORT="" +RABBIT_USER=${RABBITMQ_TEST_USER:-agent_call_integration} +RABBIT_PASSWORD=${RABBITMQ_TEST_PASSWORD:-$(openssl rand -hex 16)} +RABBIT_UID=$(docker run --rm "$IMAGE" id -u rabbitmq) +RABBIT_GID=$(docker run --rm "$IMAGE" id -g rabbitmq) + +cleanup() { + docker rm -f "$NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +docker run -d --name "$NAME" --user "${RABBIT_UID}:${RABBIT_GID}" -e RABBITMQ_DEFAULT_USER="$RABBIT_USER" -e RABBITMQ_DEFAULT_PASS="$RABBIT_PASSWORD" -p 0:5672 "$IMAGE" >/dev/null +ready=false +for _ in $(seq 1 90); do + if docker exec "$NAME" rabbitmq-diagnostics -q check_running >/dev/null 2>&1; then + ready=true + break + fi + sleep 1 +done +if [[ "$ready" != true ]]; then + docker logs "$NAME" >&2 || true + echo "RabbitMQ did not become ready" >&2 + exit 1 +fi +PORT=$(docker port "$NAME" 5672/tcp | head -1 | sed -E 's/.*:([0-9]+)$/\1/') +if [[ -z "$PORT" ]]; then + echo "could not determine RabbitMQ host port" >&2 + exit 1 +fi +IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$IMAGE") +printf 'local RabbitMQ image=%s id=%s uid=%s gid=%s port=%s\n' "$IMAGE" "$IMAGE_ID" "$RABBIT_UID" "$RABBIT_GID" "$PORT" +RABBITMQ_URL="amqp://${RABBIT_USER}:${RABBIT_PASSWORD}@127.0.0.1:${PORT}/" go test -tags=integration ./internal/mq ./internal/dispatcher