diff --git a/.env.example b/.env.example index dfc240a..053eed0 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,7 @@ JOB_WORKERS=2 JOB_HEARTBEAT_INTERVAL_SECONDS=10 JOB_STALE_AFTER_SECONDS=120 JOB_MAX_ATTEMPTS=3 +JOB_CHAT_HISTORY_LIMIT=20 MAX_UPLOAD_SIZE_MB=20 diff --git a/AGENTS.md b/AGENTS.md index 880f15e..6c1f896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,475 +1,62 @@ # CausalAgent AGENTS.md -本文件适用于仓库根目录及其所有子目录;如果更深层目录存在新的 `AGENTS.md`,以更近的文件为准。 - -## 1. 工作规则 - -1. 总是用中文回复。 -2. 严禁删除重要文件;如果确实需要删除,请提示用户自行删除,或先获得用户明确确认。 -3. 使用第一性原理思考。不要默认用户已经完全明确目标和实现路径;如果目标不清晰,先澄清问题;如果目标清晰但路径不是最短,明确指出并给出更直接的方案。 -4. 查询文档、规范、官方示例时,优先使用真实查询工具,例如 MCP、内置网络工具、已安装的合适 skills 等,并返回真实链接。 -5. 先读后写,先核实后修改;不要凭空猜测项目结构、接口、配置或业务逻辑。 -6. 以最小必要改动解决问题,不做无关重构,不引入炫技式复杂度。 -7. 修改后必须做与改动直接相关的验证;如果受环境限制无法验证,要明确说明未验证部分和风险。 -8. 对于结构、目录、启动方式、数据库初始化方式等“项目事实”的改动,需要同步检查并更新 `AGENTS.md` 和 `README.md` 是否仍准确。 - -### git 要求 -1. 禁止主动提交更改 -2. 当修改完成时候,为修改提出相应的提交信息和批次建议 -3. git标准:keyword(function):dec - - keyword 支持 `feat`、`fix`、`docs`、`refactor`、`test`、`chore`、`ci`、`build`、`perf`、`revert` - - Pull Request 标题使用相同格式,例如 `ci(actions):增加轻量检查` -4. 分支标准:keyword(function)/dec - -### 文档日志 -1. 当一个功能完成时,补充日志 -2. 不要更改日志的历史文件,当需要新增日志的时候,请在日志后增加 - -## 2. 项目目录 - -项目结构会持续更新,以下内容仅用于快速定位;最新情况请以仓库实际目录和代码实现为准。 - -```text -. -├── CausalAgent.py # Flask 后端入口 -├── Run_causal.py # 桌面端启动入口(pywebview) -├── requirements.txt # 完整依赖 -├── requirements-base.txt # 基础依赖(docker/生产使用) -├── requirements-test.txt # Docker 单元测试依赖 -├── Dockerfile -├── docker-compose.yml # MySQL 主从 + PostgreSQL checkpoint 开发拓扑 -├── docker-compose.prod.yml -├── docker-compose.replica.yml # 旧路径兼容副本,不作为默认开发入口 -├── docker-compose.test.yml # 按需创建的一次性单元测试环境 -├── .github/ # GitHub Actions 与 Issue 模板 -│ ├── workflows/ # GitHub Actions 工作流 -│ └── ISSUE_TEMPLATE/ # Issue Form 模板 -├── docker-compose.admin-e2e.yml # 3.1/3.2 独立主从 + PostgreSQL 验收覆盖 -├── README.md # 项目说明 -├── Document/ -│ └── admin/ # 管理员 API、开发部署与测试文档 -├── admin-frontend/ # Vue 3 + TypeScript 管理员后台 -│ ├── src/ -│ ├── tests/ -│ ├── package.json -│ └── package-lock.json -├── database_init.log # 数据库初始化日志 -├── app/ # Flask 应用主目录(Blueprint 结构) -│ ├── __init__.py # 创建 Flask app,注册蓝图 -│ ├── db.py # 数据库会话与连接封装 -│ ├── main/ # 通用页面相关路由 -│ ├── auth/ # 登录、注册等认证相关路由 -│ ├── admin/ # 管理员 API、审计服务与受保护 Vue 入口 -│ ├── agent/ # 分析任务 API、共享队列服务与独立 worker -│ │ ├── routes.py # Web 任务创建与 SSE 订阅 -│ │ ├── job_service.py # Web、monitor、worker 共享持久化服务 -│ │ ├── core.py # 无运行时状态的兼容导入门面 -│ │ └── worker/ # worker package 与 python -m 入口 -│ ├── chat/ # 聊天与会话相关路由和服务 -│ ├── files/ # 文件上传与管理相关路由 -│ └── static/ # 前端静态资源 -│ ├── chat.html # 主聊天界面 -│ ├── css/ -│ ├── js/ -│ └── generated_graphs/ -├── Agent/ # 因果分析与智能体核心逻辑 -│ ├── causal/ -│ ├── causal_agent/ -│ ├── Processing/ -│ ├── Postprocessing/ -│ ├── Report/ -│ ├── knowledge_base/ # RAG 知识库 -│ │ ├── build_knowledge.py -│ │ ├── db/ -│ │ └── models/ -│ └── tool_node/ -├── Database/ # 数据库初始化与迁移逻辑 -│ ├── database_init.py -│ ├── bootstrap.py # MySQL/Alembic/PostgreSQL 统一初始化入口 -│ ├── audit_before_db_upgrade.py -│ ├── inspection.py # 数据库看板统一只读检查服务 -│ ├── deep_audit.py # 手动 deep 数据库事实审计 -│ ├── checkpoint_inspection.py # PostgreSQL checkpoint 管理员只读与审计 -│ ├── monitoring.py # 共享快照存取、调度与兼容接口 -│ ├── monitor_worker.py # 数据库看板分层采集进程 -│ ├── monitor_settings.py # 在线配置解析、缓存、校验与事务写入 -│ ├── lifecycle_repair.py # 3.2 孤立关系有限 dry-run/人工确认修复 CLI -│ ├── mysql/ # MySQL 主从配置与初始化脚本 -│ ├── agent_connect.py -│ └── migrations/ -├── config/ -│ └── settings.py -├── tests/ # 后端测试:先按层级、再按业务分类 -│ ├── unit/ -│ ├── integration/ -│ ├── e2e/ -│ ├── run_admin_31_e2e.ps1 -│ └── run_admin_32_e2e.ps1 -└── setting/ - ├── manual.md - └── Userprivacy.md -``` - -## 3. 开发环境与项目事实 - -- GitHub Actions 轻量 CI 位于 `.github/workflows/lightweight-ci.yml`,对 `main`、`develop` 的 push 和 Pull Request 生效;它只执行 Python 语法编译、无外部服务依赖的轻量测试以及 Pull Request 策略检查。 -- GitHub Issue 使用 `.github/ISSUE_TEMPLATE/issue.yml` 统一填写背景、问题描述、预期结果、复现步骤、验收标准和环境信息;除附件外的字段启用原生必填校验,但不限制填写内容。普通贡献者不能选择空白 Issue。 -- 功能分支应向 `develop` 发起 Pull Request,只有 `develop` 可以向 `main` 发起 Pull Request;分支保护需要在 GitHub Rulesets 中启用,并把 `Python syntax`、`Light tests`、`Pull request policy` 设置为必需检查。 -- 桌面端入口是 `Run_causal.py`,它固定加载 `http://127.0.0.1:5001`;桌面模式本质上仍依赖先启动后端。 -- Web 后端入口是 `CausalAgent.py`,它导入 `app/__init__.py` 中的 `create_app()` 生成 Flask app;本地直接运行时使用 `app.run(host='0.0.0.0', port=5001, debug=True, use_reloader=False)`,Docker 镜像默认通过 `gunicorn ... CausalAgent:app` 启动。 -- `create_app()` 会先执行 `app/db.py` 中的 `check_database_readiness()`,确认数据库和关键表已就绪,然后再注册蓝图。 -- 当前实际注册的蓝图有 7 个:`auth`、`chat`、`files`、`agent`、`main`、`admin`、`admin_page`。 -- Web 进程只负责登录态校验、短请求、analysis job 入队和 SSE 推送;Agent/RAG/MCP 长任务不在 Web 进程内执行,而是由独立 worker 进程处理。 -- 后台 worker 入口仍是 `python -m app.agent.worker`,实际由 `app/agent/worker/__main__.py` 调用 `bootstrap.main()`;启动流程是:数据库就绪检查 -> PostgreSQL checkpoint 检查 -> 创建显式进程 runtime(LLM、RAG 可用性)-> 按 `JOB_WORKERS` 启动多个 slot。 -- 每个 worker slot 会独占一组 MCP server process、一个通过 `MultiServerMCPClient.session("causal")` 打开的持久 `ClientSession`、一组由 `load_mcp_tools(session)` 生成的 LangChain tools,以及一个编译好的 Agent graph;`runtime.py` 通过 `ProcessRuntime` 和 `SlotRuntime` 显式返回这些依赖,执行函数不读取 `app.agent.core` 的全局 LLM 或 graph。真实执行单元是 slot,不是 Flask 请求线程。 -- 父图当前只暴露 `mcp`、`rag` 两个工具阶段节点:`mcp` 子图正常路径执行 `mcp_planner -> mcp_tool_node -> mcp_result_parser`,planner、ToolNode 和 parser 的失败路径会在子图内生成标准 `success=False` 结果并结束子图;`rag` 子图内部执行 `rag_question_planner -> rag_tool_node -> rag_result_parser`。worker 使用 LangGraph v2 `updates/messages/custom/tasks` 多流:根图 `tasks` 形成用户时间线,子图工具事件折叠到 `mcp`/`rag` 阶段,只有 `normal_chat` 和 `inquiry_answer` 的文字进入 `text_delta`,原始 Prompt、ToolMessage、完整工具结果和内部 attempt 不进入普通用户 SSE 协议。 -- Pydantic 结构化输出统一通过 `Agent/llm_structured_output.py` 的同步/异步入口执行,固定使用普通 `function_calling`;调用器仅对结构化请求发送 `thinking.type=disabled`,避免 DeepSeek Thinking 与固定 `tool_choice` 冲突。MCP 继续使用原生 Tool Calls;只有 MCP planner 使用关闭 Thinking 的 LLM 副本和 `tool_choice="required"`,确保模型必须自行选择一个已加载工具。 -- `agent` 与 `fold` 的条件路由只读取 `route_decision`、`fold_decision` 显式 State 字段;展示消息仅用于用户可见内容和审计,不参与控制流。 -- 配置统一由 `config/settings.py` 从系统环境变量读取;若项目根目录存在 `.env`,会先通过 `python-dotenv` 加载到环境变量。 -- 普通用户前端仍是 Flask 静态资源方案,聊天 API/SSE 契约不变;关键文件是: - - `app/static/chat.html` - - `app/static/css/style.css` - - `app/static/js/script.js` -- 管理员前端独立位于 `admin-frontend/`,使用 Vue 3、严格 TypeScript、Vue Router、Element Plus、Vite、Vitest 和 Playwright;路由为 `/admin/database` 与 `/admin/database/settings`,Vite base 固定为 `/admin/`。 -- 管理员 Vue 生产构建默认从 `admin-frontend/dist` 读取;Docker 镜像使用 Node 24 构建阶段,并把产物复制到 `/opt/causalagent-admin`。最终 Python 运行镜像不包含 Node、不启动 Vite、不开放 Node 端口。开发期只有显式设置 `ADMIN_VITE_DEV_SERVER_URL` 时,Flask 在完成页面鉴权后才跳转到 Vite。 -- `admin-frontend/dist/` 中只提交入口文件 `index.html`,`dist/assets/` 的哈希构建产物由 `.gitignore` 排除并在 Dockerfile 的 Node 构建阶段重新生成;`.dockerignore` 继续排除本地前端产物,镜像会从当前源码构建并复制到 `/opt/causalagent-admin`。 -- 旧管理员 `db_admin.html`、`db_admin.css`、`db_admin.js` 已在等价测试、真实快照和整版回滚演练通过后移除;管理员页面只使用 Vue 生产构建或显式启用的 Vite 开发服务器,普通用户静态前端不受影响。 -- `Database/database_init.py` 只负责加载环境变量、确保 MySQL 数据库存在并检查连接;`Database/bootstrap.py` 负责按顺序编排 MySQL 建库、Alembic migration 和 LangGraph PostgreSQL checkpoint setup;业务表结构维护入口仍是 Alembic,而不是 `database_init.py`。 -- Alembic 迁移目录由 `alembic.ini` 指向 `Database/migrations`;业务 schema 变更应以迁移脚本为准。 -- LangGraph checkpoint 的运行时真相在 PostgreSQL;`Database/checkpoint_setup.py` 使用官方 `AsyncPostgresSaver.setup()` 创建 schema,`Database/checkpoint_cleanup_worker.py` 消费 MySQL `checkpoint_cleanup_outbox` 并调用 `adelete_thread()`。MySQL 只保存 outbox,不再保存 checkpoint 数据。 -- `Database/audit_before_db_upgrade.py` 是旧库添加外键前的 schema-aware preflight,不是新库初始化步骤:仅当相关表已存在、目标外键尚未建立且待执行迁移需要该约束时才做孤立数据扫描;全新空库直接执行 Alembic。 -- `app/db.py` 提供写库连接、业务读连接、复制状态观测连接、慢查询计时、从库延迟回退和不暴露真实主机名的逻辑来源标记;`get_db_connection()` 仅作为兼容旧代码的主库写入口。 -- `get_read_connection(consistency='strong')` 固定读主库;`consistency='eventual'` 只会在从库复制状态正常且延迟不超过阈值时使用副本,否则安全回退主库。 -- 用户角色采用 `users.role` 的最小两级模型,只允许 `user` / `admin`;登录、会话恢复和管理员授权每次都通过主库强一致读确认 `role` 与 `is_active`,不把 session 中的角色值作为后端授权依据。 -- `/api/admin/*` 由统一管理员装饰器保护:无有效会话返回 `401`,普通登录用户返回 `403`;管理员页面未登录时回到统一登录入口并只保留白名单管理页面的安全回跳,普通用户直访页面先返回真实 `403` 拒绝页,再回普通首页提示“无管理员权限”。初始管理员只通过 `python -m app.auth.admin_cli promote ` 提升现有启用用户,不提供公开管理员注册接口。 -- `POST /api/login` 可接收可选的内部 `next`,只在服务端白名单校验后返回 `redirect_to`;没有安全回跳时管理员默认进入 `/admin/database`。登录成功和有效 `check_auth` 会返回 Session 绑定的 CSRF token;管理员刷新、完整性审计、配置保存/重置和全部 3.2 受控写入必须提供匹配的 `X-CSRF-Token`。所有响应都有 `X-Request-ID`,格式合法的上游 request ID 会被沿用。 -- 管理员普通登录后仍以 `/admin/database` 为默认落点,但可通过后台“进入聊天”使用普通用户界面;聊天接口继续只按当前管理员自身的 `user_id` 访问会话、文件和任务,聊天页向管理员提供返回后台入口。管理员主动进入 `/` 时不会被强制送回后台,重新进入任一管理页面时仍由服务端实时复核角色和启用状态。普通用户继续进入聊天页。后台为白色简约 Vue 页面,现开放业务概览、用户、会话、任务、文件、数据库看板、采集配置和 Schema/deep 审计;3.2 只增加受控用户启停/角色/改密以及用户/文件物理删除,不提供聊天、任意 SQL、修复、迁移、账号授权、复制控制或任务控制。桌面左侧导航可在 248px/约 76px 间收缩并持久化,移动端为可关闭抽屉,Logo 通过受保护接口复用 `README/CausalAgent.png`。 -- 3.1 管理列表默认 20、最多 50 条并使用不透明游标;消息/附件正文和任务输入/结果/错误只允许管理员明确点击后按最多 64 KiB 源字节分块读取。成功敏感访问要求审计可写;审计只保存管理员、动作、目标、结果、错误码和 request ID,不保存正文。 -- 3.2 用户/文件写接口固定在 `/api/admin/business/*`:执行前必须主库预览、CSRF、当前管理员密码重新认证、明确确认和 `Idempotency-Key`;批量默认 20、硬上限 50,成功变更与 `admin_operations`、`admin_operation_items` 和逐目标 `admin_audit_events` 同事务提交。操作者不能禁用、降级或删除自己,事务锁保护最后一个启用管理员;角色、状态和密码实际变化通过 `users.auth_version` 使旧 Session 失效。用户删除的 MySQL 业务数据先提交,PostgreSQL checkpoint cleanup 通过 `/api/admin/operations/` 查询并聚合为 `running/succeeded/failed`。 -- 3.2 文件物理删除同时删除 `uploaded_files` 行与 BLOB,不提供回收站;因文件与 job 没有稳定直接关系,归属用户存在 queued/running job 时保守阻断。用户物理删除显式处理 archived session,并为每个会话写入 checkpoint cleanup outbox,其余依赖现有外键级联,并受同步关联行阈值保护。 -- CSV 预览最多读取 256 KiB、100 行、50 列、单元格 1000 字符且只按文本渲染;管理员预览/下载以及 Agent 真正读取文件内容会在同一主库事务原子更新 `last_accessed_at`、`access_count`,重复上传命中已有文件不计为访问。 -- 管理看板新增聚合读取接口 `GET /api/admin/db/dashboard`,以及只登记共享刷新请求的 `POST /api/admin/db/refresh` 和 `POST /api/admin/db/integrity/run`;`/db/health`、`/db/overview`、`/db/integrity`、`/db/slow-queries`、`/jobs/workers` 继续兼容,分析任务页复用 `/jobs/workers` 的汇总和明细。数据库看板按 URL query 的 database、cleanup-worker、outbox 三段视图展示,cleanup worker 约每 10 秒写入 `checkpoint_cleanup_runtime` 心跳,monitor 采集脱敏 `checkpoint_cleanup_outbox` 摘要;所有 GET 都只读取最近快照,不现场执行完整数据库采集。 -- 在线配置接口为 `GET/PUT /api/admin/db/settings`、`POST /api/admin/db/settings/reset` 和 `GET /api/admin/db/settings/history`。七项有效值固定按“数据库覆盖 > 环境变量 > 代码默认值”解析,`NULL` 表示继承;每个进程最多缓存 5 秒,读取失败时先使用最后有效值、再回退环境/默认值并标记降级。保存使用乐观版本锁,成功、拒绝和失败结果写入 `admin_audit_events`。 -- 独立 monitor 入口是 `python -m Database.monitor_worker`;它按 `realtime`、`sql_performance`、`capacity`、`integrity` 四类核心周期生成 MySQL 共享快照,并额外按实时周期采集 `checkpoint_cleanup_outbox` 队列摘要,通过命名锁避免多个 monitor 或并发手动请求重复采集。默认周期分别为 `10s`、`60s`、`900s`,完整性定时审计默认关闭,启用后默认 `86400s`。 -- monitor 还接受仅手动请求的 `deep_audit` 快照:它永不定时调度,覆盖 revision、关键 schema、字符集/UTC/隔离级别、账号职责结论、Job/Event、checkpoint cleanup outbox、归档关系、`active_session_key` 和逐从库状态;每项有查询超时和异常样本上限,不自动修复,也不返回账号、host 或 grants。 -- 看板连接使用率 warning/error 默认阈值为 `70%`/`85%`,由 `DB_DASHBOARD_CONNECTION_WARNING_PERCENT` 和 `DB_DASHBOARD_CONNECTION_CRITICAL_PERCENT` 配置;快速 SELECT 超时由 `DB_INSPECTION_QUERY_TIMEOUT_MS` 配置,默认 `3000ms`。刷新和采集配置统一由 `DB_MONITOR_AUTO_REFRESH_ENABLED`、`DB_MONITOR_REALTIME_INTERVAL_SECONDS`、`DB_MONITOR_SQL_INTERVAL_SECONDS`、`DB_MONITOR_TABLE_CAPACITY_INTERVAL_SECONDS`、`DB_MONITOR_SLOW_QUERY_WARNING_DELTA`、`DB_MONITOR_INTEGRITY_ENABLED`、`DB_MONITOR_INTEGRITY_INTERVAL_SECONDS` 控制,不得在路由、SQL 或前端硬编码。 -- SQL digest 区块语义是“SQL 性能摘要/高负载 SQL”,按单次平均 `AVG_TIMER_WAIT` 降序选取和展示,平均耗时相同时按累计 `SUM_TIMER_WAIT` 降序次排序;它不等价于超过 `long_query_time` 的单次慢查询,慢查询告警优先使用采集窗口内 `Slow_queries` 增量,累计值仅作兼容和辅助展示。 -- 运行期完整性审计不再查询已经迁移走的 MySQL checkpoint 表,而是轻量确认 cleanup outbox 外键/领取索引,并报告失败清理任务;也不再要求 `chat_messages` 必须存在分区。 -- `check_database_readiness()` 当前会检查 `users`、`sessions`、`chat_messages`、`chat_attachments`、`uploaded_files`、`archived_sessions`、`checkpoint_cleanup_outbox`、`analysis_jobs`、`analysis_job_events`、`database_monitor_snapshots`、`database_monitor_settings`、`admin_audit_events`、`admin_operations`、`admin_operation_items` 这些关键表,以及 `users.role`、`users.auth_version`、`users.password_changed_at`、cleanup outbox 领取索引和管理员幂等索引是否已存在。 -- 当前 LangGraph PostgreSQL checkpointer 使用 `session_id` 作为 `thread_id`;会话或用户删除只能在 MySQL 事务内先写 `checkpoint_cleanup_outbox`,不能把 PostgreSQL `adelete_thread()` 假装纳入 MySQL 事务。 -- quick integrity 通过独立 PostgreSQL 只读连接检查 checkpoint 连通性、官方四表集合和 setup migration 版本;deep audit 额外检查官方字段/主键、三张数据表的估算统计,并抽取最多 20 个 `thread_id → sessions.id` 跨库关系样本。所有结果只返回逻辑别名,不返回 PostgreSQL host、账号或连接串。 -- `checkpoint_cleanup_outbox` 使用 `(thread_id)` 唯一键幂等,cleanup worker 用 `FOR UPDATE SKIP LOCKED` 领取任务,租约过期可恢复,最多执行三次,失败后按 10 秒、30 秒退避;管理员用户删除的操作状态由 outbox 聚合推进。 -- cleanup worker 心跳周期由 `CHECKPOINT_CLEANUP_HEARTBEAT_INTERVAL_SECONDS` 控制,默认 `10s`;运行快照只写入逻辑别名、计数、时间和安全错误结论,不写入主机、账号或 `last_error` 原文。 -- `Database/lifecycle_repair.py` 默认只列出有限孤立 archived session 和失败/过期 cleanup outbox 主键;只有 `--apply --confirm-database <精确库名>` 才执行,migration 不得调用它或静默删除历史数据。 -- `analysis_jobs` 和 `analysis_job_events` 是当前长任务系统的真实持久化基础:前者是任务队列,后者是事件日志;job 创建、领取、状态更新、事件写入和 SSE 读取都必须走主库或强一致读。 -- 管理员任务详情通过单视图选择器展示 MySQL `analysis_job_events` 的节点/任务事件或 PostgreSQL checkpoint 安全摘要,默认进入节点/任务事件;新 checkpoint 通过 `metadata.job_id` 与任务精确关联,旧记录缺少该字段时不得按时间猜测归属。管理员接口不得读取或返回 checkpoint 状态正文、blob 或 pending writes。 -- 同一 `user_id + session_id` 同时只允许一个 `queued/running` job;当前实现不是 generated column,而是把 `active_session_key` 作为可空普通列,并通过唯一键 `uq_analysis_jobs_active_session` 兜底并发竞态。 -- 旧 `/api/send_stream` 只保留为迁移提示接口,返回 `410`;前端真实路径应使用 `POST /api/agent/jobs` 创建任务,再用 `GET /api/agent/jobs//events` 订阅 SSE,断线续传依赖 `Last-Event-ID`。创建任务必须提供客户端 `Idempotency-Key`;相同用户、相同幂等键和相同请求参数返回原 job,不同参数返回 `409`。 -- 数据库账号按职责拆分: - - `MYSQL_WRITE_USER` / `MYSQL_WRITE_PASSWORD`:应用写主库、迁移、启动检查用。 - - `MYSQL_READ_USER` / `MYSQL_READ_PASSWORD`:业务读主库/从库数据用;除业务库 `SELECT` 外,只额外读取 `performance_schema.events_statements_summary_by_digest`,不得扩大为全局 `SELECT`。 - - `MYSQL_REPLICA_STATUS_USER` / `MYSQL_REPLICA_STATUS_PASSWORD`:只用于执行 `SHOW REPLICA STATUS`。 - - `MYSQL_REPLICATION_USER` / `MYSQL_REPLICATION_PASSWORD`:只给 MySQL 从库复制通道拉 binlog 用。 - - `MYSQL_USER` / `MYSQL_PASSWORD`:仅作为写/读账号兼容兜底,不承担复制状态检查职责。 -- `docker-compose.yml` 是本地主从加 PostgreSQL checkpoint 开发拓扑,当前包含 `mysql-primary`、`mysql-replica`、`postgres-checkpoint`、`db-bootstrap`、`app`、`worker`、`monitor`、`checkpoint-cleanup` 八个服务;`db-bootstrap` 是一次性初始化服务,运行成功后其他运行服务才启动,本轮仍不提供自动故障切换。`docker-compose.replica.yml` 仅保留为旧路径兼容副本。 -- `docker-compose.prod.yml` 同样包含 PostgreSQL checkpoint、统一 bootstrap、Agent worker、monitor 和 checkpoint cleanup;`app` 与 `monitor` 需要 PostgreSQL 配置以支持管理员 checkpoint 摘要和 quick/deep 审计。 -- 连接池按 OS 进程计算:`write_pool + read_pool * (1 + replica_count)`,worker slot 共享所在进程的池。默认建连/获取池/管理员锁等待/从库状态缓存分别为 5s/3s/5s/2s;复制状态失效或异常只回退主库,不自动切主。真实容量依据和读写矩阵记录在 `setting/database_governance.md`。 -- Docker 是当前首选开发方式;`docker-compose.yml` 中 `app` 和 `worker` 都会挂载以下知识库目录: - - `Agent/knowledge_base/models` - - `Agent/knowledge_base/db` -- 后端单元测试使用独立 `docker-compose.test.yml`:`unit-test` 服务基于 Dockerfile 的 `test` 目标预装 `requirements-test.txt`,不依赖数据库,以 `tests/unit-test.env` 屏蔽项目 `.env` 并关闭 LangSmith 追踪,禁用网络,只读挂载当前仓库;通过 `docker compose ... run --rm` 按需创建和删除测试容器,测试镜像继续复用。 -- RAG 启动期只检查知识库目录是否可用,不会在启动时完整加载向量库;若 `Agent/knowledge_base/db` 不存在,worker 会记录 warning,并以“无知识库模式”继续运行。 -- DirectLiNGAM 已作为独立 MCP 工具 `causal_direct_lingam` 接入 `Agent/CausalAgentMCP/mcp_server.py`;显式点名 DirectLiNGAM 时 planner 会确定性选择该工具。runner 只接受连续数值 CSV,返回因果顺序、`matrix_convention="target_to_source"` 的系数矩阵和带权有向图;后处理会保留未反转边的 `weight`,报告必须说明线性、非高斯、误差独立、DAG 和无潜在混杂假设。 - - -### 3.2 常用命令 - -激活本地 conda 环境(仅在不用 Docker 时): - -```bash -conda activate causalagent -``` - -本地启动后端: - -```bash -python CausalAgent.py -``` - -本地启动后台 worker: - -```bash -python -m app.agent.worker -``` - -本地启动数据库监控采集器: - -```bash -python -m Database.monitor_worker -``` - -管理员 Vue 本地验证: - -```bash -cd admin-frontend -npm ci -npm run typecheck -npm run test:unit -npm run test:e2e:mock -npm run build -``` - -后端单元测试默认使用按需 Docker 环境: - -```bash -docker compose -f docker-compose.test.yml build unit-test -docker compose -f docker-compose.test.yml run --rm unit-test -``` - -指定测试可以在服务名后覆盖默认命令;只有依赖变化时才需要重新构建测试镜像。集成测试、本地 Python 回退和完整分类见 `tests/README.md`。 - -真实隔离环境 E2E 还需提供 `PLAYWRIGHT_BASE_URL`、管理员/普通用户测试凭据后运行 -`npm run test:e2e`;本机仅有 Edge 时可显式设置 `PLAYWRIGHT_CHANNEL=msedge`, -未设置时仍使用 Playwright 标准 Chromium。 - -3.2 完整隔离主从验收在管理员生产构建完成后运行: - -```powershell -powershell -ExecutionPolicy Bypass -File tests/run_admin_32_e2e.ps1 -``` - -该脚本不会触碰当前开发库,覆盖空库升级、3.2 migration 往返、受控写入/删除、主从追平和普通用户回归;不会自动删除隔离容器和卷,清理仍需单独明确确认。物理删除种子不能通过 `KeepSeededData` 重放。 - -本地启动桌面端: - -```bash -python Run_causal.py -``` - - -Docker 主从开发启动(推荐): - -```bash -docker compose -f docker-compose.yml up -d -``` - -首次启动、空卷重建或数据库环境重建后,推荐按下面顺序执行;全新空库不要先运行 preflight: - -```bash -docker compose -f docker-compose.yml up -d -``` - -`.env` 必须提供非空 `CHECKPOINT_POSTGRES_PASSWORD`;Compose 会自动运行 -`db-bootstrap` 和 `checkpoint-cleanup`。本地等价命令为: - -```bash -python -m Database.bootstrap -python -m Database.checkpoint_cleanup_worker -``` - -如果你当前不是在 Docker 里开发,再使用本地等价命令: - -```bash -python -m Database.bootstrap -``` - -如需在不重启运行服务的情况下手动重跑一次性初始化入口,可执行: - -```bash -docker compose -f docker-compose.yml run --rm db-bootstrap -``` - -只有旧库尚未建立目标外键、且即将执行添加这些外键的迁移时,才在 `alembic upgrade head` 前运行 `Database/audit_before_db_upgrade.py`。 - -### 3.2 数据库相关特别要求 - -数据库结构变更不能只改一处;至少同时检查以下位置: -注意数据库采用主从开发 - -```text -Database/database_init.py -Database/bootstrap.py -Database/migrations/versions/* -app/db.py -相关 SQL 读写代码 -``` -不要把“读写分离”简化成“所有 SELECT 都去副本”;先按一致性要求区分 strong read、eventual read 和必须写主库的实时路径。 - -MYSQL_WRITE_USER:应用写主库、迁移、启动检查用。 -MYSQL_READ_USER:应用读主库/从库业务数据用。 -MYSQL_REPLICA_STATUS_USER:只给应用执行 SHOW REPLICA STATUS 用。 -MYSQL_REPLICATION_USER:只给 MySQL 从库拉主库 binlog 用。 -MYSQL_USER/MYSQL_PASSWORD:现在主要是兼容兜底,主从开发里不依赖它。 - -### 3.3 环境问题 -1. 如果在本地无法找到包,如langgrph,尝试访问conda环境 - -## 4. 工具与 skills 使用原则 - -1. 如果某个 skill 不可用,必须回退到通用工具链继续完成任务,不能因为缺少该 skill 就中止工作。 -2. 对外部资料查询类任务,优先返回真实来源链接,而不是只给二手总结。 - -## 5. 工作方式 - -### 5.1 先读后写 - -修改前至少先检查与当前任务相关的这些内容: - -- 调用入口 -- 路由 -- service 或核心业务函数 -- 数据库表结构或迁移 -- 前端调用点 -- README 或用户文档中是否已有说明 - -优先使用 `rg` 搜索已有实现,避免重复造轮子。 - -### 5.2 基于事实,不靠猜 - -- 不要假设某个接口、文件、表、字段一定存在。 -- 不要因为 README 写了某句话,就忽略代码中的真实行为。 -- 如果 README、注释、实现不一致,以当前实现为准,并在最终答复中指出差异。 - -### 5.3 精确改动 - -- 不做与当前任务无关的全局重命名、风格统一或大重构。 -- 不为“未来可能用到”预埋复杂抽象。 -- 优先修根因,不打表面补丁。 -- 每个函数需要补充函数层描述 - -### 5.4 举一反三 - -如果一个 bug 由模式性问题引起,要顺手检查同类位置是否也存在相同风险,例如: - -- 新增数据库表但忘了更新 `check_database_readiness` -- 修改接口返回结构但没有检查前端 `script.js` -- 改了上传或聊天附件结构却没同步恢复逻辑 -- 修改 MCP、RAG 或 worker 初始化路径但没检查 `app/agent/worker/runtime.py`、`bootstrap.py` 和 Docker Compose 入口 - -## 6. 修改后的验证要求 - -### 6.1 Python / 后端改动 - -至少做以下一项或多项验证: - -```bash -python -m py_compile <变更的Python文件> -``` - -如果改动涉及导入链、启动链、配置链,优先再做一次后端启动级验证: - -```bash -python CausalAgent.py -``` - -如果因为缺少 `.env`、数据库或模型目录而无法启动,要明确说明。 - -### 6.2 数据库相关改动 - -必须检查: - -- `Database/database_init.py` 和 `Database/bootstrap.py` 是否同步 -- 对应 Alembic migration 是否存在且升级/回滚逻辑自洽 -- `app/db.py` 的就绪检查是否需要更新 -- 相关 SQL 是否仍兼容旧数据和空数据场景 - -未经用户明确确认,不要执行高风险数据库操作。 - -### 6.3 前端改动 - -普通用户前端仍为 Flask 静态资源;管理员前端有独立 Node 构建流程。改动后至少要: - -- 检查 `chat.html`、`style.css`、`script.js` 的引用关系 -- 检查接口路径是否仍与后端一致 -- 检查加载态、空态、失败态是否受影响 -- 在 `admin-frontend/` 执行 `npm ci`、`npm run typecheck`、`npm run test:unit` 和 `npm run build` -- 管理员看板变更必须通过等价矩阵测试;真实数据库写流程只允许在隔离环境提供 Playwright 凭据后执行 -- 如条件允许,启动后端并在浏览器中做一次最小交互验证 - -### 6.4 RAG / MCP / Agent 图改动 - -至少核对: - -- `app/agent/worker/runtime.py` -- `app/agent/worker/bootstrap.py` -- `app/agent/worker/graph_runner.py` -- `Agent/causal_agent/` -- `Agent/tool_node/` -- `Agent/knowledge_base/` - -## 7. 敏感文件与高风险区域 - -以下内容默认视为敏感或高风险,不能随意改动、清空或覆盖: - -- `.env` -- `secrets.json` -- `database_init.log` -- `Agent/knowledge_base/db/` -- `Agent/knowledge_base/models/` -- 用户上传和历史数据对应的数据库表 -- `Database/migrations/versions/` 中已存在的迁移脚本 -- 任何可能包含用户数据、密钥、知识库索引或生成产物的目录 - -补充要求: - -- 不要输出密钥、口令、数据库连接信息。 -- 不要擅自清理知识库目录、数据库目录或日志目录。 -- 不要仅因为本地运行失败就删除迁移脚本、数据库表、缓存目录或静态资源目录。 - -## 8. 危险操作确认机制 - -以下操作属于高风险操作,执行前必须得到用户明确确认: - -- 删除文件或目录 -- 批量修改大量文件 -- 移动系统关键文件 -- `git commit` / `git push` / `git reset --hard` / 强制覆盖 -- 修改环境变量、系统配置、权限 -- 数据库删除、结构变更、批量更新 -- 调用生产环境 API -- 全局安装 / 卸载依赖,升级核心依赖 -- 任何可能造成数据丢失、环境破坏、不可逆副作用的操作 - -确认时必须使用这个格式: - -```text -检测到危险操作! -操作类型:[具体操作] -影响范围:[详细说明] -风险评估:[潜在后果] -``` - -## 9. 决策型问题的回答方式 - -当用户提出的是“需要做选择”的问题,而不是“让我直接实现”的问题时,先不要直接给结论,先做四件事: - -1. 指出问题里的隐含假设。 -2. 说明哪些关键信息缺失会显著改变结论。 -3. 指出这类问题最常见的一个错误。 -4. 向用户提出一个能显著提升最终建议质量的关键问题。 - -只有在这些前置信息澄清后,再给最终建议。 - -适用场景包括但不限于: - -- 技术选型 -- 架构调整 -- 数据库结构变更 -- 依赖升级 -- 成本、复杂度、风险差异明显的方案比较 - -## 10. 文档与代码冲突时的优先级 - -优先级从高到低如下: - -1. 用户当前明确指令 -2. 更近目录下的 `AGENTS.md` -3. 根目录 `AGENTS.md` -4. 当前代码实现 -5. `README` / 注释 / 历史文档 - -如果发现文档与实现不一致: - -- 不要盲目按旧文档修改代码 -- 先说明差异 -- 以当前可运行实现为准提出建议 +生效目录:仓库根目录及所有未被更近的 AGENTS.md 覆盖的子目录。 + +负责约束的修改类型:全局协作规则、代码/文档变更边界、跨模块核对、敏感信息保护、验证义务和 Git 工作方式。系统当前事实不在本文件完整展开,统一从 Document/ 导航。 + +## 全局工作规则 + +- 必须使用中文回复;需要解释时保持完整段落和清晰逻辑,不用零散结论替代推理。 +- 必须先读后写:先确认调用入口、相关实现、schema/migration、配置、前端调用、测试和权威文档,再修改文件。 +- 使用渐进式阅读文档方法,首先理解这次任务修改范围,然后再阅读涉及到板块的文档。 +- 必须以当前实现为准,不能因为旧 README、日志或设计文本写过某行为就假设代码仍然如此;发现冲突时要在修改前说明并修正文档或实现边界,**目前文档系统和agents.MD系统,均使用嵌套系统,需要进入深层目录修改具体内容** +- 必须保持最小必要改动,禁止无关重构、全局风格清理和为未来场景预埋复杂抽象。 +- 必须保留用户已有的未提交改动;不能使用 git reset --hard、git checkout -- 或其他强制覆盖命令。 +- 禁止删除重要文件、数据库数据、迁移历史、知识库索引或生成产物。确实需要删除时必须先让用户明确处理。 +- 不得输出或提交 .env、密码、API key、Cookie、Token、数据库连接串、用户文件正文。 +- 默认不主动提交、推送、建 PR 或改分支;完成后提供建议的提交信息、批次和 PR/MR 文案,完整功能实现后,输出更改的完整日志(不影响写入日志文件) +- 解释复杂内容时善用可视化 +- 保持简洁直接,区分事实和猜测 +- 基于可靠来源工作,必要时调用相关skills获取官方文档和事实 +- 不偏离用户目标和约束 +- 合理使用子Agent,避免无意义并行 +- 修改代码保持克制,不做无关重构 + +## 文档体系 + +Document/ 是开发者和 AI 使用的当前技术事实库,回答“系统现在是什么、如何工作、为什么这样设计”。每个新建或重构文档的标题后必须立即写出“文档职责”和“适用范围”。 + +- 入口:Document/README.md +- 架构:Document/architecture/ +- API:Document/api/ +- 数据库:Document/database/ +- 开发与维护:Document/development/ +- 管理员模块:Document/admin/ + +AGENTS.md 只写“修改时必须怎么做”的执行约束,不复制完整系统说明。当前目录的局部规则如下,修改对应模块时必须阅读: + +- Database/AGENTS.md:migration、主从、checkpoint、monitor 和危险数据库操作。 +- app/agent/AGENTS.md:Job、幂等、SSE 脱敏、checkpoint 和 worker。 +- Agent/AGENTS.md:LangGraph、结构化输出、MCP、RAG 和因果工具。 +- admin-frontend/AGENTS.md:TypeScript、管理员 API、构建和浏览器验证。 + +## 跨模块不可省略的约束 + +- 修改 Job 或文件流程时,必须核对 Session 真实存在/归属、analysis_job_inputs 输入账本、文件快照、active Job 唯一约束、worker lease/fencing、SSE Last-Event-ID 和 checkpoint cleanup outbox。 +- 修改数据库 schema、读写路径或 Compose 时,必须分别核对 migration、check_database_readiness()、strong/eventual read、主从回退、PostgreSQL checkpoint 和 Docker 服务依赖。 +- 修改 Agent、MCP、RAG 或 worker 初始化时,必须核对 worker runtime/bootstrap、slot 资源、显式 State 路由、结构化输出配置、工具失败路径和公共事件适配器。 +- 修改管理员后端或前端时,必须核对实时主库授权、CSRF、request ID、分页上限、敏感读取审计、受控写入幂等和 401/403/empty/error 状态。 +- 修改目录、启动方式、schema 或部署事实时,必须同步检查唯一权威 Document/ 页面和局部 AGENTS.md。 +- 测试的时候需要阅读 tests/README.md 相关内容 + +## 验证义务 + +- 任何代码改动都必须运行与风险直接相关的测试;依赖 MySQL/PostgreSQL、主从、Docker、浏览器或真实模型的证据必须分别说明,不能用 unit 测试冒充。 +- 文档改动至少必须检查相对链接指向现存文件、顶部职责声明、失效路径引用、git diff --check、日志 rename 纯度和 git diff -- README.md 为空。 +- 迁移、数据库、管理员 E2E 和 worker 变更的具体命令以 Document/development/testing.md 和局部 AGENTS.md 为准。 +- 如果环境限制导致某项验证未执行,最终答复必须明确未执行内容、原因和残余风险。 + +## 日志与 Git + +- CHANGELOG.md 是根目录追加式开发日志;历史正文禁止改写,只允许在文件末尾追加新记录。 +- 分支名遵循 keyword(function)/dec,提交标题遵循 keyword(function):dec。 diff --git a/Agent/AGENTS.md b/Agent/AGENTS.md new file mode 100644 index 0000000..fcb4803 --- /dev/null +++ b/Agent/AGENTS.md @@ -0,0 +1,25 @@ +# Agent/AGENTS.md + +生效目录:`Agent/` 及其子目录。 + +负责约束的修改类型:LangGraph 图、State/路由、结构化输出、MCP server/tool、RAG、因果分析工具、后处理和报告输出。 + +## 修改前必须阅读 + +- 必须阅读 [`Document/architecture/agent-runtime.md`](../Document/architecture/agent-runtime.md)、[`Document/architecture/job-file-lifecycle.md`](../Document/architecture/job-file-lifecycle.md) 和 [`Document/development/testing.md`](../Document/development/testing.md)。 +- 必须从当前调用者开始检查 worker runtime、graph runner、对应 State、工具注册、错误路径和测试;不能只修改一个节点后假设运行时会自动适配。 +- 修改 MCP、RAG 或 worker 初始化时,必须检查 `app/agent/worker/runtime.py`、`bootstrap.py`、Docker Compose 环境变量和知识库挂载。 + +## Graph、输出与工具规则 + +- 条件路由必须读取显式 State 字段,例如 `route_decision`、`fold_decision`;禁止用展示消息文本猜测控制流。 +- 涉及到修改agent链路的,需要告诉用户修改后的逻辑和目前的逻辑区别。 +- 结构化输出必须使用统一的 `Agent/llm_structured_output.py` 入口和当前 function calling 约定;修改 thinking、tool choice 或 schema 时必须同步检查调用器和测试。 +- MCP planner 使用原生 Tool Calls +- RAG 启动检查与完整加载是两个阶段;知识库不可用时必须遵守当前无知识库模式,不得在 worker 启动中擅自增加全量加载。 +- 因果工具必须记录输入限制、矩阵方向、边权语义和方法假设;修改 DirectLiNGAM 时必须保持连续数值 CSV 和 `target_to_source` 契约。 + +## 修改后验证 + +- 必须覆盖成功路径、工具不可用、结构化解析失败、超时/异常、路由字段缺失和用户事件脱敏。 +- Agent 测试通过不代表 Job/worker/API 已验证;跨层变更必须追加 `tests/unit/agent/`、`tests/unit/` 或 Docker 验证,并在结果中区分真实模型/MCP 是否运行。 diff --git a/Agent/CausalAgentMCP/mcp_server.py b/Agent/CausalAgentMCP/mcp_server.py index f3affa4..13f1353 100644 --- a/Agent/CausalAgentMCP/mcp_server.py +++ b/Agent/CausalAgentMCP/mcp_server.py @@ -1,39 +1,71 @@ -import os -import logging -import sys +"""因果分析 MCP server;通过 Job 冻结身份读取 MySQL 文件正文。""" -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) # .../Agent/CausalAgentMCP -AGENT_DIR = os.path.dirname(CURRENT_DIR) # .../Agent -PROJECT_ROOT = os.path.dirname(AGENT_DIR) # 项目根目录 +from __future__ import annotations -for p in (PROJECT_ROOT, AGENT_DIR): - if p not in sys.path: - sys.path.insert(0, p) +import logging +import os +import sys +from typing import Any +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +AGENT_DIR = os.path.dirname(CURRENT_DIR) +PROJECT_ROOT = os.path.dirname(AGENT_DIR) +for path in (PROJECT_ROOT, AGENT_DIR): + if path not in sys.path: + sys.path.insert(0, path) from mcp.server.fastmcp import FastMCP + from Agent.causal.causalachieve import ( run_direct_lingam_analysis, run_olc_analysis, run_pc_analysis, ) +from Database.agent_connect import require_frozen_file_for_job -log_file_path = os.path.join(CURRENT_DIR, 'mcp_server.log') +log_file_path = os.path.join(CURRENT_DIR, "mcp_server.log") logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(log_file_path, encoding='utf-8'), - ] + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler(log_file_path, encoding="utf-8")], ) -logging.info("MCP Server Script Started, Logging Initialized") - mcp = FastMCP("causal-analyzer") + +def _load_csv( + user_id: int, + job_id: str, + input_user_file_id: int, + input_object_id: int, +) -> str: + """按可信运行时身份读取 Job BLOB,仅在 MCP 进程内存中解码。""" + file_row = require_frozen_file_for_job( + user_id, + job_id, + input_user_file_id, + input_object_id, + ) + return file_row["file_content"].decode("utf-8") + + +def _error_result(message: str, error_type: str) -> dict[str, Any]: + """构造不包含文件正文或异常原文的 MCP 失败结果。""" + return { + "success": False, + "message": message, + "error_type": error_type, + } + + @mcp.tool() -async def causal_pc(csv_data: str) -> dict: +async def causal_pc( + user_id: int, + job_id: str, + input_user_file_id: int, + input_object_id: int, +) -> dict: """ 使用PC算法对CSV数据执行因果发现分析。 @@ -50,31 +82,37 @@ async def causal_pc(csv_data: str) -> dict: - 仅提供条件独立性信息的场景 - 使用快速邻接搜索优化的大规模问题 - 这是一个纯计算工具,不执行任何数据库或文件系统操作。 Args: - csv_data: 一个包含完整CSV文件内容的字符串。 + user_id: 由 Agent runtime 注入的当前用户 ID。 + job_id: 由 Agent runtime 注入的当前分析任务 ID。 + input_user_file_id: Job 创建时冻结的逻辑文件 ID。 + input_object_id: Job 创建时冻结的不可变文件对象 ID。 + + 文件正文由工具按 Job 冻结身份从文件库读取,模型不得传入 csv_data。 Returns: 一个包含分析结果的结构化字典,包括因果图结构和边的方向信息。 """ - logging.info(f"工具 'causal_pc' 已被调用,输入数据长度: {len(csv_data)}。") + try: - # 工具的核心职责:执行分析 - analysis_result = run_pc_analysis(csv_data) - - return analysis_result - - except Exception as e: - logging.error(f"'causal_pc' 工具执行出错: {e}", exc_info=True) - return { - "success": False, - "message": f"执行分析时发生内部错误: {e}", - "error_type": type(e).__name__, - } + csv_data = _load_csv(user_id, job_id, input_user_file_id, input_object_id) + logging.info("工具 causal_pc 读取文件字节数=%s", len(csv_data.encode("utf-8"))) + return run_pc_analysis(csv_data) + except FileNotFoundError: + return _error_result("任务文件不可用", "FrozenFileNotFound") + except Exception: + logging.error("causal_pc 执行失败", exc_info=True) + return _error_result("执行分析时发生内部错误", "AnalysisError") + @mcp.tool() -async def causal_olc(csv_data: str) -> dict: +async def causal_olc( + user_id: int, + job_id: str, + input_user_file_id: int, + input_object_id: int, +) -> dict: """ 使用OLC算法对CSV数据执行因果发现分析,专门处理存在隐藏混杂因素的场景。 @@ -91,53 +129,65 @@ async def causal_olc(csv_data: str) -> dict: - 非加性噪声模型 - 非常小的样本量(<200个样本) - 这是一个纯计算工具,不执行任何数据库或文件系统操作。 - Args: - csv_data: 一个包含完整CSV文件内容的字符串,数据应为连续值变量。 + user_id: 由 Agent runtime 注入的当前用户 ID。 + job_id: 由 Agent runtime 注入的当前分析任务 ID。 + input_user_file_id: Job 创建时冻结的逻辑文件 ID。 + input_object_id: Job 创建时冻结的不可变文件对象 ID。 + + 文件正文由工具按 Job 冻结身份从文件库读取,模型不得传入 csv_data。 Returns: 一个包含分析结果的结构化字典,包括因果图结构和潜在混杂因素信息。 """ - logging.info(f"工具 'causal_olc' 已被调用,输入数据长度: {len(csv_data)}。") try: - # 工具的核心职责:执行分析 - analysis_result = run_olc_analysis(csv_data) - - return analysis_result - except Exception as e: - logging.error(f"'causal_olc' 工具执行出错: {e}", exc_info=True) - return { - "success": False, - "message": f"执行分析时发生内部错误: {e}", - "error_type": type(e).__name__, - } + csv_data = _load_csv(user_id, job_id, input_user_file_id, input_object_id) + logging.info("工具 causal_olc 读取文件字节数=%s", len(csv_data.encode("utf-8"))) + return run_olc_analysis(csv_data) + except FileNotFoundError: + return _error_result("任务文件不可用", "FrozenFileNotFound") + except Exception: + logging.error("causal_olc 执行失败", exc_info=True) + return _error_result("执行分析时发生内部错误", "AnalysisError") @mcp.tool() -async def causal_direct_lingam(csv_data: str) -> dict: - """使用 DirectLiNGAM 对连续数值 CSV 数据执行因果发现分析。""" - logging.info( - "工具 'causal_direct_lingam' 已被调用,输入数据长度: %s。", - len(csv_data), - ) +async def causal_direct_lingam( + user_id: int, + job_id: str, + input_user_file_id: int, + input_object_id: int, +) -> dict: + """使用 DirectLiNGAM 对连续数值 CSV 数据执行因果发现分析。 + + 适用于线性、非高斯、误差独立且无潜在混杂的连续数值变量;文件正文由 + 工具按 Job 冻结身份读取,模型不得传入 csv_data。 + + Args: + user_id: 由 Agent runtime 注入的当前用户 ID。 + job_id: 由 Agent runtime 注入的当前分析任务 ID。 + input_user_file_id: Job 创建时冻结的逻辑文件 ID。 + input_object_id: Job 创建时冻结的不可变文件对象 ID。 + """ try: + csv_data = _load_csv(user_id, job_id, input_user_file_id, input_object_id) + logging.info( + "工具 causal_direct_lingam 读取文件字节数=%s", + len(csv_data.encode("utf-8")), + ) return run_direct_lingam_analysis(csv_data) - except Exception as exc: - logging.error("'causal_direct_lingam' 工具执行出错: %s", exc, exc_info=True) - return { - "success": False, - "algorithm": "direct_lingam", - "message": f"执行 DirectLiNGAM 分析时发生内部错误: {exc}", - "error_type": type(exc).__name__, - } + except FileNotFoundError: + return _error_result("任务文件不可用", "FrozenFileNotFound") + except Exception: + logging.error("causal_direct_lingam 执行失败", exc_info=True) + return _error_result("执行 DirectLiNGAM 分析时发生内部错误", "AnalysisError") + if __name__ == "__main__": logging.info("MCP 因果分析服务器启动") - try: - mcp.run(transport='stdio') - except Exception as e: - logging.error(f"MCP 服务器运行时出现致命错误: {e}", exc_info=True) + mcp.run(transport="stdio") + except Exception: + logging.error("MCP 服务器运行时出现致命错误", exc_info=True) finally: - logging.info("MCP 因果分析服务器关闭") + logging.info("MCP 服务器关闭") diff --git a/Agent/causal_agent/edges.py b/Agent/causal_agent/edges.py index c5fd091..4c3b79a 100644 --- a/Agent/causal_agent/edges.py +++ b/Agent/causal_agent/edges.py @@ -3,7 +3,7 @@ ROUTE_DECISIONS = {"fold", "postprocess", "normal_chat", "inquiry_answer"} -FOLD_DECISIONS = {"preprocess", "agent"} +FOLD_DECISIONS = {"preprocess", "agent", "normal_chat"} def decision_router(state: CausalAgentState) -> str: """ diff --git a/Agent/causal_agent/fault_tolerance.py b/Agent/causal_agent/fault_tolerance.py index 254be65..ebe8068 100644 --- a/Agent/causal_agent/fault_tolerance.py +++ b/Agent/causal_agent/fault_tolerance.py @@ -57,12 +57,22 @@ def timeout(run_timeout: float, idle_timeout: float | None = None) -> TimeoutPol def _error_message(error: NodeError) -> str: - return f"{error.node} 节点执行失败: {error.error}" + """生成不包含异常原文的节点失败摘要。""" + return f"{error.node} 节点执行失败" def sanitize_error(exc: BaseException) -> str: - """Return a display-safe error string without exposing implementation detail.""" - return str(exc) or exc.__class__.__name__ + """把异常归类为有限的公开错误,避免泄露路径、连接串或文件正文。""" + normalized = str(exc).lower() + if "timeout" in normalized: + return "调用超时" + if any(token in normalized for token in ("connection", "connect", "network")): + return "服务连接失败" + if any(token in normalized for token in ("permission", "auth")): + return "服务授权失败" + if any(token in normalized for token in ("rate", "limit")): + return "服务当前繁忙" + return "节点执行失败" def route_to_normal_chat(state: CausalAgentState, error: NodeError) -> Command: diff --git a/Agent/causal_agent/graph.py b/Agent/causal_agent/graph.py index e99f5b1..9d31628 100644 --- a/Agent/causal_agent/graph.py +++ b/Agent/causal_agent/graph.py @@ -116,7 +116,8 @@ def build_graph(llm: "ChatOpenAI", mcp_tools: list, rag_tools: list, checkpointe edges.fold_router,#由fold_router函数决定路由 { "preprocess": "preprocess", - "agent": "agent" + "agent": "agent", + "normal_chat": "normal_chat", } ) diff --git a/Agent/causal_agent/nodes.py b/Agent/causal_agent/nodes.py index 8dd92db..bcf8472 100644 --- a/Agent/causal_agent/nodes.py +++ b/Agent/causal_agent/nodes.py @@ -31,7 +31,22 @@ from .back_prompt import causal_report_prompt # 数据库 -from Database.agent_connect import get_file_content, get_recent_file +from Database.agent_connect import require_frozen_file_for_job + + +def resume_value_to_message_content(value: Any) -> str: + """把 interrupt 恢复值转成消息可接受的文本,保留原值供节点逻辑继续使用。""" + if isinstance(value, str): + return value + try: + return json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + return str(value) def llm_prompt_messages(messages: list[BaseMessage]) -> list[BaseMessage]: @@ -175,11 +190,7 @@ async def agent_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: return {"messages": [response_message], "route_decision": route_decision} class foldQuery(BaseModel): - """从用户对话中提取文件名及因果分析所需的关键参数。""" - filename: Optional[str] = Field( - None, - description="从用户对话中识别出的要分析的数据文件名 (e.g., 'data.csv')。如果未明确提及,则留空。" - ) + """只从用户对话中提取因果分析所需的关键参数。""" target: Optional[str] = Field( None, description="从用户对话中识别出的目标变量(target)或结果变量(outcome)。如果未提及,则留空。" @@ -195,6 +206,11 @@ class foldQuery(BaseModel): from Agent.Processing.data_visualize import generate_visualizations +FILE_LOAD_INTERRUPT_MESSAGE = ( + "当前任务冻结的 CSV 文件无法读取或解析。请取消任务,重新上传可用的 CSV 文件后再试。" +) + + def _normalize_optional_llm_text(value: str | None) -> str | None: """将 LLM 可空文本中的明确缺失哨兵转换为 None。""" if value is None: @@ -208,34 +224,44 @@ def _normalize_optional_llm_text(value: str | None) -> str | None: async def fold_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: """ 文件加载、解析与验证节点。 - 1. 使用LLM从对话中一次性提取文件名、目标和处理变量。 - 2. 从数据库加载文件内容。 + 1. 使用LLM从对话中提取目标和处理变量。 + 2. 按 Job 冻结的对象 ID 从数据库加载文件内容。 3. 运行 get_data_summary 进行全面的数据分析。 4. 调用 validate_analysis 进行严格的条件验证。 5. 根据验证结果,决策进入 'preprocess' 节点或 'ask_human' 节点。 """ logging.info(" 步骤: 文件加载、解析与验证节点 ") user_id = state.get("user_id") + file_summary = state.get("file_summary") or {} + input_user_file_id = file_summary.get("user_file_id") + input_object_id = file_summary.get("object_id") - # 1. 使用LLM一次性提取文件名和分析意图 + if not input_user_file_id or not input_object_id: + return { + "messages": [ + AIMessage( + content="请先上传并选择一个 CSV 文件,再开始因果分析。", + name="fold", + ) + ], + "fold_decision": "normal_chat", + } + + # 1. 使用 LLM 提取分析参数,不从自然语言选择文件 prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个智能助手,你的任务是从用户的最新消息中识别出以下信息,并以JSON格式返回: - 1. 用户想要分析的文件名 (通常以 `.csv` 结尾)。 - 2. 用户关心的目标变量 (target/outcome)。 - 3. 用户想要评估效果的处理变量 (treatment/intervention)。 + 1. 用户关心的目标变量 (target/outcome)。 + 2. 用户想要评估效果的处理变量 (treatment/intervention)。 - - 如果用户明确提到了文件名,请提取它。 - - 如果用户只是说"分析数据"或"用最新的文件",没有指定具体名称,请将 `filename` 字段设为 null(不要使用字符串 "None")。 - - 如果用户提到了目标或处理变量,请提取它们。如果没提,请设为 null(不要使用字符串 "None")。 + 文件已经由当前 Job 的冻结输入确定,严禁从自然语言选择文件。 + 如果用户没有提到目标或处理变量,请将对应字段设为 null。 示例: - - 用户: "用 `marketing_campaign.csv` 帮我分析一下'销售额'和'促销活动'的关系..." - -> 提取: `filename='marketing_campaign.csv'`, `target='销售额'`, `treatment='促销活动'` + - 用户: "帮我分析销售额和促销活动的关系..." + -> 提取: `target='销售额'`, `treatment='促销活动'` - 用户: "分析一下我的数据,看看是什么影响了客户流失" - -> 提取: `filename=null`, `target='客户流失'`, `treatment=null` - - 用户: "帮我跑一下最新的数据" - -> 提取: `filename=null`, `target=null`, `treatment=null` + -> 提取: `target='客户流失'`, `treatment=null` 你必须严格按照 `foldQuery` 的 schema 返回一个 JSON 对象。 **绝对不要**在你的回复中包含任何Markdown格式或解释性文字。 @@ -243,14 +269,12 @@ async def fold_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: 示例输出(所有字段都有值): {{ - "filename": "marketing_campaign.csv", "target": "销售额", "treatment": "促销活动" }} 示例输出(部分字段为空): {{ - "filename": null, "target": "客户流失", "treatment": null }} @@ -267,56 +291,48 @@ async def fold_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: node_name="fold", ) - filename = _normalize_optional_llm_text(structured_response.filename) target = _normalize_optional_llm_text(structured_response.target) treatment = _normalize_optional_llm_text(structured_response.treatment) - except StructuredOutputError as e: - logging.error(f"无法从LLM响应中解析或验证提取信息: {e}。将返回错误值") - filename = None + except StructuredOutputError: + logging.error("无法从 LLM 响应中解析或验证 fold 参数,将返回空值") target = None treatment = None - - loaded_filename = None - - logging.info(f"filename: {filename}, state.get('fold_name'): {state.get('fold_name')}") - try: - if filename : - file_content_bytes = await asyncio.to_thread(get_file_content, user_id, filename) - state['fold_name'] = filename - - # 注意这里的文件名后续并没有用到 - loaded_filename = filename - elif state.get('fold_name'): - loaded_filename = state.get('fold_name') - file_content_bytes = await asyncio.to_thread(get_file_content, user_id, loaded_filename) - else: - file_content_bytes , loaded_filename = await asyncio.to_thread(get_recent_file, user_id) - - if not file_content_bytes or not loaded_filename: - raise FileNotFoundError("找不到任何可供分析的文件。请先上传一个CSV文件。") - - state['fold_name'] = loaded_filename - file_content_str = file_content_bytes.decode('utf-8') + try: + file_row = await asyncio.to_thread( + require_frozen_file_for_job, + user_id, + state.get("job_id", ""), + input_user_file_id, + input_object_id, + ) + file_content_str = file_row["file_content"].decode("utf-8") df = await asyncio.to_thread(pd.read_csv, io.StringIO(file_content_str)) data_summary = await asyncio.to_thread(get_data_summary, df) - except Exception as e: - error_msg = f"在文件加载或解析阶段发生错误: {e}" - logging.error(error_msg, exc_info=True) + except Exception as exc: + logging.error( + "文件加载或解析阶段失败: error_type=%s", + type(exc).__name__, + ) # 使用 interrupt() 暂停并等待用户输入 - user_response = interrupt(error_msg) - new_message = HumanMessage(content=user_response) + user_response = interrupt(FILE_LOAD_INTERRUPT_MESSAGE) + new_message = HumanMessage(content=resume_value_to_message_content(user_response)) return {"messages": [new_message], "fold_decision": "agent"} - ## 优化:只保存 file_content 和摘要,不保存 DataFrame - # 原因:DataFrame 序列化体积大,file_content 可随时重新生成 DataFrame - state['file_content'] = file_content_str - # state['dataframe'] = df # 避免序列化开销 state['analysis_parameters'] = data_summary + file_summary = { + "user_file_id": input_user_file_id, + "object_id": input_object_id, + "file_hash": file_summary.get("file_hash"), + "filename": file_summary.get("filename"), + "rows": data_summary.get("n_rows"), + "columns": data_summary.get("columns", []), + } + state["file_summary"] = file_summary # 运行确定性验证 is_ready, issues, recommends = await asyncio.to_thread( @@ -343,8 +359,7 @@ async def fold_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: return {"messages": new_messages, "analysis_parameters": state['analysis_parameters'], - "fold_name": state['fold_name'], - "file_content": state['file_content'], + "file_summary": file_summary, "tool_call_request": False, "fold_decision": "preprocess", } @@ -380,12 +395,11 @@ async def fold_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: # 让 fold_router 知道需要回到 agent 重新判断 return { "messages": [ - HumanMessage(content=user_response), + HumanMessage(content=resume_value_to_message_content(user_response)), AIMessage(content="决策:已收到用户输入,返回 agent 重新判断", name="fold") ], - "fold_name": state['fold_name'], - "file_content": state['file_content'], "analysis_parameters": state['analysis_parameters'], + "file_summary": state.get("file_summary"), "fold_decision": "agent", } @@ -401,22 +415,33 @@ async def preprocess_node(state: CausalAgentState, llm: ChatOpenAI) -> dict: """ logging.info(" 步骤: 数据预处理与分析节点 ") - # 从 file_content 动态生成 DataFrame - file_content = state.get("file_content") analysis_parameters = state.get("analysis_parameters", {}) - if file_content is None or not analysis_parameters: + file_summary = state.get("file_summary") or {} + input_user_file_id = file_summary.get("user_file_id") + input_object_id = file_summary.get("object_id") + + if ( + not analysis_parameters + or not input_user_file_id + or not input_object_id + ): error_msg = "无法执行预处理,因为数据或其摘要信息在状态中丢失。" logging.error(error_msg) - - user_response = interrupt(error_msg) - new_message = HumanMessage(content=user_response) - - return {"messages": [new_message]} - - # 动态生成 DataFrame(从 file_content) - df = await asyncio.to_thread(pd.read_csv, io.StringIO(file_content)) - logging.info(f"从 file_content 重新生成 DataFrame,shape: {df.shape}") + raise RuntimeError(error_msg) + + file_row = await asyncio.to_thread( + require_frozen_file_for_job, + state.get("user_id"), + state.get("job_id", ""), + input_user_file_id, + input_object_id, + ) + df = await asyncio.to_thread( + pd.read_csv, + io.BytesIO(file_row["file_content"]), + ) + logging.info("已从 Job 冻结文件重新生成 DataFrame,shape=%s", df.shape) # 生成可视化图表 visualizations = {} diff --git a/Agent/causal_agent/state.py b/Agent/causal_agent/state.py index ca88736..c4fce07 100644 --- a/Agent/causal_agent/state.py +++ b/Agent/causal_agent/state.py @@ -4,6 +4,17 @@ from langchain_core.messages import BaseMessage +class FileSummary(TypedDict, total=False): + """当前 Job 的冻结文件元数据和受限数据摘要。""" + + user_file_id: Optional[int] + object_id: Optional[int] + file_hash: Optional[str] + filename: Optional[str] + rows: Optional[int] + columns: List[str] + + class CausalAgentState(TypedDict): """ Represents the state of our graph. This TypedDict acts as the "memory" @@ -16,7 +27,7 @@ class CausalAgentState(TypedDict): session_id: The ID of the current chat session. tool_call_request: Whether downstream nodes should continue the tool flow. analysis_parameters: 数据摘要及分析参数。 - file_content: 数据源文件内容字符串。 + file_summary: 文件的有限数据摘要。 causal_analysis_result: 因果分析任务结果。 knowledge_base_result: 结构化RAG结果,包含问题、证据链和汇总摘要。 preprocess_summary: 预处理阶段的自然语言总结。 @@ -31,17 +42,17 @@ class CausalAgentState(TypedDict): username: str user_id: int session_id: str - fold_name: str + job_id: NotRequired[str] + file_summary: NotRequired[Optional[FileSummary]] route_decision: NotRequired[ Literal["fold", "postprocess", "normal_chat", "inquiry_answer"] ] - fold_decision: NotRequired[Literal["preprocess", "agent"]] + fold_decision: NotRequired[Literal["preprocess", "agent", "normal_chat"]] tool_call_request: Optional[bool] analysis_parameters: Optional[dict] - file_content: Optional[str] causal_analysis_result: Optional[dict] knowledge_base_result: Optional[Dict[str, Any]] diff --git a/Agent/tool_node/mcp_tool_call_adapter.py b/Agent/tool_node/mcp_tool_call_adapter.py index 7274079..5f7762b 100644 --- a/Agent/tool_node/mcp_tool_call_adapter.py +++ b/Agent/tool_node/mcp_tool_call_adapter.py @@ -56,18 +56,24 @@ def _inject_mcp_runtime_arguments( state: CausalAgentState, mcp_tools: list, ) -> AIMessage: - """在模型选定 MCP 工具后,只向声明 csv_data 的工具补充运行时数据。""" - file_content = state.get("file_content") - if not file_content: - return ai_message - + """向 MCP 工具注入可信 Job 身份,不把 CSV 正文放入 ToolMessage。""" + file_summary = state.get("file_summary") or {} + runtime_values = { + "user_id": state.get("user_id"), + "job_id": state.get("job_id"), + "input_user_file_id": file_summary.get("user_file_id"), + "input_object_id": file_summary.get("object_id"), + } tool_index = _tools_by_name(mcp_tools) for tool_call in getattr(ai_message, "tool_calls", []) or []: tool = tool_index.get(tool_call.get("name")) - if tool is None or not _tool_accepts_argument(tool, "csv_data"): + if tool is None: continue args = tool_call.setdefault("args", {}) - args["csv_data"] = file_content + args.pop("csv_data", None) + for name, value in runtime_values.items(): + if value is not None and _tool_accepts_argument(tool, name): + args[name] = value return ai_message diff --git "a/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" b/CHANGELOG.md similarity index 91% rename from "README/\345\274\200\345\217\221\346\227\245\345\277\227.md" rename to CHANGELOG.md index 6aa2069..ed66698 100644 --- "a/README/\345\274\200\345\217\221\346\227\245\345\277\227.md" +++ b/CHANGELOG.md @@ -556,3 +556,32 @@ - 新增显式 `ProcessRuntime` 与 `SlotRuntime`:LLM 在进程级创建,MCP session、tools 与 graph 在 slot 级创建;任务执行不再读取 `app.agent.core.llm` 等模块全局变量。 - `job_service.py` 和 `routes.py` 保持在 worker package 外,继续供 Web、monitor、管理员看板与 worker 共用;管理员任务、checkpoint 和 SSE 数据契约不变。 - 测试改为直接导入新职责模块,并将管理员 checkpoint 约束检查指向实际写入 `job_id` metadata 的 `graph_runner.py`。 + +--- +2026.8.8 +- 【文件库与可恢复 Job】 + - 将文件存储拆为 `file_objects` 不可变 BLOB 和 `user_files` 逻辑文件记录,上传、选择草稿和创建 Job 的职责边界分离;Job 创建时冻结文件对象、hash 和文件名。 + - 新增 `analysis_job_inputs` 输入账本,支持同一 Job 的 interrupt、`waiting_input`、resume、取消和稳定生命周期事件;stale worker 通过 lease epoch fencing,旧 worker 不能覆盖新 worker 的结果。 + - Agent、MCP 和管理员预览/下载按主库事务读取冻结文件并累计访问次数;完整 CSV 不进入 State、ToolMessage、checkpoint、事件、日志或 SSE。 + - 新增 `a1b2c3d4e5f6_add_file_library_and_job_recovery`。该 migration 只面向开发/测试库,直接删除旧 `uploaded_files` 表,不回填旧数据、不提供 fallback、不拒绝旧数据迁移;downgrade 只恢复空旧表结构。 + - 补充迁移静态测试、Job 恢复定向测试和管理员文件访问事务测试;完整 Docker 后端、管理员前端和隔离数据库往返验证在本次执行中单独记录。 +- 【聊天文件附件预览与等待任务提示】 + - 修复 `hidden` 属性被 CSS `display` 覆盖导致的空白文件栏和空白任务栏;无文件、无等待输入时不再占据输入区空间。 + - 文件上传或从文件列表选择后,在输入区内部显示 CSV 文件卡片,包含文件名、类型、大小和清除按钮;输入区随附件卡片自然增高,并覆盖桌面端与 390px 移动端布局。 + - 将按钮文案明确为“取消等待任务”;当前接口只取消 `waiting_input` Job,不中断 `queued`/`running` Job 或正在执行的 LLM/MCP 调用。 + - 完成真实 Flask 页面 DOM 检查、桌面/移动端视觉检查、JavaScript 语法检查和 `git diff --check`。 +- 【修复:Job checkpoint identity 与跨 Job 聊天历史】 + - LangGraph 根图统一使用 `analysis_jobs.job_id` 作为 `thread_id`,根 `checkpoint_ns` 保持为空;业务 `session_id` 继续作为会话外键和 State 上下文,不再使用 `job:` namespace 或 session-thread identity。 + - 新 Job 按创建事务记录的 `chat_message_id` 从 MySQL 读取同一业务会话的最近消息窗口;同一 Job 的 interrupt/resume 和 stale recovery 继续使用原 checkpoint,当前用户消息不会重复注入。 + - 输入账本按 `initial`/`resume` 区分运行时解析,结构化 resume 值仅作为 `Command(resume=...)` 的原值传入,并在图消息中统一转换为 JSON 文本;PostgreSQL checkpoint 不可用时阻止 stale Job 盲目重放。 + - 会话和管理员用户删除按全部 `analysis_jobs.job_id` 写入 cleanup outbox;管理员 checkpoint/deep audit 按 Job ID 归属,并识别 cleanup outbox 过渡状态。 +- 【聊天页面滚动与文件日期显示修复】 + - 将页面和主聊天容器固定在视口内,长报告、时间线和历史消息只在 `chat-area` 内滚动,输入区保持固定可见。 + - 自动滚动目标改为聊天区域,避免新增报告内容继续推动整个页面滚动。 + - 文件列表时间仅显示 `YYYY-MM-DD`,并补充报告图片、表格和代码块的宽度约束,避免内容超出聊天区域。 + +--- +2026.8.10 +- 【管理员会话正文审计提示前置】 + - 将“读取正文将会记录管理员、目标、结果和 request ID。”从会话正文弹窗移至会话详情的消息摘要标题下方,使管理员在读取正文前即可看到提示。 + - 保留分析任务等其他敏感正文弹窗的默认审计提示,并补充组件与 Mock E2E 覆盖。 diff --git a/Database/AGENTS.md b/Database/AGENTS.md new file mode 100644 index 0000000..fb9646f --- /dev/null +++ b/Database/AGENTS.md @@ -0,0 +1,34 @@ +# Database/AGENTS.md + +生效目录:`Database/` 及其子目录。 + +负责约束的修改类型:Alembic migration、bootstrap、MySQL 主从连接、PostgreSQL checkpoint、monitor、cleanup worker、数据库审计和任何可能改变数据/结构的操作。 + +## 修改前必须核对 + +- 必须阅读 [`Document/database/overview.md`](../Document/database/overview.md)、[`consistency.md`](../Document/database/consistency.md)、[`migrations-checkpoints.md`](../Document/database/migrations-checkpoints.md) 和 [`monitoring.md`](../Document/database/monitoring.md)。 +- 必须检查当前 `alembic heads`、相关 migration 的 `revision/down_revision`、`Database/bootstrap.py`、`app/db.py`、当前 Compose 拓扑和对应测试。 +- 修改表结构时必须检查所有 SQL 读写、`check_database_readiness()`、管理员/Job/File 服务和测试种子;不能只检查 migration 文件。 + +## Schema 与迁移规则 + +- 业务表结构必须通过 `Database/migrations/versions/` 维护;禁止把业务建表逻辑重新放入 `database_init.py`。 +- 禁止修改已形成事实的历史 migration 来掩盖旧库问题;需要兼容旧库时必须新增 migration,并明确升级、降级和数据风险。 +- 破坏性迁移必须明确说明是否删除数据、是否回填、downgrade 是否只恢复空结构;禁止静默删除、隐式 fallback 或在 migration 中调用自动修复 CLI,由修改任务定义 +- 全新空库必须直接走 bootstrap;`audit_before_db_upgrade.py` 只用于满足其前置条件的旧库外键升级 preflight。 +- 修改迁移 head、Compose 启动顺序或数据库初始化方式时,必须同步检查 `Document/` 和根 `AGENTS.md`;本轮文档重构不得修改根 `README.md`。 + +## 一致性与危险操作 + +- 必须按 strong/eventual 语义选择读连接;授权、Job 队列/事件、所有权校验、删除和计数更新不得为了“读写分离”改走未确认健康的副本。 +- 复制状态异常或延迟超阈值只能回退主库,禁止在应用内擅自实现自动切主。 +- MySQL 与 PostgreSQL 之间禁止伪造分布式事务;业务删除必须和 cleanup outbox 在同一 MySQL 事务落盘,再由 cleanup worker 异步执行 checkpoint 删除。 +- 禁止在 SQL、日志、快照或 API 中输出密码、账号、host、连接串、grants、文件 BLOB 或原始 `last_error`。 +- 删除表、数据卷、历史 migration、checkpoint 或用户数据属于高风险操作;执行前必须有明确范围和隔离环境验证,不能用 `down -v` 代替数据管理。 + +## 修改后验证 + +- 必须运行与变更直接相关的 unit/integration 测试;迁移变更至少检查 `alembic heads`、空库 upgrade、指定目标 downgrade/upgrade 和 `git diff --check`。 +- checkpoint、主从或 monitor 变更必须在 Docker 拓扑中验证;unit 结果不能替代真实数据库证据。 +- 必须检查快照的 `source_role/source_alias`、`observed_at`、estimate/warning 语义和脱敏边界。 +- 不能验证真实数据库时,必须在最终说明中明确未验证的迁移、主从、checkpoint 或容量风险。 diff --git a/Database/agent_connect.py b/Database/agent_connect.py index 72f3e41..42087bb 100644 --- a/Database/agent_connect.py +++ b/Database/agent_connect.py @@ -1,78 +1,101 @@ -''' -node节点中的数据库交互 -''' -import mysql.connector +"""Agent 与 MySQL 文件库之间的冻结输入访问边界。""" + +from __future__ import annotations + import logging +from typing import Any + +import mysql.connector + from app.db import get_write_connection -# 这些函数帮助节点与应用程序的数据库进行交互,以获取文件等资源。 -def get_file_content(user_id: int, filename: str) -> bytes | None: - """读取指定文件正文,并在同一主库事务中更新真实使用次数。""" +class FrozenFileNotFoundError(FileNotFoundError): + """表示 Job 的冻结文件快照不存在或归属校验失败。""" + + +def get_frozen_file_for_job( + user_id: int, + job_id: str, + input_user_file_id: int | None, + input_object_id: int | None, +) -> dict[str, Any] | None: + """按 Job 冻结的两个对象 ID 读取文件,并原子记录一次真实访问。""" + if not input_user_file_id or not input_object_id: + return None + try: - with get_write_connection() as conn: - cursor = conn.cursor(dictionary=True) + with get_write_connection() as connection: + cursor = connection.cursor(dictionary=True) cursor.execute( """ - SELECT id, file_content - FROM uploaded_files - WHERE user_id = %s AND original_filename = %s - ORDER BY last_accessed_at DESC - LIMIT 1 + SELECT uf.id AS user_file_id, + fo.id AS object_id, + fo.content_hash AS file_hash, + fo.file_content, + uf.filename, + uf.mime_type, + uf.file_size + FROM analysis_jobs AS j + JOIN user_files AS uf + ON uf.id = j.input_user_file_id + AND uf.user_id = j.user_id + JOIN file_objects AS fo + ON fo.id = j.input_object_id + AND fo.owner_user_id = j.user_id + AND fo.id = uf.object_id + WHERE j.job_id = %s + AND j.user_id = %s + AND j.input_user_file_id = %s + AND j.input_object_id = %s FOR UPDATE """, - (user_id, filename) + (job_id, user_id, input_user_file_id, input_object_id), ) - result = cursor.fetchone() - if not result: - conn.rollback() + row = cursor.fetchone() + if not row: + connection.rollback() return None + cursor.execute( """ - UPDATE uploaded_files + UPDATE user_files SET last_accessed_at = UTC_TIMESTAMP(6), access_count = access_count + 1 - WHERE id = %s + WHERE id = %s AND user_id = %s """, - (result["id"],), + (row["user_file_id"], user_id), ) - conn.commit() - return result["file_content"] - except mysql.connector.Error as e: - logging.error(f"Agent Node: 从数据库获取文件 '{filename}' (用户ID: {user_id}) 时出错: {e}") - return None + if cursor.rowcount != 1: + connection.rollback() + return None + connection.commit() + return row + except mysql.connector.Error: + logging.error( + "Agent 读取冻结文件失败: user_id=%s job_id=%s user_file_id=%s object_id=%s", + user_id, + job_id, + input_user_file_id, + input_object_id, + exc_info=True, + ) + raise -def get_recent_file(user_id: int) -> tuple[bytes | None, str | None]: - """读取最近文件正文和名称,并原子更新真实使用次数。""" - try: - with get_write_connection() as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT id, file_content, original_filename - FROM uploaded_files - WHERE user_id = %s - ORDER BY last_accessed_at DESC - LIMIT 1 - FOR UPDATE - """, - (user_id,) - ) - result = cursor.fetchone() - if result: - cursor.execute( - """ - UPDATE uploaded_files - SET last_accessed_at = UTC_TIMESTAMP(6), - access_count = access_count + 1 - WHERE id = %s - """, - (result["id"],), - ) - conn.commit() - return result["file_content"], result["original_filename"] - conn.rollback() - return None, None - except mysql.connector.Error as e: - logging.error(f"Agent Node: 为用户 {user_id} 获取最近文件时出错: {e}") - return None, None + +def require_frozen_file_for_job( + user_id: int, + job_id: str, + input_user_file_id: int | None, + input_object_id: int | None, +) -> dict[str, Any]: + """读取并返回 Job 冻结文件;缺失时抛出不含文件正文的明确错误。""" + file_row = get_frozen_file_for_job( + user_id, + job_id, + input_user_file_id, + input_object_id, + ) + if not file_row: + raise FrozenFileNotFoundError("任务冻结的文件不存在或归属关系无效") + return file_row diff --git a/Database/checkpoint_cleanup_worker.py b/Database/checkpoint_cleanup_worker.py index 4678ed6..c91525a 100644 --- a/Database/checkpoint_cleanup_worker.py +++ b/Database/checkpoint_cleanup_worker.py @@ -119,15 +119,15 @@ async def heartbeat_loop() -> None: await asyncio.to_thread(publish_runtime, force=True) try: logging.info( - "[checkpoint-cleanup] deleting thread=%s attempt=%s", - item["thread_id"], + "[checkpoint-cleanup] deleting job=%s attempt=%s", + item["job_id"], item["attempts"], ) - await saver.adelete_thread(item["thread_id"]) + await saver.adelete_thread(item["job_id"]) except Exception as exc: logging.error( - "[checkpoint-cleanup] failed thread=%s: %s", - item["thread_id"], + "[checkpoint-cleanup] failed job=%s: %s", + item["job_id"], exc, exc_info=True, ) diff --git a/Database/deep_audit.py b/Database/deep_audit.py index cff4767..19a4478 100644 --- a/Database/deep_audit.py +++ b/Database/deep_audit.py @@ -33,16 +33,20 @@ }, "chat_messages": { "id", "session_id", "user_id", "message_type", "content", - "has_attachment", "created_at", + "has_attachment", "analysis_job_id", "analysis_job_input_id", + "source_event_id", "created_at", }, "chat_attachments": { "id", "message_id", "attachment_type", "content", "content_size", "created_at", }, - "uploaded_files": { - "id", "user_id", "filename", "original_filename", "mime_type", - "file_size", "file_content", "upload_timestamp", "last_accessed_at", - "access_count", + "file_objects": { + "id", "owner_user_id", "content_hash", "file_size", "mime_type", + "file_content", "created_at", + }, + "user_files": { + "id", "user_id", "object_id", "filename", "mime_type", "file_size", + "uploaded_at", "last_accessed_at", "access_count", }, "archived_sessions": { "id", "user_id", "original_session_data", "message_count", "archived_at", @@ -54,10 +58,21 @@ }, "analysis_jobs": { "id", "job_id", "user_id", "session_id", "status", "worker_id", - "created_at", "active_session_key", "idempotency_key", - "request_fingerprint", + "lease_epoch", "locked_at", "heartbeat_at", "attempt_count", + "recovery_count", "resume_count", "finished_at", "created_at", + "active_session_key", "idempotency_key", "request_fingerprint", + "input_user_file_id", "input_object_id", "input_file_hash", + "input_filename", "current_question_id", "current_waiting_prompt", + "cancel_idempotency_key", "cancel_request_fingerprint", + }, + "analysis_job_events": { + "id", "job_id", "event_type", "event_key", "payload_json", "created_at", + }, + "analysis_job_inputs": { + "input_id", "job_id", "sequence", "input_type", "input_text", + "question_id", "idempotency_key", "request_fingerprint", + "chat_message_id", "created_at", }, - "analysis_job_events": {"id", "job_id", "event_type", "payload_json", "created_at"}, "database_monitor_snapshots": { "snapshot_key", "payload_json", "observed_at", "refresh_requested_at", "updated_at", @@ -86,9 +101,23 @@ "analysis_jobs": { "PRIMARY", "idx_analysis_jobs_admin_created", - "uq_analysis_jobs_user_idempotency", + "idx_analysis_jobs_input_user_file_status", + "uq_analysis_jobs_user_idempotency", "uq_analysis_jobs_cancel_idempotency", + }, + "analysis_job_events": {"PRIMARY", "uq_analysis_job_events_event_key"}, + "analysis_job_inputs": { + "PRIMARY", + "uq_analysis_job_inputs_sequence", + "uq_analysis_job_inputs_idempotency", }, - "uploaded_files": {"PRIMARY", "idx_uploaded_files_admin_uploaded"}, + "file_objects": { + "PRIMARY", "uq_file_objects_owner_hash", "idx_file_objects_owner_created", + }, + "user_files": { + "PRIMARY", "uq_user_files_name_object", "idx_user_files_user_accessed", + "idx_user_files_user_filename", "idx_user_files_object", + }, + "chat_messages": {"PRIMARY", "uq_chat_messages_source_event"}, "admin_audit_events": {"PRIMARY", "idx_admin_audit_target_created"}, "checkpoint_cleanup_outbox": { "PRIMARY", @@ -108,10 +137,15 @@ "fk_chat_messages_session", "fk_chat_messages_user", "fk_chat_attachments_message", - "fk_uploaded_files_user", + "fk_file_objects_owner", + "fk_user_files_user", + "fk_user_files_object", "fk_analysis_jobs_user", "fk_analysis_jobs_session", + "fk_analysis_jobs_input_user_file", + "fk_analysis_jobs_input_object", "fk_analysis_job_events_job", + "fk_analysis_job_inputs_job", "fk_checkpoint_cleanup_outbox_operation", "fk_admin_operations_actor", "fk_admin_operation_items_operation", @@ -395,17 +429,24 @@ def _relationship_check() -> dict[str, Any]: SELECT job_id AS sample_id FROM analysis_jobs WHERE ( - status IN ('queued', 'running') + status IN ('queued', 'running', 'waiting_input') AND ( active_session_key IS NULL OR active_session_key <> CONCAT(user_id, ':', session_id) ) ) OR ( - status NOT IN ('queued', 'running') + status NOT IN ('queued', 'running', 'waiting_input') AND active_session_key IS NOT NULL ) LIMIT %s """, + "waiting_input_with_lease": """ + SELECT job_id AS sample_id + FROM analysis_jobs + WHERE status = 'waiting_input' + AND (worker_id IS NOT NULL OR locked_at IS NOT NULL OR heartbeat_at IS NOT NULL) + LIMIT %s + """, } samples: dict[str, list[str]] = {} with get_read_connection(consistency="strong") as conn: @@ -433,7 +474,7 @@ def _relationship_check() -> dict[str, Any]: def _checkpoint_postgres_checks() -> list[dict[str, Any]]: - """检查 PostgreSQL checkpoint schema、统计与跨库会话关系样本。""" + """检查 PostgreSQL checkpoint schema、统计与 Job 关系样本。""" try: facts = collect_checkpoint_deep_facts( timeout_ms=int(settings.DB_INSPECTION_QUERY_TIMEOUT_MS), @@ -458,8 +499,8 @@ def _checkpoint_postgres_checks() -> list[dict[str, Any]]: "检查失败或查询超时", ), _check( - "checkpoint_session_relationships", - "Checkpoint thread 与 MySQL 会话关系样本", + "checkpoint_job_relationships", + "Checkpoint thread 与 analysis_jobs Job 关系样本", "unknown", "检查失败或查询超时", ), @@ -503,7 +544,8 @@ def _checkpoint_postgres_checks() -> list[dict[str, Any]]: ) thread_ids = facts["thread_ids"] - existing_sessions: set[str] = set() + existing_jobs: set[str] = set() + cleanup_jobs: set[str] = set() try: if thread_ids: placeholders = ", ".join(["%s"] * len(thread_ids)) @@ -512,13 +554,24 @@ def _checkpoint_postgres_checks() -> list[dict[str, Any]]: cursor.execute( f""" SELECT /*+ MAX_EXECUTION_TIME({int(settings.DB_INSPECTION_QUERY_TIMEOUT_MS)}) */ - id - FROM sessions - WHERE id IN ({placeholders}) + job_id + FROM analysis_jobs + WHERE job_id IN ({placeholders}) """, tuple(thread_ids), ) - existing_sessions = {str(row["id"]) for row in cursor.fetchall()} + existing_jobs = {str(row["job_id"]) for row in cursor.fetchall()} + cursor.execute( + f""" + SELECT /*+ MAX_EXECUTION_TIME({int(settings.DB_INSPECTION_QUERY_TIMEOUT_MS)}) */ + thread_id + FROM checkpoint_cleanup_outbox + WHERE thread_id IN ({placeholders}) + AND status <> 'succeeded' + """, + tuple(thread_ids), + ) + cleanup_jobs = {str(row["thread_id"]) for row in cursor.fetchall()} except Exception as exc: LOGGER.warning( "checkpoint thread 跨库关系抽样失败: %s", @@ -529,8 +582,8 @@ def _checkpoint_postgres_checks() -> list[dict[str, Any]]: schema_check, stats_check, _check( - "checkpoint_session_relationships", - "Checkpoint thread 与 MySQL 会话关系样本", + "checkpoint_job_relationships", + "Checkpoint thread 与 analysis_jobs Job 关系样本", "unknown", "检查失败或查询超时", ), @@ -538,24 +591,29 @@ def _checkpoint_postgres_checks() -> list[dict[str, Any]]: samples = [ { "thread_id": thread_id, - "session_exists": thread_id in existing_sessions, + "job_exists": thread_id in existing_jobs, + "cleanup_pending": thread_id in cleanup_jobs, } for thread_id in thread_ids ] - missing_count = sum(1 for item in samples if not item["session_exists"]) + missing_count = sum( + 1 + for item in samples + if not item["job_exists"] and not item["cleanup_pending"] + ) relationship_check = _check( - "checkpoint_session_relationships", - "Checkpoint thread 与 MySQL 会话关系样本", + "checkpoint_job_relationships", + "Checkpoint thread 与 analysis_jobs Job 关系样本", "healthy" if missing_count == 0 else "error", ( - "抽样 thread 均可关联 MySQL 会话" + "抽样 thread 均可关联 analysis_jobs Job 或处于 cleanup outbox 过渡" if missing_count == 0 - else "发现无法关联 MySQL 会话的 checkpoint thread 样本" + else "发现无法关联 analysis_jobs Job 且不在 cleanup outbox 中的 checkpoint thread 样本" ), { "sample_limit": ANOMALY_SAMPLE_LIMIT, "sample_count": len(samples), - "missing_session_count": missing_count, + "missing_job_count": missing_count, "samples": samples, }, ) diff --git a/Database/inspection.py b/Database/inspection.py index bfe187d..a90c302 100644 --- a/Database/inspection.py +++ b/Database/inspection.py @@ -608,7 +608,13 @@ def inspect_slow_queries( ("chat_attachments", "fk_chat_attachments_message", (("message_id", "chat_messages", "id"),)), ("analysis_jobs", "fk_analysis_jobs_user", (("user_id", "users", "id"),)), ("analysis_jobs", "fk_analysis_jobs_session", (("session_id", "sessions", "id"),)), + ("analysis_jobs", "fk_analysis_jobs_input_user_file", (("input_user_file_id", "user_files", "id"),)), + ("analysis_jobs", "fk_analysis_jobs_input_object", (("input_object_id", "file_objects", "id"),)), ("analysis_job_events", "fk_analysis_job_events_job", (("job_id", "analysis_jobs", "job_id"),)), + ("analysis_job_inputs", "fk_analysis_job_inputs_job", (("job_id", "analysis_jobs", "job_id"),)), + ("file_objects", "fk_file_objects_owner", (("owner_user_id", "users", "id"),)), + ("user_files", "fk_user_files_user", (("user_id", "users", "id"),)), + ("user_files", "fk_user_files_object", (("object_id", "file_objects", "id"),)), ( "checkpoint_cleanup_outbox", "fk_checkpoint_cleanup_outbox_operation", @@ -622,6 +628,36 @@ def inspect_slow_queries( "uq_analysis_jobs_user_idempotency", ("user_id", "idempotency_key"), ), + ( + "analysis_jobs", + "uq_analysis_jobs_cancel_idempotency", + ("job_id", "cancel_idempotency_key"), + ), + ( + "user_files", + "uq_user_files_name_object", + ("user_id", "object_id", "filename"), + ), + ( + "analysis_job_inputs", + "uq_analysis_job_inputs_sequence", + ("job_id", "sequence"), + ), + ( + "analysis_job_inputs", + "uq_analysis_job_inputs_idempotency", + ("job_id", "idempotency_key"), + ), + ( + "analysis_job_events", + "uq_analysis_job_events_event_key", + ("job_id", "event_key"), + ), + ( + "chat_messages", + "uq_chat_messages_source_event", + ("source_event_id",), + ), ) diff --git a/Database/migrations/versions/a1b2c3d4e5f6_add_file_library_and_job_recovery.py b/Database/migrations/versions/a1b2c3d4e5f6_add_file_library_and_job_recovery.py new file mode 100644 index 0000000..18256a2 --- /dev/null +++ b/Database/migrations/versions/a1b2c3d4e5f6_add_file_library_and_job_recovery.py @@ -0,0 +1,213 @@ +"""replace legacy files and add resumable analysis-job inputs + +Revision ID: a1b2c3d4e5f6 +Revises: f9a0b1c2d3e4 +Create Date: 2026-08-07 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "f9a0b1c2d3e4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """直接替换测试库旧文件表,并建立冻结输入与可恢复 Job 所需结构。""" + op.execute("DROP TABLE IF EXISTS uploaded_files") + + op.execute( + """ + CREATE TABLE file_objects ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + owner_user_id INT NOT NULL, + content_hash CHAR(64) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_content LONGBLOB NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + CONSTRAINT fk_file_objects_owner + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uq_file_objects_owner_hash (owner_user_id, content_hash), + INDEX idx_file_objects_owner_created (owner_user_id, created_at DESC) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + op.execute( + """ + CREATE TABLE user_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + object_id BIGINT NOT NULL, + filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + uploaded_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + last_accessed_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + access_count INT NOT NULL DEFAULT 0, + CONSTRAINT fk_user_files_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_files_object + FOREIGN KEY (object_id) REFERENCES file_objects(id) ON DELETE RESTRICT, + UNIQUE KEY uq_user_files_name_object (user_id, object_id, filename), + INDEX idx_user_files_user_accessed (user_id, last_accessed_at DESC, id), + INDEX idx_user_files_user_filename (user_id, filename), + INDEX idx_user_files_object (object_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + + op.execute( + """ + ALTER TABLE analysis_jobs + MODIFY status ENUM( + 'queued', 'running', 'waiting_input', 'succeeded', 'failed', 'canceled' + ) NOT NULL DEFAULT 'queued', + ADD COLUMN lease_epoch BIGINT NOT NULL DEFAULT 0 AFTER worker_id, + ADD COLUMN recovery_count INT NOT NULL DEFAULT 0 AFTER attempt_count, + ADD COLUMN resume_count INT NOT NULL DEFAULT 0 AFTER recovery_count, + ADD COLUMN input_user_file_id BIGINT DEFAULT NULL AFTER message, + ADD COLUMN input_object_id BIGINT DEFAULT NULL AFTER input_user_file_id, + ADD COLUMN input_file_hash CHAR(64) DEFAULT NULL AFTER input_object_id, + ADD COLUMN input_filename VARCHAR(255) DEFAULT NULL AFTER input_file_hash, + ADD COLUMN current_question_id VARCHAR(255) DEFAULT NULL AFTER input_filename, + ADD COLUMN current_waiting_prompt MEDIUMTEXT DEFAULT NULL AFTER current_question_id, + ADD COLUMN cancel_idempotency_key CHAR(36) DEFAULT NULL AFTER current_waiting_prompt, + ADD COLUMN cancel_request_fingerprint CHAR(64) DEFAULT NULL AFTER cancel_idempotency_key, + ADD INDEX idx_analysis_jobs_input_user_file_status + (input_user_file_id, status), + ADD UNIQUE KEY uq_analysis_jobs_cancel_idempotency + (job_id, cancel_idempotency_key), + ADD CONSTRAINT fk_analysis_jobs_input_user_file + FOREIGN KEY (input_user_file_id) REFERENCES user_files(id) + ON DELETE SET NULL, + ADD CONSTRAINT fk_analysis_jobs_input_object + FOREIGN KEY (input_object_id) REFERENCES file_objects(id) + ON DELETE SET NULL + """ + ) + + op.execute( + """ + CREATE TABLE analysis_job_inputs ( + input_id BIGINT AUTO_INCREMENT PRIMARY KEY, + job_id VARCHAR(36) NOT NULL, + sequence INT NOT NULL, + input_type ENUM('initial', 'resume') NOT NULL, + input_text MEDIUMTEXT NOT NULL, + question_id VARCHAR(255) DEFAULT NULL, + idempotency_key CHAR(36) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + chat_message_id BIGINT DEFAULT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uq_analysis_job_inputs_sequence (job_id, sequence), + UNIQUE KEY uq_analysis_job_inputs_idempotency (job_id, idempotency_key), + INDEX idx_analysis_job_inputs_job_created (job_id, created_at, input_id), + CONSTRAINT fk_analysis_job_inputs_job + FOREIGN KEY (job_id) REFERENCES analysis_jobs(job_id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + + op.execute( + """ + ALTER TABLE analysis_job_events + ADD COLUMN event_key VARCHAR(255) DEFAULT NULL AFTER event_type, + ADD UNIQUE KEY uq_analysis_job_events_event_key (job_id, event_key) + """ + ) + + # 当前 head 已由 f6b8c9d0e1a2 移除 chat_messages 分区并将主键改为 id。 + # 这里直接增加全局唯一的 source_event_id,不重复移除分区布局。 + op.execute( + """ + ALTER TABLE chat_messages + ADD COLUMN analysis_job_id VARCHAR(36) DEFAULT NULL AFTER user_id, + ADD COLUMN analysis_job_input_id BIGINT DEFAULT NULL AFTER analysis_job_id, + ADD COLUMN source_event_id BIGINT DEFAULT NULL AFTER analysis_job_input_id, + ADD UNIQUE KEY uq_chat_messages_source_event (source_event_id), + ADD INDEX idx_chat_messages_analysis_job (analysis_job_id, created_at), + ADD INDEX idx_chat_messages_analysis_input (analysis_job_input_id) + """ + ) + + +def downgrade() -> None: + """回滚新增结构;旧文件表只恢复空表结构,不恢复被替换的数据。""" + op.execute("DROP TABLE IF EXISTS analysis_job_inputs") + op.execute( + """ + ALTER TABLE chat_messages + DROP INDEX idx_chat_messages_analysis_input, + DROP INDEX idx_chat_messages_analysis_job, + DROP INDEX uq_chat_messages_source_event, + DROP COLUMN source_event_id, + DROP COLUMN analysis_job_input_id, + DROP COLUMN analysis_job_id + """ + ) + op.execute( + """ + ALTER TABLE analysis_job_events + DROP INDEX uq_analysis_job_events_event_key, + DROP COLUMN event_key + """ + ) + op.execute( + """ + ALTER TABLE analysis_jobs + DROP FOREIGN KEY fk_analysis_jobs_input_object, + DROP FOREIGN KEY fk_analysis_jobs_input_user_file, + DROP INDEX idx_analysis_jobs_input_user_file_status, + DROP COLUMN input_filename, + DROP COLUMN input_file_hash, + DROP COLUMN input_object_id, + DROP COLUMN input_user_file_id, + DROP COLUMN current_waiting_prompt, + DROP COLUMN current_question_id, + DROP INDEX uq_analysis_jobs_cancel_idempotency, + DROP COLUMN cancel_request_fingerprint, + DROP COLUMN cancel_idempotency_key, + DROP COLUMN resume_count, + DROP COLUMN recovery_count, + DROP COLUMN lease_epoch, + MODIFY status ENUM( + 'queued', 'running', 'succeeded', 'failed', 'canceled' + ) NOT NULL DEFAULT 'queued' + """ + ) + op.execute("DROP TABLE IF EXISTS user_files") + op.execute("DROP TABLE IF EXISTS file_objects") + op.execute( + """ + CREATE TABLE uploaded_files ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + filename VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + file_hash VARCHAR(64) NOT NULL, + file_content LONGBLOB NOT NULL, + upload_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + access_count INT DEFAULT 0, + CONSTRAINT fk_uploaded_files_user + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_user_hash (user_id, file_hash), + INDEX idx_user_files (user_id, upload_timestamp DESC), + INDEX idx_filename_search (user_id, filename), + INDEX idx_size_cleanup (file_size, last_accessed_at), + INDEX idx_hash_dedup (file_hash), + INDEX idx_uploaded_files_user_accessed (user_id, last_accessed_at DESC), + INDEX idx_uploaded_files_user_filename_accessed + (user_id, original_filename, last_accessed_at DESC), + INDEX idx_uploaded_files_admin_uploaded (upload_timestamp DESC, id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) diff --git a/Document/README.md b/Document/README.md new file mode 100644 index 0000000..d594513 --- /dev/null +++ b/Document/README.md @@ -0,0 +1,52 @@ +# CausalAgent 技术文档 + +文档职责:作为 `Document/` 当前技术事实库的入口,定义各主题的唯一权威归属和阅读路径。 + +适用范围:面向开发者和 AI agent 的系统架构、API、数据库、开发运维与管理员模块说明;执行约束以根目录和局部 `AGENTS.md` 为准。 + +## 使用方式 + +`Document/` 描述系统当前是什么、如何工作以及为什么这样设计。文档中的命令、路由、表名和配置必须以当前代码、迁移、Compose 和测试入口核对后为准;若实现与文档冲突,应先修正文档事实或指出实现偏移,不应把旧文档当作代码依据。 + +执行规则见根目录 [`AGENTS.md`](../AGENTS.md) 以及各实现目录的局部规则文件。文档职责与修改边界见 [`development/documentation.md`](development/documentation.md)。 + +## 文档导航 + +### 架构 + +- [`architecture/overview.md`](architecture/overview.md):进程边界、主要数据流、运行拓扑和组件职责。 +- [`architecture/agent-runtime.md`](architecture/agent-runtime.md):Web、Job worker、LangGraph、MCP、RAG、结构化输出和用户事件流。 +- [`architecture/job-file-lifecycle.md`](architecture/job-file-lifecycle.md):Session、Job、输入账本、文件库、checkpoint 与跨库清理生命周期。 + +### API + +- [`api/conventions.md`](api/conventions.md):鉴权、CSRF、request ID、错误结构、分页、敏感内容和 SSE 通用约定。 +- [`api/agent-jobs.md`](api/agent-jobs.md):分析 Job 创建、恢复、取消、幂等与 SSE。 +- [`api/chat-files.md`](api/chat-files.md):普通用户会话、消息和文件接口。 + +### 数据库 + +- [`database/overview.md`](database/overview.md):MySQL 业务数据、PostgreSQL checkpoint 和跨库边界。 +- [`database/consistency.md`](database/consistency.md):主从读写、一致性级别、账号职责和连接池。 +- [`database/migrations-checkpoints.md`](database/migrations-checkpoints.md):bootstrap、Alembic、checkpoint setup、cleanup outbox 与迁移风险。 +- [`database/monitoring.md`](database/monitoring.md):monitor worker、共享快照、在线配置、quick/deep audit 和 cleanup 运行状态。 + +### 开发 + +- [`development/setup.md`](development/setup.md):本地、Docker 和管理员前端开发入口。 +- [`development/testing.md`](development/testing.md):后端、前端、集成和隔离 E2E 验证矩阵。 +- [`development/deployment.md`](development/deployment.md):镜像构建、开发/生产 Compose 拓扑和发布边界。 +- [`development/documentation.md`](development/documentation.md):文档归属、维护、链接和日志规则。 + +### 管理员模块 + +- [`admin/README.md`](admin/README.md):管理员模块边界与内部索引。 +- [`admin/architecture.md`](admin/architecture.md):Flask 管理员 API、Vue 页面、鉴权和共享能力消费关系。 +- [`admin/api.md`](admin/api.md):管理员页面和 API 的完整契约。 +- [`admin/development.md`](admin/development.md):管理员前端构建、开发入口和发布依赖。 +- [`admin/testing.md`](admin/testing.md):管理员专项单元、Mock E2E 和隔离主从验收。 + +## 归属原则 + +系统级 checkpoint、cleanup worker、MySQL 主从、数据库连接和 monitor 内部机制只在 `database/` 维护;管理员页面如何消费这些能力只在 `admin/` 维护。Job、文件冻结和普通用户 SSE 的业务生命周期只在架构/API 对应页面维护,管理员 API 只引用其消费契约。 + diff --git a/Document/admin/README.md b/Document/admin/README.md index 6a064c2..c650321 100644 --- a/Document/admin/README.md +++ b/Document/admin/README.md @@ -1,11 +1,14 @@ # 管理员系统文档 -本目录集中记录管理员后台的使用契约。项目根 `README.md` 只保留首次部署所需步骤,接口、安全约束和验收细节在这里维护。 +文档职责:作为管理员模块的内部入口,集中记录管理员页面、Flask API、鉴权、受控操作和专项验证的当前事实。 + +适用范围:修改 `app/admin/`、`admin-frontend/` 或管理员与数据库 monitor、Job、文件、checkpoint 交界时使用;系统级能力的内部实现不在本目录重复维护。 ## 文档索引 +- [模块架构](architecture.md):后端、Vue、实时授权以及对共享系统能力的消费关系。 - [API 契约](api.md):页面入口、数据库看板、业务查询、受控写入和审计接口。 -- [开发与部署](development.md):生产构建、本地 Vite 开发、初始管理员和 monitor 进程。 +- [开发与部署](development.md):管理员前端构建、本地 Vite 开发、初始管理员和发布依赖。 - [测试说明](testing.md):后端测试层级、管理员前端测试和隔离主从 E2E。 ## 系统边界 @@ -16,9 +19,10 @@ 普通用户前端和聊天 API/SSE 契约不受管理员后台影响。 -## 深入设计 +## 依赖边界 -- [数据库治理、读写矩阵和恢复流程](../../setting/database_governance.md) -- [阶段三并发设计交接](../../setting/phase3_concurrency_handoff.md) +- 数据库主从、一致性、迁移、checkpoint、cleanup 和 monitor 内部机制见 [`../database/`](../database/overview.md)。 +- Job、文件冻结和普通用户 SSE 生命周期见 [`../architecture/job-file-lifecycle.md`](../architecture/job-file-lifecycle.md) 与 [`../api/agent-jobs.md`](../api/agent-jobs.md)。 +- 管理员如何消费这些能力保留在本目录,不复制其内部实现。 -接口或启动方式变化时,应同步更新本目录、根 `README.md` 和 `AGENTS.md` 中对应的项目事实。 +接口或启动方式变化时,必须先核对实现和对应权威文档,再同步更新本目录、根规则和局部 `AGENTS.md` 中的执行约束;根 `README.md` 不属于本轮文档重构的修改范围。 diff --git a/Document/admin/api.md b/Document/admin/api.md index 71333ed..6655acf 100644 --- a/Document/admin/api.md +++ b/Document/admin/api.md @@ -1,5 +1,9 @@ # 管理员 API 契约 +文档职责:记录 `/api/admin` 管理员 API、`/admin` 页面入口、响应边界和受控业务操作契约。 + +适用范围:修改 `app/admin/routes.py`、管理员 service、鉴权/CSRF 或 Vue API 调用时使用;数据库 monitor、主从和 checkpoint 内部实现见 [`../database/overview.md`](../database/overview.md) 与 [`../database/monitoring.md`](../database/monitoring.md)。 + 管理员 API 统一使用 `/api/admin` 前缀。除特别说明外,接口只允许数据库中 `role = 'admin'` 且 `is_active = TRUE` 的用户访问;后端每次请求都会通过主库强一致读重新确认用户状态,不把浏览器 Session 中的角色缓存作为授权依据。 ## 通用约定 @@ -84,7 +88,7 @@ SQL 性能摘要按 Performance Schema 的单次平均 `AVG_TIMER_WAIT` 降序 | `GET` | `/api/admin/business/files//download` | 下载文件 | | `GET` | `/api/admin/operations/` | 查询受控用户删除及 checkpoint cleanup 状态 | -密码哈希、Cookie、Token、文件哈希、数据库账号、host 和 grants 不进入列表 DTO。任务事件接口只从 MySQL payload 提取节点名、说明和耗时;checkpoint 接口按 `thread_id=session_id` 与 `metadata.job_id` 精确归属任务,默认 20、最多 50 条并按不透明 `checkpoint_id` 游标分页,只返回 ID、父 ID、namespace、时间、step、source 和更新通道。它不读取或返回 checkpoint 状态正文、blob 和 pending writes;历史记录缺少 `job_id` 时仅返回 `legacy_unattributed=true`,不会按时间猜测。PostgreSQL 不可用时返回 `503 checkpoint_unavailable`,任务详情和 MySQL 事件接口仍可用。 +密码哈希、Cookie、Token、文件哈希、数据库账号、host 和 grants 不进入列表 DTO。任务事件接口只从 MySQL payload 提取节点名、说明和耗时;checkpoint 接口按 `thread_id=analysis_jobs.job_id` 与 `metadata.job_id` 精确归属任务,根 namespace 为空,默认 20、最多 50 条并按不透明 `checkpoint_id` 游标分页,只返回 ID、父 ID、namespace、时间、step、source 和更新通道。它不读取或返回 checkpoint 状态正文、blob 和 pending writes;旧 session-thread checkpoint 不迁移、不读取、不清理。历史记录缺少 `job_id` 时不会按时间猜测归属。PostgreSQL 不可用时返回 `503 checkpoint_unavailable`,任务详情和 MySQL 事件接口仍可用。 消息、附件及任务内容最多按 64 KiB 源字节分块读取;成功的敏感读取要求审计可写。CSV 预览最多读取 256 KiB、100 行、50 列,单元格最多 1000 字符,并且只按文本渲染。 @@ -99,4 +103,4 @@ SQL 性能摘要按 Performance Schema 的单次平均 `AVG_TIMER_WAIT` 降序 | `GET` | `/api/admin/business/files//delete-impact` | 预览文件删除影响 | | `DELETE` | `/api/admin/business/files/` | 物理删除文件和 BLOB | -操作者不能禁用、降级或删除自己,也不能移除最后一个启用管理员。角色、状态或密码实际变化会通过 `users.auth_version` 使目标用户旧 Session 失效。用户删除先提交 MySQL 业务数据,PostgreSQL checkpoint 清理由 outbox worker 异步完成;接口可能返回 `202`,可通过操作查询接口读取 `running/succeeded/failed`。物理删除没有回收站,详细删除/保留矩阵见 [数据库治理文档](../../setting/database_governance.md)。 +操作者不能禁用、降级或删除自己,也不能移除最后一个启用管理员。角色、状态或密码实际变化会通过 `users.auth_version` 使目标用户旧 Session 失效。用户删除先提交 MySQL 业务数据,PostgreSQL checkpoint 清理由 outbox worker 异步完成;接口可能返回 `202`,可通过操作查询接口读取 `running/succeeded/failed`。物理删除没有回收站,Job/file 生命周期和跨库清理边界见 [`../architecture/job-file-lifecycle.md`](../architecture/job-file-lifecycle.md) 与 [`../database/migrations-checkpoints.md`](../database/migrations-checkpoints.md)。 diff --git a/Document/admin/architecture.md b/Document/admin/architecture.md new file mode 100644 index 0000000..e07e9f3 --- /dev/null +++ b/Document/admin/architecture.md @@ -0,0 +1,50 @@ +# 管理员模块架构 + +文档职责:记录管理员 Flask 后端、Vue 前端、实时鉴权以及对数据库 monitor、Job、文件和 checkpoint 能力的消费关系。 + +适用范围:修改 `app/admin/`、`admin-frontend/`、管理员页面入口、管理员鉴权或管理员与系统级服务的交界时使用;数据库/worker 内部实现分别见 [`../database/overview.md`](../database/overview.md) 与 [`../architecture/agent-runtime.md`](../architecture/agent-runtime.md)。 + +## 模块组成 + +管理员模块由三部分组成: + +- Flask `admin_bp`,前缀为 `/api/admin`,负责授权、DTO、分页、审计和管理员业务服务。 +- Flask `admin_page_bp`,前缀为 `/admin`,负责页面鉴权、Vite 开发跳转或生产 `index.html`/静态资源托管。 +- `admin-frontend/`,使用 Vue 3、严格 TypeScript、Vue Router、Element Plus、Vite、Vitest 和 Playwright;它只调用 Flask API,不直接连接数据库。 + +`app/__init__.py` 注册 `admin` 和 `admin_page` blueprint。`app/admin/routes.py` 在页面和 API 进入业务代码前调用 `admin_required`;管理员身份每次从主库确认用户存在、启用状态、角色和 `auth_version`,不信任 Session 中缓存的角色。 + +## 页面边界 + +Vue router 的 base 固定为 `/admin/`,当前页面为: + +| 页面 | 作用 | +| --- | --- | +| `/admin/overview` | 业务聚合概览 | +| `/admin/users` | 用户查看和受控用户操作 | +| `/admin/sessions` | 会话、消息和附件元数据 | +| `/admin/jobs` | Job、MySQL 事件和 checkpoint 安全摘要 | +| `/admin/files` | 文件逻辑记录、预览、下载和删除影响 | +| `/admin/database` | 数据库、monitor、cleanup worker 和 outbox 看板 | +| `/admin/database/settings` | monitor 在线配置 | +| `/admin/database/audit` | deep audit 结果 | + +后台默认落点是 `/admin/database`。管理员仍可通过普通用户入口访问自己的聊天、文件和 Job;管理员后台不会扩大其普通用户资源访问范围。 + +## 共享能力消费关系 + +| 管理员功能 | 消费的系统能力 | 管理员侧边界 | +| --- | --- | --- | +| 数据库看板 | `database_monitor_snapshots`、monitor refresh 请求和 cleanup 心跳 | GET 只读最近快照,不在 Web 请求中运行完整采集 | +| Job 详情 | MySQL `analysis_job_events` 与 PostgreSQL checkpoint 安全摘要 | 不返回 checkpoint 状态正文、blob 或 pending writes | +| 用户/文件删除 | MySQL 业务事务和 `checkpoint_cleanup_outbox` | 业务删除先提交,跨库清理异步查询 | +| 文件预览/下载 | `user_files`/`file_objects` 主库事务访问记录 | 有界读取并记录审计,不返回文件 hash 到列表 | +| monitor 配置 | `database_monitor_settings` 的版本锁和来源解析 | 只提交覆盖值,`NULL` 表示继承 | + +数据库主从、checkpoint 表、outbox 领取租约、monitor 调度和账号职责不是管理员模块内部实现;变更这些能力时先更新 `Document/database/`,再更新本页消费契约。 + +## 安全边界与非目标 + +管理员 API 返回 `401`/`403`,写请求需要 Session CSRF;敏感正文读取要求成功审计可写。受控用户/文件写入还需要主库预览、当前密码重新认证、明确确认和幂等键,批量默认 20、硬上限 50。 + +后台当前不提供任意 SQL、DDL/DML、migration 按钮、数据库账号授权、复制控制、连接池管理、自动修复、任务强制控制或普通用户数据越权浏览。完整路径和响应边界见 [`api.md`](api.md)。 diff --git a/Document/admin/development.md b/Document/admin/development.md index fed125e..3c6f363 100644 --- a/Document/admin/development.md +++ b/Document/admin/development.md @@ -1,5 +1,9 @@ # 管理员前端开发与部署 +文档职责:记录管理员 Vue 前端的本地开发、生产构建、初始管理员入口和它依赖的系统级服务。 + +适用范围:修改 `admin-frontend/`、管理员静态资源托管或管理员开发/发布命令时使用;完整 Docker/数据库部署事实分别见 [`../development/deployment.md`](../development/deployment.md) 与 [`../database/migrations-checkpoints.md`](../database/migrations-checkpoints.md)。 + ## 生产部署 Dockerfile 使用 Node 24 构建 `admin-frontend/`,再把产物复制到最终 Python 镜像的 `/opt/causalagent-admin`。运行镜像不包含 Node、不启动 Vite,也不开放 Node 端口。 @@ -31,10 +35,9 @@ docker compose -f docker-compose.yml run --rm app python -m app.auth.admin_cli p 该命令只做幂等提升,不创建用户,也不负责降级管理员。管理员登录后进入 `/admin/database`。 -## PostgreSQL checkpoint +## 系统服务依赖 -迁移前请在 `.env` 设置非空的 `CHECKPOINT_POSTGRES_PASSWORD`。数据库初始化统一由 -`db-bootstrap` 完成,Docker 和本地都调用同一个 Python 入口: +管理员后台依赖数据库 bootstrap、PostgreSQL checkpoint、monitor 和 cleanup worker。迁移前请在 `.env` 设置非空的 `CHECKPOINT_POSTGRES_PASSWORD`;完整初始化顺序和破坏性迁移规则见 [`../database/migrations-checkpoints.md`](../database/migrations-checkpoints.md)。常用入口为: ```bash docker compose -f docker-compose.yml run --rm db-bootstrap @@ -43,30 +46,12 @@ docker compose -f docker-compose.yml run --rm db-bootstrap python -m Database.bootstrap ``` -该入口按顺序执行 MySQL 建库、Alembic migration 和 LangGraph 官方 PostgreSQL -checkpoint setup。`checkpoint-cleanup` 仍是持续运行的跨库清理 worker: +数据库初始化统一由 `db-bootstrap` 完成,`checkpoint-cleanup` 是持续运行的跨库清理 worker: ```bash python -m Database.checkpoint_cleanup_worker ``` -如需只检查或重新执行 PostgreSQL checkpoint schema,也可以单独运行: - -```bash -python -m Database.checkpoint_setup -``` - -统一 bootstrap 中的 Alembic migration 会删除 MySQL checkpoint 表和数据; -`alembic downgrade` 只重建空的兼容表结构,不恢复已删除数据。 -由于该迁移合并了两个历史 head,回退时必须指定明确目标 revision,不能使用 -`alembic downgrade -1`;例如回退到 `e4f5a6b7c8d9`。 - -管理员任务详情和数据库审计复用 `CHECKPOINT_POSTGRES_*` 建立独立只读连接。 -新任务由 worker 把 `job_id` 写入 LangGraph `config.metadata`,管理员按 -`thread_id=session_id + metadata.job_id` 读取安全摘要;旧 checkpoint 缺少 -`job_id` 时不做时间归属猜测。quick integrity 检查连接、官方表集合和 setup -版本;deep audit 只读取 schema、主键、估算行数及最多 20 个跨库关系样本。 - ## 启动 monitor 数据库看板读取共享快照。要持续产生新快照,需要独立启动: @@ -75,7 +60,7 @@ python -m Database.checkpoint_setup python -m Database.monitor_worker ``` -如果 monitor 未运行,页面仍可读取已有快照,但自动刷新和手动刷新请求不会产生新的采集结果。 +如果 monitor 未运行,页面仍可读取已有快照,但自动刷新和手动刷新请求不会产生新的采集结果。快照、配置优先级和 quick/deep audit 的内部事实见 [`../database/monitoring.md`](../database/monitoring.md)。 ## 本地 Vite 开发 @@ -97,4 +82,4 @@ Flask 仍先完成管理员页面鉴权,再跳转到 Vite。Vite 只代理 `/a ## 发布产物 -`admin-frontend/dist/` 是随管理员 Vue 源码同步更新的发布产物。`.dockerignore` 排除本地产物,因为镜像会从当前源码重新构建;最终镜像使用 `/opt/causalagent-admin` 中的构建结果。 +`admin-frontend/dist/` 是管理员 Vue 的构建结果。`.dockerignore` 排除本地产物,因为镜像会从当前源码重新构建;最终镜像使用 `/opt/causalagent-admin` 中的构建结果。系统整体部署顺序见 [`../development/deployment.md`](../development/deployment.md)。 diff --git a/Document/admin/testing.md b/Document/admin/testing.md index 56958ae..9d2e196 100644 --- a/Document/admin/testing.md +++ b/Document/admin/testing.md @@ -1,5 +1,9 @@ # 管理员系统测试 +文档职责:记录管理员后端、Vue、Mock E2E 和隔离主从/PostgreSQL checkpoint 验收的专项验证。 + +适用范围:修改管理员 API、页面、migration 交界或隔离验收脚本时使用;全仓测试矩阵见 [`../development/testing.md`](../development/testing.md)。 + 后端测试的统一目录与执行顺序见 [`tests/README.md`](../../tests/README.md)。管理员系统同时包含 Python 后端测试、Vue 组件测试、Mock E2E 和隔离主从 + PostgreSQL checkpoint E2E。 ## 前端快速验证 diff --git a/Document/api/agent-jobs.md b/Document/api/agent-jobs.md new file mode 100644 index 0000000..65fdc98 --- /dev/null +++ b/Document/api/agent-jobs.md @@ -0,0 +1,71 @@ +# 分析 Job API + +文档职责:记录普通用户创建、查看、订阅、恢复和取消分析 Job 的 HTTP/SSE 契约。 + +适用范围:修改 `app/agent/routes.py`、`app/agent/job_service.py`、前端 Job 状态恢复或 `analysis_job_events` payload 时使用;内部 worker 执行机制见 [`../architecture/agent-runtime.md`](../architecture/agent-runtime.md)。 + +## 接口总览 + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| `POST` | `/api/agent/jobs` | 创建或按幂等键重放一个分析 Job | +| `GET` | `/api/agent/jobs/active` | 读取当前用户活动 Job,可用 `session_id` 过滤 | +| `GET` | `/api/agent/jobs//events` | 订阅该 Job 的 SSE 事件 | +| `POST` | `/api/agent/jobs//resume` | 提交 `waiting_input` Job 的恢复输入 | +| `POST` | `/api/agent/jobs//cancel` | 取消 `waiting_input` Job | +| `POST` | `/api/send_stream` | 已废弃,固定返回 `410` 和迁移提示 | + +所有路径都要求当前用户登录,并按 `job_id + user_id` 校验资源归属。Web 层不执行 Agent,只校验参数、持久化 Job 并入队。 + +## 创建 Job + +请求 JSON 至少包含: + +```json +{ + "message": "请分析这些变量之间的关系", + "session_id": "session-uuid", + "input_user_file_id": 123 +} +``` + +`input_user_file_id` 可省略;消息必须是非空文本,长度受服务端限制。请求必须带标准 UUID v4 `Idempotency-Key`。服务端在一个 MySQL 事务中检查会话归属、活动 Job、文件归属,冻结文件快照,写入 Job、initial input、用户聊天消息和请求指纹。 + +- 新 Job 返回 `202`,`success=true`、`existing=false`、`job_id` 和 `status=queued`。 +- 相同用户、相同幂等键和相同请求参数重放原 Job,返回 `200`、`existing=true`。 +- 同一 `user_id + session_id` 已有 `queued`、`running` 或 `waiting_input` Job 时返回 `409`,错误码为 `active_job_conflict`。 +- 同一个幂等键对应不同请求参数时返回 `409`。 +- 缺少或非 UUID v4 幂等键、消息为空或文件/会话无权访问时返回 `400` 或 `403`。 + +## 活动 Job + +`GET /api/agent/jobs/active` 返回当前用户的活动 Job 摘要,并附带当前公开事件最大 ID `last_event_id`。可传 `session_id` 只恢复指定会话。摘要包含状态、worker/lease 观测、尝试和恢复次数、冻结文件名以及等待输入的问题 ID/公开提示,但不返回内部 checkpoint 状态或文件正文。 + +## SSE 订阅 + +`GET /api/agent/jobs//events` 返回 `text/event-stream`。客户端断线重连时使用上次收到的事件 ID: + +```text +Last-Event-ID: 42 +``` + +也兼容 `last_event_id=42` 查询参数。服务端从 `analysis_job_events.id > 42` 读取事件,返回标准 SSE 的 `id`、`event`、`data` 字段;没有新事件时按配置轮询并发送 heartbeat。事件 payload 经过 `_public_event_payload` 清理,worker 的 `attempt` 等内部字段不会对外可见。 + +收到 `interrupt` 后,前端应展示公开问题并等待恢复;收到 `final_result`、`error` 或 `canceled` 后连接结束。若 Job 已经进入终态,即使数据库查询时没有新的事件,服务端也会结束连接。 + +## Resume 与 Cancel + +恢复请求示例: + +```json +{ + "question_id": "question-uuid", + "answer": "补充信息" +} +``` + +`answer` 也兼容 `message` 字段,可以是文本或受限 JSON。恢复请求必须使用新的、可复用的 UUID v4 `Idempotency-Key`;服务端追加 `analysis_job_inputs.input_type='resume'`,然后重新排队同一个 Job。相同键重放返回原结果,不同问题或答案返回冲突。 + +取消不接受任意运行中 Job 的强制终止,只允许 `waiting_input` 状态。请求同样需要 UUID v4 幂等键,成功后追加稳定 `canceled` 生命周期事件并返回 `202`;已重放操作返回 `200`。状态不允许操作时返回 `409 job_state_conflict`。 + +Job 状态、输入冻结、checkpoint identity 和旧 worker fencing 的完整关系见 [`../architecture/job-file-lifecycle.md`](../architecture/job-file-lifecycle.md)。 diff --git a/Document/api/chat-files.md b/Document/api/chat-files.md new file mode 100644 index 0000000..1c3d297 --- /dev/null +++ b/Document/api/chat-files.md @@ -0,0 +1,37 @@ +# 会话与文件 API + +文档职责:记录普通用户会话、聊天消息和文件库接口的当前路径、授权边界与持久化语义。 + +适用范围:修改 `app/chat/routes.py`、`app/files/routes.py`、普通用户前端调用或文件库迁移时使用;Job 创建时的文件快照见 [`../architecture/job-file-lifecycle.md`](../architecture/job-file-lifecycle.md)。 + +## 会话接口 + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| `POST` | `/api/new_chat` | 生成 UUID 并立即在主库创建 Session | +| `GET` | `/api/sessions` | 读取当前用户未归档会话列表 | +| `GET` | `/api/load_session?session=` | 读取当前用户会话消息与展示附件 | +| `POST` | `/api/change_session` | 修改当前用户会话标题 | +| `POST` | `/api/delete_session` | 删除会话业务数据并登记 checkpoint cleanup | + +所有会话接口按当前用户过滤。未知或不属于当前用户的 Session 不会被自动重建。新建会话使用主库写入;列表可以使用允许回退的 eventual read;加载、修改、删除和 Job 相关的实时路径使用 strong read 或主库事务。 + +删除会话前,服务端锁定该会话的 Job 和 Session;如果存在 `queued`、`running` 或 `waiting_input` Job,返回 `409`。否则在同一个 MySQL 事务中登记所有 Job 的 checkpoint cleanup outbox、删除附件、删除聊天消息和删除 Session,然后返回后台清理状态。MySQL 业务删除成功不代表 PostgreSQL checkpoint 已同步完成。 + +## 文件接口 + +| 方法 | 路径 | 作用 | +| --- | --- | --- | +| `GET` | `/api/files` | 读取当前用户的文件库逻辑记录 | +| `POST` | `/api/upload_file` | 上传到 `file_objects`/`user_files` 文件库 | +| `POST` | `/api/delete_file` | 删除用户文件逻辑记录,必要时删除未引用 BLOB | + +上传只进入文件库,不创建 Session 关联或 Job。服务端按 SHA-256 在同一用户范围内复用不可变 `file_objects`,文件名通过 `user_files` 逻辑记录保存。响应只返回文件元数据和 `user_file_id`,不把 BLOB 内容作为列表结果返回。 + +删除文件时,仍被活动 Job 的 `input_user_file_id` 使用会返回 `409`;否则先删除 `user_files`,只有对象没有其他逻辑引用时才删除 `file_objects`。删除没有回收站。文件预览、下载和 Agent 真实读取更新访问次数与最近访问时间,重复上传命中已有对象不计为访问。 + +## 数据边界 + +普通用户只能访问自己的 Session、消息、文件和 Job。聊天附件可以承载因果图或分析结果等展示数据,但不得把内部 prompt、ToolMessage、完整工具结果或隐藏推理作为普通用户 API 的隐式扩展。文件内容读取必须经过有界大小、类型和权限校验。 + +管理员查看业务数据使用独立 `/api/admin/business/*` 契约,不应为了复用前端而放宽普通用户接口;管理员契约见 [`../admin/api.md`](../admin/api.md)。 diff --git a/Document/api/conventions.md b/Document/api/conventions.md new file mode 100644 index 0000000..1d7e81a --- /dev/null +++ b/Document/api/conventions.md @@ -0,0 +1,37 @@ +# API 通用约定 + +文档职责:记录普通用户 API、管理员 API 和 SSE 共同遵守的鉴权、请求关联、错误、分页与敏感数据边界。 + +适用范围:修改 Flask 路由、认证/CSRF、中间件、统一响应或事件协议时使用;管理员专属路径和业务 DTO 见 [`../admin/api.md`](../admin/api.md)。 + +## 身份与授权 + +普通用户接口通过当前 Cookie Session 找到用户,并每次从 MySQL 确认用户仍存在、`is_active` 为真且 `auth_version` 匹配。管理员接口额外要求主库强一致读取到 `role = 'admin'`;Session 中缓存的角色不能作为后端授权依据。 + +未登录或会话失效的 API 返回 `401`;已登录但不具备管理员权限的管理员 API 返回 `403`。管理员页面未登录时回到统一登录入口,普通用户访问管理页面先返回真实 `403` 再回普通首页。登录成功和 `check_auth` 会返回当前 Session 绑定的 CSRF token。 + +管理员写请求必须回传 `X-CSRF-Token`。管理员数据库刷新、完整性审计、在线配置写入以及 3.2 受控业务操作还分别受密码重新认证、预览、明确确认和 `Idempotency-Key` 等接口契约约束,不能把这些约束下沉为前端自律。 + +## Request ID 与错误 + +请求上下文从 `X-Request-ID` 接受符合 `[A-Za-z0-9._:-]{1,64}` 的上游值,否则生成 UUID;所有响应通过 `X-Request-ID` 返回该值。管理员统一响应还包含 `request_id` 字段,失败响应包含稳定 `code`,字段校验错误放在 `fields`。 + +内部异常只能记录在服务端日志,不能把堆栈、数据库连接、凭据、原始 prompt 或工具结果直接返回给客户端。需要给用户展示的错误必须是稳定、有限且不泄露内部结构的消息。 + +## 分页和内容读取 + +管理员列表默认 `limit=20`,硬上限为 50,使用不透明 cursor;不要把数据库主键排序值直接当作公开分页协议。管理员消息、附件和 Job 输入/结果/错误正文只有在明确点击的敏感读取接口中按源字节分块返回,单次最多 64 KiB,成功读取还必须能写入审计,否则拒绝返回正文。 + +文件预览是另一条受限路径:只允许文本化 CSV 预览,最多 256 KiB、100 行、50 列,每个单元格最多 1000 字符,不执行公式、HTML 或脚本。管理员列表 DTO 不得包含密码哈希、Cookie、Token、文件正文/哈希、数据库账号、host 或 grants。 + +## 幂等键边界 + +分析 Job 的创建、resume 和 cancel 请求必须提供标准 UUID v4 `Idempotency-Key`;管理员受控业务写入使用自己的操作幂等记录。相同身份、相同键和相同请求指纹可以重放原结果,不同参数必须返回冲突,不得静默覆盖第一次请求。 + +幂等落盘不等于外部 LLM、MCP 或 PostgreSQL 操作的分布式 exactly-once。Job 的 MySQL 状态、事件、assistant 消息和 worker fencing 负责本地持久化一致性;外部调用的重试边界必须在对应运行时文档中说明。 + +## SSE 公共协议 + +普通用户 Job SSE 使用 `text/event-stream`,事件由 MySQL `analysis_job_events` 按递增事件 ID 读取。客户端可通过 `Last-Event-ID` 或 `last_event_id` 查询参数续传;服务端发送 `id`、`event` 和 JSON `data`,定期发送 `heartbeat` 保活。 + +公共事件必须经过脱敏适配器:只暴露公开文字、阶段和稳定状态,移除内部 `attempt` 等字段,不暴露原始 prompt、ToolMessage、完整工具结果、图状态、文件内容或隐藏推理。终态事件或 `interrupt` 到达后连接结束,页面刷新后应通过活动 Job 接口和最后事件 ID恢复状态。 diff --git a/Document/architecture/agent-runtime.md b/Document/architecture/agent-runtime.md new file mode 100644 index 0000000..530c2d3 --- /dev/null +++ b/Document/architecture/agent-runtime.md @@ -0,0 +1,37 @@ +# Agent 运行时 + +文档职责:记录 Agent worker、LangGraph、MCP、RAG、结构化输出和执行事件的当前协作方式。 + +适用范围:修改 `Agent/`、`app/agent/worker/`、MCP server、RAG 初始化或用户可见事件协议时使用;Job 的持久化生命周期见 [`job-file-lifecycle.md`](job-file-lifecycle.md),执行约束见 [`../../Agent/AGENTS.md`](../../Agent/AGENTS.md)。 + +## Worker 启动与 slot + +`python -m app.agent.worker` 进入 `app/agent/worker/__main__.py`,再调用 bootstrap。启动顺序是数据库就绪检查、PostgreSQL checkpoint 检查、创建显式进程 runtime,然后按 `JOB_WORKERS` 启动 slot。每个 slot 持有一组独立运行依赖: + +1. MCP server process 与一个通过 `MultiServerMCPClient.session("causal")` 建立的持久 `ClientSession`。 +2. 由该 session 加载的 LangChain tools。 +3. 当前配置下的 LLM、RAG 可用性和编译后的 Agent graph。 + +`runtime.py` 通过 `ProcessRuntime` 和 `SlotRuntime` 显式传递这些对象;执行函数不能从 `app.agent.core` 读取全局 graph 或 LLM。这样可以把真实并发单元限定为 slot,并让 MCP session 与 graph 的生命周期一致。 + +## 父图与工具阶段 + +父图当前只暴露 `mcp` 和 `rag` 两个工具阶段。MCP 子图的正常路径为 `mcp_planner -> mcp_tool_node -> mcp_result_parser`;RAG 子图内部对应 `rag_question_planner -> rag_tool_node -> rag_result_parser`。planner、ToolNode 和 parser 的失败路径在子图内转换为标准 `success=False` 结果并结束该阶段,不把异常对象直接写入用户事件。 + +结构化输出统一通过 `Agent/llm_structured_output.py` 的同步/异步入口调用,固定使用普通 `function_calling`。结构化请求会关闭 thinking;MCP planner 仍使用原生 Tool Calls,并对关闭 thinking 的 LLM 副本设置 `tool_choice="required"`,确保 planner 必须选择一个已加载工具。`agent` 和 `fold` 的条件路由只读取显式 State 字段 `route_decision`、`fold_decision`,不使用展示消息猜测控制流。 + +RAG 启动时只检查知识库目录是否可用,不在 worker 启动阶段完整加载向量库;知识库缺失时记录 warning 并以无知识库模式继续。DirectLiNGAM 作为 `causal_direct_lingam` MCP 工具提供连续数值 CSV 分析,输出的系数矩阵约定为 `target_to_source`,报告需要保留线性、非高斯、误差独立、DAG 和无潜在混杂等假设。 + +## 事件流与脱敏 + +worker 使用 LangGraph v2 的 `updates`、`messages`、`custom` 和 `tasks` 流,将内部执行事件转换为 `analysis_job_events`。根图 `tasks` 构成用户时间线,子图工具事件折叠为 `mcp` 或 `rag` 阶段。普通用户 SSE 只允许 `normal_chat` 和 `inquiry_answer` 的公开文字进入 `text_delta`;原始 prompt、ToolMessage、完整工具结果、图状态、内部 attempt 和隐藏推理都不能进入普通用户协议。 + +事件写入由 Job 的 `lease_epoch`、worker、attempt 和稳定 `event_key` 共同保护。终态事件与 assistant 消息、Job 状态在同一个 MySQL 事务中落盘;旧 worker 失去 lease 后不能覆盖新执行结果。前端断线恢复使用 Event ID 读取 MySQL 事件,不依赖 worker 内存。 + +## 修改时的验证边界 + +- 修改图节点或路由时,必须核对显式 State 字段和失败路径,不能只验证成功样例。 +- 修改事件适配器、结果展示或 SSE 时,必须确认公共 payload 没有内部字段和原始工具数据。 +- 修改 worker 初始化时,必须同时检查 `runtime.py`、`bootstrap.py`、Docker Compose 的 worker 入口和 slot 资源占用。 +- 修改结构化输出或 MCP planner 时,必须分别验证普通 function calling、thinking 配置和原生 Tool Calls。 +- 修改 RAG 或因果工具时,必须分别验证“知识库缺失可启动”和工具输入/输出契约。 diff --git a/Document/architecture/job-file-lifecycle.md b/Document/architecture/job-file-lifecycle.md new file mode 100644 index 0000000..37d6bc4 --- /dev/null +++ b/Document/architecture/job-file-lifecycle.md @@ -0,0 +1,55 @@ +# Job 与文件生命周期 + +文档职责:记录 Session、analysis Job、输入账本、文件库、LangGraph checkpoint 以及删除清理之间的当前数据关系和状态转换。 + +适用范围:修改 `app/agent/job_service.py`、会话/文件路由、相关 Alembic migration、worker fencing 或跨库删除流程时使用;普通用户 API 入口见 [`../api/agent-jobs.md`](../api/agent-jobs.md) 与 [`../api/chat-files.md`](../api/chat-files.md)。 + +## 核心实体 + +| 实体 | 存储 | 作用 | +| --- | --- | --- | +| Session | MySQL `sessions` | 用户可见的会话容器,`id` 是 UUID 字符串;业务访问仍按 `session_id` 授权 | +| Job | MySQL `analysis_jobs` | 长任务队列、状态、lease、重试、冻结文件快照和终态摘要 | +| Job 输入 | MySQL `analysis_job_inputs` | initial/resume 输入账本、顺序、问题 ID、幂等键和对应聊天消息 | +| Job 事件 | MySQL `analysis_job_events` | SSE 时间线、生命周期事件和内部执行摘要 | +| 文件对象 | MySQL `file_objects` | 按用户和 SHA-256 去重的不可变 BLOB | +| 用户文件 | MySQL `user_files` | 用户可见文件名、访问统计和对象引用 | +| checkpoint | PostgreSQL 官方 LangGraph 表 | Job 的恢复状态;`thread_id` 是 `analysis_jobs.job_id` | +| cleanup outbox | MySQL `checkpoint_cleanup_outbox` | 跨库删除请求的可靠账本,按 `thread_id` 唯一 | + +新建会话时,`POST /api/new_chat` 先在 MySQL 主库插入 `sessions` 记录再返回 ID。创建 Job、保存聊天、修改标题和上传文件都要求会话或用户文件真实存在且属于当前用户,不会根据未知 ID 自动重建对象。 + +## 文件库与冻结输入 + +文件上传进入文件库,不自动关联 Session 或 Job。相同用户相同 SHA-256 的内容复用 `file_objects`,不同文件名仍可以形成不同的 `user_files` 逻辑记录;浏览器中尚未提交的选择只是 composer 状态,不构成 Job 输入。 + +创建 Job 时,服务端在同一个 MySQL 事务中锁定并快照 `input_user_file_id`、对象 ID、文件 hash 和文件名,同时写入 initial `analysis_job_inputs` 和用户聊天消息。之后用户替换或清除浏览器草稿不会改变已创建 Job 的输入。 + +文件预览、下载以及 Agent 真正读取文件内容都在主库事务内更新 `last_accessed_at` 和 `access_count`;命中已有对象的重复上传不计为访问。CSV 预览最多读取 256 KiB、100 行、50 列,单元格最多 1000 字符,并且只按文本处理。 + +## Job 状态和并发 + +活动状态为 `queued`、`running`、`waiting_input`,终态为 `succeeded`、`failed`、`canceled`。同一 `user_id + session_id` 同时最多有一个活动 Job;`active_session_key` 是可空普通列,唯一键 `uq_analysis_jobs_active_session` 负责并发兜底。 + +领取时 worker 写入 `worker_id`、`attempt_count`、`lease_epoch`、锁定时间和心跳。终态写入、事件写入和 assistant 消息提交前必须确认 worker、attempt 和 lease epoch 仍匹配,旧 worker 不能覆盖新尝试。stale recovery 只在确认 PostgreSQL checkpoint 可读且未恢复的 interrupt 后进行;无法可靠读取恢复状态时禁止冒险重放。 + +## 等待输入、恢复与取消 + +Agent 产生 interrupt 后,Job 进入 `waiting_input`,保留 `active_session_key` 以阻止同一会话创建第二个 Job,但释放 worker lease。服务端将问题 ID、公开提示和稳定 interrupt 事件写入 MySQL;恢复请求把输入追加为 `resume` 记录,随后重新排队同一个 Job,继续同一个 checkpoint。 + +恢复输入只允许文本或受限 JSON,服务端限制长度、深度、字段/数组项数和 UTF-8 字节数。恢复与取消操作各自要求 UUID v4 `Idempotency-Key`,相同键重放相同请求时返回原结果,不同参数返回冲突。取消只作用于 `waiting_input` Job,并写入稳定 `canceled` 生命周期事件。 + +## Checkpoint 身份和跨库清理 + +当前 checkpoint 身份是: + +```text +thread_id = analysis_jobs.job_id +checkpoint_ns = "" +``` + +业务 `session_id` 仍然是 MySQL `sessions.id`,不是 checkpoint thread。新 Job 从同一 Session 的 MySQL 聊天历史加载有界初始窗口;同一 Job 的 resume 和 stale recovery 才继续原 checkpoint。旧的 session-thread checkpoint 不迁移、不读取、不清理。管理员 checkpoint 摘要必须使用 `metadata.job_id` 精确关联,缺少该字段的记录不能按时间猜测归属。 + +删除 Session 或用户时,MySQL 事务先锁定并删除业务数据,同时为相关 Job 写入 `checkpoint_cleanup_outbox`。cleanup worker 用租约领取并调用 PostgreSQL `adelete_thread(job_id)`;租约过期可以恢复,失败按有限次数和退避重试。两个数据库之间没有伪造的分布式事务,后台清理状态必须可查询。 + +删除逻辑文件时,如果仍有活动 Job 使用该 `user_file_id`,请求必须被阻断;删除 `user_files` 后只有在没有其他逻辑引用时才删除 `file_objects` BLOB,不提供回收站。 diff --git a/Document/architecture/overview.md b/Document/architecture/overview.md new file mode 100644 index 0000000..b97dfef --- /dev/null +++ b/Document/architecture/overview.md @@ -0,0 +1,59 @@ +# 系统架构总览 + +文档职责:记录 CausalAgent 当前的进程边界、组件职责、运行拓扑和主要数据流。 + +适用范围:修改 Flask 应用注册、Web/worker/monitor/cleanup 进程、Compose 服务或跨组件数据流时使用;Job 和文件的细粒度生命周期见 [`job-file-lifecycle.md`](job-file-lifecycle.md)。 + +## 系统边界 + +CausalAgent 的 Web 入口是 `CausalAgent.py`,它调用 `app/__init__.py` 的 `create_app()`。应用启动时先执行数据库就绪检查,再注册 `auth`、`chat`、`files`、`agent`、`main`、`admin` 和 `admin_page` 七个 blueprint。Web 进程只负责认证、短请求、Job 入队和 SSE 推送,不在请求线程中执行 Agent、MCP 或 RAG 长任务。 + +桌面入口 `Run_causal.py` 固定加载 `http://127.0.0.1:5001`,因此桌面模式仍依赖 Web 后端先启动。普通用户前端是 Flask 静态资源;管理员前端是独立的 Vue 3 + TypeScript 工程,但生产运行时由 Flask 提供构建后的同源静态文件。 + +## 进程与职责 + +| 组件 | 当前入口 | 主要职责 | 持久化边界 | +| --- | --- | --- | --- | +| Web | `python CausalAgent.py` 或 Gunicorn `CausalAgent:app` | 认证、会话/文件短请求、Job 入队、普通用户 SSE、管理员 API | MySQL 业务表;读取 PostgreSQL 安全摘要 | +| Agent worker | `python -m app.agent.worker` | 领取 Job、运行 LangGraph/MCP/RAG、写事件和终态结果 | MySQL Job/Event;PostgreSQL LangGraph checkpoint | +| monitor | `python -m Database.monitor_worker` | 采集 MySQL/PostgreSQL 运行事实并写共享快照 | MySQL monitor 快照和在线配置 | +| checkpoint cleanup | `python -m Database.checkpoint_cleanup_worker` | 消费 MySQL outbox 并删除 PostgreSQL Job checkpoint | MySQL outbox;PostgreSQL checkpoint | +| db-bootstrap | `python -m Database.bootstrap` | 建库、Alembic migration、PostgreSQL checkpoint schema setup | 修改初始化目标数据库 | + +worker 的实际执行单元是 slot。一个 worker 进程可以启动多个 slot;每个 slot 独占自己的 MCP server process、持久 MCP `ClientSession`、已加载工具和编译后的 Agent graph。具体运行时约束见 [`agent-runtime.md`](agent-runtime.md)。 + +## 主要数据流 + +```mermaid +flowchart LR + Browser[普通用户或管理员浏览器] --> Web[Flask Web] + Web -->|strong write/read| MySQL[(MySQL 主库)] + Web -->|SSE 轮询| Events[(analysis_job_events)] + Worker[Agent worker slots] -->|领取 Job / 写 Event| MySQL + Worker -->|checkpoint| PostgreSQL[(PostgreSQL checkpoint)] + Web -->|只读安全摘要| PostgreSQL + MySQL -->|checkpoint_cleanup_outbox| Cleanup[cleanup worker] + Cleanup -->|adelete_thread(job_id)| PostgreSQL + Monitor[monitor worker] -->|采集| MySQL + Monitor -->|quick/deep 只读检查| PostgreSQL + MySQL --> Snapshots[(database_monitor_snapshots)] + Snapshots --> Web +``` + +跨库删除不使用分布式事务。MySQL 业务删除和 cleanup outbox 在同一 MySQL 事务提交,cleanup worker 之后异步删除 PostgreSQL checkpoint;用户接口和管理员操作查询接口分别暴露后台清理状态。 + +## Docker 拓扑 + +默认开发 Compose `docker-compose.yml` 当前包含八个服务:`mysql-primary`、`mysql-replica`、`postgres-checkpoint`、`db-bootstrap`、`app`、`worker`、`monitor` 和 `checkpoint-cleanup`。`db-bootstrap` 成功后,依赖它的运行服务才启动;开发拓扑没有自动故障切换。 + +生产 Compose `docker-compose.prod.yml` 使用生产命名的 MySQL、PostgreSQL checkpoint、`app`、`worker`、`monitor`、`checkpoint-cleanup` 和 `db-bootstrap` 服务。它不是开发主从 Compose 的自动升级版,生产数据、网络和镜像由生产配置单独管理。部署入口见 [`../development/deployment.md`](../development/deployment.md)。 + +## 组件边界 + +- `app/` 负责 HTTP、认证、持久化服务编排和 Job worker 外壳,不承载因果算法实现。 +- `Agent/` 负责 LangGraph 图、结构化输出、MCP/RAG 工具节点和因果工具。 +- `Database/` 负责连接、迁移、bootstrap、monitor、checkpoint setup 和 cleanup worker。 +- `admin-frontend/` 只负责管理员页面与 API 消费,不替代 Flask 后端,也不直接连接数据库。 +- `Document/admin/` 只描述管理员如何消费系统能力;数据库内部机制归 `Document/database/`。 + +修改这些边界时,必须同时核对对应目录的局部 `AGENTS.md` 和本页链接的权威文档。 diff --git a/Document/database/consistency.md b/Document/database/consistency.md new file mode 100644 index 0000000..4fdebf3 --- /dev/null +++ b/Document/database/consistency.md @@ -0,0 +1,48 @@ +# 数据库一致性与主从 + +文档职责:记录 MySQL 写库、业务读库、复制状态观测、strong/eventual read 和连接池的当前实现。 + +适用范围:修改 `app/db.py`、账号权限、读写路由、复制回退或连接容量时使用;管理员看板只描述消费结果,内部采集机制见 [`monitoring.md`](monitoring.md)。 + +## 连接职责 + +| 连接/账号 | 允许的职责 | +| --- | --- | +| `MYSQL_WRITE_USER` | 主库写入、migration、启动就绪检查 | +| `MYSQL_READ_USER` | 主库/从库业务读;额外读取允许的 Performance Schema digest | +| `MYSQL_REPLICA_STATUS_USER` | 仅执行 `SHOW REPLICA STATUS` | +| `MYSQL_REPLICATION_USER` | 仅供 MySQL 从库复制通道拉取 binlog | +| `MYSQL_USER`/`MYSQL_PASSWORD` | 历史兼容兜底,不替代职责账号 | + +PostgreSQL checkpoint 使用 `CHECKPOINT_POSTGRES_*` 配置,管理员和 monitor 的 checkpoint 检查使用只读连接;账号、host 和连接串不能进入 API、快照或审计。 + +## Read 选择 + +`get_write_connection()` 固定连接 MySQL 主库。`get_read_connection(consistency="strong")` 固定读取主库;`consistency="eventual"` 才允许尝试副本。副本必须有专用状态账号、IO/SQL 复制线程均为 `Yes`,并且延迟不超过 `MYSQL_REPLICA_MAX_LAG_SECONDS`;否则回退主库,不自动切换写主。 + +复制状态有短时缓存,默认 `MYSQL_REPLICA_STATUS_CACHE_SECONDS=2`;默认可接受延迟为 2 秒。连接失败、状态缺失或延迟超限都只触发主库回退。来源返回值只使用逻辑别名 `primary`、`replica-1` 等,不暴露真实主机名。 + +## 读写矩阵 + +| 路径 | 一致性要求 | 说明 | +| --- | --- | --- | +| Job 创建/领取/心跳/状态、事件和输入写入 | 主库事务 | 这是队列、fencing 和 SSE 事件的权威状态 | +| 用户登录、会话恢复、角色/启用状态和 `auth_version` | strong 主库 | 不能用副本或 Session 缓存完成授权 | +| Session 列表等允许短暂延迟的普通读取 | eventual,可回退 | 只读且不影响资源归属判断 | +| Session/Job/File 所有权校验、删除、文件访问计数 | strong/主库事务 | 防止副本延迟造成越权或错误删除 | +| 管理员列表和在线配置 | strong 主库或共享快照 | 管理员页面不依赖弱一致授权 | +| monitor 容量估算 | 按采集器策略 | 必须返回 source、observed_at 和 estimate 语义 | + +不能把“所有 SELECT 都去副本”当作读写分离规则。每个新读取路径必须先说明它是否可以接受副本延迟,以及回退主库后的来源标签如何展示。 + +## 容量和进程边界 + +连接池按 OS 进程计算,容量估算为: + +```text +write_pool + read_pool * (1 + replica_count) +``` + +worker slot 共享所在 worker 进程的连接池;slot 数增加会增加实际执行并发和数据库事务压力,但不会为每个 slot 自动复制一套池。默认建连超时 5 秒、获取池连接超时 3 秒、获取失败重试间隔 50 毫秒;池大小上限由配置校验限制。 + +当前开发 Compose 固定使用 `mysql-primary`、`mysql-replica` 和 `postgres-checkpoint` 服务名与独立数据卷,没有自动故障切换。切换 worktree 或 Compose project 时必须核对固定容器名、端口和数据卷,不能用 `down -v` 代替数据保留操作。 diff --git a/Document/database/migrations-checkpoints.md b/Document/database/migrations-checkpoints.md new file mode 100644 index 0000000..c11d903 --- /dev/null +++ b/Document/database/migrations-checkpoints.md @@ -0,0 +1,61 @@ +# 迁移与 Checkpoint + +文档职责:记录 MySQL/Alembic/bootstrap、PostgreSQL checkpoint setup、cleanup outbox 以及具有破坏性的迁移事实。 + +适用范围:修改 `Database/bootstrap.py`、`Database/migrations/versions/`、checkpoint 配置、cleanup worker 或部署初始化顺序时使用;数据库危险操作的执行约束见 [`../../Database/AGENTS.md`](../../Database/AGENTS.md)。 + +## 初始化顺序 + +`Database/database_init.py` 只加载环境变量、确保 MySQL 数据库存在并执行连接检查。完整入口 `python -m Database.bootstrap` 按顺序执行: + +1. MySQL 建库/连接准备。 +2. 必要的旧库 schema-aware preflight。 +3. `alembic upgrade head` 维护 MySQL 业务 schema。 +4. 官方 LangGraph PostgreSQL checkpointer schema setup。 + +Docker Compose 中 `db-bootstrap` 是一次性服务,`app`、worker、monitor 和 cleanup 依赖其成功退出后再启动。全新空库不需要先运行旧库 preflight;`Database/audit_before_db_upgrade.py` 只服务于已存在、尚未建立目标外键且即将执行相关迁移的旧库。 + +## 当前迁移链 + +当前 head 是 `a1b2c3d4e5f6`。迁移按职责演进如下,最终链路和 down revision 以文件内容为准: + +| Revision | 当前作用 | +| --- | --- | +| `1a2b3c4d5e6f` | 核心用户、Session、消息、附件、旧文件和归档表 | +| `bae097eab4b3` | 早期 MySQL checkpoint 表 | +| `9359bc171e66` / `d876b980dc9a` | 附件可视化字段和内容容量调整 | +| `f6b8c9d0e1a2` | 移除聊天分区、重建主键/索引并补充业务外键 | +| `e7a9b2c3d4f5` | `analysis_jobs` 与 Job Event | +| `a8b9c0d1e2f3` / `b1c2d3e4f5a6` / `c2d3e4f5a6b7` | 用户角色、共享 monitor 快照、在线配置和管理员审计 | +| `d3e4f5a6b7c8` / `e4f5a6b7c8d9` | 管理员读取索引、受控写入、操作账本和 `auth_version` | +| `f8b9c0d1e2f3` | MySQL checkpoint -> PostgreSQL,建立 cleanup outbox | +| `f9a0b1c2d3e4` | Job 请求幂等键和请求指纹 | +| `a1b2c3d4e5f6` | 文件库替换、冻结文件快照、resume 输入账本和 Job 恢复字段 | + +`f8b9c0d1e2f3` 的 `down_revision` 声明为 `e4f5a6b7c8d9` 与 `e7a9b2c3d4f5`,随后由 `f9a0b1c2d3e4` 和 `a1b2c3d4e5f6` 继续。回退这类合并迁移必须指定明确目标 revision,不能用 `alembic downgrade -1` 代替。 + +## 破坏性事实 + +- `f8b9c0d1e2f3` 建立 `checkpoint_cleanup_outbox` 后直接删除 MySQL `checkpoint_writes` 和 `checkpoints` 表及其数据;PostgreSQL 才是运行时 checkpoint 真相。downgrade 只重建空的兼容表结构,不恢复数据。 +- `a1b2c3d4e5f6` 直接 `DROP TABLE IF EXISTS uploaded_files`,创建 `file_objects`、`user_files` 和 Job 输入结构;不回填旧数据、不提供旧数据 fallback,也不增加旧数据拒绝迁移逻辑。downgrade 只恢复空的旧 `uploaded_files` 表结构。 +- 迁移脚本属于高风险历史事实,不应为了让本地旧库“看起来能升级”而静默删除、回填或修改历史 migration。 + +## PostgreSQL checkpoint + +`Database/checkpoint_setup.py` 调用官方 `AsyncPostgresSaver.setup()` 创建 checkpoint schema。worker 以 `analysis_jobs.job_id` 作为 `thread_id`,根 `checkpoint_ns` 为空;`config.metadata` 同时保存 `job_id` 和业务 `session_id`,供管理员安全摘要精确关联。 + +管理员和 monitor 的 quick integrity 只读检查连接、官方表集合和 setup migration 版本;deep audit 额外检查字段/主键、估算统计和最多 20 个跨库 `thread_id -> analysis_jobs.job_id` 关系样本。checkpoint API 不读取或返回状态正文、blob 或 pending writes,缺少 `metadata.job_id` 的历史记录不按时间猜测归属。 + +## Cleanup outbox + +MySQL 删除 Session 或用户时,在同一个业务事务中为相关 Job 写入 `(thread_id)` 唯一的 `checkpoint_cleanup_outbox` 记录。cleanup worker 使用 `FOR UPDATE SKIP LOCKED` 领取,写入租约并调用 PostgreSQL `adelete_thread(job_id)`;租约过期可再次领取,最多执行有限次数,失败按退避重试。管理员用户删除操作通过 `operation_id` 聚合 outbox 状态为 `running`、`succeeded` 或 `failed`。 + +cleanup worker 按 `CHECKPOINT_CLEANUP_HEARTBEAT_INTERVAL_SECONDS` 发布脱敏心跳,默认 10 秒;运行快照只保存逻辑 worker 状态、计数、时间和安全错误结论,不保存 host、账号或原始 `last_error`。 + +## 相关入口 + +- [`../../alembic.ini`](../../alembic.ini):Alembic script location。 +- [`../../Database/bootstrap.py`](../../Database/bootstrap.py):统一初始化编排。 +- [`../../Database/checkpoint_setup.py`](../../Database/checkpoint_setup.py):PostgreSQL setup。 +- [`../../Database/checkpoint_cleanup_worker.py`](../../Database/checkpoint_cleanup_worker.py):跨库清理 worker。 +- [`../../Database/audit_before_db_upgrade.py`](../../Database/audit_before_db_upgrade.py):旧库升级前 preflight。 diff --git a/Document/database/monitoring.md b/Document/database/monitoring.md new file mode 100644 index 0000000..29a8def --- /dev/null +++ b/Document/database/monitoring.md @@ -0,0 +1,60 @@ +# 数据库监控 + +文档职责:记录 monitor worker、数据库共享快照、在线采集配置、quick/deep audit 和 cleanup 运行状态的当前实现。 + +适用范围:修改 `Database/monitor_worker.py`、`Database/monitoring.py`、`Database/monitor_settings.py`、数据库看板 API 或 cleanup 心跳时使用;管理员页面只描述消费契约,见 [`../admin/api.md`](../admin/api.md)。 + +## 采集模型 + +monitor 入口是 `python -m Database.monitor_worker`。它把采集结果写入 MySQL `database_monitor_snapshots`,以便多个 Web 进程共享同一份最近事实。采集由 MySQL 命名锁保护,避免多个 monitor 或手动请求同时执行同一快照。 + +核心快照分为: + +| 快照组 | 典型内容 | 默认周期 | +| --- | --- | --- | +| `realtime` | 主库/从库状态、连接、Job/worker 和 cleanup 运行摘要 | 10 秒 | +| `sql_performance` | Performance Schema digest、`Slow_queries` 窗口增量 | 60 秒 | +| `capacity` | revision、表容量等可带估算性质的容量信息 | 900 秒 | +| `integrity` | 运行期 quick integrity | 默认不定时;启用后 86400 秒 | +| `deep_audit` | schema-aware 手动深审计 | 仅手动 | +| `checkpoint_cleanup_outbox` | 脱敏 outbox 汇总和有限明细 | 按 realtime 采集 | + +数据库看板的 GET 只读最近快照,不在 Web 请求中现场运行完整采集。`POST /api/admin/db/refresh` 和 `POST /api/admin/db/integrity/run` 只登记 `refresh_requested_at`,实际工作由 monitor 进程完成。 + +## 在线配置 + +七个配置字段保存在 `database_monitor_settings` 单例行中,解析优先级固定为: + +```text +数据库覆盖 > 环境变量 > 代码默认值 +``` + +数据库字段为 `NULL` 时表示继承。每个进程最多缓存 5 秒;数据库读取失败时先使用最后有效值,再回退到环境变量/代码默认值并标记降级。保存使用版本锁,成功、拒绝和失败都写入 `admin_audit_events`。 + +| 字段 | 默认值 | 有效范围 | +| --- | --- | --- | +| `auto_refresh_enabled` | `true` | 布尔 | +| `realtime_interval_seconds` | `10` | 5-10 | +| `sql_interval_seconds` | `60` | 30-60 | +| `table_capacity_interval_seconds` | `900` | 300-900 | +| `slow_query_warning_delta` | `1` | 大于等于 1 | +| `integrity_enabled` | `false` | 布尔 | +| `integrity_interval_seconds` | `86400` | 大于等于 3600 | + +路由、SQL 和 Vue 前端不能硬编码这些调度策略。连接使用率 warning/error 默认阈值为 70%/85%,快速 SELECT 超时默认由 `DB_INSPECTION_QUERY_TIMEOUT_MS=3000` 控制。 + +## SQL 性能语义 + +SQL digest 区块表示“SQL 性能摘要/高负载 SQL”,不是慢查询日志。候选语句按单次平均 `AVG_TIMER_WAIT` 降序,平均耗时相同时按累计 `SUM_TIMER_WAIT` 降序;慢查询告警优先使用采集窗口内 `Slow_queries` 增量,累计值只作兼容和辅助展示。 + +所有 SQL 性能摘要必须展示 `observed_at`、逻辑 `source_role/source_alias`、warning 和 estimate 语义。监控账号只能读取业务库允许范围和指定 Performance Schema digest,不能因为看板而扩大为全局权限。 + +## Integrity 与 Deep Audit + +quick integrity 复用独立 PostgreSQL 只读连接,确认 checkpoint 连通性、官方表集合和 setup migration 版本,同时检查 MySQL cleanup outbox 的外键/领取索引与失败清理任务。它不再查询已经迁移走的 MySQL checkpoint 表,也不要求 `chat_messages` 必须分区。 + +deep audit 只接受手动请求,不定时调度,不自动修复。它覆盖 Alembic revision、关键 schema、utf8mb4/UTC/隔离级别、账号职责结论、Job/Event、cleanup outbox、归档关系、`active_session_key` 和逐从库状态;每项有超时和异常样本上限。返回值只包含逻辑别名、计数和安全结论,不返回账号、host、grants、密码或连接串。 + +## Cleanup 运行状态 + +cleanup worker 约每 10 秒写入 `checkpoint_cleanup_runtime` 心跳。monitor 还采集 `checkpoint_cleanup_outbox` 的 pending、due、processing、租约过期和 failed 汇总以及最多 100 条脱敏条目;不返回 `last_error` 原文,只返回 `has_error` 和安全错误状态。管理员后台的 database、cleanup-worker、outbox 三段视图都消费这些共享快照。 diff --git a/Document/database/overview.md b/Document/database/overview.md new file mode 100644 index 0000000..911a07f --- /dev/null +++ b/Document/database/overview.md @@ -0,0 +1,39 @@ +# 数据库职责总览 + +文档职责:记录 MySQL 业务数据库、PostgreSQL checkpoint 数据库、业务表关系和跨库一致性边界。 + +适用范围:修改数据库连接、迁移、表结构、checkpoint、cleanup outbox 或跨库删除流程时使用;具体主从读策略见 [`consistency.md`](consistency.md),初始化与迁移顺序见 [`migrations-checkpoints.md`](migrations-checkpoints.md)。 + +## 两个数据库的职责 + +| 数据库 | 当前权威数据 | 不负责的内容 | +| --- | --- | --- | +| MySQL 主库 | 用户、Session、消息、附件、文件库、Job、Job Event、输入账本、管理员操作/审计、monitor 快照、cleanup outbox | LangGraph checkpoint 正文 | +| PostgreSQL checkpoint | LangGraph 官方 checkpoint schema 和 Job 恢复状态 | 用户业务数据、管理员业务审计和公开 API 资源归属 | + +MySQL 是业务状态和队列的权威来源;PostgreSQL 是 checkpoint 的运行时真相。当前 LangGraph `thread_id` 使用 `analysis_jobs.job_id`,根 `checkpoint_ns` 为空,业务 `session_id` 仍然引用 MySQL `sessions.id`。 + +## MySQL 业务表分组 + +- **身份与会话**:`users`、`sessions`、`archived_sessions`、`chat_messages`、`chat_attachments`。 +- **文件库**:`file_objects` 保存不可变 BLOB,`user_files` 保存用户可见逻辑文件。 +- **Job**:`analysis_jobs` 是队列和状态,`analysis_job_inputs` 是 initial/resume 输入账本,`analysis_job_events` 是事件日志。 +- **管理员**:`admin_audit_events` 记录管理员动作和结果,`admin_operations`/`admin_operation_items` 记录受控批量操作。 +- **监控**:`database_monitor_snapshots` 保存跨 Web 进程共享的最近采集结果,`database_monitor_settings` 保存单例在线覆盖配置。 +- **跨库清理**:`checkpoint_cleanup_outbox` 按 `thread_id` 唯一登记 PostgreSQL checkpoint 删除请求。 + +文件对象与逻辑文件分离后,同一用户相同 hash 可以复用 BLOB;不同文件名保留不同逻辑记录。删除逻辑文件只有在没有其他逻辑引用并且没有活动 Job 使用时才删除 BLOB。 + +## 关系和删除 + +用户和 Session 的业务外键负责常规级联;聊天附件通过消息关系删除;Job Event/Input 通过 Job 关系删除。用户物理删除还必须显式处理归档 Session、为每个相关 Job 写入 cleanup outbox,并受关联行数量阈值保护。文件物理删除不提供回收站。 + +跨 MySQL 和 PostgreSQL 的删除不使用分布式事务。MySQL 事务提交业务删除与 outbox 后,独立 cleanup worker 通过 `adelete_thread(job_id)` 清理 PostgreSQL;outbox 的状态和租约是可恢复的外部工作账本。 + +## Schema 权威与就绪检查 + +业务 schema 的唯一维护入口是 `alembic.ini` 指向的 `Database/migrations`;`Database/database_init.py` 只确保 MySQL 数据库存在并检查连接,不创建业务表。`app/db.py` 的 `check_database_readiness()` 会在 Flask 启动前检查关键表、Job 恢复字段、冻结文件字段、用户安全字段、cleanup outbox 索引和幂等索引。 + +当前关键表集合包括:`users`、`sessions`、`chat_messages`、`chat_attachments`、`file_objects`、`user_files`、`archived_sessions`、`checkpoint_cleanup_outbox`、`analysis_jobs`、`analysis_job_events`、`analysis_job_inputs`、`database_monitor_snapshots`、`database_monitor_settings`、`admin_audit_events`、`admin_operations` 和 `admin_operation_items`。不存在 MySQL checkpoint 作为运行时数据源的兼容读取路径。 + +数据库代码入口:[`../../app/db.py`](../../app/db.py)、[`../../Database/database_init.py`](../../Database/database_init.py)、[`../../Database/bootstrap.py`](../../Database/bootstrap.py) 和 [`../../Database/migrations/versions`](../../Database/migrations/versions)。 diff --git a/Document/development/deployment.md b/Document/development/deployment.md new file mode 100644 index 0000000..e3c5315 --- /dev/null +++ b/Document/development/deployment.md @@ -0,0 +1,47 @@ +# 构建与部署 + +文档职责:记录 CausalAgent Docker 镜像构建、开发/生产 Compose 拓扑、初始化顺序和管理员静态产物的发布边界。 + +适用范围:修改 Dockerfile、Compose 服务、镜像启动命令、生产环境变量或发布流程时使用;数据库内部迁移和 checkpoint 细节见 [`../database/migrations-checkpoints.md`](../database/migrations-checkpoints.md)。 + +## 镜像构建 + +`Dockerfile` 当前有三个重要阶段: + +1. `python-deps` 安装基础 Python 依赖和 CPU PyTorch。 +2. `test` 在共享依赖上安装 `requirements-test.txt`,默认执行 `tests/unit`。 +3. `admin-builder` 使用 Node 24 Alpine 执行管理员前端构建,`runtime` 是最终 Python 镜像,将产物复制到 `/opt/causalagent-admin`。 + +最终运行镜像不包含 Node,不启动 Vite,不开放 Node 端口;Gunicorn 默认绑定 `0.0.0.0:5001`,由 `WEB_WORKERS`、`WEB_THREADS` 和 `WEB_TIMEOUT` 调整 Web 进程参数。 + +## 开发部署 + +`docker-compose.yml` 是 MySQL 主从 + PostgreSQL checkpoint 的开发拓扑,服务职责如下: + +| 服务 | 作用 | +| --- | --- | +| `mysql-primary` / `mysql-replica` | 主库写入与复制副本,分别使用独立数据卷 | +| `postgres-checkpoint` | LangGraph checkpoint 数据库 | +| `db-bootstrap` | 一次性 MySQL/Alembic/PostgreSQL 初始化 | +| `app` | Flask Web,暴露 5001 | +| `worker` | Agent Job worker | +| `monitor` | 数据库共享快照采集 | +| `checkpoint-cleanup` | 跨库 checkpoint 删除 | + +`app`、worker、monitor 和 cleanup 依赖 `db-bootstrap` 成功退出;开发拓扑当前不提供自动故障切换。启动命令见 [`setup.md`](setup.md)。 + +## 生产部署 + +`docker-compose.prod.yml` 使用生产 MySQL、PostgreSQL checkpoint、统一 bootstrap、Web、worker、monitor 和 cleanup 服务;生产环境不挂载源代码,使用独立卷、网络和日志轮转设置。当前生产 Compose 是单独的生产配置,不能假设它自动提供开发 Compose 的 MySQL replica 或故障切换能力。 + +生产必须通过环境变量或安全的 secret 机制提供 `SECRET_KEY`、模型配置、MySQL 职责账号和非空 `CHECKPOINT_POSTGRES_PASSWORD`。不要在文档、镜像层、命令行日志或 API 响应中打印密钥。 + +## 管理员产物 + +本地非 Docker 发布前必须在 `admin-frontend/` 执行 typecheck、unit、Mock E2E 和 build。未设置 `ADMIN_VITE_DEV_SERVER_URL` 时,Flask 从 `admin-frontend/dist/`(或 `ADMIN_FRONTEND_DIST_DIR` 指定目录)提供 `/admin/`;Docker 运行镜像从 `/opt/causalagent-admin` 提供构建结果。 + +`.dockerignore` 排除本地产物,镜像构建阶段从当前源代码重新生成。开发热更新才显式启动 Vite,生产不要把 Vite 端口作为后端依赖。 + +## 数据库发布顺序 + +空库或数据库环境重建时先启动依赖数据库,再运行 `Database.bootstrap` 完成 Alembic 和 checkpoint setup,确认成功后才启动 app/worker/monitor/cleanup。具有破坏性的 checkpoint/file migration 不会自动回填旧数据;执行 downgrade 必须选择明确 revision,并在隔离环境先验证往返。迁移风险和 preflight 规则见 [`../database/migrations-checkpoints.md`](../database/migrations-checkpoints.md)。 diff --git a/Document/development/documentation.md b/Document/development/documentation.md new file mode 100644 index 0000000..09bd1bd --- /dev/null +++ b/Document/development/documentation.md @@ -0,0 +1,46 @@ +# 文档维护规则 + +文档职责:定义 `AGENTS.md`、`Document/`、日志和遗留政策文档的职责边界,以及本仓库文档重构的维护约束。 + +适用范围:新增、重构或核对项目文档时使用;它不替代根目录和局部 `AGENTS.md` 的执行规则。 + +## 两类文档边界 + +| 文件 | 负责回答的问题 | 内容要求 | +| --- | --- | --- | +| `AGENTS.md` | 修改某模块时必须怎么做 | 使用“必须、禁止、修改后核对”等可执行约束;根文件负责全局规则,局部文件只补充目录风险 | +| `Document/` | 系统当前是什么、如何工作、为什么这样设计 | 记录可由当前代码、迁移、配置、Compose 和测试核验的技术事实,每项事实只设一个权威归属 | + +根和局部 `AGENTS.md` 顶部必须声明生效目录和负责约束的修改类型。每个新建或重构的 `Document/` 文档都必须在标题后立即声明“文档职责”和“适用范围”。 + +## 主题归属 + +- 系统边界、进程和数据流归 `architecture/overview.md`。 +- Agent、worker、LangGraph、MCP、RAG 和事件脱敏归 `architecture/agent-runtime.md`。 +- Session、Job、文件冻结和 checkpoint 生命周期归 `architecture/job-file-lifecycle.md`。 +- 普通用户 HTTP/SSE 契约归 `api/`;管理员完整接口归 `admin/api.md`。 +- MySQL/PostgreSQL、主从、迁移、checkpoint、cleanup 和 monitor 内部机制归 `database/`。 +- 启动、测试、构建部署和文档维护归 `development/`。 +- 管理员页面、后端授权和管理员 API 如何消费共享能力归 `admin/`,不复制数据库 worker 内部实现。 + +`Document/operations/` 不创建。原计划中的部署、监控和运行维护内容分别归 `development/deployment.md`、`database/monitoring.md` 和 `admin/`。 + +## 维护流程 + +修改路由、表结构、Compose 服务、配置默认值或测试入口后,必须搜索其文档引用并更新唯一权威页面;跨主题页面只链接和说明消费关系,不复制完整实现。新文档中的相对链接必须指向现存文件;命令必须能从当前仓库结构推导,不能照搬历史日志。 + +根 `README.md` 是项目入口,不在本轮文档重构中修改。`CHANGELOG.md` 是由原 `README/开发日志.md` 纯重命名得到的历史日志,正文只允许在末尾追加新记录,不修改历史段落。 + +`setting/Userprivacy.md` 是隐私政策,属于独立政策材料而非架构事实库;其安全边界与 `Document/api/`、`Document/admin/` 的事实必须保持一致。 + +## 提交前静态检查 + +至少检查: + +```powershell +git diff --check +git diff -- README.md +git diff --find-renames --summary +``` + +确认每个 `Document/` Markdown 文件都有顶部职责声明。 diff --git a/Document/development/setup.md b/Document/development/setup.md new file mode 100644 index 0000000..b2af4f1 --- /dev/null +++ b/Document/development/setup.md @@ -0,0 +1,69 @@ +# 开发环境 + +文档职责:记录当前仓库的本地、Docker、管理员前端和数据库初始化入口。 + +适用范围:首次配置开发环境、切换运行方式或修改启动入口时使用;服务拓扑与镜像发布见 [`deployment.md`](deployment.md),测试命令见 [`testing.md`](testing.md)。 + +## 配置前提 + +配置统一由 `config/settings.py` 从系统环境变量读取;仓库根目录存在 `.env` 时会先加载它。至少需要应用密钥、模型配置、MySQL 写/读账号、业务数据库名和非空 `CHECKPOINT_POSTGRES_PASSWORD`。不要把 `.env`、密码、API key 或数据库连接串提交到 Git、日志或文档。 + +主从开发使用职责分离账号:写主库、业务读、复制状态观测和复制通道账号各自配置。没有专用复制状态账号时,eventual read 会安全回退主库。 + +## Docker 开发 + +Docker 是当前首选开发方式: + +```bash +docker compose -f docker-compose.yml up -d +``` + +首次启动、空卷重建或数据库环境重建时,Compose 会先运行 `db-bootstrap`。需要单独重跑一次性初始化时执行: + +```bash +docker compose -f docker-compose.yml run --rm db-bootstrap +``` + +开发 Compose 使用 `mysql-primary`、`mysql-replica`、`postgres-checkpoint`、`app`、`worker`、`monitor` 和 `checkpoint-cleanup`;固定端口和数据卷属于共享 Docker daemon 资源,多个 worktree 同时运行时必须采用独立 project/端口策略,不能误用 `down -v`。 + +## 本地 Python + +不使用 Docker 时先进入项目 Python 环境,然后按需启动: + +```bash +python -m Database.bootstrap +python CausalAgent.py +python -m app.agent.worker +python -m Database.monitor_worker +python -m Database.checkpoint_cleanup_worker +``` + +`Database/database_init.py` 只确保 MySQL 数据库存在并检查连接;完整业务表和 PostgreSQL checkpoint schema 仍由 `Database.bootstrap` 负责。新空库不要先运行旧库 preflight。 + +## 管理员前端开发 + +管理员 Vue 源码位于 `admin-frontend/`。需要热更新时执行: + +```bash +cd admin-frontend +npm ci +npm run dev +``` + +Vite 固定使用 `/admin/` base,默认端口为 5173,并把 `/api` 代理到 `http://127.0.0.1:5001`。Flask 仍先完成页面鉴权;只有显式设置 `ADMIN_VITE_DEV_SERVER_URL=http://127.0.0.1:5173` 才跳转到 Vite。普通部署保持该变量为空,让 Flask 托管构建产物。 + +## 初始管理员 + +完成 migration 并注册一个启用的普通用户后,只通过现有 CLI 提升管理员: + +```bash +python -m app.auth.admin_cli promote +``` + +Docker 中可使用: + +```bash +docker compose -f docker-compose.yml run --rm app python -m app.auth.admin_cli promote +``` + +该命令不创建公开管理员注册接口,也不负责降级管理员。 diff --git a/Document/development/testing.md b/Document/development/testing.md new file mode 100644 index 0000000..7aa66cd --- /dev/null +++ b/Document/development/testing.md @@ -0,0 +1,68 @@ +# 测试与验证 + +文档职责:定义后端、管理员前端、集成迁移和隔离主从/PostgreSQL 验收的验证矩阵与稳定命令。 + +适用范围:修改代码、迁移、管理员页面或文档中命令时使用;管理员专项测试补充说明见 [`../admin/testing.md`](../admin/testing.md)。 + +## 测试层级 + +| 层级 | 位置/入口 | 主要证明 | +| --- | --- | --- | +| 后端 unit | `tests/unit/` | 单模块状态机、权限、Job fencing、结构化输出和 monitor 逻辑 | +| 后端 integration | `tests/integration/` | 跨模块静态契约、部署边界和 migration 链 | +| 管理员 Vue unit | `admin-frontend/tests/*.spec.ts` | API DTO、组件、看板/设置语义和 SQL digest 展示 | +| 管理员 Mock E2E | `admin-frontend` `test:e2e:mock` | 无真实数据库的页面导航、鉴权和交互 | +| 隔离 E2E | `tests/run_admin_31_e2e.ps1` / `run_admin_32_e2e.ps1` | 空库升级、migration 往返、主从、PostgreSQL checkpoint、受控写入/删除和普通用户回归 | + +`tests/README.md` 是后端测试目录和 Docker 单元测试环境的补充入口;新增测试时先判断是否需要真实跨模块依赖,再选择 unit 或 integration。 + +## 后端 Docker 单元测试 + +测试镜像基于 Dockerfile 的 `test` target,安装 `requirements-test.txt`。`unit-test` 服务不依赖 app/worker/monitor/MySQL,关闭容器网络,只读挂载仓库,并用 `tests/unit-test-env` 屏蔽项目 `.env`: + +```bash +docker compose -f docker-compose.test.yml build unit-test +docker compose -f docker-compose.test.yml run --rm unit-test +``` + +运行指定测试: + +```bash +docker compose -f docker-compose.test.yml run --rm unit-test python -m pytest -p no:cacheprovider tests/unit/admin +docker compose -f docker-compose.test.yml run --rm unit-test python -m pytest -p no:cacheprovider tests/unit/agent/test_job_lifecycle.py +``` + +环境受限时才使用本地回退: + +```bash +python -m pytest tests/unit +``` + +## 管理员前端 + +```bash +cd admin-frontend +npm ci +npm run typecheck +npm run test:unit +npm run test:e2e:mock +npm run build +``` + +`npm run build` 会先执行 typecheck,再生成 `/admin/` base 的 Vite 产物。修改管理员 API 或页面后必须至少覆盖 loading、empty、error、401/403 和敏感内容边界;真实数据库写流程只能在隔离环境运行。 + +## 隔离管理员 E2E + +完成管理员生产构建后,从仓库根目录运行: + +```powershell +powershell -ExecutionPolicy Bypass -File tests/run_admin_32_e2e.ps1 +``` + +脚本通过 `docker-compose.admin-e2e.yml` 叠加固定的隔离容器名、端口和数据卷,不触碰开发库;覆盖空库 `alembic upgrade head`、3.2 migration downgrade/upgrade、PostgreSQL checkpoint 读取/清理、受控用户/文件写入、逐目标审计、主从追平和普通用户回归。需要 `PLAYWRIGHT_BASE_URL`、管理员/普通用户凭据;只有 Edge 时可设置 `PLAYWRIGHT_CHANNEL=msedge`。 + +脚本结束后会保留隔离容器和卷,不自动清理。物理删除用例会修改种子数据,不能用 `KeepSeededData` 重放。 + +## 文档重构验证 + +本轮不改应用行为和公开 API,不要求完整应用测试。必须执行文档静态检查、`git diff --check`、迁移链检查以及 `git diff -- README.md` 空检查;文档中的运行命令和路由已按当前代码/Compose/测试入口核对。 diff --git a/README.md b/README.md index e5fbf25..16c398e 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,6 @@ docker compose -f docker-compose.yml run --rm app python Database/audit_before_d - 复制状态检查账号:`MYSQL_REPLICA_STATUS_USER` / `MYSQL_REPLICA_STATUS_PASSWORD`,只用于读取 `SHOW REPLICA STATUS`;缺失或不可用时,`eventual` 读安全回退主库读连接。 - 复制通道账号:`MYSQL_REPLICATION_USER` / `MYSQL_REPLICATION_PASSWORD`,只用于 MySQL 主从复制链路,不参与应用业务查询。 -`/api/new_chat` 生成 ID 后会立即在 MySQL 主库创建会话记录;创建 job、保存聊天、修改标题和上传文件都要求该会话已经存在且属于当前用户,不会根据未知 ID 自动重建。删除已经创建的会话时,主库事务会删除会话、聊天消息和附件,并写入 `checkpoint_cleanup_outbox`;独立 cleanup worker 随后调用 PostgreSQL `adelete_thread()` 清理同一 `session_id` 对应的 LangGraph checkpoint。两个数据库之间不伪造分布式事务,用户接口会明确返回后台清理状态。 #### 管理员后台 @@ -314,14 +313,18 @@ docker compose -f docker-compose.test.yml run --rm unit-test sh 5. 等待 `Python syntax`、`Light tests` 和 `Pull request policy` 检查通过后再合并 -轻量 CI 不连接数据库、不加载知识库或模型,也不调用外部 API。GitHub 分支保护需要在仓库 `Settings -> Rules -> Rulesets` 中单独启用,并将上述三个检查设置为必需检查。 - 新建 Issue 时请使用仓库提供的 [`Issue Form`](.github/ISSUE_TEMPLATE/issue.yml),按模板填写背景、问题描述、预期结果、复现步骤、验收标准和环境信息。除附件外的字段为 GitHub 原生必填项,但不限制填写内容;普通贡献者不能选择空白 Issue。 ## Star 趋势 -[![Star History Chart](https://api.star-history.com/svg?repos=Heyflyingpig/CausalAgent&type=Date)](https://star-history.com/#Heyflyingpig/CausalAgent&Date) - +## Star History + + + + + Star History Chart + + ## 项目结构 diff --git a/admin-frontend/AGENTS.md b/admin-frontend/AGENTS.md new file mode 100644 index 0000000..d7db8ad --- /dev/null +++ b/admin-frontend/AGENTS.md @@ -0,0 +1,25 @@ +# admin-frontend/AGENTS.md + +生效目录:`admin-frontend/` 及其子目录。 + +负责约束的修改类型:Vue/TypeScript 页面、Router、管理员 API 客户端、Vite 构建、组件测试和浏览器 E2E。 + +## 修改前必须阅读 + +- 必须阅读 [`Document/admin/architecture.md`](../Document/admin/architecture.md)、[`Document/admin/api.md`](../Document/admin/api.md)、[`Document/admin/testing.md`](../Document/admin/testing.md) 和 [`Document/development/deployment.md`](../Document/development/deployment.md)。 +- 必须检查 `src/api.ts`、`src/types.ts`、router、对应 view/component、Flask 路由和现有 Vitest/Playwright 用例;不能凭页面文字猜 API。 +- 修改 database dashboard、monitor settings、Job 或敏感内容组件时,必须阅读对应 `Document/database/` 或 `Document/architecture/` 权威事实。 + +## TypeScript 与 API 规则 + +- 必须保持严格 TypeScript 和当前 `/admin/` Vite base;路由、API 客户端、类型定义和页面状态必须一起修改。 +- 管理员前端只能调用 Flask API,禁止直接连接 MySQL/PostgreSQL、在浏览器保存密钥或自行实现授权。 +- 必须保留 401/403、loading、empty、error、分页、CSRF、request ID、敏感读取上限和脱敏 DTO 的处理;不能因为 UI 方便而显示密码哈希、文件 hash/BLOB、checkpoint 正文或原始错误。 +- monitor/数据库页面必须消费共享快照和服务端返回的来源/时间/estimate 语义;禁止在前端硬编码采集周期、数据库 host 或阈值。 +- 修改受控写入页面时必须保留预览、密码重新认证、明确确认、幂等键和操作状态查询流程。 + +## 构建与浏览器验证 + +- 修改后至少运行 `npm run typecheck`、相关 `npm run test:unit`、`npm run test:e2e:mock` 和 `npm run build`;不要只依赖编辑器类型提示。 +- 修改页面导航、鉴权回跳或真实 API 交互时,必须在可用条件下运行 Playwright;截图/浏览器验证应覆盖桌面和移动布局中的文字溢出、遮挡、空态和错误态。 +- 真实数据库写操作只能使用隔离 E2E 环境;不要在开发库执行物理删除验收。 diff --git a/admin-frontend/dist/index.html b/admin-frontend/dist/index.html index e99be69..bb434c1 100644 --- a/admin-frontend/dist/index.html +++ b/admin-frontend/dist/index.html @@ -5,12 +5,13 @@ CausalAgent 管理后台 - - - - - - + + + + + + +
diff --git a/admin-frontend/pnpm-lock.yaml b/admin-frontend/pnpm-lock.yaml new file mode 100644 index 0000000..3032dd3 --- /dev/null +++ b/admin-frontend/pnpm-lock.yaml @@ -0,0 +1,2356 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@lucide/vue': + specifier: 1.27.0 + version: 1.27.0(vue@3.5.40(typescript@5.9.3)) + element-plus: + specifier: 2.14.3 + version: 2.14.3(vue@3.5.40(typescript@5.9.3)) + vue: + specifier: 3.5.40 + version: 3.5.40(typescript@5.9.3) + vue-router: + specifier: 5.2.0 + version: 5.2.0(@vue/compiler-sfc@3.5.41)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + devDependencies: + '@playwright/test': + specifier: 1.62.0 + version: 1.62.0 + '@types/node': + specifier: 26.1.1 + version: 26.1.1 + '@vitejs/plugin-vue': + specifier: 6.0.8 + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + '@vue/test-utils': + specifier: 2.4.11 + version: 2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.40)(vue@3.5.40(typescript@5.9.3)) + jsdom: + specifier: 29.1.1 + version: 29.1.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0)) + vue-tsc: + specifier: 3.3.8 + version: 3.3.8(typescript@5.9.3) + +packages: + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.4': + resolution: {integrity: sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.4': + resolution: {integrity: sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.4': + resolution: {integrity: sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@ctrl/tinycolor@4.2.0': + resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lucide/vue@1.27.0': + resolution: {integrity: sha512-LKDEZJ0Uo9UaT4vxBMYVwXeK/0dGcWCTdav1AK/aGD/jYsDPtiVvOgfM7jPlx9BVeyGZ1Uf0aQhHA++rmbe7rw==} + peerDependencies: + vue: '>=3.0.1' + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.62.0': + resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue-macros/common@3.1.4': + resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@3.3.8': + resolution: {integrity: sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA==} + + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} + + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} + + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} + + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + + element-plus@2.14.3: + resolution: {integrity: sha512-pJcvxcpZjYruNzuJhAeVwnbYjfNgzBKnWHwSVEhwzM2/kcLI3brzmtIBxtPqd4hQWJfD1PRnjoc1WipLw2eBGg==} + peerDependencies: + vue: ^3.3.7 + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + playwright-core@1.62.0: + resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.0: + resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.3.9: + resolution: {integrity: sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==} + + vue-router@5.2.0: + resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 + pinia: ^3.0.4 || ^4.0.2 + vite: ^7.3.0 || ^8.0.0 + vue: ^3.5.34 || ^4.0.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + + vue-tsc@3.3.8: + resolution: {integrity: sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.4 + '@babel/types': 8.0.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.0': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.4': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.4': + dependencies: + '@babel/types': 8.0.4 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.4': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.4 + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@ctrl/tinycolor@4.2.0': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))': + dependencies: + vue: 3.5.40(typescript@5.9.3) + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@exodus/bytes@1.15.1': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lucide/vue@1.27.0(vue@3.5.40(typescript@5.9.3))': + dependencies: + vue: 3.5.40(typescript@5.9.3) + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@one-ini/wasm@0.1.1': {} + + '@oxc-project/types@0.139.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.62.0': + dependencies: + playwright: 1.62.0 + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/jsesc@2.5.1': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@6.0.8(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + vue: 3.5.40(typescript@5.9.3) + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.41 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.40(typescript@5.9.3) + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@3.3.8': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + + '@vue/reactivity@3.5.40': + dependencies: + '@vue/shared': 3.5.40 + + '@vue/runtime-core@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/runtime-dom@3.5.40': + dependencies: + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.40': + dependencies: + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + + '@vue/shared@3.5.41': {} + + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.41)(@vue/server-renderer@3.5.40)(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.41 + js-beautify: 1.15.4 + vue: 3.5.40(typescript@5.9.3) + vue-component-type-helpers: 3.3.9 + optionalDependencies: + '@vue/server-renderer': 3.5.40 + + '@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.40(typescript@5.9.3)) + vue: 3.5.40(typescript@5.9.3) + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))': + dependencies: + vue: 3.5.40(typescript@5.9.3) + + abbrev@2.0.0: {} + + acorn@8.18.0: {} + + alien-signals@3.2.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + assertion-error@2.0.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.8 + pathe: 2.0.3 + + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + ast-kit: 2.2.0 + + async-validator@4.2.5: {} + + balanced-match@1.0.2: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + birpc@2.9.0: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + chai@6.2.2: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + dayjs@1.11.21: {} + + decimal.js@10.6.0: {} + + detect-libc@2.1.2: {} + + eastasianwidth@0.2.0: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.5 + + element-plus@2.14.3(vue@3.5.40(typescript@5.9.3)): + dependencies: + '@ctrl/tinycolor': 4.2.0 + '@element-plus/icons-vue': 2.3.2(vue@3.5.40(typescript@5.9.3)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 14.3.0(vue@3.5.40(typescript@5.9.3)) + async-validator: 4.2.5 + dayjs: 1.11.21 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.40(typescript@5.9.3) + vue-component-type-helpers: 3.3.9 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + exsolve@1.1.1: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + hookable@5.5.3: {} + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + ini@1.3.8: {} + + is-fullwidth-code-point@3.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + + js-cookie@3.0.8: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + memoize-one@6.0.0: {} + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + muggle-string@0.4.1: {} + + nanoid@3.3.17: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + normalize-wheel-es@1.2.0: {} + + nostics@1.2.0: {} + + obug@2.1.4: {} + + package-json-from-dist@1.0.1: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + path-browserify@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + playwright-core@1.62.0: {} + + playwright@1.62.0: + dependencies: + playwright-core: 1.62.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + readdirp@5.1.1: {} + + require-from-string@2.0.2: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scule@1.3.0: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@8.3.0: {} + + undici@7.29.0: {} + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin@3.3.0(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.1.5 + vite: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + + vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@3.3.9: {} + + vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)): + dependencies: + '@babel/generator': 8.0.0 + '@vue-macros/common': 3.1.4(vue@3.5.40(typescript@5.9.3)) + '@vue/devtools-api': 8.2.1 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + nostics: 1.2.0 + pathe: 2.0.3 + picomatch: 4.0.5 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(rolldown@1.1.5)(vite@8.1.5(@types/node@26.1.1)(yaml@2.9.0)) + unplugin-utils: 0.3.2 + vue: 3.5.40(typescript@5.9.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.41 + vite: 8.1.5(@types/node@26.1.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + + vue-tsc@3.3.8(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.8 + typescript: 5.9.3 + + vue@3.5.40(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 + optionalDependencies: + typescript: 5.9.3 + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + webpack-virtual-modules@0.6.2: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yaml@2.9.0: {} diff --git a/admin-frontend/src/api.ts b/admin-frontend/src/api.ts index af0c9ee..8a6cc3c 100644 --- a/admin-frontend/src/api.ts +++ b/admin-frontend/src/api.ts @@ -486,10 +486,12 @@ export const adminApi = { jobId: string, kind: 'input' | 'result' | 'error', offset = 0, + sequence?: number, ) => apiRequest( withQuery(`/api/admin/business/jobs/${encodeURIComponent(jobId)}/content`, { kind, offset, + sequence, }), { cache: 'no-store' }, ), diff --git a/admin-frontend/src/components/SensitiveContentDialog.vue b/admin-frontend/src/components/SensitiveContentDialog.vue index d7cd2b1..683703b 100644 --- a/admin-frontend/src/components/SensitiveContentDialog.vue +++ b/admin-frontend/src/components/SensitiveContentDialog.vue @@ -3,10 +3,13 @@ import { ref, watch } from 'vue' import { ApiError } from '../api' import type { SensitiveContentChunk } from '../types' -const props = defineProps<{ +const props = withDefaults(defineProps<{ title: string loadChunk: (offset: number) => Promise -}>() + showAuditNotice?: boolean +}>(), { + showAuditNotice: true, +}) const visible = defineModel({ required: true }) const content = ref('') @@ -42,6 +45,7 @@ watch(visible, (opened) => { diff --git a/admin-frontend/tests/business-ui.spec.ts b/admin-frontend/tests/business-ui.spec.ts index 77dc4cb..fc99e01 100644 --- a/admin-frontend/tests/business-ui.spec.ts +++ b/admin-frontend/tests/business-ui.spec.ts @@ -68,10 +68,34 @@ describe('3.1 管理员界面交互边界', () => { await flushPromises() expect(loadChunk).toHaveBeenCalledOnce() + expect(wrapper.find('.sensitive-notice').exists()).toBe(true) expect(wrapper.find('pre').text()).toBe('') expect(wrapper.find('img').exists()).toBe(false) }) + it('敏感正文弹窗允许由上层页面承载审计提示', async () => { + const wrapper = mount(SensitiveContentDialog, { + props: { + modelValue: true, + title: '消息正文', + loadChunk: vi.fn(async () => ({ + content: '正文', + offset: 0, + limit: 65536, + total_length: 6, + complete: true, + next_offset: null, + })), + showAuditNotice: false, + 'onUpdate:modelValue': () => undefined, + }, + global: { stubs: elementStubs }, + }) + await flushPromises() + + expect(wrapper.find('.sensitive-notice').exists()).toBe(false) + }) + it('桌面侧栏在 248/76 模式间切换并持久化,Logo 始终复用受保护原图', async () => { const router = createRouter({ history: createMemoryHistory('/admin/'), diff --git a/admin-frontend/tests/e2e-mock/admin-ui.spec.ts b/admin-frontend/tests/e2e-mock/admin-ui.spec.ts index 10b18a9..765be67 100644 --- a/admin-frontend/tests/e2e-mock/admin-ui.spec.ts +++ b/admin-frontend/tests/e2e-mock/admin-ui.spec.ts @@ -305,6 +305,26 @@ test('3.2 业务页面、受控写入、敏感揭示和可收缩导航在 mock 'base64', ), })) + await page.route('**/api/admin/jobs/workers', route => route.fulfill({ + json: { + success: true, + data: [], + summary: { + queued: 0, + running: 0, + waiting_input: 0, + stale: 0, + max_attempts_running: 0, + }, + meta: { + status: 'healthy', + observed_at: observedAt, + source_alias: 'mock-worker-snapshot', + warning: null, + }, + request_id: 'mock-workers', + }, + })) await page.route('**/api/admin/business/**', async route => { const requestUrl = new URL(route.request().url()) const path = requestUrl.pathname @@ -701,8 +721,19 @@ test('3.2 业务页面、受控写入、敏感揭示和可收缩导航在 mock expect(messageContentReads).toBe(0) await page.getByRole('button', { name: '查看详情' }).click() await expect(page.getByText('只读摘要')).toBeVisible() + const sessionDrawer = page.locator('.el-drawer').filter({ hasText: '会话详情' }) + const auditNotice = sessionDrawer.locator('.drawer-section-title + .sensitive-notice') + await expect(auditNotice).toHaveText('读取正文将会记录管理员、目标、结果和 request ID。') expect(messageContentReads).toBe(0) + + await page.setViewportSize({ width: 720, height: 900 }) + await expect(auditNotice).toBeVisible() + expect(await auditNotice.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + await page.setViewportSize({ width: 1280, height: 900 }) + await page.getByRole('button', { name: '查看正文' }).click() + const contentDialog = page.getByRole('dialog', { name: '消息 #11 正文' }) + await expect(contentDialog.locator('.sensitive-notice')).toHaveCount(0) await expect(page.getByText('只作为文本', { exact: true })).toBeVisible() expect(messageContentReads).toBe(1) await expect(page.locator('.sensitive-content b')).toHaveCount(0) diff --git a/admin-frontend/tests/sql-digest-table.spec.ts b/admin-frontend/tests/sql-digest-table.spec.ts index fc5493a..e16019b 100644 --- a/admin-frontend/tests/sql-digest-table.spec.ts +++ b/admin-frontend/tests/sql-digest-table.spec.ts @@ -25,7 +25,7 @@ describe('SqlDigestTable', () => { avg_seconds: 0.12, }, { - digest_text: 'SELECT * FROM uploaded_files WHERE user_id = ?', + digest_text: 'SELECT * FROM user_files WHERE user_id = ?', total_seconds: 9, avg_seconds: 'invalid', }, diff --git a/admin-frontend/tests/sql-semantics.spec.ts b/admin-frontend/tests/sql-semantics.spec.ts index d9a91ad..47b0360 100644 --- a/admin-frontend/tests/sql-semantics.spec.ts +++ b/admin-frontend/tests/sql-semantics.spec.ts @@ -43,10 +43,10 @@ describe('SQL Digest 业务语义映射', () => { ['SELECT * FROM users WHERE id = ?', '用户与权限'], ['SELECT * FROM sessions WHERE user_id = ?', '聊天会话'], ['INSERT INTO chat_messages (content) VALUES (?)', '聊天消息'], - ['SELECT * FROM uploaded_files WHERE user_id = ?', '文件管理'], + ['SELECT * FROM user_files WHERE user_id = ?', '文件管理'], + ['SELECT * FROM file_objects WHERE owner_user_id = ?', '文件管理'], ['DELETE FROM chat_attachments WHERE message_id = ?', '消息附件'], - ['SELECT * FROM checkpoints WHERE thread_id = ?', 'Agent 运行状态'], - ['INSERT INTO checkpoint_writes (thread_id) VALUES (?)', 'Agent 运行状态'], + ['SELECT * FROM checkpoint_cleanup_outbox WHERE status = ?', 'Checkpoint 生命周期'], ['SELECT * FROM database_monitor_snapshots WHERE snapshot_key = ?', '数据库监控'], ['UPDATE database_monitor_settings SET version = ?', '监控配置'], ['INSERT INTO admin_audit_events (action) VALUES (?)', '管理员审计'], @@ -56,6 +56,15 @@ describe('SQL Digest 业务语义映射', () => { expect(meaning.confidence).toBe('confirmed') }) + it.each([ + 'SELECT * FROM checkpoints WHERE thread_id = ?', + 'INSERT INTO checkpoint_writes (thread_id) VALUES (?)', + ])('已迁移的 MySQL checkpoint 表不再标记为现行 Agent 状态:%s', (sql) => { + const meaning = mapSqlBusinessMeaning(sql) + expect(meaning.module).toBe('其他数据库表') + expect(meaning.confidence).toBe('inferred') + }) + it.each([ ['COMMIT', '提交事务'], ['ROLLBACK', '回滚事务'], diff --git a/app/admin/business_service.py b/app/admin/business_service.py index 1806332..6e94c6f 100644 --- a/app/admin/business_service.py +++ b/app/admin/business_service.py @@ -33,7 +33,7 @@ CSV_PREVIEW_CELL_CHARS = 1000 USER_ROLES = {"user", "admin"} -JOB_STATUSES = {"queued", "running", "succeeded", "failed", "canceled"} +JOB_STATUSES = {"queued", "running", "waiting_input", "succeeded", "failed", "canceled"} MESSAGE_TYPES = {"user", "ai"} CONTENT_KINDS = {"input", "result", "error"} @@ -156,7 +156,8 @@ def get_business_overview() -> dict[str, Any]: "sessions": "会话", "chat_messages": "消息", "chat_attachments": "附件", - "uploaded_files": "文件", + "user_files": "文件", + "file_objects": "文件对象", "analysis_jobs": "分析任务", "analysis_job_events": "任务事件", } @@ -587,10 +588,13 @@ def list_jobs( db_cursor.execute( f""" SELECT j.id AS row_id, j.job_id, j.user_id, u.username, j.session_id, - j.status, j.worker_id, j.attempt_count, j.max_attempts, + j.status, j.worker_id, j.lease_epoch, j.attempt_count, + j.recovery_count, j.resume_count, j.max_attempts, (j.result_json IS NOT NULL) AS has_result, + (j.message IS NOT NULL) AS has_input, j.locked_at, j.heartbeat_at, j.created_at, j.started_at, - j.finished_at, j.chat_saved_at + j.finished_at, j.chat_saved_at, j.current_question_id, + (j.input_user_file_id IS NOT NULL) AS has_frozen_file FROM analysis_jobs AS j JOIN users AS u ON u.id = j.user_id WHERE {' AND '.join(clauses)} @@ -601,7 +605,8 @@ def list_jobs( ) rows = db_cursor.fetchall() for row in rows: - row["has_result"] = bool(row.get("has_result")) + for field in ("has_result", "has_input", "has_frozen_file"): + row[field] = bool(row.get(field)) return page_result( rows, limit=limit, @@ -610,15 +615,23 @@ def list_jobs( def get_job_detail(job_id: str) -> dict[str, Any]: - """读取任务元数据,不返回输入、结果或完整错误正文。""" + """读取任务元数据和有界输入账本,不返回输入正文。""" with get_read_connection(consistency="strong") as conn: cursor = conn.cursor(dictionary=True) cursor.execute( """ SELECT j.job_id, j.user_id, u.username, j.session_id, j.status, - j.worker_id, j.locked_at, j.heartbeat_at, j.attempt_count, + j.worker_id, j.lease_epoch, j.locked_at, j.heartbeat_at, + j.attempt_count, j.recovery_count, j.resume_count, j.max_attempts, j.created_at, j.started_at, j.finished_at, - j.chat_saved_at, (j.message IS NOT NULL) AS has_input, + j.chat_saved_at, j.input_user_file_id, j.input_object_id, + j.input_file_hash, j.input_filename, j.current_question_id, + j.current_waiting_prompt, (j.message IS NOT NULL) AS has_input, + ( + SELECT COUNT(*) + FROM analysis_job_inputs AS i + WHERE i.job_id = j.job_id + ) AS input_count, (j.result_json IS NOT NULL) AS has_result, ( j.error_message IS NOT NULL OR j.last_error IS NOT NULL @@ -632,8 +645,25 @@ def get_job_detail(job_id: str) -> dict[str, Any]: row = cursor.fetchone() if not row: raise AdminApiError("not_found", "分析任务不存在", 404) + row.pop("input_object_id", None) for field in ("has_input", "has_result", "has_error"): row[field] = bool(row.get(field)) + with get_read_connection(consistency="strong") as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT input_id, sequence, input_type, question_id, created_at, + OCTET_LENGTH(input_text) AS input_bytes + FROM analysis_job_inputs + WHERE job_id = %s + ORDER BY sequence ASC + LIMIT 51 + """, + (job_id,), + ) + inputs = cursor.fetchall() + row["inputs"] = inputs[:50] + row["inputs_truncated"] = len(inputs) > 50 return row @@ -724,7 +754,7 @@ def list_job_checkpoints( try: result = list_job_checkpoint_summaries( - thread_id=str(job["session_id"]), + thread_id=str(job_id), job_id=job_id, limit=limit, before_checkpoint_id=before_checkpoint_id, @@ -747,7 +777,7 @@ def list_job_checkpoints( else None ), "source_alias": CHECKPOINT_SOURCE_ALIAS, - "attribution": "thread_id+metadata.job_id", + "attribution": "thread_id=job_id", } @@ -757,8 +787,9 @@ def get_job_content( kind: str | None, offset: int, limit: int, + sequence: int | None = None, ) -> dict[str, Any]: - """按固定类别读取任务输入、结果或错误正文片段。""" + """按固定类别读取任务正文;输入按 Job 输入账本序号读取。""" normalized_kind = _validated_enum( kind, field="kind", @@ -770,19 +801,60 @@ def get_job_content( "kind 不能为空", fields={"kind": "必须指定 input、result 或 error"}, ) - content_sql = { - "input": "message", - "result": "CAST(result_json AS CHAR CHARACTER SET utf8mb4)", - "error": "CONCAT_WS('\\n', error_message, last_error)", - }[normalized_kind] - result = _read_text_chunk( - table="analysis_jobs", - id_column="job_id", - id_value=job_id, - content_sql=content_sql, - offset=offset, - limit=limit, - ) + if normalized_kind == "input": + selected_sequence = 0 if sequence is None else sequence + if selected_sequence < 0: + raise AdminApiError( + "invalid_query", + "sequence 必须是非负整数", + fields={"sequence": "必须是非负整数"}, + ) + with get_read_connection(consistency="strong") as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT SUBSTRING(CAST(input_text AS BINARY), %s, %s) AS content, + OCTET_LENGTH(input_text) AS total_length + FROM analysis_job_inputs + WHERE job_id = %s AND sequence = %s + """, + (offset + 1, limit + 4, job_id, selected_sequence), + ) + row = cursor.fetchone() + if not row: + raise AdminApiError("not_found", "任务输入不存在", 404) + content = row.get("content") + if content is None: + content = "" + if isinstance(content, (bytes, bytearray)): + content, returned_bytes = _decode_text_chunk(bytes(content), limit=limit) + else: + content = str(content) + returned_bytes = len(content.encode("utf-8")) + total_length = int(row.get("total_length") or returned_bytes) + next_offset = offset + returned_bytes + result = { + "content": content, + "offset": offset, + "limit": limit, + "total_length": total_length, + "complete": next_offset >= total_length, + "next_offset": None if next_offset >= total_length else next_offset, + "sequence": selected_sequence, + } + else: + content_sql = { + "result": "CAST(result_json AS CHAR CHARACTER SET utf8mb4)", + "error": "CONCAT_WS('\\n', error_message, last_error)", + }[normalized_kind] + result = _read_text_chunk( + table="analysis_jobs", + id_column="job_id", + id_value=job_id, + content_sql=content_sql, + offset=offset, + limit=limit, + ) result["kind"] = normalized_kind return result @@ -809,7 +881,7 @@ def list_files( clauses = ["1 = 1"] params: list[Any] = [] if search: - clauses.append("f.original_filename LIKE %s") + clauses.append("f.filename LIKE %s") params.append(_prefix_like(search)) if owner_id: clauses.append("f.user_id = %s") @@ -821,8 +893,8 @@ def list_files( uploaded_at = _timestamp_cursor(cursor_values[0]) file_id = _validated_positive_id(cursor_values[1], field="cursor") clauses.append( - "(f.upload_timestamp < %s " - "OR (f.upload_timestamp = %s AND f.id < %s))" + "(f.uploaded_at < %s " + "OR (f.uploaded_at = %s AND f.id < %s))" ) params.extend((uploaded_at, uploaded_at, file_id)) params.append(limit + 1) @@ -830,13 +902,18 @@ def list_files( db_cursor = conn.cursor(dictionary=True) db_cursor.execute( f""" - SELECT f.id, f.user_id, u.username, f.filename, f.original_filename, - f.mime_type, f.file_size, f.upload_timestamp, - f.last_accessed_at, f.access_count - FROM uploaded_files AS f + SELECT f.id, f.user_id, u.username, + f.filename, f.filename AS original_filename, + o.content_hash AS file_hash, f.mime_type, f.file_size, + f.uploaded_at AS upload_timestamp, f.last_accessed_at, + f.access_count, + (SELECT COUNT(*) FROM user_files AS refs + WHERE refs.object_id = f.object_id) AS object_reference_count + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id JOIN users AS u ON u.id = f.user_id WHERE {' AND '.join(clauses)} - ORDER BY f.upload_timestamp DESC, f.id DESC + ORDER BY f.uploaded_at DESC, f.id DESC LIMIT %s """, tuple(params), @@ -855,10 +932,15 @@ def get_file_detail(file_id: int) -> dict[str, Any]: cursor = conn.cursor(dictionary=True) cursor.execute( """ - SELECT f.id, f.user_id, u.username, f.filename, f.original_filename, - f.mime_type, f.file_size, f.upload_timestamp, - f.last_accessed_at, f.access_count - FROM uploaded_files AS f + SELECT f.id, f.user_id, u.username, + f.filename, f.filename AS original_filename, + o.content_hash AS file_hash, f.mime_type, f.file_size, + f.uploaded_at AS upload_timestamp, f.last_accessed_at, + f.access_count, + (SELECT COUNT(*) FROM user_files AS refs + WHERE refs.object_id = f.object_id) AS object_reference_count + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id JOIN users AS u ON u.id = f.user_id WHERE f.id = %s """, @@ -896,34 +978,43 @@ def _record_file_access( file_id: int, actor: dict[str, Any], action: str, + cursor=None, ) -> None: - """在同一事务中更新访问计数并写入不含正文的审计事件。""" + """在调用方事务中更新访问计数并写入不含正文的审计事件。""" + if cursor is not None: + cursor.execute( + """ + UPDATE user_files + SET last_accessed_at = UTC_TIMESTAMP(6), + access_count = access_count + 1 + WHERE id = %s + """, + (file_id,), + ) + if cursor.rowcount != 1: + raise AdminApiError("not_found", "文件不存在", 404) + insert_admin_audit_event( + cursor, + actor=actor, + action=action, + target_type="user_file", + target_id=str(file_id), + old_values=None, + new_values={"access_count_increment": 1}, + result="success", + request_id=get_request_id(), + error_code=None, + ) + return + try: with get_write_connection() as conn: cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - UPDATE uploaded_files - SET last_accessed_at = UTC_TIMESTAMP(6), - access_count = access_count + 1 - WHERE id = %s - """, - (file_id,), - ) - if cursor.rowcount != 1: - conn.rollback() - raise AdminApiError("not_found", "文件不存在", 404) - insert_admin_audit_event( - cursor, + _record_file_access( + file_id=file_id, actor=actor, action=action, - target_type="uploaded_file", - target_id=str(file_id), - old_values=None, - new_values={"access_count_increment": 1}, - result="success", - request_id=get_request_id(), - error_code=None, + cursor=cursor, ) conn.commit() except AdminApiError: @@ -942,82 +1033,104 @@ def preview_file_csv( actor: dict[str, Any], ) -> dict[str, Any]: """安全解析受限 CSV 预览,并在成功后原子记录访问。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT id, original_filename, mime_type, file_size, - SUBSTRING(file_content, 1, %s) AS preview_content - FROM uploaded_files - WHERE id = %s - """, - (CSV_PREVIEW_BYTES + 4, file_id), - ) - row = cursor.fetchone() - if not row: - raise AdminApiError("not_found", "文件不存在", 404) - filename = str(row.get("original_filename") or "") - mime_type = str(row.get("mime_type") or "") - if os.path.splitext(filename)[1].lower() != ".csv" or mime_type not in { - "text/csv", - "application/vnd.ms-excel", - }: - raise AdminApiError( - code="preview_not_supported", - message="仅支持 CSV 文件安全预览", - status=415, - ) - content = bytes(row.get("preview_content") or b"")[:CSV_PREVIEW_BYTES] - decoded, encoding = _decode_csv_bytes(content) - reader = csv.reader(StringIO(decoded, newline="")) - parsed_rows: list[list[str]] = [] - columns_truncated = False - cells_truncated = False - rows_truncated = False try: - for index, csv_row in enumerate(reader): - if index >= CSV_PREVIEW_ROWS + 1: - rows_truncated = True - break - if len(csv_row) > CSV_PREVIEW_COLUMNS: - columns_truncated = True - normalized_row = [] - for cell in csv_row[:CSV_PREVIEW_COLUMNS]: - if len(cell) > CSV_PREVIEW_CELL_CHARS: - cells_truncated = True - normalized_row.append(cell[:CSV_PREVIEW_CELL_CHARS]) - parsed_rows.append(normalized_row) - except csv.Error as exc: + with get_write_connection() as conn: + cursor = conn.cursor(dictionary=True) + conn.start_transaction() + cursor.execute( + """ + SELECT f.id, f.filename AS original_filename, f.mime_type, f.file_size, + SUBSTRING(o.file_content, 1, %s) AS preview_content + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id + WHERE f.id = %s + FOR UPDATE + """, + (CSV_PREVIEW_BYTES + 4, file_id), + ) + row = cursor.fetchone() + if not row: + raise AdminApiError("not_found", "文件不存在", 404) + filename = str(row.get("original_filename") or "") + mime_type = str(row.get("mime_type") or "") + if os.path.splitext(filename)[1].lower() != ".csv" or mime_type not in { + "text/csv", + "application/vnd.ms-excel", + }: + raise AdminApiError( + code="preview_not_supported", + message="仅支持 CSV 文件安全预览", + status=415, + ) + content = bytes(row.get("preview_content") or b"")[:CSV_PREVIEW_BYTES] + decoded, encoding = _decode_csv_bytes(content) + reader = csv.reader(StringIO(decoded, newline="")) + parsed_rows: list[list[str]] = [] + columns_truncated = False + cells_truncated = False + rows_truncated = False + try: + for index, csv_row in enumerate(reader): + if index >= CSV_PREVIEW_ROWS + 1: + rows_truncated = True + break + if len(csv_row) > CSV_PREVIEW_COLUMNS: + columns_truncated = True + normalized_row = [] + for cell in csv_row[:CSV_PREVIEW_COLUMNS]: + if len(cell) > CSV_PREVIEW_CELL_CHARS: + cells_truncated = True + normalized_row.append(cell[:CSV_PREVIEW_CELL_CHARS]) + parsed_rows.append(normalized_row) + except csv.Error as exc: + raise AdminApiError( + code="preview_parse_failed", + message="CSV 内容解析失败", + status=422, + ) from exc + columns = parsed_rows[0] if parsed_rows else [] + data_rows = parsed_rows[1:] if len(parsed_rows) > 1 else [] + byte_truncated = int(row.get("file_size") or 0) > CSV_PREVIEW_BYTES + _record_file_access( + file_id=file_id, + actor=actor, + action="business.file.preview", + cursor=cursor, + ) + conn.commit() + return { + "file_id": file_id, + "filename": filename, + "mime_type": mime_type, + "encoding": encoding, + "columns": columns, + "rows": data_rows, + "truncated": bool( + byte_truncated or rows_truncated or columns_truncated or cells_truncated + ), + "limits": { + "bytes": CSV_PREVIEW_BYTES, + "rows": CSV_PREVIEW_ROWS, + "columns": CSV_PREVIEW_COLUMNS, + "cell_chars": CSV_PREVIEW_CELL_CHARS, + }, + } + except AdminApiError: + try: + conn.rollback() + except (UnboundLocalError, AttributeError): + pass + raise + except Exception as exc: + try: + conn.rollback() + except (UnboundLocalError, AttributeError): + pass raise AdminApiError( - code="preview_parse_failed", - message="CSV 内容解析失败", - status=422, + code="audit_unavailable", + message="文件访问审计暂时不可用", + status=503, ) from exc - columns = parsed_rows[0] if parsed_rows else [] - data_rows = parsed_rows[1:] if len(parsed_rows) > 1 else [] - byte_truncated = int(row.get("file_size") or 0) > CSV_PREVIEW_BYTES - _record_file_access( - file_id=file_id, - actor=actor, - action="business.file.preview", - ) - return { - "file_id": file_id, - "filename": filename, - "mime_type": mime_type, - "encoding": encoding, - "columns": columns, - "rows": data_rows, - "truncated": bool( - byte_truncated or rows_truncated or columns_truncated or cells_truncated - ), - "limits": { - "bytes": CSV_PREVIEW_BYTES, - "rows": CSV_PREVIEW_ROWS, - "columns": CSV_PREVIEW_COLUMNS, - "cell_chars": CSV_PREVIEW_CELL_CHARS, - }, - } def download_file( @@ -1026,23 +1139,46 @@ def download_file( actor: dict[str, Any], ) -> tuple[BytesIO, dict[str, Any]]: """读取完整文件并在返回前原子记录访问计数与审计。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute( - """ - SELECT id, original_filename, mime_type, file_size, file_content - FROM uploaded_files - WHERE id = %s - """, - (file_id,), - ) - row = cursor.fetchone() - if not row: - raise AdminApiError("not_found", "文件不存在", 404) - content = bytes(row.pop("file_content") or b"") - _record_file_access( - file_id=file_id, - actor=actor, - action="business.file.download", - ) - return BytesIO(content), row + try: + with get_write_connection() as conn: + cursor = conn.cursor(dictionary=True) + conn.start_transaction() + cursor.execute( + """ + SELECT f.id, f.filename AS original_filename, f.mime_type, + f.file_size, o.file_content + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id + WHERE f.id = %s + FOR UPDATE + """, + (file_id,), + ) + row = cursor.fetchone() + if not row: + raise AdminApiError("not_found", "文件不存在", 404) + content = bytes(row.pop("file_content") or b"") + _record_file_access( + file_id=file_id, + actor=actor, + action="business.file.download", + cursor=cursor, + ) + conn.commit() + return BytesIO(content), row + except AdminApiError: + try: + conn.rollback() + except (UnboundLocalError, AttributeError): + pass + raise + except Exception as exc: + try: + conn.rollback() + except (UnboundLocalError, AttributeError): + pass + raise AdminApiError( + code="audit_unavailable", + message="文件访问审计暂时不可用", + status=503, + ) from exc diff --git a/app/admin/routes.py b/app/admin/routes.py index 4427de6..f8eecc8 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -749,6 +749,10 @@ def business_job_content(job_id: str): field="offset", ), limit=content_chunk_limit(request.args.get("limit")), + sequence=parse_non_negative_int( + request.args.get("sequence"), + field="sequence", + ), ) return api_success(data) @@ -772,12 +776,12 @@ def business_files(): @admin_api_endpoint @audited_access( action="business.file.detail.view", - target_type="uploaded_file", + target_type="user_file", target_id=lambda values: str(values["file_id"]), ) @admin_required def business_file_detail(file_id: int): - """返回文件元数据且不夹带 BLOB 或哈希。""" + """返回文件元数据且不夹带 BLOB 或内部对象 ID。""" return api_success(get_file_detail(file_id)) @@ -785,7 +789,7 @@ def business_file_detail(file_id: int): @admin_api_endpoint @audited_access( action="business.file.preview", - target_type="uploaded_file", + target_type="user_file", target_id=lambda values: str(values["file_id"]), audit_success=False, ) @@ -799,7 +803,7 @@ def business_file_preview(file_id: int): @admin_api_endpoint @audited_access( action="business.file.download", - target_type="uploaded_file", + target_type="user_file", target_id=lambda values: str(values["file_id"]), audit_success=False, ) @@ -823,7 +827,7 @@ def business_file_download(file_id: int): @admin_api_endpoint @audited_access( action="business.file.delete.preview", - target_type="uploaded_file", + target_type="user_file", target_id=lambda values: str(values["file_id"]), ) @admin_required @@ -836,13 +840,13 @@ def business_file_delete_impact(file_id: int): @admin_api_endpoint @audited_access( action="business.file.delete", - target_type="uploaded_file", + target_type="user_file", target_id=lambda values: str(values["file_id"]), audit_success=False, ) @admin_write_required def business_file_delete(file_id: int): - """物理删除 uploaded_files 记录和 BLOB,不提供回收站。""" + """删除 user_files 逻辑记录,并按引用计数清理不可变 BLOB。""" return api_success(delete_managed_file( file_id, request.get_json(silent=True), diff --git a/app/admin/write_service.py b/app/admin/write_service.py index 6a6592c..a1d58f0 100644 --- a/app/admin/write_service.py +++ b/app/admin/write_service.py @@ -829,7 +829,7 @@ def _user_impact(cursor, user: dict[str, Any]) -> dict[str, Any]: JOIN chat_messages AS m ON m.id = a.message_id WHERE m.user_id = %s ) AS attachments, - (SELECT COUNT(*) FROM uploaded_files WHERE user_id = %s) AS files, + (SELECT COUNT(*) FROM user_files WHERE user_id = %s) AS files, (SELECT COUNT(*) FROM analysis_jobs WHERE user_id = %s) AS jobs, ( SELECT COUNT(*) @@ -841,13 +841,13 @@ def _user_impact(cursor, user: dict[str, Any]) -> dict[str, Any]: ( SELECT COUNT(*) FROM checkpoint_cleanup_outbox AS o - JOIN sessions AS s ON s.id = o.thread_id - WHERE s.user_id = %s AND o.status <> 'succeeded' + JOIN analysis_jobs AS j ON j.job_id = o.thread_id + WHERE j.user_id = %s AND o.status <> 'succeeded' ) AS checkpoint_cleanup_pending, ( SELECT COUNT(*) FROM analysis_jobs - WHERE user_id = %s AND status IN ('queued', 'running') + WHERE user_id = %s AND status IN ('queued', 'running', 'waiting_input') ) AS active_jobs """, (user_id,) * 9, @@ -900,7 +900,7 @@ def get_user_delete_impact( if user["role"] == "admin" and user["is_active"] and enabled_admin_count <= 1: blockers.append("不能删除最后一个启用管理员") if impact["active_jobs"]: - blockers.append("目标用户仍有 queued/running 分析任务") + blockers.append("目标用户仍有 queued/running/waiting_input 分析任务") if impact["total_related_rows"] > settings.ADMIN_DELETE_MAX_RELATED_ROWS: blockers.append( "关联记录超过同步删除安全上限,需在维护窗口使用专用流程处理" @@ -1011,18 +1011,19 @@ def delete_user( ) cursor.execute( """ - SELECT job_id + SELECT job_id, status FROM analysis_jobs - WHERE user_id = %s AND status IN ('queued', 'running') + WHERE user_id = %s ORDER BY id FOR UPDATE """, (user_id,), ) - if cursor.fetchall(): + job_rows = cursor.fetchall() + if any(row["status"] in {"queued", "running", "waiting_input"} for row in job_rows): raise AdminApiError( "active_jobs_block_delete", - "目标用户仍有 queued/running 分析任务", + "目标用户仍有 queued/running/waiting_input 分析任务", status=409, ) impact = _user_impact(cursor, user) @@ -1032,11 +1033,6 @@ def delete_user( "关联记录超过同步删除安全上限", status=409, ) - cursor.execute( - "SELECT id FROM sessions WHERE user_id = %s ORDER BY id FOR UPDATE", - (user_id,), - ) - session_ids = [str(row["id"]) for row in cursor.fetchall()] _insert_operation( cursor, operation_id=operation_id, @@ -1048,9 +1044,25 @@ def delete_user( ) cleanup_count = enqueue_checkpoint_cleanup_many( cursor, - session_ids, + [str(row["job_id"]) for row in job_rows], operation_id=operation_id, ) + cursor.execute( + "SELECT id FROM file_objects WHERE owner_user_id = %s FOR UPDATE", + (user_id,), + ) + object_ids = [int(row["id"]) for row in cursor.fetchall()] + cursor.execute("DELETE FROM user_files WHERE user_id = %s", (user_id,)) + deleted_user_files = cursor.rowcount + deleted_file_objects = 0 + for object_id in object_ids: + cursor.execute( + "SELECT COUNT(*) AS reference_count FROM user_files WHERE object_id = %s", + (object_id,), + ) + if int((cursor.fetchone() or {}).get("reference_count") or 0) == 0: + cursor.execute("DELETE FROM file_objects WHERE id = %s", (object_id,)) + deleted_file_objects += cursor.rowcount cursor.execute( "DELETE FROM archived_sessions WHERE user_id = %s", (user_id,), @@ -1068,6 +1080,8 @@ def delete_user( new_values = { "deleted": True, "deleted_archived_sessions": deleted_archives, + "deleted_user_files": deleted_user_files, + "deleted_file_objects": deleted_file_objects, "checkpoint_cleanup": { "status": "pending" if cleanup_count else "succeeded", "total": cleanup_count, @@ -1206,15 +1220,20 @@ def get_operation(operation_id: str, *, actor: dict[str, Any]) -> dict[str, Any] def get_file_delete_impact(file_id: int) -> dict[str, Any]: - """预览文件 BLOB 删除元数据,并保守检查归属用户活动任务。""" + """预览逻辑文件删除影响,并检查冻结输入和共享 BLOB 引用。""" with get_read_connection(consistency="strong") as conn: cursor = conn.cursor(dictionary=True) cursor.execute( """ - SELECT f.id, f.user_id, u.username, f.original_filename, - f.mime_type, f.file_size, f.upload_timestamp, - f.last_accessed_at, f.access_count - FROM uploaded_files AS f + SELECT f.id, f.user_id, u.username, f.object_id, + f.filename, f.filename AS original_filename, + o.content_hash AS file_hash, f.mime_type, f.file_size, + f.uploaded_at AS upload_timestamp, + f.last_accessed_at, f.access_count, + (SELECT COUNT(*) FROM user_files AS refs + WHERE refs.object_id = f.object_id) AS object_reference_count + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id JOIN users AS u ON u.id = f.user_id WHERE f.id = %s """, @@ -1227,22 +1246,31 @@ def get_file_delete_impact(file_id: int) -> dict[str, Any]: """ SELECT COUNT(*) AS count_value FROM analysis_jobs - WHERE user_id = %s AND status IN ('queued', 'running') + WHERE input_user_file_id = %s + AND status IN ('queued', 'running', 'waiting_input') """, - (file_row["user_id"],), + (file_id,), ) active_jobs = int((cursor.fetchone() or {}).get("count_value") or 0) blockers = ( - ["文件缺少稳定 job 关联,归属用户存在 queued/running 任务"] + ["文件仍被 queued/running/waiting_input 任务冻结使用"] if active_jobs else [] ) + reference_count = int(file_row.get("object_reference_count") or 0) + # object_id 只用于本次查询和引用计数,不能进入管理员公开 DTO。 + file_row.pop("object_id", None) return { "file": file_row, "impact": { "database_rows": 1, - "blob_bytes": int(file_row.get("file_size") or 0), + "blob_bytes": ( + int(file_row.get("file_size") or 0) + if reference_count == 1 + else 0 + ), "owner_active_jobs": active_jobs, + "object_reference_count": reference_count, }, "can_delete": not blockers, "blockers": blockers, @@ -1259,7 +1287,7 @@ def delete_file( actor: dict[str, Any], idempotency_key: str | None, ) -> dict[str, Any]: - """原子删除 uploaded_files 行和 BLOB,并保留去敏操作审计。""" + """原子删除逻辑文件,并仅在无其他引用时删除不可变 BLOB。""" request_body = _require_body(body) if request_body.get("confirmed") is not True: raise AdminApiError( @@ -1304,7 +1332,7 @@ def delete_file( connection.rollback() return replay cursor.execute( - "SELECT user_id FROM uploaded_files WHERE id = %s", + "SELECT user_id FROM user_files WHERE id = %s", (file_id,), ) owner = cursor.fetchone() @@ -1319,10 +1347,13 @@ def delete_file( raise AdminApiError("owner_not_found", "文件归属用户不存在", 409) cursor.execute( """ - SELECT f.id, f.user_id, u.username, f.original_filename, - f.mime_type, f.file_size, f.upload_timestamp, + SELECT f.id, f.user_id, f.object_id, u.username, + f.filename, f.filename AS original_filename, + o.content_hash AS file_hash, f.mime_type, f.file_size, + f.uploaded_at AS upload_timestamp, f.last_accessed_at, f.access_count - FROM uploaded_files AS f + FROM user_files AS f + JOIN file_objects AS o ON o.id = f.object_id JOIN users AS u ON u.id = f.user_id WHERE f.id = %s FOR UPDATE @@ -1342,18 +1373,25 @@ def delete_file( """ SELECT job_id FROM analysis_jobs - WHERE user_id = %s AND status IN ('queued', 'running') + WHERE input_user_file_id = %s + AND status IN ('queued', 'running', 'waiting_input') ORDER BY id FOR UPDATE """, - (file_row["user_id"],), + (file_id,), ) if cursor.fetchall(): raise AdminApiError( "active_jobs_block_delete", - "文件归属用户仍有 queued/running 分析任务", + "文件仍被 queued/running/waiting_input 任务冻结使用", status=409, ) + cursor.execute( + "SELECT id FROM file_objects WHERE id = %s FOR UPDATE", + (file_row["object_id"],), + ) + if not cursor.fetchone(): + raise AdminApiError("object_not_found", "文件对象不存在", 409) _insert_operation( cursor, operation_id=operation_id, @@ -1363,9 +1401,21 @@ def delete_file( fingerprint=fingerprint, target_count=1, ) - cursor.execute("DELETE FROM uploaded_files WHERE id = %s", (file_id,)) + cursor.execute("DELETE FROM user_files WHERE id = %s", (file_id,)) if cursor.rowcount != 1: raise RuntimeError("文件删除影响行数异常") + cursor.execute( + "SELECT COUNT(*) AS reference_count FROM user_files WHERE object_id = %s", + (file_row["object_id"],), + ) + reference_count = int((cursor.fetchone() or {}).get("reference_count") or 0) + blob_deleted = False + if reference_count == 0: + cursor.execute( + "DELETE FROM file_objects WHERE id = %s", + (file_row["object_id"],), + ) + blob_deleted = cursor.rowcount == 1 old_values = { "user_id": file_row["user_id"], "username": file_row["username"], @@ -1375,11 +1425,16 @@ def delete_file( "upload_timestamp": file_row["upload_timestamp"], "access_count": int(file_row["access_count"] or 0), } - new_values = {"deleted": True, "blob_deleted": True, "recycle_bin": False} + new_values = { + "deleted": True, + "blob_deleted": blob_deleted, + "object_reference_count": reference_count, + "recycle_bin": False, + } _insert_operation_item( cursor, operation_id=operation_id, - target_type="uploaded_file", + target_type="user_file", target_id=str(file_id), target_label=file_row["original_filename"], old_values=old_values, @@ -1389,7 +1444,7 @@ def delete_file( cursor, actor=actor, action=operation_type, - target_type="uploaded_file", + target_type="user_file", target_id=str(file_id), old_values=old_values, new_values=new_values, @@ -1404,7 +1459,7 @@ def delete_file( "file_id": file_id, "filename": file_row["original_filename"], "deleted": True, - "blob_deleted": True, + "blob_deleted": blob_deleted, "replayed": False, } _complete_operation( diff --git a/app/agent/AGENTS.md b/app/agent/AGENTS.md new file mode 100644 index 0000000..5173367 --- /dev/null +++ b/app/agent/AGENTS.md @@ -0,0 +1,32 @@ +# app/agent/AGENTS.md + +生效目录:`app/agent/` 及其子目录。 + +负责约束的修改类型:analysis Job API、Job service、输入/文件快照、幂等、SSE、事件日志、checkpoint recovery、worker lease/fencing 和 worker 运行时。 + +## 修改前必须阅读 + +- 必须阅读 [`Document/architecture/job-file-lifecycle.md`](../../Document/architecture/job-file-lifecycle.md)、[`Document/architecture/agent-runtime.md`](../../Document/architecture/agent-runtime.md)、[`Document/api/agent-jobs.md`](../../Document/api/agent-jobs.md) 和 [`Document/api/conventions.md`](../../Document/api/conventions.md)。 +- 必须检查调用入口、`app/agent/routes.py`、`job_service.py`、worker runtime/bootstrap、相关 migration、普通用户前端调用和对应 unit 测试。 +- 修改 checkpoint identity、Job 字段或事件 payload 时必须同时核对 PostgreSQL inspection、管理员 Job API 和 SSE 恢复逻辑。 + +## Job 不变量 + +- 创建、resume、cancel 操作必须使用标准 UUID v4 `Idempotency-Key`;相同键重试必须复用原请求,不同请求参数必须返回冲突。 +- 同一 `user_id + session_id` 同时最多允许一个 `queued/running/waiting_input` Job;不能通过绕过服务层或重建 Session 破坏这个约束。 +- Job 初始输入必须在 MySQL 事务中写入 `analysis_job_inputs`,并冻结 `input_user_file_id`、对象 ID、hash 和文件名快照;浏览器未发送的文件选择不是持久化输入。 +- `waiting_input` 必须保留活动会话锁但释放 worker lease;resume 追加 input 后重新排队同一个 Job;cancel 只允许作用于等待输入的 Job。 +- 所有 worker 状态、事件和终态更新必须校验 worker、attempt 和 `lease_epoch`;失去 lease 的旧 worker 禁止覆盖新尝试。 + +## 公共事件与安全 + +- SSE 只能从持久化 `analysis_job_events` 按 Event ID 续传;必须支持 `Last-Event-ID`,不能依赖 worker 内存状态。 +- 普通用户事件必须经过脱敏,只允许公开文字和稳定状态;禁止输出原始 prompt、ToolMessage、完整工具结果、文件正文、图状态、内部 attempt 或隐藏推理。 +- terminal/interrupt 事件的写入、assistant 消息和 Job 状态必须保持事务语义;稳定生命周期事件必须有可重放的 event key。 +- checkpoint 读取失败时禁止盲目 stale recovery;必须阻止可能重复执行的恢复动作并返回稳定错误。 + +## 修改后验证 + +- Job/API 变更至少覆盖正常、重复请求、参数冲突、活动 Job 冲突、状态冲突、越权、断线续传和终态收敛。 +- worker/事件变更至少运行 `tests/unit/agent/` 中对应测试,并检查普通用户 payload 的脱敏结果。 +- 修改文件、Session 删除或 outbox 时必须联测 `app/chat/`、`app/files/`、`Database/` 和对应 migration;不能只跑 Agent 图测试。 diff --git a/app/agent/checkpoint_cleanup.py b/app/agent/checkpoint_cleanup.py index d19f0a9..087ed42 100644 --- a/app/agent/checkpoint_cleanup.py +++ b/app/agent/checkpoint_cleanup.py @@ -64,11 +64,11 @@ def write_cleanup_runtime_snapshot(payload: dict[str, Any]) -> None: def enqueue_checkpoint_cleanup( cursor, - thread_id: str, + job_id: str, *, operation_id: str | None = None, ) -> bool: - """登记一个幂等 cleanup 任务,并返回是否仍需后台清理。""" + """登记一个 Job checkpoint cleanup 任务,并返回是否仍需后台清理。""" cursor.execute( """ INSERT INTO checkpoint_cleanup_outbox ( @@ -83,11 +83,11 @@ def enqueue_checkpoint_cleanup( lease_expires_at = NULL, last_error = NULL """, - (thread_id, operation_id), + (job_id, operation_id), ) cursor.execute( "SELECT status FROM checkpoint_cleanup_outbox WHERE thread_id = %s", - (thread_id,), + (job_id,), ) row = cursor.fetchone() status = row.get("status") if isinstance(row, dict) else (row[0] if row else None) @@ -96,14 +96,14 @@ def enqueue_checkpoint_cleanup( def enqueue_checkpoint_cleanup_many( cursor, - thread_ids: Iterable[str], + job_ids: Iterable[str], *, operation_id: str | None = None, ) -> int: - """批量登记用户删除涉及的 checkpoint thread,并返回输入数量。""" + """批量登记删除涉及的 Job checkpoint,并返回仍需处理的数量。""" count = 0 - for thread_id in thread_ids: - if enqueue_checkpoint_cleanup(cursor, str(thread_id), operation_id=operation_id): + for job_id in job_ids: + if enqueue_checkpoint_cleanup(cursor, str(job_id), operation_id=operation_id): count += 1 return count @@ -253,7 +253,7 @@ def claim_cleanup_item( _update_operation_aggregate(cursor, operation_id) cursor.execute( """ - SELECT id, thread_id, operation_id, attempts + SELECT id, thread_id AS job_id, operation_id, attempts FROM checkpoint_cleanup_outbox WHERE status = 'pending' AND attempts < %s diff --git a/app/agent/checkpoint_recovery.py b/app/agent/checkpoint_recovery.py new file mode 100644 index 0000000..1426fc0 --- /dev/null +++ b/app/agent/checkpoint_recovery.py @@ -0,0 +1,158 @@ +"""只读检查 LangGraph checkpoint 中尚未恢复的 interrupt。""" + +from __future__ import annotations + +import math +from typing import Any + + +PENDING_INTERRUPT_CHANNEL = "__interrupt__" +RESUME_CHANNEL = "__resume__" +ROOT_CHECKPOINT_NS = "" + + +class CheckpointRecoveryUnavailable(RuntimeError): + """表示 stale recovery 无法可靠读取 PostgreSQL checkpoint。""" + + +def checkpoint_identity(job_id: str) -> tuple[str, str]: + """返回当前 Job 的 LangGraph thread identity 和根 namespace。""" + normalized_job_id = str(job_id).strip() if job_id is not None else "" + if not normalized_job_id: + raise ValueError("checkpoint identity 缺少 job_id") + return normalized_job_id, ROOT_CHECKPOINT_NS + + +def _connection_options(timeout_ms: int) -> str: + """构造只读连接选项,并把单次查询限制在有限时间内。""" + return ( + "-c default_transaction_read_only=on " + f"-c statement_timeout={max(1, int(timeout_ms))}" + ) + + +def _interrupts(value: Any) -> list[Any]: + """把 saver 解码后的 interrupt 写入规范化为列表。""" + if isinstance(value, (list, tuple)): + return list(value) + return [value] + + +def _interrupt_snapshot(value: Any) -> dict[str, str] | None: + """提取不含内部状态的稳定问题 ID 和公开提示。""" + for item in _interrupts(value): + if isinstance(item, dict): + question_id = item.get("id") + prompt = item.get("value") + else: + question_id = getattr(item, "id", None) + prompt = getattr(item, "value", None) + if not question_id: + continue + if isinstance(prompt, str): + message = prompt + else: + message = str(prompt) if prompt is not None else "请补充分析信息" + return { + "question_id": str(question_id)[:255], + "message": message[:20000], + } + return None + + +def get_pending_interrupt( + *, + job_id: str, + timeout_ms: int, +) -> dict[str, str] | None: + """读取指定 Job 根 checkpoint 的未恢复 interrupt。 + + 只解码 checkpoint_writes 中的控制信号,不读取 checkpoint 正文或 BLOB,避免 + stale recovery 检查把文件内容重新载入内存。与 LangGraph 运行时一致,已经有 + 同一 task 的 ``__resume__`` 写入时,该 interrupt 不再视为待恢复。 + """ + try: + # 保持 job_service 的轻量导入链可用;只有 stale recovery 真正发生时才加载 + # PostgreSQL/LangGraph 依赖。 + import psycopg + from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + from psycopg.rows import dict_row + + from config.checkpoint_settings import CheckpointPostgresConfig + + config = CheckpointPostgresConfig.from_env() + config.validate(require_credentials=True) + thread_id, checkpoint_ns = checkpoint_identity(job_id) + serializer = JsonPlusSerializer(pickle_fallback=True) + with psycopg.connect( + host=config.host, + port=config.port, + dbname=config.database, + user=config.user, + password=config.password, + connect_timeout=max(1, math.ceil(config.connect_timeout_seconds)), + options=_connection_options(timeout_ms), + autocommit=True, + prepare_threshold=0, + row_factory=dict_row, + ) as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT checkpoint_id + FROM checkpoints + WHERE thread_id = %s AND checkpoint_ns = %s + ORDER BY checkpoint_id DESC + LIMIT 1 + """, + (str(thread_id), str(checkpoint_ns)), + ) + checkpoint = cursor.fetchone() + if not checkpoint: + return None + + cursor.execute( + """ + SELECT task_id, channel, type, blob + FROM checkpoint_writes + WHERE thread_id = %s + AND checkpoint_ns = %s + AND checkpoint_id = %s + ORDER BY task_id, idx + """, + ( + str(thread_id), + str(checkpoint_ns), + checkpoint["checkpoint_id"], + ), + ) + rows = cursor.fetchall() + except Exception as exc: + raise CheckpointRecoveryUnavailable( + "无法读取 PostgreSQL checkpoint 的恢复状态" + ) from exc + + pending: dict[str, dict[str, str]] = {} + resumed_tasks: set[str] = set() + try: + for row in rows: + task_id = str(row["task_id"]) + channel = str(row["channel"]) + if channel not in {PENDING_INTERRUPT_CHANNEL, RESUME_CHANNEL}: + continue + value = serializer.loads_typed((str(row["type"]), row["blob"])) + if channel == RESUME_CHANNEL: + resumed_tasks.add(task_id) + continue + snapshot = _interrupt_snapshot(value) + if snapshot: + pending[task_id] = snapshot + except Exception as exc: + raise CheckpointRecoveryUnavailable( + "无法解码 PostgreSQL checkpoint 的恢复状态" + ) from exc + + for task_id, snapshot in pending.items(): + if task_id not in resumed_tasks: + return snapshot + return None diff --git a/app/agent/job_service.py b/app/agent/job_service.py index 5908c28..dc05b14 100644 --- a/app/agent/job_service.py +++ b/app/agent/job_service.py @@ -1,9 +1,4 @@ -""" -长任务队列服务。 - -Web 进程只调用创建 job、读取事件;worker 进程调用领取、心跳、 -写事件和终态更新。实时路径全部走写库,避免副本延迟影响 SSE。 -""" +"""长任务队列、冻结输入和生命周期幂等服务。""" from __future__ import annotations @@ -12,47 +7,150 @@ import hmac import json import logging -import re -import uuid +import math from typing import Any +from uuid import UUID, uuid4 import mysql.connector from mysql.connector import errorcode +from app.chat.services import ( + save_assistant_for_job_in_transaction, + save_user_input_for_job_in_transaction, +) from app.db import get_read_connection, get_read_connection_with_source, get_write_connection -from app.chat.services import save_chat_for_job_in_transaction from config.settings import settings -ACTIVE_STATUSES = ("queued", "running") +ACTIVE_STATUSES = ("queued", "running", "waiting_input") TERMINAL_STATUSES = ("succeeded", "failed", "canceled") -TERMINAL_EVENTS = {"final_result", "interrupt", "error"} -MAX_ATTEMPTS_ERROR = "任务达到最大尝试次数,且最后一次 worker 心跳已过期" -IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9._:-]{16,128}$") +TERMINAL_EVENTS = {"final_result", "error", "canceled"} +MAX_ATTEMPTS_ERROR = "任务达到最大 stale recovery 次数,且最后一次 worker 心跳已过期" +MAX_INPUT_TEXT_LENGTH = 20000 +MAX_STRUCTURED_INPUT_BYTES = 8192 +MAX_STRUCTURED_INPUT_DEPTH = 4 +MAX_STRUCTURED_INPUT_ITEMS = 64 +CHECKPOINT_RECOVERY_TIMEOUT_MS = 3000 class InvalidIdempotencyKeyError(ValueError): - """表示客户端没有提供符合 API 约束的幂等键。""" + """表示请求没有提供标准 UUID v4 幂等键。""" class IdempotencyConflictError(ValueError): - """表示同一个幂等键被用于不同的分析请求。""" + """表示同一个幂等键被用于不同的请求参数。""" + + +class ActiveJobConflictError(ValueError): + """表示同一 session 已经存在活动 Job。""" + + def __init__(self, job: dict[str, Any]): + super().__init__("当前会话已有 queued/running/waiting_input 任务") + self.job = job + + +class JobStateConflictError(ValueError): + """表示 Job 当前状态不允许执行请求。""" + + def __init__(self, message: str, job: dict[str, Any] | None = None): + super().__init__(message) + self.job = job + + +class _CheckpointRecoveryBlocked(RuntimeError): + """PostgreSQL checkpoint 不可读时阻止本轮 stale recovery。""" def normalize_idempotency_key(value: str | None) -> str: - """校验并返回分析任务创建 API 使用的规范化幂等键。""" + """校验标准 UUID v4 并规范化为小写 canonical 文本。""" normalized = value.strip() if isinstance(value, str) else "" - if not IDEMPOTENCY_KEY_PATTERN.fullmatch(normalized): - raise InvalidIdempotencyKeyError( - "Idempotency-Key 必须是 16 到 128 位安全字符" + if len(normalized) != 36 or normalized.count("-") != 4: + raise InvalidIdempotencyKeyError("Idempotency-Key 必须是标准 UUID v4") + try: + parsed = UUID(normalized) + except (TypeError, ValueError) as exc: + raise InvalidIdempotencyKeyError("Idempotency-Key 必须是标准 UUID v4") from exc + if parsed.version != 4 or str(parsed) != normalized.lower(): + raise InvalidIdempotencyKeyError("Idempotency-Key 必须是标准 UUID v4") + return str(parsed) + + +def _validate_input_text(value: Any) -> str: + """校验普通文本输入的类型、非空约束和长度。""" + if not isinstance(value, str): + raise ValueError("消息必须是文本") + text = value.strip() + if not text: + raise ValueError("消息不能为空") + if len(text) > MAX_INPUT_TEXT_LENGTH: + raise ValueError("输入内容超过长度限制") + return text + + +def _validate_structured_value(value: Any, *, depth: int = 0) -> None: + """递归校验恢复回答中的有限 JSON 类型、深度、项数和字符串大小。""" + if depth > MAX_STRUCTURED_INPUT_DEPTH: + raise ValueError("结构化回答嵌套层级过深") + if value is None or isinstance(value, (str, bool, int)): + if isinstance(value, str): + if len(value) > MAX_INPUT_TEXT_LENGTH: + raise ValueError("结构化回答中的文本超过长度限制") + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("结构化回答包含无效数字") + return + if isinstance(value, dict): + if len(value) > MAX_STRUCTURED_INPUT_ITEMS: + raise ValueError("结构化回答字段过多") + for key, item in value.items(): + if not isinstance(key, str) or not key or len(key) > 128: + raise ValueError("结构化回答字段名无效") + _validate_structured_value(item, depth=depth + 1) + return + if isinstance(value, list): + if len(value) > MAX_STRUCTURED_INPUT_ITEMS: + raise ValueError("结构化回答数组项过多") + for item in value: + _validate_structured_value(item, depth=depth + 1) + return + raise ValueError("回答必须是文本或受限 JSON 对象/数组") + + +def _normalize_input_value(value: Any) -> tuple[str, Any]: + """规范化文本或结构化恢复回答,返回存储文本和 worker 运行时值。""" + if isinstance(value, str): + text = _validate_input_text(value) + return text, text + if not isinstance(value, (dict, list)): + raise ValueError("回答必须是文本或受限 JSON 对象/数组") + _validate_structured_value(value) + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), ) - return normalized + except (TypeError, ValueError) as exc: + raise ValueError("结构化回答不是有效 JSON") from exc + if len(encoded.encode("utf-8")) > MAX_STRUCTURED_INPUT_BYTES: + raise ValueError("结构化回答超过大小限制") + return encoded, value -def _request_fingerprint(session_id: str, message: str) -> str: - """使用服务端密钥为当前分析请求生成不包含正文的稳定指纹。""" +def _request_fingerprint( + session_id: str, + message: str, + input_user_file_id: int | None = None, +) -> str: + """使用服务端密钥为创建请求生成包含冻结文件选择的指纹。""" canonical = json.dumps( - {"message": message, "session_id": session_id}, + { + "message": message, + "session_id": session_id, + "input_user_file_id": input_user_file_id, + }, ensure_ascii=False, sort_keys=True, separators=(",", ":"), @@ -61,22 +159,47 @@ def _request_fingerprint(session_id: str, message: str) -> str: return hmac.new(secret, canonical.encode("utf-8"), hashlib.sha256).hexdigest() +def _action_fingerprint( + *, + job_id: str, + action: str, + text: str = "", + question_id: str = "", +) -> str: + """为恢复/取消操作生成不包含敏感正文的服务端指纹。""" + canonical = json.dumps( + { + "action": action, + "job_id": job_id, + "question_id": question_id, + "text": text, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return hmac.new( + str(settings.SECRET_KEY).encode("utf-8"), + canonical.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + def _ensure_same_request(job: dict[str, Any], fingerprint: str) -> dict[str, Any]: - """确认已有幂等记录与本次请求参数一致,并返回原 job。""" + """确认已有幂等记录与本次创建请求参数一致。""" if job.get("request_fingerprint") != fingerprint: raise IdempotencyConflictError("Idempotency-Key 已用于不同的分析请求") return job def _json_dumps(value: Any) -> str: + """把内部结构化值编码成 JSON。""" return json.dumps(value, ensure_ascii=False) def _json_loads(value: Any) -> Any: - """将原本的 JSON 字符串反序列化为 Python 的字典或列表""" - if value is None: - return None - if isinstance(value, (dict, list)): + """把 MySQL JSON 值转换为 Python 值。""" + if value is None or isinstance(value, (dict, list)): return value if isinstance(value, (bytes, bytearray)): value = value.decode("utf-8") @@ -84,7 +207,7 @@ def _json_loads(value: Any) -> Any: def _row_to_job(row: dict[str, Any] | None) -> dict[str, Any] | None: - """数据清洗""" + """清洗 Job 行中的 JSON 结果。""" if not row: return None if "result_json" in row: @@ -93,16 +216,15 @@ def _row_to_job(row: dict[str, Any] | None) -> dict[str, Any] | None: def get_active_job(user_id: int, session_id: str) -> dict[str, Any] | None: - """读取同一用户同一会话下尚未结束的 job。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) + """读取同一用户同一会话下尚未结束的 Job。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) cursor.execute( """ SELECT * FROM analysis_jobs - WHERE user_id = %s - AND session_id = %s - AND status IN ('queued', 'running') + WHERE user_id = %s AND session_id = %s + AND status IN ('queued', 'running', 'waiting_input') ORDER BY created_at ASC LIMIT 1 """, @@ -111,10 +233,46 @@ def get_active_job(user_id: int, session_id: str) -> dict[str, Any] | None: return _row_to_job(cursor.fetchone()) +def get_active_jobs(user_id: int, session_id: str | None = None) -> list[dict[str, Any]]: + """读取当前用户全部或指定会话的活动 Job,供页面刷新恢复执行状态。""" + clauses = ["user_id = %s", "status IN ('queued', 'running', 'waiting_input')"] + params: list[Any] = [user_id] + if session_id: + clauses.append("session_id = %s") + params.append(session_id) + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + f""" + SELECT job_id, user_id, session_id, status, worker_id, lease_epoch, + attempt_count, recovery_count, resume_count, max_attempts, + current_question_id, current_waiting_prompt, input_user_file_id, + input_filename, created_at + FROM analysis_jobs + WHERE {' AND '.join(clauses)} + ORDER BY created_at ASC + """, + tuple(params), + ) + return cursor.fetchall() + + +def get_latest_event_id(job_id: str) -> int: + """读取 Job 当前公开事件的最大 ID,避免刷新后重放整条时间线。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT COALESCE(MAX(id), 0) AS event_id FROM analysis_job_events WHERE job_id = %s", + (job_id,), + ) + row = cursor.fetchone() or {} + return int(row.get("event_id") or 0) + + def get_job_by_idempotency_key(user_id: int, idempotency_key: str) -> dict[str, Any] | None: - """按用户和幂等键强一致读取历史 job,供请求重放返回原任务。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) + """按用户和创建幂等键读取历史 Job。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) cursor.execute( """ SELECT * @@ -127,9 +285,9 @@ def get_job_by_idempotency_key(user_id: int, idempotency_key: str) -> dict[str, def get_job_for_user(job_id: str, user_id: int) -> dict[str, Any] | None: - """按 job_id 和用户校验读取 job,返回该用户job的所有值。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) + """按 Job ID 和用户归属强一致读取 Job。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) cursor.execute( "SELECT * FROM analysis_jobs WHERE job_id = %s AND user_id = %s", (job_id, user_id), @@ -137,109 +295,508 @@ def get_job_for_user(job_id: str, user_id: int) -> dict[str, Any] | None: return _row_to_job(cursor.fetchone()) +def _load_file_snapshot(cursor, user_id: int, user_file_id: int | None) -> dict[str, Any] | None: + """锁定用户文件及其不可变对象,并返回创建 Job 所需快照。""" + if user_file_id is None: + return None + cursor.execute( + """ + SELECT uf.id AS input_user_file_id, + fo.id AS input_object_id, + fo.content_hash AS input_file_hash, + uf.filename AS input_filename + FROM user_files AS uf + JOIN file_objects AS fo ON fo.id = uf.object_id + WHERE uf.id = %s AND uf.user_id = %s AND fo.owner_user_id = %s + FOR UPDATE + """, + (user_file_id, user_id, user_id), + ) + snapshot = cursor.fetchone() + if not snapshot: + raise PermissionError("文件不存在或不属于当前用户") + return snapshot + + def create_job( user_id: int, session_id: str, message: str, idempotency_key: str, + input_user_file_id: int | None = None, ) -> tuple[dict[str, Any], bool]: - """ - 原子创建或重放一个分析 job。 - - 同一用户重复使用同一个幂等键时,参数一致则返回原 job,参数不一致则拒绝请求。 - 同会话 active job 互斥仍由 active_session_key 唯一约束兜底。 - """ + """在一个 MySQL 事务中创建或重放带冻结文件输入的 Job。""" + normalized_message = _validate_input_text(message) idempotency_key = normalize_idempotency_key(idempotency_key) - fingerprint = _request_fingerprint(session_id, message) - job_id = str(uuid.uuid4()) - now = datetime.now() - conn = get_write_connection() + if input_user_file_id is not None: + try: + input_user_file_id = int(input_user_file_id) + except (TypeError, ValueError) as exc: + raise ValueError("input_user_file_id 无效") from exc + fingerprint = _request_fingerprint(session_id, normalized_message, input_user_file_id) + job_id = str(uuid4()) + connection = get_write_connection() try: - cursor = conn.cursor(dictionary=True) - conn.start_transaction() - # 锁定并校验服务端已经创建的 session,禁止未知 ID 自动建行。 + cursor = connection.cursor(dictionary=True) + connection.start_transaction() cursor.execute( - "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", - (session_id, user_id), + """ + SELECT * + FROM analysis_jobs + WHERE user_id = %s AND idempotency_key = %s + FOR UPDATE + """, + (user_id, idempotency_key), ) - if not cursor.fetchone(): - conn.rollback() - raise PermissionError("会话不存在或不属于当前用户") + existing = _row_to_job(cursor.fetchone()) + if existing: + _ensure_same_request(existing, fingerprint) + connection.commit() + return existing, True cursor.execute( """ SELECT * FROM analysis_jobs - WHERE user_id = %s AND idempotency_key = %s + WHERE user_id = %s AND session_id = %s + AND status IN ('queued', 'running', 'waiting_input') + ORDER BY created_at ASC + LIMIT 1 FOR UPDATE """, - (user_id, idempotency_key), + (user_id, session_id), ) - existing_request = _row_to_job(cursor.fetchone()) - if existing_request: - _ensure_same_request(existing_request, fingerprint) - conn.commit() - return existing_request, True + active = _row_to_job(cursor.fetchone()) + if active: + raise ActiveJobConflictError(active) - # f"{user_id}:{session_id}" 对应UNIQUE KEY uq_analysis_jobs_active_session (active_session_key)唯一键 + cursor.execute( + "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", + (session_id, user_id), + ) + if not cursor.fetchone(): + raise PermissionError("会话不存在或不属于当前用户") + + snapshot = _load_file_snapshot(cursor, user_id, input_user_file_id) + snapshot_values = snapshot or { + "input_user_file_id": None, + "input_object_id": None, + "input_file_hash": None, + "input_filename": None, + } cursor.execute( """ INSERT INTO analysis_jobs ( - job_id, user_id, session_id, message, status, max_attempts, created_at, - active_session_key, idempotency_key, request_fingerprint - ) VALUES (%s, %s, %s, %s, 'queued', %s, %s, %s, %s, %s) + job_id, user_id, session_id, message, + input_user_file_id, input_object_id, input_file_hash, input_filename, + status, max_attempts, active_session_key, + idempotency_key, request_fingerprint + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, + 'queued', %s, %s, %s, %s + ) """, ( job_id, user_id, session_id, - message, + normalized_message, + snapshot_values["input_user_file_id"], + snapshot_values["input_object_id"], + snapshot_values["input_file_hash"], + snapshot_values["input_filename"], settings.JOB_MAX_ATTEMPTS, - now, f"{user_id}:{session_id}", idempotency_key, fingerprint, ), ) - conn.commit() + cursor.execute( + """ + INSERT INTO analysis_job_inputs ( + job_id, sequence, input_type, input_text, + idempotency_key, request_fingerprint + ) VALUES (%s, 0, 'initial', %s, %s, %s) + """, + (job_id, normalized_message, idempotency_key, fingerprint), + ) + input_id = int(cursor.lastrowid) + chat_message_id = save_user_input_for_job_in_transaction( + cursor, + job_id=job_id, + input_id=input_id, + user_id=user_id, + session_id=session_id, + text=normalized_message, + ) + cursor.execute( + "UPDATE analysis_job_inputs SET chat_message_id = %s WHERE input_id = %s", + (chat_message_id, input_id), + ) + connection.commit() job = get_job_for_user(job_id, user_id) if job is None: - raise RuntimeError("job 创建后无法读取") - # 创建成功后,返回新 job。第二个返回值 False 表示:这不是已有任务,是新创建的任务。 + raise RuntimeError("Job 创建后无法读取") return job, False except IdempotencyConflictError: - conn.rollback() + connection.rollback() + raise + except ActiveJobConflictError: + connection.rollback() raise except mysql.connector.Error as exc: - conn.rollback() + connection.rollback() if exc.errno == errorcode.ER_DUP_ENTRY: - existing_request = get_job_by_idempotency_key(user_id, idempotency_key) - if existing_request: - _ensure_same_request(existing_request, fingerprint) - return existing_request, True - existing = get_active_job(user_id, session_id) + existing = get_job_by_idempotency_key(user_id, idempotency_key) if existing: + _ensure_same_request(existing, fingerprint) return existing, True + active = get_active_job(user_id, session_id) + if active: + raise ActiveJobConflictError(active) from exc raise except Exception: - conn.rollback() + connection.rollback() raise finally: - conn.close() + connection.close() + + +def _event_key(event_type: str, payload: dict[str, Any], question_id: str | None = None) -> str | None: + """为生命周期事件生成稳定语义键;普通进度事件保持可重复。""" + if event_type == "interrupt": + stable_question_id = question_id or payload.get("question_id") + return f"interrupt:{stable_question_id}" if stable_question_id else None + if event_type == "final_result": + return "terminal:final_result" + if event_type == "error": + return "terminal:error" + if event_type == "canceled": + return "terminal:canceled" + return None + + +def _payload_matches(existing_payload: Any, payload: dict[str, Any]) -> bool: + """比较生命周期重放的受限 JSON payload。""" + try: + return _json_loads(existing_payload) == payload + except (TypeError, ValueError, json.JSONDecodeError): + return False + + +def _existing_event(cursor, job_id: str, event_key: str | None): + """读取同一生命周期键已有事件。""" + if not event_key: + return None + cursor.execute( + """ + SELECT id, payload_json + FROM analysis_job_events + WHERE job_id = %s AND event_key = %s + FOR UPDATE + """, + (job_id, event_key), + ) + return cursor.fetchone() + + +def _lock_owned_running_job( + cursor, + job_id: str, + worker_id: str, + attempt_count: int, + lease_epoch: int | None = None, +) -> bool: + """锁定 Job 并确认 worker、attempt 和 lease epoch 仍然匹配。""" + cursor.execute( + """ + SELECT status, worker_id, attempt_count, lease_epoch, + user_id, session_id + FROM analysis_jobs + WHERE job_id = %s + FOR UPDATE + """, + (job_id,), + ) + job = cursor.fetchone() + if not job or job["status"] != "running": + return False + if job["worker_id"] != worker_id or int(job["attempt_count"]) != int(attempt_count): + return False + if lease_epoch is not None and "lease_epoch" in job: + return int(job["lease_epoch"]) == int(lease_epoch) + return True + + +def _insert_event( + cursor, + *, + job_id: str, + event_type: str, + payload: dict[str, Any], + event_key: str | None, +) -> int: + """插入或幂等重放一条事件,并拒绝同键不同 payload。""" + existing = _existing_event(cursor, job_id, event_key) + if existing: + if not _payload_matches(existing["payload_json"], payload): + raise IdempotencyConflictError("同一生命周期事件键对应了不同 payload") + return int(existing["id"]) + cursor.execute( + """ + INSERT INTO analysis_job_events (job_id, event_type, event_key, payload_json) + VALUES (%s, %s, %s, %s) + """, + (job_id, event_type, event_key, _json_dumps(payload)), + ) + return int(cursor.lastrowid) + + +def _latest_input_id(cursor, job_id: str) -> int | None: + """读取当前 Job 最近一条用户输入账本 ID。""" + cursor.execute( + """ + SELECT input_id + FROM analysis_job_inputs + WHERE job_id = %s + ORDER BY sequence DESC + LIMIT 1 + """, + (job_id,), + ) + row = cursor.fetchone() + return int(row["input_id"] if isinstance(row, dict) else row[0]) if row else None + + +def _lock_job_then_session( + cursor, + *, + job_id: str, + user_id: int, +) -> dict[str, Any] | None: + """按统一的 ``analysis_jobs -> sessions`` 顺序锁定用户 Job。""" + cursor.execute( + """ + SELECT * + FROM analysis_jobs + WHERE job_id = %s AND user_id = %s + FOR UPDATE + """, + (job_id, user_id), + ) + job = _row_to_job(cursor.fetchone()) + if not job: + return None + cursor.execute( + "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", + (job["session_id"], user_id), + ) + if not cursor.fetchone(): + raise PermissionError("会话不存在或不属于当前用户") + return job + + +def _get_pending_checkpoint_interrupt(job: dict[str, Any]) -> dict[str, str] | None: + """读取 Job 根 checkpoint 的 pending interrupt;失败时不冒险重放 Agent。""" + try: + from app.agent.checkpoint_recovery import ( + CheckpointRecoveryUnavailable, + get_pending_interrupt, + ) + + return get_pending_interrupt( + job_id=str(job["job_id"]), + timeout_ms=CHECKPOINT_RECOVERY_TIMEOUT_MS, + ) + except CheckpointRecoveryUnavailable as exc: + raise _CheckpointRecoveryBlocked( + f"无法确认 Job {job['job_id']} 的 checkpoint 恢复状态" + ) from exc + + +def _repair_pending_interrupt(cursor, job: dict[str, Any]) -> bool: + """把 PostgreSQL 中尚未恢复的 interrupt 幂等修复到 MySQL waiting_input。""" + pending = _get_pending_checkpoint_interrupt(job) + if not pending: + return False + + question_id = pending["question_id"] + payload = { + "type": "interrupt", + "message": pending["message"], + "question_id": question_id, + } + event_id = _insert_event( + cursor, + job_id=job["job_id"], + event_type="interrupt", + payload=payload, + event_key=_event_key("interrupt", payload, question_id), + ) + save_assistant_for_job_in_transaction( + cursor, + job_id=job["job_id"], + user_id=int(job["user_id"]), + session_id=job["session_id"], + ai_response={"type": "human_input_required", "summary": pending["message"]}, + source_event_id=event_id, + analysis_job_input_id=_latest_input_id(cursor, job["job_id"]), + ) + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'waiting_input', worker_id = NULL, + locked_at = NULL, heartbeat_at = NULL, + current_question_id = %s, current_waiting_prompt = %s, + finished_at = NULL + WHERE job_id = %s AND status = 'running' + """, + (question_id, pending["message"], job["job_id"]), + ) + if cursor.rowcount != 1: + raise RuntimeError("修复 pending interrupt 时 Job 状态已发生变化") + return True + + +def _complete_job_lifecycle( + job: dict[str, Any], + worker_id: str | None, + event_type: str, + payload: dict[str, Any], + *, + lease_epoch: int | None = None, + question_id: str | None = None, + chat_response: dict[str, Any] | str | None = None, + result: dict[str, Any] | None = None, + status: str, +) -> bool: + """在一个事务中完成事件、assistant 消息和 Job 状态更新。""" + job_id = job["job_id"] + event_key = _event_key(event_type, payload, question_id) + with get_write_connection() as connection: + try: + connection.start_transaction() + cursor = connection.cursor(dictionary=True) + existing = _existing_event(cursor, job_id, event_key) + if existing: + if not _payload_matches(existing["payload_json"], payload): + raise IdempotencyConflictError("同一生命周期事件键对应了不同 payload") + connection.commit() + return True + + if worker_id is not None and not _lock_owned_running_job( + cursor, + job_id, + worker_id, + int(job["attempt_count"]), + lease_epoch, + ): + connection.rollback() + return False + if worker_id is None: + cursor.execute( + "SELECT status FROM analysis_jobs WHERE job_id = %s FOR UPDATE", + (job_id,), + ) + current = cursor.fetchone() + if not current or current["status"] != "waiting_input": + connection.rollback() + return False + + event_id = _insert_event( + cursor, + job_id=job_id, + event_type=event_type, + payload=payload, + event_key=event_key, + ) + if chat_response is not None: + save_assistant_for_job_in_transaction( + cursor, + job_id=job_id, + user_id=int(job["user_id"]), + session_id=job["session_id"], + ai_response=chat_response, + source_event_id=event_id, + analysis_job_input_id=_latest_input_id(cursor, job_id), + ) + + if status == "waiting_input": + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'waiting_input', worker_id = NULL, + locked_at = NULL, heartbeat_at = NULL, + current_question_id = %s, current_waiting_prompt = %s, + finished_at = NULL + WHERE job_id = %s AND status = 'running' + """, + ( + question_id, + payload.get("message"), + job_id, + ), + ) + elif status == "succeeded": + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'succeeded', result_json = %s, + active_session_key = NULL, worker_id = NULL, + locked_at = NULL, heartbeat_at = UTC_TIMESTAMP(6), + chat_saved_at = UTC_TIMESTAMP(6), finished_at = UTC_TIMESTAMP(6), + current_question_id = NULL, current_waiting_prompt = NULL + WHERE job_id = %s AND status = 'running' + """, + (_json_dumps(result or {}), job_id), + ) + elif status == "failed": + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'failed', error_message = %s, last_error = %s, + active_session_key = NULL, worker_id = NULL, + locked_at = NULL, heartbeat_at = UTC_TIMESTAMP(6), + chat_saved_at = UTC_TIMESTAMP(6), finished_at = UTC_TIMESTAMP(6), + current_question_id = NULL, current_waiting_prompt = NULL + WHERE job_id = %s AND status = 'running' + """, + (payload.get("message", "任务执行失败"), payload.get("message", "任务执行失败"), job_id), + ) + elif status == "canceled": + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'canceled', active_session_key = NULL, + worker_id = NULL, locked_at = NULL, heartbeat_at = NULL, + current_question_id = NULL, current_waiting_prompt = NULL, + finished_at = UTC_TIMESTAMP(6) + WHERE job_id = %s AND status = 'waiting_input' + """, + (job_id,), + ) + else: + raise ValueError(f"不支持的终态: {status}") + if cursor.rowcount != 1: + connection.rollback() + return False + connection.commit() + return True + except Exception: + connection.rollback() + raise def _finalize_exhausted_job(cursor, stale_after: int) -> bool: - """锁定并失败一个已耗尽尝试次数且心跳过期的 job。""" + """锁定并失败一个 stale recovery 已耗尽的 Job。""" cursor.execute( f""" - SELECT job_id + SELECT job_id, user_id, session_id, attempt_count, lease_epoch FROM analysis_jobs WHERE status = 'running' - AND attempt_count >= max_attempts - AND ( - heartbeat_at IS NULL - OR heartbeat_at < (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND) - ) + AND recovery_count >= max_attempts + AND (heartbeat_at IS NULL OR heartbeat_at < + (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND)) ORDER BY heartbeat_at ASC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED @@ -248,62 +805,60 @@ def _finalize_exhausted_job(cursor, stale_after: int) -> bool: job = cursor.fetchone() if not job: return False - + if _repair_pending_interrupt(cursor, job): + return True + payload = {"type": "error", "message": MAX_ATTEMPTS_ERROR} + event_id = _insert_event( + cursor, + job_id=job["job_id"], + event_type="error", + payload=payload, + event_key="terminal:error", + ) + save_assistant_for_job_in_transaction( + cursor, + job_id=job["job_id"], + user_id=int(job["user_id"]), + session_id=job["session_id"], + ai_response={"type": "text", "summary": MAX_ATTEMPTS_ERROR}, + source_event_id=event_id, + analysis_job_input_id=_latest_input_id(cursor, job["job_id"]), + ) cursor.execute( """ UPDATE analysis_jobs - SET status = 'failed', - error_message = %s, - last_error = %s, - active_session_key = NULL, - heartbeat_at = UTC_TIMESTAMP(6), + SET status = 'failed', error_message = %s, last_error = %s, + active_session_key = NULL, worker_id = NULL, locked_at = NULL, + heartbeat_at = UTC_TIMESTAMP(6), chat_saved_at = UTC_TIMESTAMP(6), finished_at = UTC_TIMESTAMP(6) WHERE job_id = %s AND status = 'running' """, (MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_ERROR, job["job_id"]), ) - if cursor.rowcount != 1: - return False - - cursor.execute( - """ - INSERT INTO analysis_job_events (job_id, event_type, payload_json) - VALUES (%s, 'error', %s) - """, - (job["job_id"], _json_dumps({"type": "error", "message": MAX_ATTEMPTS_ERROR})), - ) - return True + return cursor.rowcount == 1 def claim_next_job(worker_id: str, stale_after_seconds: int | None = None) -> dict[str, Any] | None: - """ - 领取一个可执行 job。 - - 领取范围包括 queued job,以及 heartbeat 超时且未超过最大尝试次数的 - running job。SQL 使用 FOR UPDATE SKIP LOCKED,避免多个 worker slot - 抢到同一个任务。 - """ + """领取 queued Job 或 stale running Job,并递增 lease epoch。""" stale_after = int(stale_after_seconds or settings.JOB_STALE_AFTER_SECONDS) - conn = get_write_connection() + connection = get_write_connection() try: - cursor = conn.cursor(dictionary=True) - conn.start_transaction() + cursor = connection.cursor(dictionary=True) + connection.start_transaction() _finalize_exhausted_job(cursor, stale_after) cursor.execute( f""" SELECT * FROM analysis_jobs WHERE ( - status = 'queued' - OR ( - status = 'running' - AND ( - heartbeat_at IS NULL - OR heartbeat_at < (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND) - ) - ) - ) - AND attempt_count < max_attempts + status = 'queued' + OR ( + status = 'running' + AND (heartbeat_at IS NULL OR heartbeat_at < + (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND)) + AND recovery_count < max_attempts + ) + ) ORDER BY CASE WHEN status = 'queued' THEN 0 ELSE 1 END, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED @@ -311,109 +866,204 @@ def claim_next_job(worker_id: str, stale_after_seconds: int | None = None) -> di ) job = cursor.fetchone() if not job: - conn.commit() + connection.commit() + return None + stale = job["status"] == "running" + if stale and _repair_pending_interrupt(cursor, job): + connection.commit() return None - # 抢完任务之后,执行running cursor.execute( """ UPDATE analysis_jobs - SET status = 'running', - worker_id = %s, - locked_at = UTC_TIMESTAMP(6), - heartbeat_at = UTC_TIMESTAMP(6), + SET status = 'running', worker_id = %s, + lease_epoch = lease_epoch + 1, + locked_at = UTC_TIMESTAMP(6), heartbeat_at = UTC_TIMESTAMP(6), started_at = COALESCE(started_at, UTC_TIMESTAMP(6)), - attempt_count = attempt_count + 1 - WHERE id = %s + attempt_count = attempt_count + 1, + recovery_count = recovery_count + %s + WHERE id = %s AND status IN ('queued', 'running') """, - (worker_id, job["id"]), + (worker_id, 1 if stale else 0, job["id"]), ) - conn.commit() - return _row_to_job(get_job_by_id(job["job_id"])) + if cursor.rowcount != 1: + connection.rollback() + return None + connection.commit() + claimed = _row_to_job(get_job_by_id(job["job_id"])) + if claimed is not None: + claimed["claim_kind"] = ( + "stale_recovery" + if stale + else "user_resume" + if int(job.get("resume_count") or 0) > 0 + else "initial" + ) + return claimed + except _CheckpointRecoveryBlocked: + # 不确认 checkpoint 就不能安全地把 stale Job 重新执行;回滚本轮锁定, + # 让下一次领取继续重试,而不是把任务误判为可重放或失败。 + connection.rollback() + return None except Exception: - conn.rollback() + connection.rollback() raise finally: - conn.close() + connection.close() def get_job_by_id(job_id: str) -> dict[str, Any] | None: - """按 job_id 强一致读取 job,不做用户权限判断。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) + """按 Job ID 强一致读取,不做用户权限判断。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) cursor.execute("SELECT * FROM analysis_jobs WHERE job_id = %s", (job_id,)) return _row_to_job(cursor.fetchone()) -def update_heartbeat(job_id: str, worker_id: str, attempt_count: int) -> bool: - """仅为持有指定 attempt 的 running job 刷新 worker 心跳。""" - with get_write_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - UPDATE analysis_jobs - SET heartbeat_at = UTC_TIMESTAMP(6) - WHERE job_id = %s - AND worker_id = %s - AND attempt_count = %s - AND status = 'running' - """, - (job_id, worker_id, attempt_count), - ) - conn.commit() - return cursor.rowcount == 1 +def _decode_input_record(row: dict[str, Any]) -> dict[str, Any]: + """按 input_type 解码输入账本,初始 JSON 文本永远保持字符串。""" + stored = row.get("input_text") + if isinstance(stored, (bytes, bytearray)): + stored = bytes(stored).decode("utf-8") + if not isinstance(stored, str): + stored = "" if stored is None else str(stored) + input_type = str(row.get("input_type") or "") + runtime_value: Any = stored + if input_type == "resume" and stored[:1] in {"{", "["}: + try: + parsed = json.loads(stored) + except (TypeError, ValueError, json.JSONDecodeError): + parsed = None + if isinstance(parsed, (dict, list)): + runtime_value = parsed + return { + "input_type": input_type, + "runtime_value": runtime_value, + "stored_text": stored, + "chat_message_id": ( + int(row["chat_message_id"]) + if row.get("chat_message_id") is not None + else None + ), + } + + +def _get_input_record(job_id: str, *, sequence: int | None = None) -> dict[str, Any] | None: + """读取 Job 输入账本的指定序号或最后一条输入。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) + if sequence is None: + cursor.execute( + """ + SELECT input_type, input_text, chat_message_id + FROM analysis_job_inputs + WHERE job_id = %s + ORDER BY sequence DESC + LIMIT 1 + """, + (job_id,), + ) + else: + cursor.execute( + """ + SELECT input_type, input_text, chat_message_id + FROM analysis_job_inputs + WHERE job_id = %s AND sequence = %s + LIMIT 1 + """, + (job_id, sequence), + ) + row = cursor.fetchone() + if not row: + return None + return _decode_input_record(row) -def _lock_owned_running_job(cursor, job_id: str, worker_id: str, attempt_count: int) -> bool: - """锁定 job 行并确认它仍属于指定 worker attempt。""" - cursor.execute( - """ - SELECT status, worker_id, attempt_count - FROM analysis_jobs - WHERE job_id = %s - FOR UPDATE - """, - (job_id,), - ) - job = cursor.fetchone() - return bool( - job - and job["status"] == "running" - and job["worker_id"] == worker_id - and int(job["attempt_count"]) == int(attempt_count) - ) +def get_latest_input_value(job_id: str) -> dict[str, Any] | None: + """读取 Job 输入账本最后一条,并只为 resume 恢复结构化运行时值。""" + return _get_input_record(job_id) -def write_event(job_id: str, worker_id: str, attempt_count: int, event_type: str, payload: dict[str, Any]) -> int | None: - """仅为当前 worker attempt 写入一个事件,旧租约会被静默拒绝。""" - with get_write_connection() as conn: - try: - conn.start_transaction() - cursor = conn.cursor(dictionary=True) - if not _lock_owned_running_job(cursor, job_id, worker_id, attempt_count): - conn.rollback() - return None +def get_initial_input_value(job_id: str) -> dict[str, Any] | None: + """读取 Job 序号为 0 的冻结初始输入,用于无 checkpoint 的 stale 重启。""" + return _get_input_record(job_id, sequence=0) + + +def update_heartbeat( + job_id: str, + worker_id: str, + attempt_count: int, + lease_epoch: int | None = None, +) -> bool: + """仅为当前 worker attempt 和 lease epoch 刷新 heartbeat。""" + with get_write_connection() as connection: + cursor = connection.cursor() + if lease_epoch is None: + cursor.execute( + """ + UPDATE analysis_jobs SET heartbeat_at = UTC_TIMESTAMP(6) + WHERE job_id = %s AND worker_id = %s AND attempt_count = %s + AND status = 'running' + """, + (job_id, worker_id, attempt_count), + ) + else: cursor.execute( """ - INSERT INTO analysis_job_events (job_id, event_type, payload_json) - VALUES (%s, %s, %s) + UPDATE analysis_jobs SET heartbeat_at = UTC_TIMESTAMP(6) + WHERE job_id = %s AND worker_id = %s AND attempt_count = %s + AND lease_epoch = %s AND status = 'running' """, - (job_id, event_type, _json_dumps(payload)), + (job_id, worker_id, attempt_count, lease_epoch), ) - event_id = cursor.lastrowid - conn.commit() - return int(event_id) + connection.commit() + return cursor.rowcount == 1 + + +def write_event( + job_id: str, + worker_id: str, + attempt_count: int, + event_type: str, + payload: dict[str, Any], + *, + lease_epoch: int | None = None, + event_key: str | None = None, +) -> int | None: + """仅为当前租约写入普通事件,生命周期键支持安全重放。""" + with get_write_connection() as connection: + try: + connection.start_transaction() + cursor = connection.cursor(dictionary=True) + if not _lock_owned_running_job( + cursor, + job_id, + worker_id, + attempt_count, + lease_epoch, + ): + connection.rollback() + return None + event_id = _insert_event( + cursor, + job_id=job_id, + event_type=event_type, + payload=payload, + event_key=event_key or _event_key(event_type, payload), + ) + connection.commit() + return event_id except Exception: - conn.rollback() + connection.rollback() raise def read_events_after(job_id: str, after_id: int = 0, limit: int = 100) -> list[dict[str, Any]]: - """读取某个 job 在指定事件 id 之后的新事件,可以供 SSE 断线续传使用。""" - with get_read_connection(consistency="strong") as conn: - cursor = conn.cursor(dictionary=True) + """读取指定事件 ID 之后的强一致公开事件。""" + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) cursor.execute( """ - SELECT id, job_id, event_type, payload_json, created_at + SELECT id, job_id, event_type, event_key, payload_json, created_at FROM analysis_job_events WHERE job_id = %s AND id > %s ORDER BY id ASC @@ -427,27 +1077,26 @@ def read_events_after(job_id: str, after_id: int = 0, limit: int = 100) -> list[ return rows -def complete_job(job_id: str, worker_id: str, attempt_count: int, result: dict[str, Any] | None) -> bool: - """仅把当前 worker attempt 持有的 job 标记为成功。""" - with get_write_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - UPDATE analysis_jobs - SET status = 'succeeded', - result_json = %s, - active_session_key = NULL, - heartbeat_at = UTC_TIMESTAMP(6), - finished_at = UTC_TIMESTAMP(6) - WHERE job_id = %s - AND worker_id = %s - AND attempt_count = %s - AND status = 'running' - """, - (_json_dumps(result or {}), job_id, worker_id, attempt_count), - ) - conn.commit() - return cursor.rowcount == 1 +def complete_job( + job_id: str, + worker_id: str, + attempt_count: int, + result: dict[str, Any] | None, + lease_epoch: int | None = None, +) -> bool: + """为没有显式终态事件的旧执行路径补写成功终态。""" + job = get_job_by_id(job_id) + if not job: + return False + return _complete_job_lifecycle( + job, + worker_id, + "final_result", + {"type": "final_result", "data": result or {}}, + lease_epoch=lease_epoch, + result=result or {}, + status="succeeded", + ) def complete_job_with_chat( @@ -455,140 +1104,284 @@ def complete_job_with_chat( worker_id: str, event_type: str, payload: dict[str, Any], - chat_response: dict[str, Any], + chat_response: dict[str, Any] | str, result: dict[str, Any] | None, + *, + lease_epoch: int | None = None, + question_id: str | None = None, ) -> bool: - """在一个事务内幂等保存聊天、写终态事件并完成当前 worker attempt。""" - job_id = job["job_id"] - attempt_count = int(job["attempt_count"]) - with get_write_connection() as conn: - try: - conn.start_transaction() - cursor = conn.cursor(dictionary=True) - if not _lock_owned_running_job(cursor, job_id, worker_id, attempt_count): - conn.rollback() - return False + """在一个事务内完成 final、error 或 interrupt 生命周期。""" + if event_type == "interrupt": + return _complete_job_lifecycle( + job, + worker_id, + event_type, + payload, + lease_epoch=lease_epoch, + question_id=question_id, + chat_response=chat_response, + status="waiting_input", + ) + if event_type == "error": + return _complete_job_lifecycle( + job, + worker_id, + event_type, + payload, + lease_epoch=lease_epoch, + chat_response=chat_response, + status="failed", + ) + return _complete_job_lifecycle( + job, + worker_id, + "final_result", + payload, + lease_epoch=lease_epoch, + chat_response=chat_response, + result=result, + status="succeeded", + ) + + +def fail_job( + job_id: str, + worker_id: str, + attempt_count: int, + message: str, + *, + lease_epoch: int | None = None, + write_error_event: bool = True, +) -> bool: + """以当前 lease 将 Job 失败,并原子写入 error 事件和 assistant 消息。""" + logging.error("analysis job %s failed", job_id) + job = get_job_by_id(job_id) + if not job or int(job.get("attempt_count") or 0) != int(attempt_count): + return False + if not write_error_event: + with get_write_connection() as connection: + cursor = connection.cursor() + if lease_epoch is None: + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'failed', error_message = %s, last_error = %s, + active_session_key = NULL, finished_at = UTC_TIMESTAMP(6) + WHERE job_id = %s AND worker_id = %s AND attempt_count = %s + AND status = 'running' + """, + (message, message, job_id, worker_id, attempt_count), + ) + else: + cursor.execute( + """ + UPDATE analysis_jobs + SET status = 'failed', error_message = %s, last_error = %s, + active_session_key = NULL, finished_at = UTC_TIMESTAMP(6) + WHERE job_id = %s AND worker_id = %s AND attempt_count = %s + AND lease_epoch = %s AND status = 'running' + """, + (message, message, job_id, worker_id, attempt_count, lease_epoch), + ) + connection.commit() + return cursor.rowcount == 1 + return _complete_job_lifecycle( + job, + worker_id, + "error", + {"type": "error", "message": message}, + lease_epoch=lease_epoch, + chat_response={"type": "text", "summary": message}, + status="failed", + ) - save_chat_for_job_in_transaction( + +def resume_job( + user_id: int, + job_id: str, + question_id: str, + answer: Any, + idempotency_key: str, +) -> tuple[dict[str, Any], bool]: + """在 waiting_input Job 上追加恢复输入并重新排队。""" + normalized_answer, _runtime_answer = _normalize_input_value(answer) + question_id = question_id.strip() if isinstance(question_id, str) else "" + if not question_id or len(question_id) > 255: + raise ValueError("question_id 无效") + idempotency_key = normalize_idempotency_key(idempotency_key) + fingerprint = _action_fingerprint( + job_id=job_id, + action="resume", + text=normalized_answer, + question_id=question_id, + ) + with get_write_connection() as connection: + try: + cursor = connection.cursor(dictionary=True) + connection.start_transaction() + job = _lock_job_then_session( cursor, - job_id, - job["user_id"], - job["session_id"], - job["message"], - chat_response, + job_id=job_id, + user_id=user_id, + ) + if not job: + raise PermissionError("任务不存在或无权访问") + cursor.execute( + """ + SELECT * FROM analysis_job_inputs + WHERE job_id = %s AND idempotency_key = %s + FOR UPDATE + """, + (job_id, idempotency_key), + ) + existing_input = cursor.fetchone() + if existing_input: + if existing_input["request_fingerprint"] != fingerprint: + raise IdempotencyConflictError("Idempotency-Key 已用于不同的恢复输入") + connection.commit() + return job, True + if job["status"] != "waiting_input": + raise JobStateConflictError("任务当前不在 waiting_input 状态", job) + if job.get("current_question_id") != question_id: + raise JobStateConflictError("question_id 与当前等待问题不匹配", job) + + cursor.execute( + "SELECT COALESCE(MAX(sequence), -1) + 1 AS next_sequence FROM analysis_job_inputs WHERE job_id = %s", + (job_id,), ) + sequence_row = cursor.fetchone() or {} + sequence = int(sequence_row.get("next_sequence") or 0) cursor.execute( """ - INSERT INTO analysis_job_events (job_id, event_type, payload_json) - VALUES (%s, %s, %s) + INSERT INTO analysis_job_inputs ( + job_id, sequence, input_type, input_text, question_id, + idempotency_key, request_fingerprint + ) VALUES (%s, %s, 'resume', %s, %s, %s, %s) """, - (job_id, event_type, _json_dumps(payload)), + (job_id, sequence, normalized_answer, question_id, idempotency_key, fingerprint), + ) + input_id = int(cursor.lastrowid) + chat_message_id = save_user_input_for_job_in_transaction( + cursor, + job_id=job_id, + input_id=input_id, + user_id=user_id, + session_id=job["session_id"], + text=normalized_answer, + ) + cursor.execute( + "UPDATE analysis_job_inputs SET chat_message_id = %s WHERE input_id = %s", + (chat_message_id, input_id), ) cursor.execute( """ UPDATE analysis_jobs - SET status = 'succeeded', - result_json = %s, - active_session_key = NULL, - heartbeat_at = UTC_TIMESTAMP(6), - finished_at = UTC_TIMESTAMP(6) - WHERE job_id = %s - AND worker_id = %s - AND attempt_count = %s - AND status = 'running' + SET status = 'queued', resume_count = resume_count + 1, + current_question_id = NULL, current_waiting_prompt = NULL, + finished_at = NULL + WHERE job_id = %s AND status = 'waiting_input' """, - (_json_dumps(result or {}), job_id, worker_id, attempt_count), + (job_id,), ) if cursor.rowcount != 1: - conn.rollback() - return False - conn.commit() - return True + raise JobStateConflictError("任务状态已被其他请求改变") + connection.commit() + refreshed = get_job_for_user(job_id, user_id) + if not refreshed: + raise RuntimeError("恢复后无法读取 Job") + return refreshed, False except Exception: - conn.rollback() + connection.rollback() raise -def fail_job( +def cancel_job( + user_id: int, job_id: str, - worker_id: str, - attempt_count: int, - message: str, - *, - write_error_event: bool = True, -) -> bool: - """仅把当前 worker attempt 持有的 job 标记为失败并可写入 error 事件。""" - logging.error("analysis job %s failed: %s", job_id, message) - with get_write_connection() as conn: + idempotency_key: str, +) -> tuple[dict[str, Any], bool]: + """取消 waiting_input Job,并幂等写入 canceled 生命周期事件。""" + idempotency_key = normalize_idempotency_key(idempotency_key) + fingerprint = _action_fingerprint(job_id=job_id, action="cancel") + payload = {"type": "canceled", "message": "任务已取消"} + event_key = "terminal:canceled" + with get_write_connection() as connection: try: - conn.start_transaction() - cursor = conn.cursor(dictionary=True) - if not _lock_owned_running_job(cursor, job_id, worker_id, attempt_count): - conn.rollback() - return False - if write_error_event: - cursor.execute( - """ - INSERT INTO analysis_job_events (job_id, event_type, payload_json) - VALUES (%s, 'error', %s) - """, - (job_id, _json_dumps({"type": "error", "message": message})), - ) + cursor = connection.cursor(dictionary=True) + connection.start_transaction() + job = _lock_job_then_session( + cursor, + job_id=job_id, + user_id=user_id, + ) + if not job: + raise PermissionError("任务不存在或无权访问") + stored_key = job.get("cancel_idempotency_key") + if stored_key and stored_key == idempotency_key: + if job.get("cancel_request_fingerprint") != fingerprint: + raise IdempotencyConflictError("Idempotency-Key 已用于不同的取消请求") + connection.commit() + return job, True + existing = _existing_event(cursor, job_id, event_key) + if existing: + if not _payload_matches(existing["payload_json"], payload): + raise IdempotencyConflictError("取消事件 payload 不一致") + connection.commit() + return job, True + if job["status"] == "canceled": + raise JobStateConflictError("任务已经取消", job) + if job["status"] != "waiting_input": + raise JobStateConflictError("只有 waiting_input 任务可以取消", job) + event_id = _insert_event( + cursor, + job_id=job_id, + event_type="canceled", + payload=payload, + event_key=event_key, + ) + save_assistant_for_job_in_transaction( + cursor, + job_id=job_id, + user_id=user_id, + session_id=job["session_id"], + ai_response={"type": "text", "summary": payload["message"]}, + source_event_id=event_id, + analysis_job_input_id=_latest_input_id(cursor, job_id), + ) cursor.execute( """ UPDATE analysis_jobs - SET status = 'failed', - error_message = %s, - last_error = %s, - active_session_key = NULL, - heartbeat_at = UTC_TIMESTAMP(6), + SET status = 'canceled', active_session_key = NULL, + current_question_id = NULL, current_waiting_prompt = NULL, + cancel_idempotency_key = %s, + cancel_request_fingerprint = %s, finished_at = UTC_TIMESTAMP(6) - WHERE job_id = %s - AND worker_id = %s - AND attempt_count = %s - AND status = 'running' + WHERE job_id = %s AND status = 'waiting_input' """, - (message, message, job_id, worker_id, attempt_count), + (idempotency_key, fingerprint, job_id), ) if cursor.rowcount != 1: - conn.rollback() - return False - conn.commit() - return True + raise JobStateConflictError("任务状态已被其他请求改变") + connection.commit() + refreshed = get_job_for_user(job_id, user_id) + if not refreshed: + raise RuntimeError("取消后无法读取 Job") + return refreshed, False except Exception: - conn.rollback() + connection.rollback() raise -def mark_chat_saved(job_id: str, worker_id: str, attempt_count: int) -> bool: - """仅为当前 worker attempt 幂等标记聊天历史已保存。""" - with get_write_connection() as conn: - cursor = conn.cursor() - cursor.execute( - """ - UPDATE analysis_jobs - SET chat_saved_at = UTC_TIMESTAMP(6) - WHERE job_id = %s - AND worker_id = %s - AND attempt_count = %s - AND status = 'running' - AND chat_saved_at IS NULL - """, - (job_id, worker_id, attempt_count), - ) - changed = cursor.rowcount == 1 - conn.commit() - return changed - - def get_worker_snapshot() -> list[dict[str, Any]]: - """返回 queued/running job 快照,供轻量管理接口观察 worker 活性。""" + """返回活动 Job 快照。""" return get_worker_snapshot_report()["jobs"] def get_worker_snapshot_report() -> dict[str, Any]: - """返回任务总量、异常计数和最多 100 条活动任务,并标明主库来源。""" - observed_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + """返回 queued/running/waiting_input 汇总和 worker 活动摘要。""" + observed_at = datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) try: connection, source = get_read_connection_with_source(consistency="strong") stale_after = int(settings.JOB_STALE_AFTER_SECONDS) @@ -600,41 +1393,36 @@ def get_worker_snapshot_report() -> dict[str, Any]: SELECT /*+ MAX_EXECUTION_TIME({timeout_ms}) */ SUM(status = 'queued') AS queued, SUM(status = 'running') AS running, - SUM( - status = 'running' - AND ( - heartbeat_at IS NULL - OR heartbeat_at < (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND) - ) - ) AS stale, - SUM(status = 'running' AND attempt_count >= max_attempts) AS max_attempts_running + SUM(status = 'waiting_input') AS waiting_input, + SUM(status = 'running' AND (heartbeat_at IS NULL OR + heartbeat_at < (UTC_TIMESTAMP(6) - INTERVAL {stale_after} SECOND))) AS stale, + SUM(status = 'running' AND recovery_count >= max_attempts) AS max_attempts_running FROM analysis_jobs - WHERE status IN ('queued', 'running') + WHERE status IN ('queued', 'running', 'waiting_input') """ ) summary_row = cursor.fetchone() or {} cursor.execute( f""" SELECT /*+ MAX_EXECUTION_TIME({timeout_ms}) */ - job_id, status, worker_id, heartbeat_at, - attempt_count, max_attempts, created_at + job_id, status, worker_id, lease_epoch, heartbeat_at, + attempt_count, recovery_count, resume_count, max_attempts, created_at FROM analysis_jobs - WHERE status IN ('queued', 'running') + WHERE status IN ('queued', 'running', 'waiting_input') ORDER BY created_at ASC LIMIT 100 """ ) jobs = cursor.fetchall() - summary = { key: int(summary_row.get(key) or 0) - for key in ("queued", "running", "stale", "max_attempts_running") + for key in ("queued", "running", "waiting_input", "stale", "max_attempts_running") } warning = None status = "healthy" if summary["stale"] or summary["max_attempts_running"]: status = "warning" - warning = "存在心跳过期或达到最大尝试次数但仍运行的任务" + warning = "存在心跳过期或 stale recovery 达到上限的任务" return { "jobs": jobs, "summary": summary, @@ -644,13 +1432,14 @@ def get_worker_snapshot_report() -> dict[str, Any]: "is_estimate": False, "warning": warning, } - except Exception as exc: - logging.warning("读取 worker/job 看板快照失败: %s", exc, exc_info=True) + except Exception: + logging.warning("读取 worker/job 看板快照失败", exc_info=True) return { "jobs": [], "summary": { "queued": None, "running": None, + "waiting_input": None, "stale": None, "max_attempts_running": None, }, diff --git a/app/agent/routes.py b/app/agent/routes.py index b1b94d1..8cc918a 100644 --- a/app/agent/routes.py +++ b/app/agent/routes.py @@ -57,9 +57,10 @@ def create_analysis_job(): return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 data = request.get_json(silent=True) or {} - message = (data.get("message") or "").strip() + message = data.get("message") session_id = data.get("session_id") - if not message: + input_user_file_id = data.get("input_user_file_id") + if not isinstance(message, str) or not message.strip(): return jsonify({"success": False, "error": "消息不能为空"}), 400 if not session_id: return jsonify({"success": False, "error": "请求无效,缺少会话ID"}), 400 @@ -77,6 +78,7 @@ def create_analysis_job(): session_id, message, idempotency_key, + input_user_file_id, ) logging.info( "[job-api] user=%s session=%s job=%s existing=%s", @@ -93,13 +95,41 @@ def create_analysis_job(): }), 200 if existing else 202 except PermissionError as exc: return jsonify({"success": False, "error": str(exc)}), 403 + except job_service.ActiveJobConflictError as exc: + return jsonify({ + "success": False, + "error": str(exc), + "code": "active_job_conflict", + "active_job": { + "job_id": exc.job.get("job_id"), + "status": exc.job.get("status"), + }, + }), 409 except job_service.IdempotencyConflictError as exc: return jsonify({"success": False, "error": str(exc)}), 409 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 except Exception as exc: logging.error("创建 analysis job 失败: %s", exc, exc_info=True) return jsonify({"success": False, "error": "创建任务失败"}), 500 +@agent_bp.route('/agent/jobs/active') +def get_active_analysis_jobs(): + """读取当前用户的活动 Job 摘要,供刷新页面恢复等待或执行状态。""" + current_user = get_current_session_user() + if not current_user: + return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 + session_id = (request.args.get("session_id") or "").strip() or None + if session_id and len(session_id) > 36: + return jsonify({"success": False, "error": "会话 ID 无效"}), 400 + jobs = [] + for job in job_service.get_active_jobs(current_user["id"], session_id): + job["last_event_id"] = job_service.get_latest_event_id(job["job_id"]) + jobs.append(job) + return jsonify({"success": True, "jobs": jobs}) + + @agent_bp.route('/agent/jobs//events') def stream_analysis_job_events(job_id: str): """ @@ -138,7 +168,7 @@ def generate(): if isinstance(payload, dict): payload = _public_event_payload(payload) yield _sse(row["event_type"], payload, after_id) - if row["event_type"] in job_service.TERMINAL_EVENTS: + if row["event_type"] in job_service.TERMINAL_EVENTS or row["event_type"] == "interrupt": return current_job = job_service.get_job_for_user(job_id, current_user["id"]) @@ -161,3 +191,86 @@ def generate(): "Connection": "keep-alive", }, ) + + +@agent_bp.route("/agent/jobs//resume", methods=["POST"]) +def resume_analysis_job(job_id: str): + """提交 waiting_input Job 的恢复输入。""" + current_user = get_current_session_user() + if not current_user: + return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 + data = request.get_json(silent=True) or {} + try: + idempotency_key = job_service.normalize_idempotency_key( + request.headers.get("Idempotency-Key") + ) + job, existing = job_service.resume_job( + current_user["id"], + job_id, + data.get("question_id"), + data.get("answer", data.get("message")), + idempotency_key, + ) + return jsonify({ + "success": True, + "job_id": job["job_id"], + "status": job["status"], + "existing": existing, + }), 200 if existing else 202 + except job_service.InvalidIdempotencyKeyError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except job_service.IdempotencyConflictError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 + except job_service.JobStateConflictError as exc: + return jsonify({ + "success": False, + "error": str(exc), + "code": "job_state_conflict", + "status": (exc.job or {}).get("status"), + }), 409 + except PermissionError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + logging.error("恢复 analysis job 失败: job_id=%s", job_id, exc_info=True) + return jsonify({"success": False, "error": "恢复任务失败"}), 500 + + +@agent_bp.route("/agent/jobs//cancel", methods=["POST"]) +def cancel_analysis_job(job_id: str): + """取消 waiting_input Job。""" + current_user = get_current_session_user() + if not current_user: + return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 + try: + idempotency_key = job_service.normalize_idempotency_key( + request.headers.get("Idempotency-Key") + ) + job, existing = job_service.cancel_job( + current_user["id"], + job_id, + idempotency_key, + ) + return jsonify({ + "success": True, + "job_id": job["job_id"], + "status": job["status"], + "existing": existing, + }), 200 if existing else 202 + except job_service.InvalidIdempotencyKeyError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except job_service.IdempotencyConflictError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 + except job_service.JobStateConflictError as exc: + return jsonify({ + "success": False, + "error": str(exc), + "code": "job_state_conflict", + "status": (exc.job or {}).get("status"), + }), 409 + except PermissionError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except Exception: + logging.error("取消 analysis job 失败: job_id=%s", job_id, exc_info=True) + return jsonify({"success": False, "error": "取消任务失败"}), 500 diff --git a/app/agent/worker/event_writer.py b/app/agent/worker/event_writer.py index 9d458d9..d058415 100644 --- a/app/agent/worker/event_writer.py +++ b/app/agent/worker/event_writer.py @@ -38,6 +38,8 @@ async def _complete_terminal_event( payload, response_data, result, + lease_epoch=int(job.get("lease_epoch") or 0), + question_id=payload.get("question_id"), ) @@ -89,6 +91,7 @@ async def _persist(self, payload: dict[str, Any]) -> None: self.worker_id, attempt_count, payload.get("message", "任务执行失败"), + lease_epoch=int(self.job.get("lease_epoch") or 0), ) self.terminal_seen = True return @@ -99,6 +102,7 @@ async def _persist(self, payload: dict[str, Any]) -> None: attempt_count, event_type, payload, + lease_epoch=int(self.job.get("lease_epoch") or 0), ) async def _flush_text(self) -> None: diff --git a/app/agent/worker/execution.py b/app/agent/worker/execution.py index 423274c..9f30e67 100644 --- a/app/agent/worker/execution.py +++ b/app/agent/worker/execution.py @@ -18,6 +18,7 @@ async def heartbeat_until_stopped( job_id: str, worker_id: str, attempt_count: int, + lease_epoch: int, stop: asyncio.Event, ) -> None: """在 job 执行期间定期刷新 heartbeat,直到收到停止信号。""" @@ -30,6 +31,7 @@ async def heartbeat_until_stopped( job_id, worker_id, attempt_count, + lease_epoch, ) logging.info("[worker] heartbeat job=%s worker=%s", job_id, worker_id) @@ -42,18 +44,34 @@ async def run_job( """使用显式 slot runtime 执行 job、写事件并处理终态。""" job_id = job["job_id"] attempt_count = int(job["attempt_count"]) + lease_epoch = int(job.get("lease_epoch") or 0) stop_heartbeat = asyncio.Event() heartbeat_task = asyncio.create_task( heartbeat_until_stopped( job_id, worker_id, attempt_count, + lease_epoch, stop_heartbeat, ) ) - writer = OrderedEventWriter(job, worker_id) try: + writer = OrderedEventWriter(job, worker_id) + latest_input = await asyncio.to_thread(job_service.get_latest_input_value, job_id) + if latest_input is None: + raise RuntimeError("任务输入账本为空") + initial_input = latest_input + if ( + job.get("claim_kind") == "stale_recovery" + and latest_input.get("input_type") != "initial" + ): + initial_input = await asyncio.to_thread( + job_service.get_initial_input_value, + job_id, + ) + if initial_input is None: + raise RuntimeError("任务初始输入账本为空") logging.info( "[worker] start job=%s worker=%s session=%s tools=%s", job_id, @@ -62,13 +80,19 @@ async def run_job( len(slot_runtime.mcp_tools), ) async for payload in ai_call_stream( - job["message"], + latest_input, job["user_id"], f"user-{job['user_id']}", job["session_id"], job_id=job_id, job_attempt=attempt_count, + input_user_file_id=job.get("input_user_file_id"), + input_object_id=job.get("input_object_id"), + input_file_hash=job.get("input_file_hash"), + input_filename=job.get("input_filename"), graph=slot_runtime.graph, + claim_kind=job.get("claim_kind", "initial"), + initial_input_record=initial_input, ): await writer.submit(payload) await writer.close() @@ -80,6 +104,7 @@ async def run_job( worker_id, attempt_count, {}, + lease_epoch, ) logging.info("[worker] finish job=%s worker=%s", job_id, worker_id) except Exception as exc: @@ -96,6 +121,7 @@ async def run_job( worker_id, attempt_count, sanitize_public_error(exc), + lease_epoch=lease_epoch, ) finally: stop_heartbeat.set() diff --git a/app/agent/worker/graph_runner.py b/app/agent/worker/graph_runner.py index 43742f6..37a9e94 100644 --- a/app/agent/worker/graph_runner.py +++ b/app/agent/worker/graph_runner.py @@ -2,21 +2,28 @@ from __future__ import annotations +import asyncio import logging from typing import Any, AsyncIterator from langchain_core.messages import HumanMessage from langgraph.types import Command +from app.agent.checkpoint_recovery import checkpoint_identity +from app.chat.services import get_job_chat_history from app.agent.worker.event_adapter import ( LangGraphEventAdapter, sanitize_public_error, ) from app.agent.worker.result_presenter import process_final_result +from config.settings import settings def _snapshot_interrupts(snapshot: Any) -> list[Any]: - """汇总 LangGraph StateSnapshot 中尚待恢复的 task interrupts。""" + """读取 StateSnapshot 中尚待恢复的 interrupt,并兼容旧 task 结构。""" + public_interrupts = getattr(snapshot, "interrupts", None) + if public_interrupts: + return list(public_interrupts) return [ item for task in (getattr(snapshot, "tasks", None) or ()) @@ -24,49 +31,192 @@ def _snapshot_interrupts(snapshot: Any) -> list[Any]: ] +def _interrupt_id(interrupt_obj: Any) -> str: + """读取 LangGraph interrupt 的稳定 ID,不把完整对象写入公开事件。""" + if isinstance(interrupt_obj, dict): + value = interrupt_obj.get("id") or interrupt_obj.get("ns") + else: + value = getattr(interrupt_obj, "id", None) or getattr(interrupt_obj, "ns", None) + if isinstance(value, (list, tuple)): + value = ":".join(str(item) for item in value) + return str(value or "interrupt-unknown")[:255] + + +def _graph_config( + *, + user_id: int, + session_id: str, + job_id: str, +) -> dict[str, Any]: + """构造 Job 根图配置;业务 session 只作为 State/metadata 上下文。""" + thread_id, _ = checkpoint_identity(job_id) + configurable: dict[str, Any] = { + "thread_id": thread_id, + "user_id": user_id, + } + return { + "configurable": configurable, + "metadata": { + "job_id": thread_id, + "session_id": str(session_id), + }, + } + + +def _snapshot_has_checkpoint(snapshot: Any) -> bool: + """判断 StateSnapshot 是否来自真实 checkpoint,而不是空查询结果。""" + if snapshot is None: + return False + if getattr(snapshot, "created_at", None) is not None: + return True + metadata = getattr(snapshot, "metadata", None) + if isinstance(metadata, dict) and metadata: + return True + config = getattr(snapshot, "config", None) + if isinstance(config, dict): + configurable = config.get("configurable") or {} + if configurable.get("checkpoint_id"): + return True + if getattr(snapshot, "next", None): + return True + if getattr(snapshot, "interrupts", None): + return True + if getattr(snapshot, "tasks", None): + return True + values = getattr(snapshot, "values", None) + return bool( + isinstance(values, dict) + and any(value not in (None, "", [], {}, ()) for value in values.values()) + ) + + +def _legacy_input_record(value: Any) -> dict[str, Any]: + """兼容直接调用 graph runner 的旧测试,真实 worker 使用输入账本记录。""" + return { + "input_type": "initial", + "runtime_value": value, + "stored_text": value if isinstance(value, str) else str(value), + "chat_message_id": None, + } + + +async def _initial_graph_input( + input_record: dict[str, Any], + *, + user_id: int, + username: str, + session_id: str, + job_id: str, + input_user_file_id: int | None, + input_object_id: int | None, + input_file_hash: str | None, + input_filename: str | None, +) -> dict[str, Any]: + """从 Job 创建时冻结的 MySQL 消息边界构造新 Job State。""" + chat_message_id = input_record.get("chat_message_id") + if chat_message_id is None: + # 仅保留旧的直接函数调用兼容路径;worker 的账本记录必须有聊天关联。 + history = [HumanMessage(content=str(input_record.get("runtime_value", "")))] + else: + history = await asyncio.to_thread( + get_job_chat_history, + session_id, + user_id, + int(chat_message_id), + settings.JOB_CHAT_HISTORY_LIMIT, + ) + if not history: + raise RuntimeError("Job 初始聊天历史为空") + return { + "messages": history, + "user_id": user_id, + "username": username, + "session_id": session_id, + "job_id": job_id, + "file_summary": { + "user_file_id": input_user_file_id, + "object_id": input_object_id, + "file_hash": input_file_hash, + "filename": input_filename, + }, + } + + async def ai_call_stream( - text: str, + text: Any, user_id: int, username: str, session_id: str, *, - job_id: str | None, + job_id: str, job_attempt: int, + input_user_file_id: int | None, + input_object_id: int | None, + input_file_hash: str | None, + input_filename: str | None, graph: Any, + claim_kind: str = "initial", + input_record: dict[str, Any] | None = None, + initial_input_record: dict[str, Any] | None = None, ) -> AsyncIterator[dict[str, Any]]: """在显式传入的 graph 上执行一次调用并产出公开事件。""" if graph is None: raise RuntimeError("Agent Graph 尚未初始化") - logging.info("[流式] 处理用户 %s 的消息,会话ID: %s", username, session_id) - config = { - "configurable": { - "thread_id": session_id, - "user_id": user_id, - }, - "metadata": {"job_id": job_id} if job_id else {}, - } + current_input = input_record or ( + text if isinstance(text, dict) and "input_type" in text else _legacy_input_record(text) + ) + initial_input = initial_input_record or current_input + input_type = current_input.get("input_type") + runtime_value = current_input.get("runtime_value") + if input_type not in {"initial", "resume"}: + raise RuntimeError("Job 输入类型无效") + logging.info("[流式] 处理用户 %s 的消息,会话ID: %s, Job=%s", username, session_id, job_id) + config = _graph_config( + user_id=user_id, + session_id=session_id, + job_id=job_id, + ) - try: - state = await graph.aget_state(config) - if _snapshot_interrupts(state): - logging.info("[流式] 检测到会话 %s 处于中断状态", session_id) - input_data: Any = Command(resume=text) + state = await graph.aget_state(config) + interrupts = _snapshot_interrupts(state) + has_checkpoint = _snapshot_has_checkpoint(state) + if interrupts: + if input_type != "resume": + raise RuntimeError("checkpoint 存在 pending interrupt,但当前输入不是 resume") + logging.info("[流式] 检测到 Job %s 处于中断状态", job_id) + input_data: Any = Command(resume=runtime_value) + elif claim_kind == "stale_recovery": + if has_checkpoint: + input_data = None else: - input_data = { - "messages": [HumanMessage(content=text)], - "user_id": user_id, - "username": username, - "session_id": session_id, - } - except Exception as exc: - logging.warning("[流式] 无法获取状态,按新对话处理: %s", exc) - input_data = { - "messages": [HumanMessage(content=text)], - "user_id": user_id, - "username": username, - "session_id": session_id, - } + input_data = await _initial_graph_input( + initial_input, + user_id=user_id, + username=username, + session_id=session_id, + job_id=job_id, + input_user_file_id=input_user_file_id, + input_object_id=input_object_id, + input_file_hash=input_file_hash, + input_filename=input_filename, + ) + elif input_type == "resume": + raise RuntimeError("resume 输入没有对应的 pending interrupt") + elif claim_kind == "user_resume" or has_checkpoint: + raise RuntimeError("Job 恢复输入没有可用的 pending interrupt") + else: + input_data = await _initial_graph_input( + initial_input, + user_id=user_id, + username=username, + session_id=session_id, + job_id=job_id, + input_user_file_id=input_user_file_id, + input_object_id=input_object_id, + input_file_hash=input_file_hash, + input_filename=input_filename, + ) adapter = LangGraphEventAdapter(job_id, job_attempt) streamed_interrupts: list[Any] = [] @@ -101,6 +251,7 @@ async def ai_call_stream( yield { "type": "interrupt", "message": question, + "question_id": _interrupt_id(interrupt_obj), "attempt": job_attempt, } logging.info("[SSE] 图已暂停,等待用户输入") diff --git a/app/chat/routes.py b/app/chat/routes.py index 2bc806f..7ed6563 100644 --- a/app/chat/routes.py +++ b/app/chat/routes.py @@ -6,7 +6,7 @@ import logging import json from app.chat.response_storage import render_summary_for_display -from app.agent.checkpoint_cleanup import enqueue_checkpoint_cleanup +from app.agent.checkpoint_cleanup import enqueue_checkpoint_cleanup_many chat_bp = Blueprint('chat', __name__, url_prefix='/api') import uuid @@ -268,39 +268,46 @@ def delete_session(): try: with get_write_connection() as conn: - cursor = conn.cursor() + cursor = conn.cursor(dictionary=True) # 开启事务 conn.start_transaction() + # 与创建、恢复、取消 Job 保持一致:先锁 Job,再锁 session,避免并发 + # 删除和任务生命周期操作形成 InnoDB 锁环。 + cursor.execute(""" + SELECT job_id, status + FROM analysis_jobs + WHERE session_id = %s + AND user_id = %s + ORDER BY id + FOR UPDATE + """, (session_id, user_id)) + session_jobs = cursor.fetchall() + # 锁定目标会话,阻止删除过程中并发创建引用该会话的新任务。 cursor.execute( "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", (session_id, user_id), ) session_exists = cursor.fetchone() - + if not session_exists: conn.rollback() logging.info(f"用户 {user_id} 请求删除不存在或无权访问的会话 {session_id}") return jsonify({"success": False, "error": "会话不存在或无权访问"}), 404 - cursor.execute(""" - SELECT 1 - FROM analysis_jobs - WHERE session_id = %s - AND user_id = %s - AND status IN ('queued', 'running') - LIMIT 1 - """, (session_id, user_id)) - if cursor.fetchone(): + if any(row["status"] in {"queued", "running", "waiting_input"} for row in session_jobs): conn.rollback() logging.info(f"用户 {user_id} 尝试删除仍有 active job 的会话 {session_id}") return jsonify({"success": False, "error": "当前会话仍有任务正在运行,请等待完成后再删除"}), 409 try: # 跨库删除不能加入 MySQL 事务;outbox 与会话删除同事务提交。 - enqueue_checkpoint_cleanup(cursor, session_id) + cleanup_count = enqueue_checkpoint_cleanup_many( + cursor, + [str(row["job_id"]) for row in session_jobs], + ) # 2. 删除与该会话相关的附件 (通过连接 chat_messages) # 这是为了处理 chat_attachments 和 chat_messages 之间没有直接外键的情况 @@ -328,7 +335,7 @@ def delete_session(): return jsonify({ "success": True, "message": "会话已删除,checkpoint 正在后台清理", - "checkpoint_cleanup": "pending", + "checkpoint_cleanup": "pending" if cleanup_count else "succeeded", }), 202 except Exception: conn.rollback() diff --git a/app/chat/services.py b/app/chat/services.py index 72b997b..3a6ce65 100644 --- a/app/chat/services.py +++ b/app/chat/services.py @@ -7,6 +7,7 @@ from app.db import get_read_connection, get_write_connection from app.chat.response_storage import prepare_ai_response_for_storage from app.chat.session_title import build_session_title +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage import mysql.connector import logging from datetime import datetime @@ -43,7 +44,43 @@ def get_chat_history(session_id: str, user_id: int, limit: int) -> list: except Exception as e: logging.error(f"为会话 {session_id} 获取历史记录时发生未知错误: {e}") return [] - + + + +def get_job_chat_history( + session_id: str, + user_id: int, + chat_message_id: int, + limit: int, +) -> list[BaseMessage]: + """读取 Job 创建时可见的有界消息窗口,不吞掉数据库异常。""" + normalized_limit = max(1, int(limit)) + with get_read_connection(consistency="strong") as conn: + cursor = conn.cursor(dictionary=True) + cursor.execute( + """ + SELECT id, message_type, content + FROM chat_messages + WHERE session_id = %s + AND user_id = %s + AND id <= %s + ORDER BY id DESC + LIMIT %s + """, + (session_id, user_id, int(chat_message_id), normalized_limit), + ) + recent_rows = cursor.fetchall() + + messages: list[BaseMessage] = [] + for row in reversed(recent_rows): + content = row.get("content") + if not isinstance(content, str): + content = "" if content is None else str(content) + if row.get("message_type") == "user": + messages.append(HumanMessage(content=content)) + elif row.get("message_type") == "ai": + messages.append(AIMessage(content=content)) + return messages ## 保存历史文件 @@ -157,6 +194,136 @@ def save_chat_for_job_in_transaction( return True +def save_user_input_for_job_in_transaction( + cursor, + *, + job_id: str, + input_id: int, + user_id: int, + session_id: str, + text: str, +) -> int: + """在创建或恢复 Job 事务中写入一条带输入账本关联的用户消息。""" + cursor.execute( + """ + SELECT message_count, title + FROM sessions + WHERE id = %s AND user_id = %s + FOR UPDATE + """, + (session_id, user_id), + ) + session_data = cursor.fetchone() + if not session_data: + raise SessionNotFoundError("会话不存在或无权访问") + + cursor.execute( + """ + INSERT INTO chat_messages ( + session_id, user_id, analysis_job_id, analysis_job_input_id, + message_type, content, created_at + ) VALUES (%s, %s, %s, %s, 'user', %s, UTC_TIMESTAMP(6)) + """, + (session_id, user_id, job_id, input_id, text), + ) + message_id = int(cursor.lastrowid) + if int(session_data["message_count"] or 0) == 0: + cursor.execute( + """ + UPDATE sessions + SET title = %s, last_activity_at = UTC_TIMESTAMP(6), + message_count = message_count + 1 + WHERE id = %s AND user_id = %s + """, + (build_session_title(text), session_id, user_id), + ) + else: + cursor.execute( + """ + UPDATE sessions + SET last_activity_at = UTC_TIMESTAMP(6), + message_count = message_count + 1 + WHERE id = %s AND user_id = %s + """, + (session_id, user_id), + ) + return message_id + + +def save_assistant_for_job_in_transaction( + cursor, + *, + job_id: str, + user_id: int, + session_id: str, + ai_response, + source_event_id: int, + analysis_job_input_id: int | None = None, +) -> int | None: + """在生命周期事务中写入一条由稳定事件唯一保护的 assistant 消息。""" + cursor.execute( + """ + SELECT id + FROM chat_messages + WHERE source_event_id = %s + FOR UPDATE + """, + (source_event_id,), + ) + existing = cursor.fetchone() + if existing: + return int(existing["id"] if isinstance(existing, dict) else existing[0]) + + cursor.execute( + """ + SELECT id + FROM sessions + WHERE id = %s AND user_id = %s + FOR UPDATE + """, + (session_id, user_id), + ) + if not cursor.fetchone(): + raise SessionNotFoundError("会话不存在或无权访问") + + ai_content, attachment_to_save = prepare_ai_response_for_storage(ai_response) + cursor.execute( + """ + INSERT INTO chat_messages ( + session_id, user_id, analysis_job_id, analysis_job_input_id, + source_event_id, message_type, content, has_attachment, created_at + ) VALUES (%s, %s, %s, %s, %s, 'ai', %s, %s, UTC_TIMESTAMP(6)) + """, + ( + session_id, + user_id, + job_id, + analysis_job_input_id, + source_event_id, + ai_content, + bool(attachment_to_save), + ), + ) + message_id = int(cursor.lastrowid) + for attachment in attachment_to_save: + cursor.execute( + """ + INSERT INTO chat_attachments (message_id, attachment_type, content, created_at) + VALUES (%s, %s, %s, UTC_TIMESTAMP(6)) + """, + (message_id, attachment["type"], attachment["content"]), + ) + cursor.execute( + """ + UPDATE sessions + SET last_activity_at = UTC_TIMESTAMP(6), message_count = message_count + 1 + WHERE id = %s AND user_id = %s + """, + (session_id, user_id), + ) + return message_id + + def save_chat(user_id, session_id, user_msg, ai_response): """独立事务保存用户和 AI 的聊天消息、附件及会话元数据。""" timestamp_dt = datetime.now() diff --git a/app/db.py b/app/db.py index 732eef0..4b1290d 100644 --- a/app/db.py +++ b/app/db.py @@ -315,11 +315,13 @@ def check_database_readiness(): "sessions", "chat_messages", "chat_attachments", - "uploaded_files", + "file_objects", + "user_files", "archived_sessions", "checkpoint_cleanup_outbox", "analysis_jobs", "analysis_job_events", + "analysis_job_inputs", "database_monitor_snapshots", "database_monitor_settings", "admin_audit_events", @@ -393,7 +395,13 @@ def check_database_readiness(): FROM information_schema.columns WHERE table_schema = %s AND table_name = 'analysis_jobs' - AND column_name IN ('idempotency_key', 'request_fingerprint') + AND column_name IN ( + 'idempotency_key', 'request_fingerprint', 'lease_epoch', + 'recovery_count', 'resume_count', 'input_user_file_id', + 'input_object_id', 'input_file_hash', 'input_filename', + 'current_question_id', 'current_waiting_prompt', + 'cancel_idempotency_key', 'cancel_request_fingerprint' + ) """, (settings.MYSQL_DATABASE,), ) @@ -401,6 +409,17 @@ def check_database_readiness(): missing_job_request_columns = { "idempotency_key", "request_fingerprint", + "lease_epoch", + "recovery_count", + "resume_count", + "input_user_file_id", + "input_object_id", + "input_file_hash", + "input_filename", + "current_question_id", + "current_waiting_prompt", + "cancel_idempotency_key", + "cancel_request_fingerprint", } - job_request_columns if missing_job_request_columns: error_msg = ( @@ -431,6 +450,35 @@ def check_database_readiness(): AND index_name = 'uq_analysis_jobs_user_idempotency' AND non_unique = 0 ) + OR ( + table_name = 'analysis_jobs' + AND index_name = 'idx_analysis_jobs_input_user_file_status' + ) + OR ( + table_name = 'analysis_jobs' + AND index_name = 'uq_analysis_jobs_cancel_idempotency' + AND non_unique = 0 + ) + OR ( + table_name = 'analysis_job_inputs' + AND index_name = 'uq_analysis_job_inputs_sequence' + AND non_unique = 0 + ) + OR ( + table_name = 'analysis_job_inputs' + AND index_name = 'uq_analysis_job_inputs_idempotency' + AND non_unique = 0 + ) + OR ( + table_name = 'analysis_job_events' + AND index_name = 'uq_analysis_job_events_event_key' + AND non_unique = 0 + ) + OR ( + table_name = 'chat_messages' + AND index_name = 'uq_chat_messages_source_event' + AND non_unique = 0 + ) ) """, (settings.MYSQL_DATABASE,), @@ -446,6 +494,18 @@ def check_database_readiness(): "analysis_jobs", "uq_analysis_jobs_user_idempotency", ), + ( + "analysis_jobs", + "idx_analysis_jobs_input_user_file_status", + ), + ( + "analysis_jobs", + "uq_analysis_jobs_cancel_idempotency", + ), + ("analysis_job_inputs", "uq_analysis_job_inputs_sequence"), + ("analysis_job_inputs", "uq_analysis_job_inputs_idempotency"), + ("analysis_job_events", "uq_analysis_job_events_event_key"), + ("chat_messages", "uq_chat_messages_source_event"), } missing_indexes = required_indexes - critical_indexes if missing_indexes: diff --git a/app/files/routes.py b/app/files/routes.py index b1aa1a1..9a5f4b8 100644 --- a/app/files/routes.py +++ b/app/files/routes.py @@ -1,239 +1,293 @@ -''' -app.files.routes - 文件路由 -''' -from flask import Blueprint, request, jsonify, session +"""用户文件库路由。""" + +from __future__ import annotations + +import hashlib import logging -from app.db import get_read_connection, get_write_connection +import os + +import mysql.connector +from flask import Blueprint, jsonify, request + from app.auth.session_guard import get_current_session_user +from app.db import get_read_connection, get_write_connection from config.settings import settings -import mysql.connector -import os -import hashlib -from app.chat.services import save_chat -files_bp = Blueprint('files', __name__, url_prefix='/api') -# 获取文件列表 -@files_bp.route('/files') +files_bp = Blueprint("files", __name__, url_prefix="/api") + + +def _iso(value) -> str | None: + """把 MySQL 时间值转换为前端稳定可读的 ISO 字符串。""" + return value.isoformat() if value is not None else None + + +def _file_payload(row: dict) -> dict: + """把 user_files 行转换为不暴露 BLOB 的文件库对象。""" + return { + "id": int(row["id"]), + "user_file_id": int(row["id"]), + "filename": row["filename"], + "mime_type": row["mime_type"], + "file_size": int(row["file_size"]), + "uploaded_at": _iso(row.get("uploaded_at")), + "last_accessed_at": _iso(row.get("last_accessed_at")), + "access_count": int(row.get("access_count") or 0), + } + + +@files_bp.route("/files") def get_file_list(): + """读取当前用户的逻辑文件库,不读取或返回文件正文。""" current_user = get_current_session_user() if not current_user: return jsonify({"error": "用户未登录或会话已过期"}), 401 - - user_id = current_user['id'] - logging.info(f"用户 {user_id} 请求文件列表") + try: - with get_read_connection(consistency="eventual") as conn: - cursor = conn.cursor(dictionary=True) - cursor.execute("SELECT id, filename, last_accessed_at FROM uploaded_files WHERE user_id = %s ORDER BY last_accessed_at DESC", (user_id,)) - file_rows = cursor.fetchall() - if not file_rows: - logging.info(f"用户 {user_id} 没有文件记录") - return jsonify([]) - file_list_for_frontend = [ - ( - row["id"], - { - "preview": row["filename"], - "last_time": row["last_accessed_at"].strftime("%m-%d %H:%M") - } + with get_read_connection(consistency="strong") as connection: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """ + SELECT id, object_id, filename, mime_type, file_size, + uploaded_at, last_accessed_at, access_count + FROM user_files + WHERE user_id = %s + ORDER BY last_accessed_at DESC, id DESC + """, + (current_user["id"],), ) - for row in file_rows - ] - - except mysql.connector.Error as e: - logging.error(f"为用户 {user_id} 读取文件列表时数据库出错: {e}") - return jsonify({"error": f"读取文件列表时出错: {e}"}), 500 - - logging.info(f"为用户 {user_id} 返回 {len(file_list_for_frontend)} 个文件") - return jsonify(file_list_for_frontend) - -## 上传文件 -@files_bp.route('/upload_file', methods=['POST']) + rows = cursor.fetchall() + return jsonify([_file_payload(row) for row in rows]) + except mysql.connector.Error: + logging.error("读取用户文件库失败: user_id=%s", current_user["id"], exc_info=True) + return jsonify({"error": "读取文件列表时出错"}), 500 + + +@files_bp.route("/upload_file", methods=["POST"]) def upload_file(): - # 重构:从 Session 获取用户身份 + """上传 CSV 到不可变对象库并创建或复用一条逻辑文件记录。""" current_user = get_current_session_user() if not current_user: - return jsonify({'success': False, 'error': '用户未登录或会话已过期'}), 401 - - user_id = current_user['id'] - username = current_user['username'] # 用于日志 - - session_id = request.form.get('session_id') - if not session_id: - logging.warning(f"用户 {username} 上传文件请求缺少 session_id") - return jsonify({'success': False, 'error': '请求无效,缺少会话ID'}), 400 - # - - if 'file' not in request.files: - logging.warning(f"用户 {username} 上传CSV请求中没有文件部分") - return jsonify({'success': False, 'error': '没有文件被上传'}), 400 - - file = request.files['file'] # 获取上传的文件对象 - - # 3. 检查文件名是否为空 - if file.filename == '': - logging.warning(f"用户 {username} 上传了但未选择文件") - return jsonify({'success': False, 'error': '没有选择文件'}), 400 - - allowed_extensions = {'.csv'} - allowed_mimetypes = {'text/csv', 'application/vnd.ms-excel'} # 有些浏览器对csv的mimetype可能是后者 - - # 4. 检查文件扩展名和MIME类型 - original_filename = file.filename - file_ext = os.path.splitext(original_filename)[1].lower() # 获取文件扩展名并转为小写 - - if not (file_ext in allowed_extensions and file.mimetype in allowed_mimetypes): - logging.warning(f"用户 {username} 尝试上传非法文件类型: {original_filename} (MIME: {file.mimetype})") - return jsonify({'success': False, 'error': '只允许上传 CSV 文件。请检查文件格式和扩展名。'}), 400 - - # 5. 读取文件内容并计算哈希 + return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 + + if "file" not in request.files: + return jsonify({"success": False, "error": "没有文件被上传"}), 400 + upload = request.files["file"] + filename = (upload.filename or "").strip() + if not filename: + return jsonify({"success": False, "error": "没有选择文件"}), 400 + + file_ext = os.path.splitext(filename)[1].lower() + allowed_mimetypes = {"text/csv", "application/vnd.ms-excel"} + if file_ext != ".csv" or upload.mimetype not in allowed_mimetypes: + return jsonify({"success": False, "error": "只允许上传 CSV 文件。请检查文件格式和扩展名。"}), 400 + try: - file.seek(0) # 确保从文件开头读取 - file_content = file.read() # 将整个文件内容读取为 bytes - file_hash = hashlib.sha256(file_content).hexdigest() - file_size = len(file_content) + content = upload.read() + file_size = len(content) if file_size > settings.MAX_UPLOAD_SIZE_BYTES: - logging.warning( - "用户 %s 上传文件超过大小限制: %s bytes > %s bytes", - username, - file_size, - settings.MAX_UPLOAD_SIZE_BYTES, - ) return jsonify({ - 'success': False, - 'error': f'文件大小不能超过 {settings.MAX_UPLOAD_SIZE_MB}MB' + "success": False, + "error": f"文件大小不能超过 {settings.MAX_UPLOAD_SIZE_MB}MB", }), 413 - except Exception as e: - logging.error(f"用户 {username} 上传文件 {original_filename} 时读取内容或计算哈希失败: {e}") - return jsonify({'success': False, 'error': '处理文件内容失败'}), 500 - - # 6. 检查重复文件并保存到数据库 (使用哈希) - try: - with get_write_connection() as conn: - cursor = conn.cursor(dictionary=True) # 使用字典游标 + content_hash = hashlib.sha256(content).hexdigest() + except Exception: + logging.error("读取上传文件失败: user_id=%s filename=%s", current_user["id"], filename, exc_info=True) + return jsonify({"success": False, "error": "处理文件内容失败"}), 500 + try: + with get_write_connection() as connection: + cursor = connection.cursor(dictionary=True) + connection.start_transaction() cursor.execute( - "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", - (session_id, user_id), + "SELECT id FROM users WHERE id = %s FOR UPDATE", + (current_user["id"],), ) if not cursor.fetchone(): - conn.rollback() - logging.warning( - "用户 %s 上传文件时引用了不存在或无权访问的会话 %s", - user_id, - session_id, - ) - return jsonify({'success': False, 'error': '会话不存在或无权访问'}), 404 - - # 检查是否已存在相同哈希的文件 - cursor.execute(""" - SELECT id, filename FROM uploaded_files - WHERE user_id = %s AND file_hash = %s - """, (user_id, file_hash)) - existing_file = cursor.fetchone() - - if existing_file: - # 去重命中不等价于正文被使用,不更新访问时间或使用次数。 - # 使用原始文件名进行提示 - action_message = f'您之前已上传过内容相同的文件 (名为 "{existing_file["filename"]}")。无需重复上传。' - logging.info(f"用户 {username} (ID: {user_id}) 上传了重复内容的文件: {original_filename} (Hash: {file_hash[:10]}...)") + connection.rollback() + return jsonify({"success": False, "error": "用户不存在"}), 404 + + cursor.execute( + """ + SELECT id, file_size, mime_type + FROM file_objects + WHERE owner_user_id = %s AND content_hash = %s + FOR UPDATE + """, + (current_user["id"], content_hash), + ) + object_row = cursor.fetchone() + if object_row: + object_id = int(object_row["id"]) else: - # 文件不存在,插入新记录 - cursor.execute(""" - INSERT INTO uploaded_files (user_id, filename, original_filename, mime_type, file_size, file_hash, file_content) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, (user_id, original_filename, original_filename, file.mimetype, file_size, file_hash, file_content)) - conn.commit() - action_message = f'文件 "{original_filename}" 上传成功!' - logging.info(f"用户 {username} (ID: {user_id}) 成功上传新文件: {original_filename}") - - # 保存文件上传的聊天记录 - user_message = f"上传文件: {original_filename}" - # 修改AI响应,使其更清晰 - ai_message_text = f"已接收您的文件:`{original_filename}`。\n\n{action_message}\n\n您现在可以对我提问,例如:请对`{original_filename}`进行因果分析" - ai_response = {"type": "text", "summary": ai_message_text} - - save_chat(user_id, session_id, user_message, ai_response) - - return jsonify({'success': True, 'message': action_message, 'ai_response': ai_response}) - except mysql.connector.Error as e: - logging.error(f"用户 {username} 保存文件 {original_filename} 到数据库时出错: {e}") - return jsonify({'success': False, 'error': '保存文件到数据库失败'}), 500 - except Exception as e: # 捕获其他可能的未知错误 - logging.error(f"用户 {username} 上传文件 {original_filename} 时发生未知服务器错误: {e}") - return jsonify({'success': False, 'error': '上传文件时发生服务器内部错误'}), 500 - - -# delete file -@files_bp.route('/delete_file', methods=['POST']) + cursor.execute( + """ + INSERT INTO file_objects ( + owner_user_id, content_hash, file_size, mime_type, file_content + ) VALUES (%s, %s, %s, %s, %s) + """, + ( + current_user["id"], + content_hash, + file_size, + upload.mimetype, + content, + ), + ) + object_id = int(cursor.lastrowid) + + cursor.execute( + """ + SELECT id, object_id, filename, mime_type, file_size, + uploaded_at, last_accessed_at, access_count + FROM user_files + WHERE user_id = %s AND object_id = %s AND filename = %s + FOR UPDATE + """, + (current_user["id"], object_id, filename), + ) + user_file = cursor.fetchone() + created = False + if not user_file: + cursor.execute( + """ + INSERT INTO user_files ( + user_id, object_id, filename, mime_type, file_size + ) VALUES (%s, %s, %s, %s, %s) + """, + (current_user["id"], object_id, filename, upload.mimetype, file_size), + ) + user_file_id = int(cursor.lastrowid) + cursor.execute( + """ + SELECT id, object_id, filename, mime_type, file_size, + uploaded_at, last_accessed_at, access_count + FROM user_files + WHERE id = %s + FOR UPDATE + """, + (user_file_id,), + ) + user_file = cursor.fetchone() + created = True + connection.commit() + + payload = _file_payload(user_file) + message = ( + f'文件 "{filename}" 上传成功!' + if created + else f'文件 "{filename}" 已在文件库中,无需重复上传。' + ) + return jsonify({ + "success": True, + "message": message, + "file": payload, + "user_file_id": payload["user_file_id"], + "file_hash": content_hash, + }) + except mysql.connector.Error: + logging.error( + "保存文件库记录失败: user_id=%s filename=%s", + current_user["id"], + filename, + exc_info=True, + ) + return jsonify({"success": False, "error": "保存文件到数据库失败"}), 500 + except Exception: + logging.error( + "上传文件时发生未知错误: user_id=%s filename=%s", + current_user["id"], + filename, + exc_info=True, + ) + return jsonify({"success": False, "error": "上传文件时发生服务器内部错误"}), 500 + + +@files_bp.route("/delete_file", methods=["POST"]) def delete_file(): + """删除逻辑文件;无引用且无活动 Job 时再删除不可变 BLOB。""" current_user = get_current_session_user() if not current_user: return jsonify({"success": False, "error": "用户未登录或会话已过期"}), 401 - - user_id = current_user['id'] - data = request.json - file_id = data.get('file_id') + data = request.get_json(silent=True) or {} + file_id = data.get("file_id") if not file_id: return jsonify({"success": False, "error": "缺少文件ID"}), 400 try: - with get_write_connection() as conn: - cursor = conn.cursor(dictionary=True) - conn.start_transaction() - # 锁住归属用户,阻止删除窗口内创建带该用户外键的新任务或文件。 + file_id = int(file_id) + except (TypeError, ValueError): + return jsonify({"success": False, "error": "文件ID无效"}), 400 + + try: + with get_write_connection() as connection: + cursor = connection.cursor(dictionary=True) + connection.start_transaction() cursor.execute( "SELECT id FROM users WHERE id = %s FOR UPDATE", - (user_id,), + (current_user["id"],), ) if not cursor.fetchone(): - conn.rollback() + connection.rollback() return jsonify({"success": False, "error": "用户不存在"}), 404 + cursor.execute( """ - SELECT id - FROM uploaded_files + SELECT id, object_id, filename + FROM user_files WHERE id = %s AND user_id = %s FOR UPDATE """, - (file_id, user_id), + (file_id, current_user["id"]), ) - if not cursor.fetchone(): - conn.rollback() - logging.warning(f"用户 {user_id} 尝试删除无权或不存在的文件 {file_id}") - return jsonify({"success": False, "error": "无法删除该文件,权限不足或文件不存在"}), 404 + file_row = cursor.fetchone() + if not file_row: + connection.rollback() + return jsonify({"success": False, "error": "文件不存在或无权访问"}), 404 + cursor.execute( """ SELECT job_id FROM analysis_jobs - WHERE user_id = %s AND status IN ('queued', 'running') + WHERE input_user_file_id = %s + AND status IN ('queued', 'running', 'waiting_input') ORDER BY id FOR UPDATE """, - (user_id,), + (file_id,), ) if cursor.fetchall(): - conn.rollback() + connection.rollback() return jsonify({ "success": False, - "error": "当前仍有任务正在使用该用户的数据,请等待任务结束后再删除文件", + "error": "当前文件仍被活动任务使用,请等待任务结束后再删除", }), 409 + + cursor.execute("DELETE FROM user_files WHERE id = %s", (file_id,)) + if cursor.rowcount != 1: + connection.rollback() + raise RuntimeError("逻辑文件删除影响行数异常") + cursor.execute( - "DELETE FROM uploaded_files WHERE id = %s AND user_id = %s", - (file_id, user_id), + "SELECT COUNT(*) AS reference_count FROM user_files WHERE object_id = %s", + (file_row["object_id"],), ) - if cursor.rowcount != 1: - conn.rollback() - raise RuntimeError("文件删除影响行数异常") - conn.commit() - logging.info(f"用户 {user_id} 成功删除了文件 {file_id}") - return jsonify({"success": True, "message": "文件已成功删除"}) - - except mysql.connector.Error as e: - logging.error(f"删除文件 {file_id} (用户 {user_id}) 时数据库出错: {e}") + reference_count = int((cursor.fetchone() or {}).get("reference_count") or 0) + blob_deleted = False + if reference_count == 0: + cursor.execute("DELETE FROM file_objects WHERE id = %s", (file_row["object_id"],)) + blob_deleted = cursor.rowcount == 1 + connection.commit() + return jsonify({ + "success": True, + "message": "文件已成功删除", + "user_file_id": file_id, + "blob_deleted": blob_deleted, + }) + except mysql.connector.Error: + logging.error("删除文件失败: user_id=%s file_id=%s", current_user["id"], file_id, exc_info=True) return jsonify({"success": False, "error": "删除文件时数据库出错"}), 500 - except Exception as e: - logging.error(f"删除文件 {file_id} (用户 {user_id}) 时发生未知错误: {e}") + except Exception: + logging.error("删除文件时发生未知错误: user_id=%s file_id=%s", current_user["id"], file_id, exc_info=True) return jsonify({"success": False, "error": "删除文件时发生未知错误"}), 500 diff --git a/app/static/chat.html b/app/static/chat.html index 71ada9f..3b0b9a4 100644 --- a/app/static/chat.html +++ b/app/static/chat.html @@ -139,10 +139,28 @@
+ + - - - +
+ + + +
diff --git a/app/static/css/style.css b/app/static/css/style.css index 21552d5..a6315e8 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -36,6 +36,7 @@ } .sidebar-content { flex: 1; /* 这个元素将占据父容器的所有可用剩余空间 */ + min-height: 0; /* 允许内部列表在固定视口内收缩并独立滚动 */ overflow: hidden; /* 隐藏自身滚动条,由子容器处理滚动 */ display: flex; flex-direction: column; @@ -44,6 +45,7 @@ /* 历史记录滚动容器 */ .history-list-container { flex: 1; /* 占据所有可用的剩余空间 */ + min-height: 0; overflow-y: auto; /* 内容溢出时显示垂直滚动条 */ } @@ -55,6 +57,7 @@ .file-list-container { /* 文件列表的容器 */ flex-shrink: 0; /* 防止此容器在空间不足时被压缩 */ + min-height: 0; height: 28%; /* 固定占据侧边栏下半部分自定义的高度 */ display: flex; /* 使用Flexbox布局 */ flex-direction: column; @@ -132,8 +135,9 @@ /* 主聊天界面容器 */ margin-left: 0; /* 初始状态下左边距为0 */ transition: margin-left 0.3s; /* 当左边距变化时,平滑过渡 */ - min-height: 100vh; /* 空会话时仍占满视口,内容多时允许页面继续增长 */ - height: auto; + height: 100vh; /* 主界面固定在视口内,长内容由聊天区域滚动 */ + min-height: 0; + overflow: hidden; display: flex; /* 使用Flexbox布局 */ flex-direction: column; /* 让聊天区和输入区垂直排列 */ } @@ -143,9 +147,11 @@ } .chat-area { /* 显示所有聊天消息的区域 */ - flex: none; /* 让内容高度由消息本身决定,滚动交给页面 */ - min-height: calc(100vh - 110px); /* 空状态下把输入区保持在视口底部附近 */ - overflow: visible; + flex: 1 1 auto; /* 在固定视口内占用输入区之外的剩余空间 */ + min-height: 0; /* 允许 flex 子项缩小,否则长报告会撑开页面 */ + overflow-y: auto; /* 长报告和历史消息只在聊天区域内滚动 */ + overflow-x: hidden; + overscroll-behavior: contain; padding: 20px; /* 内边距 */ transition: margin-left 0.3s; /* 配合侧边栏的平滑过渡 */ display: flex; /* 使用Flexbox布局 */ @@ -174,22 +180,141 @@ font-size: 0.9em; /* 相对父元素设置字体大小 */ /* 颜色由下面的 .session-time 控制 */ } + /* hidden 属性必须优先于组件自身的 display 声明,避免空状态占位。 */ + [hidden] { + display: none !important; + } + + .selected-file-draft { + width: 100%; + } + + .selected-file-card { + display: flex; + align-items: center; + gap: 12px; + width: min(100%, 460px); + min-height: 64px; + padding: 10px 12px; + box-sizing: border-box; + border: 1px solid #dfe4ea; + border-radius: 12px; + background: #ffffff; + } + + .selected-file-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 40px; + width: 40px; + height: 40px; + border-radius: 9px; + background: #e9f2ff; + color: #2f7cf6; + font-size: 0.68em; + font-weight: 700; + letter-spacing: 0.04em; + } + + .selected-file-details { + display: flex; + flex: 1 1 auto; + min-width: 0; + flex-direction: column; + gap: 3px; + } + + .selected-file-name { + overflow: hidden; + color: #1f2937; + font-size: 0.95em; + font-weight: 600; + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; + } + + .selected-file-meta { + color: #7b8490; + font-size: 0.85em; + line-height: 1.2; + } + + .selected-file-card .clear-selected-file-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 28px; + width: 28px; + height: 28px; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: #6b7280; + cursor: pointer; + font-size: 1.25em; + line-height: 1; + } + + .selected-file-card .clear-selected-file-button:hover { + background: #f1f5f9; + color: #1f2937; + } + + .waiting-job-status { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 20px; + background: #f7f9fb; + border-top: 1px solid #e7ebef; + color: #334155; + font-size: 0.9em; + } + + .waiting-job-status span { + min-width: 0; + overflow-wrap: anywhere; + } + + .waiting-job-status button { + flex: 0 0 auto; + margin-left: auto; + padding: 7px 11px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + color: #334155; + cursor: pointer; + } + + .waiting-job-status button:hover { + background: #eef2f7; + } + .input-area { /* 页面底部的输入区域容器 */ - position: sticky;/* "粘性"定位,使其在滚动到视口底部时固定 */ - bottom: 0; /* 粘在底部 */ + position: relative; + flex: 0 0 auto; /* 输入区域固定在聊天滚动区域之外 */ background: white; /* 白色背景,覆盖下方内容 */ - padding: 15px 20px; /* 内边距 */ + padding: 15px 20px 18px; /* 内边距 */ box-shadow: 0 -2px 10px rgba(0,0,0,0.05); /* 顶部的细微阴影,与聊天区分隔 */ display: flex; /* 使用Flexbox布局 */ - align-items: center; /* 垂直居中输入框和按钮 */ - gap: 10px; /* 项目之间的间距 */ + flex-direction: column; + align-items: stretch; + gap: 12px; } /* 让输入框占据剩余空间 */ - #userInput { + #userInput { /* 文本输入框 */ - flex: 1; /* 占据所有可用的水平空间 */ + flex: none; + width: 100%; + min-height: 96px; + box-sizing: border-box; border: 1px solid #ccc; /* 灰色边框 */ border-radius: 5px; /* 边框圆角 */ padding: 10px; /* 内边距 */ @@ -197,6 +322,14 @@ resize: none; /* 禁止用户手动调整输入框大小 */ } + .input-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + width: 100%; + } + /* 发送按钮样式 */ .input-area button { /* 输入区域内通用按钮样式 */ @@ -212,6 +345,18 @@ /* 鼠标悬停在按钮上时的样式 */ background-color: #0056b3; } + + .input-area .waiting-job-status button { + padding: 7px 11px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + color: #334155; + } + + .input-area .waiting-job-status button:hover { + background: #eef2f7; + } /* 上传按钮样式 */ #uploadCsvButton { @@ -235,6 +380,7 @@ /* 所有消息(用户和AI)的通用容器 */ display: flex; /* 使用Flexbox布局 */ max-width: min(80%, 880px); /* 消息最大宽度不超过父容器的80% */ + min-width: 0; position: relative; /* 为内部绝对定位的元素提供基准 */ z-index: 1; /* 确保消息在水印之上 */ margin-bottom: 10px; /* 消息之间的外边距 */ @@ -247,6 +393,9 @@ word-break: break-word; /* 长单词或URL自动换行 */ line-height: 1.4; /* 行高,增加可读性 */ width: 100%; /* 占满.message容器 */ + max-width: 100%; + min-width: 0; + box-sizing: border-box; user-select: text; /* 新增:确保在所有环境中都可以选中文本 */ } @@ -296,6 +445,24 @@ background-color: #f5f5f5; border-radius: 8px; padding: 16px 20px; + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; + } + + .causal-report img { + max-width: 100%; + height: auto; + } + + .causal-report pre, + .causal-report table { + max-width: 100%; + overflow-x: auto; + } + + .causal-report table { + display: block; } /* AI 消息中的 Markdown 标题样式,避免标题过大占屏 */ @@ -392,6 +559,11 @@ background-color: #f9f9f9; /* 浅灰色背景 */ } + .file-item.selected .file-item-content { + background-color: #eef6ff; + box-shadow: inset 3px 0 0 #1473e6; + } + /* 新增:滑动删除样式 */ .history-item-content { /* 历史记录项的可视内容区域 */ @@ -802,11 +974,19 @@ /* 鼠标悬停在关闭按钮上时 */ background-color: #5a6268; } - /* 聊天内容使用文档级滚动,避免视口内再嵌套一个滚动区域。 */ + /* 页面固定在视口内,聊天内容由 .chat-area 单独滚动。 */ + html, body { - /* 聊天内容直接参与文档布局,不再创建独立的聊天滚动容器。 */ + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + } + + body { + /* 输入区保持可见,长报告不会把页面整体撑高。 */ overflow-x: hidden; - overflow-y: auto; + overflow-y: hidden; } /* 新增:AI加载动画 */ @@ -1038,6 +1218,14 @@ /* 响应式调整 */ @media (max-width: 768px) { + .input-area { + padding: 12px; + } + + .selected-file-card { + width: 100%; + } + .thinking-bubble, .thinking-detail-container { width: 95%; diff --git a/app/static/js/script.js b/app/static/js/script.js index 2e52661..f6e770d 100644 --- a/app/static/js/script.js +++ b/app/static/js/script.js @@ -26,6 +26,11 @@ let currentUserRole = null; let currentSessionId = null; // < 全局变量跟踪当前会话ID let isNewSessionPendingDisplay = false; // 用于跟踪新会话是否已在UI中临时显示 let chatEventListenersAttached = false; // 跟踪事件监听器是否已附加 +let selectedUserFile = null; // 仅保存在当前页面的文件草稿,不写入 session +let waitingJob = null; // 当前 waiting_input Job 的恢复草稿 +const activeJobsById = new Map(); // 当前用户所有活动 Job,按 job_id 隔离 +const jobSubscriptions = new Map(); // 每个 Job 最多一个 SSE 订阅 Promise +const jobEventSources = new Map(); // 用于登出或切换时关闭 SSE 连接 let currentLanguage = localStorage.getItem('language') || 'zh'; // 当前语言,默认中文,从localStorage读取 const initialNavigationParams = new URLSearchParams(window.location.search); let requestedLoginNext = initialNavigationParams.get('next'); @@ -63,6 +68,7 @@ const i18n = { inputPlaceholder: '输入消息...', upload: '上传', send: '发送', + cancelWaitingJob: '取消等待任务', // 动态消息 accountPrefix: '账号: ', noHistoryMessage: '还没有任何对话记录。', @@ -118,6 +124,7 @@ const i18n = { inputPlaceholder: 'Type a message...', upload: 'Upload', send: 'Send', + cancelWaitingJob: 'Cancel waiting task', // Dynamic messages accountPrefix: 'Account: ', noHistoryMessage: 'No conversation history yet.', @@ -150,6 +157,119 @@ function getText(key) { return i18n[currentLanguage][key] || i18n['zh'][key] || key; } +function formatSelectedFileSize(bytes) { + const size = Number(bytes); + if (!Number.isFinite(size) || size < 0) return ''; + if (size < 1024) return `${size} B`; + if (size < 1024 * 1024) return `${(size / 1024).toFixed(size < 10 * 1024 ? 1 : 0)} KB`; + return `${(size / (1024 * 1024)).toFixed(size < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} + +/** + * 只显示文件上传时间中的年月日,避免侧栏被完整时间戳撑宽。 + */ +function formatFileDate(value) { + const raw = String(value ?? '').trim(); + const match = raw.match(/^\d{4}-\d{2}-\d{2}/); + return match ? match[0] : ''; +} + +function selectedFileExtension(filename) { + const parts = String(filename || '').split('.'); + return parts.length > 1 && parts[parts.length - 1] + ? parts[parts.length - 1].toUpperCase() + : 'FILE'; +} + +function renderSelectedFileCard(file) { + const draft = document.getElementById('selectedFileDraft'); + if (!draft) return; + if (!file) { + draft.hidden = true; + return; + } + + const name = document.getElementById('selectedFileName'); + const meta = document.getElementById('selectedFileMeta'); + if (name) name.textContent = file.filename; + if (meta) { + const parts = [selectedFileExtension(file.filename), formatSelectedFileSize(file.file_size)]; + meta.textContent = parts.filter(Boolean).join(' '); + } + draft.hidden = false; +} + +function setSelectedUserFile(file) { + if (!file) { + clearSelectedUserFile(); + return; + } + const userFileId = Number(file.user_file_id ?? file.id); + if (!Number.isInteger(userFileId) || userFileId <= 0) { + showError('文件记录无效,请重新上传或选择文件。'); + return; + } + selectedUserFile = { + user_file_id: userFileId, + filename: String(file.filename || '未命名文件'), + file_size: Number(file.file_size || 0), + }; + renderSelectedFileCard(selectedUserFile); +} + +function clearSelectedUserFile() { + selectedUserFile = null; + renderSelectedFileCard(null); +} + +function activeJobKey(jobId) { + return jobId == null ? '' : String(jobId); +} + +function rememberActiveJob(job) { + const key = activeJobKey(job && job.job_id); + if (!key) return null; + const previous = activeJobsById.get(key) || {}; + const next = { ...previous, ...job, job_id: key }; + activeJobsById.set(key, next); + return next; +} + +function forgetActiveJob(jobId) { + const key = activeJobKey(jobId); + const source = jobEventSources.get(key); + if (source) source.close(); + jobEventSources.delete(key); + jobSubscriptions.delete(key); + activeJobsById.delete(key); + if (waitingJob && activeJobKey(waitingJob.job_id) === key) setWaitingJob(null); +} + +function closeAllActiveJobStreams() { + jobEventSources.forEach(source => source.close()); + jobEventSources.clear(); + jobSubscriptions.clear(); + activeJobsById.clear(); +} + +function setWaitingJob(job) { + waitingJob = job ? { + job_id: activeJobKey(job.job_id), + session_id: job.session_id || currentSessionId, + question_id: job.question_id || job.current_question_id || '', + prompt: job.prompt || job.current_waiting_prompt || '', + last_event_id: Number(job.last_event_id || job.event_id || 0), + } : null; + const status = document.getElementById('waitingJobStatus'); + const prompt = document.getElementById('waitingJobPrompt'); + const userInput = document.getElementById('userInput'); + if (status) status.hidden = !waitingJob; + if (prompt) prompt.textContent = waitingJob ? (waitingJob.prompt || '请补充信息') : ''; + if (userInput) userInput.placeholder = waitingJob + ? (waitingJob.prompt || '请输入恢复任务的回答...') + : getText('inputPlaceholder'); +} + // 应用语言到所有带有 data-i18n 属性的元素 function applyLanguage() { // 更新所有带有 data-i18n 属性的元素的文本内容 @@ -298,8 +418,10 @@ async function handleLogin() { updateUserInfo(); // 更新用户信息显示 loadHistory(); // 先加载历史记录 - loadFiles(); // 加载文件列表 - newChat(); // 然后准备一个新对话界面 + loadFiles(); // 加载文件列表 + restoreActiveJobs().then(restored => { + if (!restored) newChat(); + }); // 清空登录表单 usernameInput.value = ''; passwordInput.value = ''; @@ -351,6 +473,10 @@ async function handleLogout() { console.log("后端登出成功"); currentUsername = null; // 清除全局变量 currentUserRole = null; + currentSessionId = null; + clearSelectedUserFile(); + setWaitingJob(null); + closeAllActiveJobStreams(); chatEventListenersAttached = false; // 重置监听器标志 document.body.classList.remove('logged-in'); // 移除标记类 authOverlay.classList.add('active'); // 显示登录/注册层 @@ -391,6 +517,7 @@ async function checkLoginStatus() { updateUserInfo(); // 更新用户信息显示 (稍后修改此函数) loadHistory(); // 加载历史记录 (稍后修改此函数) loadFiles(); // 加载文件列表 + restoreActiveJobs(); if (requestedLoginNext && pendingNavigationNotice !== 'admin_required') { clearInternalNavigationParameters(); } @@ -398,6 +525,9 @@ async function checkLoginStatus() { console.log("后端验证:用户未登录,显示登录界面"); currentUsername = null; // **修改**: 确保全局变量为空 currentUserRole = null; + clearSelectedUserFile(); + setWaitingJob(null); + closeAllActiveJobStreams(); document.body.classList.remove('logged-in'); authOverlay.classList.add('active'); loginForm.style.display = 'block'; @@ -412,6 +542,7 @@ async function checkLoginStatus() { // 网络错误等,也显示登录界面 currentUsername = null; currentUserRole = null; + closeAllActiveJobStreams(); document.body.classList.remove('logged-in'); authOverlay.classList.add('active'); loginForm.style.display = 'block'; @@ -599,6 +730,8 @@ function setupChatEventListeners() { const settingButton = document.getElementById('settingButton'); const userAvatar = document.getElementById('userAvatar'); const uploadCsvButton = document.getElementById('uploadCsvButton'); + const clearSelectedFileButton = document.getElementById('clearSelectedFileButton'); + const cancelWaitingJobButton = document.getElementById('cancelWaitingJobButton'); if (menuIcon) menuIcon.addEventListener('click', toggleSidebar); if (sidebarToggle) sidebarToggle.addEventListener('click', toggleSidebar); @@ -606,6 +739,8 @@ function setupChatEventListeners() { if (settingButton) settingButton.addEventListener('click', showSettingPopup); if (userAvatar) userAvatar.addEventListener('click', showUserInfoPopup); if (uploadCsvButton) uploadCsvButton.addEventListener('click', triggerCsvUpload); + if (clearSelectedFileButton) clearSelectedFileButton.addEventListener('click', clearSelectedUserFile); + if (cancelWaitingJobButton) cancelWaitingJobButton.addEventListener('click', cancelWaitingAnalysisJob); chatEventListenersAttached = true; // 设置标志 } @@ -627,7 +762,7 @@ function createAgentRequestIdempotencyKey() { throw new Error('当前浏览器不支持安全的请求幂等键生成'); } -async function requestAgentJob(message, sessionId, idempotencyKey) { +async function requestAgentJob(message, sessionId, idempotencyKey, inputUserFileId = null) { // 对创建请求的未知结果最多重试一次,并始终复用同一个幂等键。 const request = async () => { const response = await fetch('/api/agent/jobs', { @@ -640,6 +775,7 @@ async function requestAgentJob(message, sessionId, idempotencyKey) { message: message, username: currentUsername, session_id: sessionId, + input_user_file_id: inputUserFileId, }), }); const jobData = await response.json(); @@ -661,6 +797,170 @@ async function requestAgentJob(message, sessionId, idempotencyKey) { } } +async function requestJobResume(job, answer, idempotencyKey) { + const request = async () => { + const response = await fetch(`/api/agent/jobs/${encodeURIComponent(job.job_id)}/resume`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + question_id: job.question_id, + answer, + }), + }); + const data = await response.json(); + if (!response.ok || !data.success) { + const error = new Error(data.error || `HTTP错误: ${response.status}`); + error.retryable = response.status >= 500; + throw error; + } + return data; + }; + try { + return await request(); + } catch (error) { + if (error && error.retryable === false) throw error; + return request(); + } +} + +async function cancelWaitingAnalysisJob() { + if (!waitingJob) return; + const job = waitingJob; + const idempotencyKey = createAgentRequestIdempotencyKey(); + try { + const response = await fetch(`/api/agent/jobs/${encodeURIComponent(job.job_id)}/cancel`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: '{}', + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || `HTTP错误: ${response.status}`); + } + forgetActiveJob(job.job_id); + loadHistory(); + } catch (error) { + showError(error.message || '取消任务失败,请稍后重试。'); + } +} + +function updateActiveJobFromEvent(jobId, eventData) { + const key = activeJobKey(jobId); + const state = activeJobsById.get(key); + if (!state) return; + const eventId = Number(eventData.event_id); + if (Number.isFinite(eventId) && eventId > Number(state.last_event_id || 0)) { + state.last_event_id = eventId; + } + if (eventData.type === 'interrupt') { + state.status = 'waiting_input'; + state.current_question_id = eventData.question_id || state.current_question_id || ''; + state.current_waiting_prompt = eventData.message || state.current_waiting_prompt || ''; + } else if (eventData.type === 'final_result') { + state.status = 'succeeded'; + activeJobsById.delete(key); + } else if (eventData.type === 'error') { + state.status = 'failed'; + activeJobsById.delete(key); + } +} + +function ensureActiveJobSubscription(job, thinkingElements = null) { + const state = rememberActiveJob(job); + const key = activeJobKey(job.job_id); + if (thinkingElements) state.thinkingElements = thinkingElements; + const existing = jobSubscriptions.get(key); + if (existing) return existing; + + const subscription = subscribeToJobEvents( + key, + null, + Number(state.last_event_id || 0), + ); + jobSubscriptions.set(key, subscription); + subscription.then( + () => { + if (jobSubscriptions.get(key) === subscription) jobSubscriptions.delete(key); + }, + () => { + if (jobSubscriptions.get(key) === subscription) jobSubscriptions.delete(key); + }, + ); + return subscription; +} + +function restoreOneActiveJob(job, visible) { + const state = rememberActiveJob(job); + if (state.status === 'waiting_input') { + state.thinkingElements = null; + if (visible) setWaitingJob(state); + return Promise.resolve(); + } + + if (state.thinkingElements && !state.thinkingElements.bubble.isConnected) { + state.thinkingElements = null; + } + if (visible && !state.thinkingElements) { + state.thinkingElements = addThinkingMessage(); + } + return ensureActiveJobSubscription(state, state.thinkingElements); +} + +async function restoreActiveJobs(sessionId = null) { + if (!currentUsername) return false; + const query = sessionId ? `?session_id=${encodeURIComponent(sessionId)}` : ''; + try { + const response = await fetch(`/api/agent/jobs/active${query}`); + if (!response.ok) return false; + const data = await response.json(); + const jobs = Array.isArray(data.jobs) ? data.jobs : []; + const freshIds = new Set(jobs.map(job => activeJobKey(job.job_id))); + + if (sessionId) { + for (const [jobId, state] of activeJobsById.entries()) { + if (state.session_id === sessionId && !freshIds.has(jobId)) { + forgetActiveJob(jobId); + } + } + } + + const visibleSessionId = sessionId || currentSessionId; + jobs.forEach(job => { + const visible = Boolean(visibleSessionId && job.session_id === visibleSessionId); + const subscription = restoreOneActiveJob(job, visible); + if (job.status !== 'waiting_input') { + subscription.catch(error => { + console.error(`恢复 Job ${job.job_id} 的事件订阅失败:`, error); + }); + } + }); + + if (sessionId) { + if (!jobs.length) setWaitingJob(null); + return jobs.length > 0; + } + if (!jobs.length) return false; + + if (!currentSessionId) { + // 后端按创建时间升序返回;选择最新活动会话作为当前可见页,其他 Job 已在上面逐个订阅。 + const selectedSessionId = jobs[jobs.length - 1].session_id; + await loadSession(selectedSessionId); + } else if (!jobs.some(job => job.session_id === currentSessionId)) { + setWaitingJob(null); + } + return true; + } catch (error) { + console.error('恢复活动 Job 失败:', error); + return false; + } +} + // 返回设置列表 function showSettingOptions() { console.log("返回设置选项列表"); @@ -703,8 +1003,11 @@ async function sendMessage() { return; } + const resumeJob = waitingJob; + const resumeAfterEventId = resumeJob ? (resumeJob.last_event_id || 0) : 0; + // 新会话由后端创建并持久化,这里只负责取得其 ID。 - if (!currentSessionId) { + if (!resumeJob && !currentSessionId) { console.log("检测到新对话(无会话ID),正在后端获取ID..."); try { const response = await fetch('/api/new_chat', { method: 'POST' }); @@ -725,7 +1028,7 @@ async function sendMessage() { } // 核心修改:如果是一个待显示的新会话,立即在UI上创建临时条目 - if (isNewSessionPendingDisplay) { + if (!resumeJob && isNewSessionPendingDisplay) { addTemporarySessionToUI(currentSessionId, message); isNewSessionPendingDisplay = false; // 重置标志,防止重复创建 } @@ -741,8 +1044,28 @@ async function sendMessage() { try { const idempotencyKey = createAgentRequestIdempotencyKey(); - const jobData = await requestAgentJob(message, currentSessionId, idempotencyKey); - await subscribeToJobEvents(jobData.job_id, thinkingElements); + let jobData; + if (resumeJob) { + setWaitingJob(null); + jobData = await requestJobResume(resumeJob, message, idempotencyKey); + } else { + const selectedFileId = selectedUserFile ? selectedUserFile.user_file_id : null; + jobData = await requestAgentJob( + message, + currentSessionId, + idempotencyKey, + selectedFileId, + ); + clearSelectedUserFile(); + } + const activeJob = rememberActiveJob({ + ...jobData, + job_id: jobData.job_id, + session_id: currentSessionId, + status: jobData.status || 'queued', + last_event_id: resumeAfterEventId, + }); + await ensureActiveJobSubscription(activeJob, thinkingElements); // 加载历史记录 loadHistory(); @@ -750,6 +1073,7 @@ async function sendMessage() { } catch (error) { console.error("发送消息时出错:", error); if (!error.streamHandled) { + if (resumeJob) setWaitingJob(resumeJob); stopThinkingDuration(thinkingElements); if (thinkingElements.bubble && thinkingElements.bubble.parentNode) { thinkingElements.bubble.parentNode.removeChild(thinkingElements.bubble); @@ -767,9 +1091,12 @@ async function sendMessage() { } } -function subscribeToJobEvents(jobId, thinkingElements) { +function subscribeToJobEvents(jobId, thinkingElements, afterEventId = 0) { return new Promise((resolve, reject) => { - const source = new EventSource(`/api/agent/jobs/${encodeURIComponent(jobId)}/events`); + const key = activeJobKey(jobId); + const query = afterEventId ? `?last_event_id=${encodeURIComponent(afterEventId)}` : ''; + const source = new EventSource(`/api/agent/jobs/${encodeURIComponent(jobId)}/events${query}`); + jobEventSources.set(key, source); let settled = false; const finish = (error = null) => { @@ -778,6 +1105,7 @@ function subscribeToJobEvents(jobId, thinkingElements) { } settled = true; source.close(); + if (jobEventSources.get(key) === source) jobEventSources.delete(key); if (error) { reject(error); } else { @@ -797,7 +1125,13 @@ function subscribeToJobEvents(jobId, thinkingElements) { if (eventData.type === 'heartbeat') { return; } - handleStreamEvent(eventData, thinkingElements); + const stateBeforeEvent = activeJobsById.get(key); + updateActiveJobFromEvent(jobId, eventData); + const state = activeJobsById.get(key) || stateBeforeEvent; + const target = thinkingElements || state?.thinkingElements || null; + if (target && target.bubble.isConnected) { + handleStreamEvent(eventData, target, jobId); + } if (eventData.type === 'final_result' || eventData.type === 'interrupt') { finish(); } else if (eventData.type === 'error') { @@ -818,18 +1152,26 @@ function subscribeToJobEvents(jobId, thinkingElements) { ].forEach(bindEvent); source.onopen = () => { - if (!settled) setTimelineStatus(thinkingElements, ''); + const state = activeJobsById.get(key); + const target = thinkingElements || state?.thinkingElements || null; + if (!settled && target && target.bubble.isConnected) setTimelineStatus(target, ''); }; source.onerror = () => { if (settled) { return; } + const state = activeJobsById.get(key); + const target = thinkingElements || state?.thinkingElements || null; if (source.readyState === EventSource.CONNECTING) { - setTimelineStatus(thinkingElements, '连接中断,正在重连'); + if (target && target.bubble.isConnected) { + setTimelineStatus(target, '连接中断,正在重连'); + } } else if (source.readyState === EventSource.CLOSED) { const message = '任务连接失败,请刷新后重试'; - handleStreamError({ type: 'error', message }, thinkingElements); + if (target && target.bubble.isConnected) { + handleStreamError({ type: 'error', message }, target); + } const streamError = new Error(message); streamError.streamHandled = true; finish(streamError); @@ -882,10 +1224,11 @@ function stopThinkingDuration(thinkingElements) { } /** - * 新增聊天内容时保持页面停留在最新内容;展开或收起时间线不会调用此函数。 + * 新增聊天内容时保持聊天区域停留在最新内容;展开或收起时间线不会调用此函数。 */ function keepLatestChatContentVisible() { - window.scrollTo(0, document.documentElement.scrollHeight); + if (!chatArea) return; + chatArea.scrollTop = chatArea.scrollHeight; } /** @@ -1001,7 +1344,7 @@ function toggleThinkingDetail(detailContainer, expandIcon) { /** * 处理流式事件 */ -function handleStreamEvent(eventData, thinkingElements) { +function handleStreamEvent(eventData, thinkingElements, jobId = null) { const eventType = eventData.type; if (!ChatStreamState.acceptEventId(thinkingElements.streamState, eventData.event_id)) return; @@ -1031,7 +1374,7 @@ function handleStreamEvent(eventData, thinkingElements) { break; case 'interrupt': - handleInterrupt(eventData, thinkingElements); + handleInterrupt(eventData, thinkingElements, jobId); break; case 'error': @@ -1246,7 +1589,7 @@ function handleFinalResult(eventData, thinkingElements) { /** * 处理interrupt事件 */ -function handleInterrupt(eventData, thinkingElements) { +function handleInterrupt(eventData, thinkingElements, jobId) { const { message } = eventData; stopThinkingDuration(thinkingElements); @@ -1263,6 +1606,12 @@ function handleInterrupt(eventData, thinkingElements) { type: 'human_input_required', summary: message }); + setWaitingJob({ + job_id: jobId, + question_id: eventData.question_id, + prompt: message, + last_event_id: eventData.event_id || 0, + }); } /** @@ -1315,6 +1664,8 @@ async function handleNewChatRequest() { } console.log("正在为新聊天创建会话..."); + clearSelectedUserFile(); + setWaitingJob(null); chatArea.innerHTML = '
'; // 显示加载动画 try { @@ -1747,9 +2098,12 @@ async function loadSession(sessionId) { if (data.success) { currentSessionId = sessionId; // < 核心修改:更新全局会话ID + clearSelectedUserFile(); + setWaitingJob(null); data.messages.forEach(msg => { addMessage(msg.sender, msg.text); }); + await restoreActiveJobs(sessionId); // 确保加载会话后事件监听器也是最新的 // setupChatEventListeners(); // 不再需要,因为元素是持久的 console.log(`会话 ${sessionId} 已成功加载`); @@ -1786,26 +2140,8 @@ async function handleCsvFileSelect(event) { return; } - // 检查会话ID - if (!currentSessionId) { - showError("没有活动的会话,无法上传文件。请新建一个对话或加载历史会话。"); - // 恢复按钮状态 - if (uploadCsvButton) { - uploadCsvButton.textContent = getText('upload'); - uploadCsvButton.disabled = false; - } - event.target.value = null; // 清除文件选择 - return; - } - // - - - // 显示上传开始的用户消息和AI加载动画 - addMessage('user', getText('uploadingFile') + file.name); - const loadingMessageElement = addMessage('ai', '', true); - const formData = new FormData(); formData.append('file', file); - formData.append('session_id', currentSessionId); if (uploadCsvButton) { uploadCsvButton.textContent = getText('uploading'); @@ -1820,30 +2156,20 @@ async function handleCsvFileSelect(event) { const data = await response.json(); - // 移除加载动画,显示最终结果 - if (loadingMessageElement && loadingMessageElement.parentNode) { - loadingMessageElement.parentNode.removeChild(loadingMessageElement); - } - if (data.success) { - // 显示成功的AI回复消息 - addMessage('ai', getText('fileReceived') + file.name + '\n\n' + data.message + getText('askCausalAnalysis')); + setSelectedUserFile(data.file || { + id: data.user_file_id, + user_file_id: data.user_file_id, + filename: file.name, + file_size: file.size, + }); loadFiles(); // 刷新文件列表 } else { - // 显示错误的AI回复消息 - addMessage('ai', getText('uploadFailed') + (data.error || '未知错误')); showError(data.error || '文件上传失败。'); } } catch (error) { console.error("CSV Upload error:", error); - // 移除加载动画 - if (loadingMessageElement && loadingMessageElement.parentNode) { - loadingMessageElement.parentNode.removeChild(loadingMessageElement); - } - - // 显示网络错误的AI回复 - addMessage('ai', getText('networkError')); showError('上传文件时发生网络错误。'); } finally { if (uploadCsvButton) { @@ -2042,18 +2368,21 @@ async function loadFiles() { fileList.innerHTML = ''; // 清空旧列表 - if (Object.keys(files).length === 0) { + if (!Array.isArray(files) || files.length === 0) { fileList.innerHTML = `

${getText('noFilesMessage')}

`; } else { let currentlyOpenFileItem = null; files.forEach(file => { - const file_id = file[0]; - const info = file[1]; + const file_id = Number(file.user_file_id ?? file.id); + const filename = file.filename || '未命名文件'; const fileItem = document.createElement('div'); fileItem.className = 'file-item'; fileItem.setAttribute('data-file-id', file_id); + if (selectedUserFile && selectedUserFile.user_file_id === file_id) { + fileItem.classList.add('selected'); + } const swipeActions = document.createElement('div'); swipeActions.className = 'swipe-actions'; @@ -2075,12 +2404,12 @@ async function loadFiles() { const timeDiv = document.createElement('div'); timeDiv.className = 'session-time'; - timeDiv.textContent = info.last_time; + timeDiv.textContent = formatFileDate(file.uploaded_at); const previewDiv = document.createElement('div'); previewDiv.className = 'preview-text'; - previewDiv.textContent = info.preview; - previewDiv.title = info.preview; + previewDiv.textContent = filename; + previewDiv.title = filename; sessionInfo.appendChild(timeDiv); itemContent.appendChild(sessionInfo); @@ -2145,15 +2474,13 @@ async function loadFiles() { e.stopPropagation(); return; } - // 点击文件项的逻辑:将文件名插入输入框 + setSelectedUserFile(file); + fileList.querySelectorAll('.file-item.selected').forEach(item => { + item.classList.remove('selected'); + }); + fileItem.classList.add('selected'); const userInput = document.getElementById('userInput'); - if (userInput) { - // 将预设的分析指令和文件名填入输入框 - userInput.value += `请对文件"${info.preview}"进行因果分析`; - // 聚焦输入框,方便用户直接发送 - userInput.focus(); - } - console.log(`File "${info.preview}" reference inserted into input box.`); + if (userInput) userInput.focus(); }); itemContent.addEventListener('mousedown', onDragStart); @@ -2187,6 +2514,9 @@ async function handleDeleteFile(fileId, element) { if (data.success) { console.log("文件已在后端删除"); + if (selectedUserFile && selectedUserFile.user_file_id === Number(fileId)) { + clearSelectedUserFile(); + } // 平滑的删除动画 element.style.height = `${element.offsetHeight}px`; // 固定高度 element.style.opacity = '0'; diff --git a/config/settings.py b/config/settings.py index eb82b65..4335787 100644 --- a/config/settings.py +++ b/config/settings.py @@ -267,6 +267,7 @@ def __init__(self): ) self.JOB_STALE_AFTER_SECONDS = self._get_int_config("JOB_STALE_AFTER_SECONDS", default=120) self.JOB_MAX_ATTEMPTS = self._get_int_config("JOB_MAX_ATTEMPTS", default=3) + self.JOB_CHAT_HISTORY_LIMIT = self._get_int_config("JOB_CHAT_HISTORY_LIMIT", default=20) self.SSE_POLL_INTERVAL_SECONDS = self._get_float_config("SSE_POLL_INTERVAL_SECONDS", default=1.0) self.SSE_HEARTBEAT_INTERVAL_SECONDS = self._get_int_config( "SSE_HEARTBEAT_INTERVAL_SECONDS", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 563b34d..09b4813 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -285,6 +285,7 @@ services: - JOB_HEARTBEAT_INTERVAL_SECONDS=${JOB_HEARTBEAT_INTERVAL_SECONDS:-10} - JOB_STALE_AFTER_SECONDS=${JOB_STALE_AFTER_SECONDS:-120} - JOB_MAX_ATTEMPTS=${JOB_MAX_ATTEMPTS:-3} + - JOB_CHAT_HISTORY_LIMIT=${JOB_CHAT_HISTORY_LIMIT:-20} - API_KEY=${API_KEY} - BASE_URL=${BASE_URL} - MODEL=${MODEL} diff --git a/docker-compose.yml b/docker-compose.yml index 6f31660..b7b2ba2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,7 @@ services: - JOB_HEARTBEAT_INTERVAL_SECONDS=${JOB_HEARTBEAT_INTERVAL_SECONDS:-10} - JOB_STALE_AFTER_SECONDS=${JOB_STALE_AFTER_SECONDS:-120} - JOB_MAX_ATTEMPTS=${JOB_MAX_ATTEMPTS:-3} + - JOB_CHAT_HISTORY_LIMIT=${JOB_CHAT_HISTORY_LIMIT:-20} - MAX_UPLOAD_SIZE_MB=${MAX_UPLOAD_SIZE_MB:-20} - API_KEY=${API_KEY} - BASE_URL=${BASE_URL} @@ -199,6 +200,7 @@ services: - JOB_HEARTBEAT_INTERVAL_SECONDS=${JOB_HEARTBEAT_INTERVAL_SECONDS:-10} - JOB_STALE_AFTER_SECONDS=${JOB_STALE_AFTER_SECONDS:-120} - JOB_MAX_ATTEMPTS=${JOB_MAX_ATTEMPTS:-3} + - JOB_CHAT_HISTORY_LIMIT=${JOB_CHAT_HISTORY_LIMIT:-20} - API_KEY=${API_KEY} - BASE_URL=${BASE_URL} - MODEL=${MODEL} diff --git a/tests/README.md b/tests/README.md index 90f1251..30a799f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -60,7 +60,7 @@ docker compose -f docker-compose.test.yml run --rm unit-test sh ## 本地 Python 回退方式 -未使用 Docker 时,需要自行在当前 Python 环境安装项目依赖和 `pytest`,然后运行: +未使用 Docker 时,需要先跟用户说明目前本地docker环境未启动/出错,需要在当前 Python 环境安装项目依赖和 `pytest`,用户同一之后然后运行: ```bash python -m pytest tests/unit diff --git a/tests/e2e/admin/seed_admin_31_e2e.py b/tests/e2e/admin/seed_admin_31_e2e.py index 45cf277..e8d3d9b 100644 --- a/tests/e2e/admin/seed_admin_31_e2e.py +++ b/tests/e2e/admin/seed_admin_31_e2e.py @@ -19,6 +19,7 @@ ARCHIVED_SESSION_ID = "31-archived-session" JOB_ID = "31-job-succeeded" FILE_ID = 3101 +FILE_OBJECT_ID = 4101 USER_MESSAGE_ID = 3101 AI_MESSAGE_ID = 3102 ATTACHMENT_ID = 3101 @@ -26,7 +27,9 @@ CONTROL_USER_B_ID = 3104 DELETE_USER_ID = 3105 CONTROL_FILE_ID = 3201 +CONTROL_FILE_OBJECT_ID = 4201 DELETE_FILE_ID = 3205 +DELETE_FILE_OBJECT_ID = 4205 DELETE_SESSION_ID = "32-delete-session" DELETE_ARCHIVED_SESSION_ID = "32-delete-archived-session" DELETE_JOB_ID = "32-delete-job" @@ -300,65 +303,85 @@ def seed() -> None: ).encode("utf-8") cursor.execute( """ - INSERT INTO uploaded_files ( - id, user_id, filename, original_filename, mime_type, - file_size, file_hash, file_content, upload_timestamp, - last_accessed_at, access_count + INSERT INTO file_objects ( + id, owner_user_id, content_hash, file_size, mime_type, file_content ) VALUES ( - %s, %s, %s, %s, 'text/csv', - %s, %s, %s, UTC_TIMESTAMP(), UTC_TIMESTAMP(), 0 + %s, %s, %s, %s, 'text/csv', %s ) """, ( - FILE_ID, + FILE_OBJECT_ID, USER_ID, - "31-stored.csv", - "31-report.csv", - len(file_content), "31" * 32, + len(file_content), file_content, ), ) + cursor.execute( + """ + INSERT INTO user_files (id, user_id, object_id, filename, mime_type, file_size) + VALUES (%s, %s, %s, %s, 'text/csv', %s) + """, + (FILE_ID, USER_ID, FILE_OBJECT_ID, "31-report.csv", len(file_content)), + ) controlled_file_content = b"name,value\ncontrolled,32\n" cursor.execute( """ - INSERT INTO uploaded_files ( - id, user_id, filename, original_filename, mime_type, - file_size, file_hash, file_content, upload_timestamp, - last_accessed_at, access_count + INSERT INTO file_objects ( + id, owner_user_id, content_hash, file_size, mime_type, file_content ) VALUES ( - %s, %s, '32-controlled-stored.csv', '32-delete-file.csv', - 'text/csv', %s, %s, %s, UTC_TIMESTAMP(), UTC_TIMESTAMP(), 0 + %s, %s, %s, 'text/csv', %s ) """, ( - CONTROL_FILE_ID, + CONTROL_FILE_OBJECT_ID, CONTROL_USER_A_ID, - len(controlled_file_content), "32" * 32, + len(controlled_file_content), controlled_file_content, ), ) + cursor.execute( + """ + INSERT INTO user_files (id, user_id, object_id, filename, mime_type, file_size) + VALUES (%s, %s, %s, '32-delete-file.csv', 'text/csv', %s) + """, + ( + CONTROL_FILE_ID, + CONTROL_USER_A_ID, + CONTROL_FILE_OBJECT_ID, + len(controlled_file_content), + ), + ) deleted_user_file_content = b"name,value\ndelete-user,32\n" cursor.execute( """ - INSERT INTO uploaded_files ( - id, user_id, filename, original_filename, mime_type, - file_size, file_hash, file_content, upload_timestamp, - last_accessed_at, access_count + INSERT INTO file_objects ( + id, owner_user_id, content_hash, file_size, mime_type, file_content ) VALUES ( - %s, %s, '32-user-delete-stored.csv', '32-user-delete.csv', - 'text/csv', %s, %s, %s, UTC_TIMESTAMP(), UTC_TIMESTAMP(), 0 + %s, %s, %s, 'text/csv', %s ) """, ( - DELETE_FILE_ID, + DELETE_FILE_OBJECT_ID, DELETE_USER_ID, - len(deleted_user_file_content), "33" * 32, + len(deleted_user_file_content), deleted_user_file_content, ), ) + cursor.execute( + """ + INSERT INTO user_files (id, user_id, object_id, filename, mime_type, file_size) + VALUES (%s, %s, %s, '32-user-delete.csv', 'text/csv', %s) + """, + ( + DELETE_FILE_ID, + DELETE_USER_ID, + DELETE_FILE_OBJECT_ID, + len(deleted_user_file_content), + ), + ) cursor.execute( """ INSERT INTO analysis_jobs ( diff --git a/tests/e2e/admin/verify_admin_31_e2e.py b/tests/e2e/admin/verify_admin_31_e2e.py index e683985..bb5625b 100644 --- a/tests/e2e/admin/verify_admin_31_e2e.py +++ b/tests/e2e/admin/verify_admin_31_e2e.py @@ -55,7 +55,7 @@ def verify() -> None: with get_read_connection(consistency="strong") as conn: cursor = conn.cursor(dictionary=True) cursor.execute("SELECT version_num FROM alembic_version") - assert (cursor.fetchone() or {}).get("version_num") == "f8b9c0d1e2f3" + assert (cursor.fetchone() or {}).get("version_num") == "a1b2c3d4e5f6" cursor.execute( """ SELECT INDEX_NAME AS index_name @@ -65,7 +65,7 @@ def verify() -> None: 'idx_users_admin_role_active', 'idx_sessions_admin_activity', 'idx_analysis_jobs_admin_created', - 'idx_uploaded_files_admin_uploaded', + 'idx_user_files_user_accessed', 'idx_admin_audit_target_created' ) """ @@ -94,7 +94,7 @@ def verify() -> None: ) cursor.execute( - "SELECT COUNT(*) AS total FROM uploaded_files WHERE id = %s", + "SELECT COUNT(*) AS total FROM user_files WHERE id = %s", (CONTROL_FILE_ID,), ) assert int((cursor.fetchone() or {}).get("total") or 0) == 0 @@ -105,7 +105,7 @@ def verify() -> None: (SELECT COUNT(*) FROM sessions WHERE id = %s) AS sessions, (SELECT COUNT(*) FROM chat_messages WHERE id = %s) AS messages, (SELECT COUNT(*) FROM chat_attachments WHERE id = %s) AS attachments, - (SELECT COUNT(*) FROM uploaded_files WHERE id = %s) AS files, + (SELECT COUNT(*) FROM user_files WHERE id = %s) AS files, (SELECT COUNT(*) FROM analysis_jobs WHERE job_id = %s) AS jobs, ( SELECT COUNT(*) FROM analysis_job_events @@ -163,7 +163,7 @@ def verify() -> None: assert os.environ["E2E_ADMIN_PASSWORD"] not in operation_text cursor.execute( - "SELECT access_count, last_accessed_at FROM uploaded_files WHERE id = %s", + "SELECT access_count, last_accessed_at FROM user_files WHERE id = %s", (FILE_ID,), ) file_row = cursor.fetchone() or {} @@ -307,7 +307,7 @@ def verify() -> None: (cursor.fetchone() or {}).get("total") or 0 ) cursor.execute( - "SELECT COUNT(*) AS total FROM uploaded_files WHERE id = %s", + "SELECT COUNT(*) AS total FROM user_files WHERE id = %s", (DELETE_FILE_ID,), ) replica_deleted_file_count = int( diff --git a/tests/integration/migrations/test_migration_chain.py b/tests/integration/migrations/test_migration_chain.py index 5af1323..41849f7 100644 --- a/tests/integration/migrations/test_migration_chain.py +++ b/tests/integration/migrations/test_migration_chain.py @@ -173,12 +173,64 @@ def test_readiness_and_deep_audit_require_idempotency_schema(self): audit_text = Path("Database/deep_audit.py").read_text(encoding="utf-8") self.assertIn("table_name = 'analysis_jobs'", db_text) - self.assertIn("column_name IN ('idempotency_key', 'request_fingerprint')", db_text) + self.assertIn("'idempotency_key', 'request_fingerprint', 'lease_epoch'", db_text) self.assertIn("uq_analysis_jobs_user_idempotency", db_text) self.assertIn('"idempotency_key"', audit_text) self.assertIn('"request_fingerprint"', audit_text) self.assertIn('"uq_analysis_jobs_user_idempotency"', audit_text) +class FileLibraryAndJobRecoveryMigrationTests(unittest.TestCase): + """静态验证测试库文件库替换、Job 输入账本和回滚边界。""" + + PATH = Path( + "Database/migrations/versions/a1b2c3d4e5f6_add_file_library_and_job_recovery.py" + ) + + def test_migration_extends_current_head_and_replaces_legacy_files_directly(self): + """新 revision 必须直接删除旧文件表,不读取或回填旧数据。""" + text = self.PATH.read_text(encoding="utf-8") + + self.assertIn('revision: str = "a1b2c3d4e5f6"', text) + self.assertIn('down_revision: Union[str, Sequence[str], None] = "f9a0b1c2d3e4"', text) + self.assertIn('op.execute("DROP TABLE IF EXISTS uploaded_files")', text) + self.assertIn("CREATE TABLE file_objects", text) + self.assertIn("CREATE TABLE user_files", text) + self.assertNotIn("INSERT INTO file_objects", text) + self.assertNotIn("INSERT INTO user_files", text) + self.assertNotIn("INSERT INTO uploaded_files", text) + self.assertNotIn("拒绝迁移", text) + + def test_migration_adds_frozen_inputs_and_recovery_schema(self): + """升级必须覆盖可恢复状态、冻结文件快照和用户输入账本。""" + text = self.PATH.read_text(encoding="utf-8") + + for fragment in ( + "waiting_input", + "lease_epoch", + "recovery_count", + "resume_count", + "input_user_file_id", + "input_object_id", + "current_question_id", + "analysis_job_inputs", + "uq_analysis_job_inputs_sequence", + "uq_analysis_job_inputs_idempotency", + "event_key", + "source_event_id", + ): + self.assertIn(fragment, text) + + def test_downgrade_restores_empty_legacy_table_without_old_partition_layout(self): + """回滚只恢复空旧文件表,并保持当前 head 的非分区聊天表结构。""" + text = self.PATH.read_text(encoding="utf-8") + downgrade = text.split("def downgrade()", 1)[1] + + self.assertIn("CREATE TABLE uploaded_files", downgrade) + self.assertNotIn("INSERT INTO uploaded_files", downgrade) + self.assertNotIn("PARTITION BY RANGE", downgrade) + self.assertNotIn("REMOVE PARTITIONING", text) + + if __name__ == "__main__": unittest.main() diff --git a/tests/smoke/direct_lingam_mcp_smoke.py b/tests/smoke/direct_lingam_mcp_smoke.py index 63699f4..8608eba 100644 --- a/tests/smoke/direct_lingam_mcp_smoke.py +++ b/tests/smoke/direct_lingam_mcp_smoke.py @@ -72,7 +72,8 @@ def run_mcp_smoke() -> None: columns=node_names, ).to_csv(index=False) - result = asyncio.run(mcp_server.causal_direct_lingam(csv_data)) + mcp_server._load_csv = lambda user_id, job_id, input_user_file_id, input_object_id: csv_data + result = asyncio.run(mcp_server.causal_direct_lingam(1, "smoke-job", 2, 3)) assert "causal_direct_lingam" in mcp_server.mcp.registered_tools assert result["success"] is True, result diff --git a/tests/unit/admin/test_admin_business_service.py b/tests/unit/admin/test_admin_business_service.py index 8d91bae..328f894 100644 --- a/tests/unit/admin/test_admin_business_service.py +++ b/tests/unit/admin/test_admin_business_service.py @@ -18,7 +18,7 @@ os.environ.setdefault(key, value) -from app.admin import business_service +from app.admin import business_service, write_service from app.admin.contracts import AdminApiError, decode_cursor @@ -51,6 +51,7 @@ def __init__(self, cursor): self.cursor_value = cursor self.commits = 0 self.rollbacks = 0 + self.started_transactions = 0 def __enter__(self): """进入连接上下文。""" @@ -68,6 +69,10 @@ def commit(self): """记录事务提交。""" self.commits += 1 + def start_transaction(self): + """记录显式主库事务开始。""" + self.started_transactions += 1 + def rollback(self): """记录事务回滚。""" self.rollbacks += 1 @@ -76,6 +81,44 @@ def rollback(self): class AdminBusinessServiceTests(unittest.TestCase): """验证真实查询服务的脱敏、有界读取和文件副作用边界。""" + def test_file_delete_impact_hides_internal_blob_object_id(self): + """删除预览可以返回引用统计,但不能返回 BLOB 对象主键。""" + class ImpactCursor: + def __init__(self): + self.rows = [ + { + "id": 3, + "user_id": 7, + "username": "admin", + "object_id": 99, + "filename": "report.csv", + "original_filename": "report.csv", + "file_hash": "a" * 64, + "mime_type": "text/csv", + "file_size": 12, + "upload_timestamp": datetime(2026, 7, 26, 12, 0, 0), + "last_accessed_at": None, + "access_count": 0, + "object_reference_count": 1, + }, + {"count_value": 0}, + ] + + def execute(self, _sql, _params=None): + pass + + def fetchone(self): + return self.rows.pop(0) + + cursor = ImpactCursor() + with patch( + "app.admin.write_service.get_read_connection", + return_value=FakeConnection(cursor), + ): + result = write_service.get_file_delete_impact(3) + + self.assertNotIn("object_id", result["file"]) + def test_job_list_uses_created_index_order_and_omits_error_body(self): """任务列表必须与新增创建时间索引同序且不返回错误正文摘要。""" created = datetime(2026, 7, 26, 12, 0, 0) @@ -164,15 +207,14 @@ def test_message_list_returns_length_summary_without_body_prefix(self): self.assertEqual(result["items"][0]["content_preview"], "正文 27 字符") def test_file_list_uses_upload_time_cursor_matching_new_index(self): - """文件分页的排序和游标必须与 upload_timestamp 索引一致。""" + """文件分页的排序和游标必须与 user_files.uploaded_at 索引一致。""" uploaded = datetime(2026, 7, 26, 13, 0, 0) cursor = FakeCursor(rows=[ { "id": 5, "user_id": 1, "username": "alice", - "filename": "stored.csv", - "original_filename": "report.csv", + "filename": "report.csv", "mime_type": "text/csv", "file_size": 10, "upload_timestamp": uploaded, @@ -194,7 +236,7 @@ def test_file_list_uses_upload_time_cursor_matching_new_index(self): ) self.assertIn( - "ORDER BY f.upload_timestamp DESC, f.id DESC", + "ORDER BY f.uploaded_at DESC, f.id DESC", cursor.executions[0][0], ) self.assertEqual( @@ -247,7 +289,7 @@ def test_csv_preview_applies_all_limits_then_records_access(self): actor = {"id": 7, "username": "admin"} with ( patch( - "app.admin.business_service.get_read_connection", + "app.admin.business_service.get_write_connection", return_value=FakeConnection(cursor), ), patch("app.admin.business_service._record_file_access") as record_access, @@ -265,6 +307,7 @@ def test_csv_preview_applies_all_limits_then_records_access(self): file_id=3, actor=actor, action="business.file.preview", + cursor=cursor, ) def test_file_access_count_and_audit_share_one_transaction(self): diff --git a/tests/unit/admin/test_admin_user_capabilities.py b/tests/unit/admin/test_admin_user_capabilities.py index 39767d0..a764157 100644 --- a/tests/unit/admin/test_admin_user_capabilities.py +++ b/tests/unit/admin/test_admin_user_capabilities.py @@ -22,7 +22,7 @@ from app.agent.routes import agent_bp from app.agent.job_service import IdempotencyConflictError from app.chat.routes import chat_bp -from app.files.routes import files_bp +from app.files.routes import _file_payload, files_bp ADMIN = { @@ -31,6 +31,7 @@ "role": "admin", "is_active": True, } +JOB_IDEMPOTENCY_KEY = "00000000-0000-4000-8000-000000000001" class RecordingCursor: @@ -80,6 +81,22 @@ def build_app(): class AdminUserCapabilityTests(unittest.TestCase): """验证管理员调用普通接口时只能使用自身用户 ID。""" + def test_user_file_payload_does_not_expose_blob_object_id(self): + """普通用户文件 DTO 只暴露逻辑 user_file_id,不返回 BLOB 对象 ID。""" + payload = _file_payload({ + "id": 11, + "object_id": 22, + "filename": "data.csv", + "mime_type": "text/csv", + "file_size": 12, + "uploaded_at": None, + "last_accessed_at": None, + "access_count": 0, + }) + + self.assertEqual(payload["user_file_id"], 11) + self.assertNotIn("object_id", payload) + def test_admin_session_list_is_scoped_to_own_user_id(self): """管理员读取会话列表时沿用普通用户的 user_id 条件。""" app = build_app() @@ -127,7 +144,7 @@ def test_admin_job_creation_is_scoped_to_own_user_id(self): response = client.post( "/api/agent/jobs", json={"session_id": "session-admin-1", "message": "分析数据"}, - headers={"Idempotency-Key": "job-request-123456"}, + headers={"Idempotency-Key": JOB_IDEMPOTENCY_KEY}, ) self.assertEqual(response.status_code, 202) @@ -135,7 +152,8 @@ def test_admin_job_creation_is_scoped_to_own_user_id(self): ADMIN["id"], "session-admin-1", "分析数据", - "job-request-123456", + JOB_IDEMPOTENCY_KEY, + None, ) def test_agent_job_creation_requires_idempotency_key(self): @@ -171,7 +189,7 @@ def test_agent_job_creation_rejects_idempotency_key_reuse_with_new_request(self) response = client.post( "/api/agent/jobs", json={"session_id": "session-admin-1", "message": "分析数据"}, - headers={"Idempotency-Key": "job-request-123456"}, + headers={"Idempotency-Key": JOB_IDEMPOTENCY_KEY}, ) self.assertEqual(response.status_code, 409) diff --git a/tests/unit/agent/test_agent_state_routing.py b/tests/unit/agent/test_agent_state_routing.py index b79e977..2dd194b 100644 --- a/tests/unit/agent/test_agent_state_routing.py +++ b/tests/unit/agent/test_agent_state_routing.py @@ -24,8 +24,7 @@ def _install_import_stubs(): """隔离路由测试不需要的数据库、绘图和向量库依赖。""" agent_connect = types.ModuleType("Database.agent_connect") - agent_connect.get_file_content = lambda *args, **kwargs: None - agent_connect.get_recent_file = lambda *args, **kwargs: None + agent_connect.require_frozen_file_for_job = lambda *args, **kwargs: None sys.modules.setdefault("Database.agent_connect", agent_connect) data_visualize = types.ModuleType("Agent.Processing.data_visualize") @@ -61,12 +60,35 @@ def _state(message="普通问题", **updates): state = { "messages": [HumanMessage(content=message)], "user_id": 1, - "fold_name": "", + "username": "tester", + "session_id": "session-1", + "job_id": "job-1", + "file_summary": { + "user_file_id": 11, + "object_id": 22, + "file_hash": "a" * 64, + "filename": "data.csv", + }, } state.update(updates) return state +def _mcp_tool(name="causal_pc"): + """构造声明运行时身份字段的 MCP 工具测试替身。""" + return types.SimpleNamespace( + name=name, + args={ + "csv_data": {}, + "target": {}, + "user_id": None, + "job_id": None, + "input_user_file_id": None, + "input_object_id": None, + }, + ) + + @pytest.mark.parametrize( "route", ["fold", "postprocess", "normal_chat", "inquiry_answer"], @@ -147,8 +169,8 @@ def test_mcp_protocol_failure_returns_to_agent_without_restarting_fold(): assert decision["route_decision"] == "normal_chat" -def test_fold_extraction_failure_uses_recent_file_and_can_validate(monkeypatch): - """参数提取失败视为空值,仍继续最近文件与确定性校验。""" +def test_fold_extraction_failure_uses_frozen_file_and_can_validate(monkeypatch): + """参数提取失败视为空值,仍使用 Job 冻结文件进行确定性校验。""" async def fail_structured(**kwargs): raise StructuredOutputError( node_name="fold", @@ -157,30 +179,46 @@ async def fail_structured(**kwargs): ) monkeypatch.setattr(nodes, "ainvoke_structured", fail_structured) - monkeypatch.setattr(nodes, "get_recent_file", lambda user_id: (b"A,B\n1,2\n", "latest.csv")) - monkeypatch.setattr(nodes, "get_data_summary", lambda df: {"columns": ["A", "B"]}) + monkeypatch.setattr( + nodes, + "require_frozen_file_for_job", + lambda *args: {"file_content": b"A,B\n1,2\n"}, + ) + monkeypatch.setattr( + nodes, + "get_data_summary", + lambda df: {"n_rows": 2, "columns": ["A", "B"]}, + ) monkeypatch.setattr(nodes, "validate_analysis", lambda *args, **kwargs: (1, [], [])) result = asyncio.run(nodes.fold_node(_state(), object())) assert result["fold_decision"] == "preprocess" - assert result["fold_name"] == "latest.csv" assert result["analysis_parameters"]["target"] is None assert result["analysis_parameters"]["treatment"] is None + assert result["file_summary"]["rows"] == 2 @pytest.mark.parametrize("failure_stage", ["file", "validation"]) def test_fold_resume_paths_return_to_agent(monkeypatch, failure_stage): """文件错误恢复与参数补充恢复都写 fold_decision=agent。""" async def extracted(**kwargs): - return nodes.foldQuery(filename="data.csv", target=None, treatment=None) + return nodes.foldQuery(target=None, treatment=None) monkeypatch.setattr(nodes, "ainvoke_structured", extracted) monkeypatch.setattr(nodes, "interrupt", lambda question: "用户补充信息") if failure_stage == "file": - monkeypatch.setattr(nodes, "get_file_content", lambda *args: (_ for _ in ()).throw(FileNotFoundError("missing"))) + monkeypatch.setattr( + nodes, + "require_frozen_file_for_job", + lambda *args: (_ for _ in ()).throw(FileNotFoundError("missing")), + ) else: - monkeypatch.setattr(nodes, "get_file_content", lambda *args: b"A,B\n1,2\n") + monkeypatch.setattr( + nodes, + "require_frozen_file_for_job", + lambda *args: {"file_content": b"A,B\n1,2\n"}, + ) monkeypatch.setattr(nodes, "get_data_summary", lambda df: {"columns": ["A", "B"]}) monkeypatch.setattr( nodes, @@ -297,8 +335,8 @@ async def fail_questions(*args, **kwargs): def test_mcp_adapter_preserves_standard_tool_calls_and_injects_runtime_data(): - """MCP planner 的标准 AIMessage.tool_calls 可被 ToolNode 消费并补充运行时 CSV。""" - tool = types.SimpleNamespace(name="causal_pc", args={"csv_data": {}, "target": {}}) + """MCP planner 只注入可信 Job 身份,绝不把 CSV 正文放入 ToolMessage。""" + tool = _mcp_tool() message = AIMessage( content="", tool_calls=[ @@ -313,13 +351,19 @@ def test_mcp_adapter_preserves_standard_tool_calls_and_injects_runtime_data(): normalized = normalize_mcp_tool_call_message( message, - {"file_content": "A,Y\n1,2\n"}, + _state(), [tool], ) assert isinstance(normalized, AIMessage) assert normalized.tool_calls[0]["name"] == "causal_pc" - assert normalized.tool_calls[0]["args"]["csv_data"] == "A,Y\n1,2\n" + args = normalized.tool_calls[0]["args"] + assert args["target"] == "Y" + assert args["user_id"] == 1 + assert args["job_id"] == "job-1" + assert args["input_user_file_id"] == 11 + assert args["input_object_id"] == 22 + assert "csv_data" not in args def test_mcp_planner_disables_thinking_and_requires_a_tool_choice(): @@ -357,14 +401,13 @@ def bind_tools(self, tools, **kwargs): observed["bind_kwargs"] = kwargs return FakeBoundLLM() - tool = types.SimpleNamespace(name="causal_pc", args={"csv_data": {}, "target": {}}) + tool = _mcp_tool() result = asyncio.run( - nodes.mcp_planner_node( - _state( - file_content="A,Y\n1,2\n", - analysis_parameters={"target": "Y"}, - preprocess_summary="数据已就绪", - ), + nodes.mcp_planner_node( + _state( + analysis_parameters={"target": "Y"}, + preprocess_summary="数据已就绪", + ), FakeLLM(), [tool], ) @@ -381,7 +424,12 @@ def bind_tools(self, tools, **kwargs): "parallel_tool_calls": False, } assert result["messages"][0].tool_calls[0]["name"] == "causal_pc" - assert result["messages"][0].tool_calls[0]["args"]["csv_data"] == "A,Y\n1,2\n" + args = result["messages"][0].tool_calls[0]["args"] + assert args["user_id"] == 1 + assert args["job_id"] == "job-1" + assert args["input_user_file_id"] == 11 + assert args["input_object_id"] == 22 + assert "csv_data" not in args def test_mcp_planner_deterministically_selects_explicit_direct_lingam(): @@ -395,15 +443,11 @@ class UnusedLLM: def model_copy(self, *, update): raise AssertionError("明确 DirectLiNGAM 请求不应调用 LLM 选工具") - tools = [ - types.SimpleNamespace(name="causal_pc", args={"csv_data": {}}), - types.SimpleNamespace(name="causal_direct_lingam", args={"csv_data": {}}), - ] + tools = [_mcp_tool("causal_pc"), _mcp_tool("causal_direct_lingam")] result = asyncio.run( nodes.mcp_planner_node( _state( "请使用 DirectLiNGAM 分析这份 CSV", - file_content="A,B\n1,2\n3,4\n", ), UnusedLLM(), tools, @@ -412,7 +456,9 @@ def model_copy(self, *, update): tool_call = result["messages"][0].tool_calls[0] assert tool_call["name"] == "causal_direct_lingam" - assert tool_call["args"]["csv_data"] == "A,B\n1,2\n3,4\n" + assert tool_call["args"]["user_id"] == 1 + assert tool_call["args"]["job_id"] == "job-1" + assert "csv_data" not in tool_call["args"] def test_explicit_direct_lingam_mcp_stage_parses_and_routes_to_rag(): @@ -426,12 +472,9 @@ class UnusedLLM: def model_copy(self, *, update): raise AssertionError("明确 DirectLiNGAM 请求不应调用 LLM 选工具") - tools = [ - types.SimpleNamespace(name="causal_direct_lingam", args={"csv_data": {}}), - ] + tools = [_mcp_tool("causal_direct_lingam")] state = _state( "请使用 DirectLiNGAM 分析这份 CSV", - file_content="A,B\n1,2\n3,4\n", ) planner_result = asyncio.run( nodes.mcp_planner_node(state, UnusedLLM(), tools) diff --git a/tests/unit/agent/test_direct_lingam_analysis.py b/tests/unit/agent/test_direct_lingam_analysis.py index d960a7f..f7c2414 100644 --- a/tests/unit/agent/test_direct_lingam_analysis.py +++ b/tests/unit/agent/test_direct_lingam_analysis.py @@ -351,7 +351,12 @@ def fake_runner(csv_data): return {"success": True, "algorithm": "direct_lingam"} monkeypatch.setattr(mcp_server, "run_direct_lingam_analysis", fake_runner) - result = asyncio.run(mcp_server.causal_direct_lingam("A,B\n1,2\n3,4\n")) + monkeypatch.setattr( + mcp_server, + "_load_csv", + lambda user_id, job_id, input_user_file_id, input_object_id: "A,B\n1,2\n3,4\n", + ) + result = asyncio.run(mcp_server.causal_direct_lingam(1, "job-1", 2, 3)) assert "causal_direct_lingam" in FakeFastMCP.registered_tools assert observed["csv_data"] == "A,B\n1,2\n3,4\n" diff --git a/tests/unit/agent/test_graph_runner.py b/tests/unit/agent/test_graph_runner.py new file mode 100644 index 0000000..4d7d932 --- /dev/null +++ b/tests/unit/agent/test_graph_runner.py @@ -0,0 +1,283 @@ +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from langgraph.types import Command + +from app.agent.checkpoint_recovery import checkpoint_identity +from app.agent.worker.graph_runner import ai_call_stream + + +class FakeGraph: + """提供最小 compiled graph 协议,验证 Job root checkpoint 配置。""" + + def __init__(self, states): + self.states = list(states) + self.configs = [] + self.inputs = [] + + async def aget_state(self, config): + """按调用顺序返回 root StateSnapshot。""" + self.configs.append(config) + return self.states.pop(0) + + async def astream(self, input_data, config, **_kwargs): + """记录执行输入,不产生额外公开事件。""" + self.inputs.append((input_data, config)) + if False: + yield {} + + +def _snapshot(*, interrupts=(), public_interrupts=None, values=None): + """构造 graph_runner 所需的最小 StateSnapshot。""" + return SimpleNamespace( + interrupts=public_interrupts, + tasks=tuple(SimpleNamespace(interrupts=items) for items in interrupts), + values=values or {"messages": []}, + created_at=None, + metadata={}, + config={}, + next=(), + ) + + +async def _collect(graph, text="hello", *, claim_kind="initial", input_record=None, initial_input_record=None, **file_snapshot): + """收集一次 Job 执行产生的公开事件。""" + return [ + event + async for event in ai_call_stream( + text, + 7, + "user-7", + "session-1", + job_id="job-1", + job_attempt=1, + input_user_file_id=file_snapshot.get("input_user_file_id"), + input_object_id=file_snapshot.get("input_object_id"), + input_file_hash=file_snapshot.get("input_file_hash"), + input_filename=file_snapshot.get("input_filename"), + graph=graph, + claim_kind=claim_kind, + input_record=input_record, + initial_input_record=initial_input_record, + ) + ] + + +class GraphRunnerTests(unittest.IsolatedAsyncioTestCase): + """验证 Job root State 查询、输入边界和 stale recovery。""" + + async def test_job_root_identity_is_not_a_subgraph_namespace(self): + """普通问答的两次状态查询都必须使用 Job ID 根 identity。""" + graph = FakeGraph([ + _snapshot(), + _snapshot(values={"messages": []}), + ]) + + events = await _collect(graph) + + self.assertEqual(events[-1]["type"], "final_result") + self.assertEqual(len(graph.configs), 2) + for config in graph.configs: + self.assertEqual(config["configurable"]["thread_id"], "job-1") + self.assertNotIn("checkpoint_ns", config["configurable"]) + self.assertEqual(config["metadata"]["job_id"], "job-1") + self.assertEqual(config["metadata"]["session_id"], "session-1") + + def test_checkpoint_identity_requires_job_id_and_keeps_root_namespace_empty(self): + """运行时和恢复查询共用 Job ID 根 identity,不能生成 unknown namespace。""" + self.assertEqual(checkpoint_identity("job-1"), ("job-1", "")) + with self.assertRaises(ValueError): + checkpoint_identity(" ") + + async def test_public_snapshot_interrupts_are_used_for_resume(self): + """优先读取 LangGraph 公共 interrupts 字段,兼容根 checkpoint 恢复。""" + interrupt = SimpleNamespace(id="question-public", value="请补充信息") + graph = FakeGraph([ + _snapshot(public_interrupts=(interrupt,)), + _snapshot(public_interrupts=(interrupt,)), + ]) + + events = await _collect( + graph, + input_record={ + "input_type": "resume", + "runtime_value": "补充回答", + "stored_text": "补充回答", + "chat_message_id": 42, + }, + ) + + self.assertEqual(events[-1]["type"], "interrupt") + self.assertIsInstance(graph.inputs[0][0], Command) + + async def test_same_job_resume_uses_existing_state_and_structured_value(self): + """同一 Job 的 interrupt 恢复必须继续原 State,并保留结构化回答。""" + interrupt = SimpleNamespace(id="question-1", value={"target": "sales"}) + graph = FakeGraph([ + _snapshot(interrupts=((interrupt,),)), + _snapshot(interrupts=((interrupt,),)), + ]) + + events = await _collect( + graph, + input_record={ + "input_type": "resume", + "runtime_value": {"target": "revenue"}, + "stored_text": '{"target":"revenue"}', + "chat_message_id": 42, + }, + ) + + self.assertEqual(events[-1]["type"], "interrupt") + self.assertIsInstance(graph.inputs[0][0], Command) + self.assertEqual(graph.inputs[0][0].resume, {"target": "revenue"}) + self.assertIs(graph.inputs[0][1], graph.configs[0]) + + async def test_initial_state_uses_job_history_without_appending_current_message(self): + """初始输入从 chat_message_id 截止的历史启动,当前消息不额外追加。""" + history = [ + SimpleNamespace(type="human", content="旧问题"), + SimpleNamespace(type="ai", content="旧回答"), + SimpleNamespace(type="human", content="当前问题"), + ] + graph = FakeGraph([_snapshot(), _snapshot(values={"messages": []})]) + with patch( + "app.agent.worker.graph_runner.get_job_chat_history", + return_value=history, + ): + await _collect( + graph, + input_record={ + "input_type": "initial", + "runtime_value": "当前问题", + "stored_text": "当前问题", + "chat_message_id": 99, + }, + ) + + self.assertEqual(graph.inputs[0][0]["messages"], history) + self.assertEqual( + [message.content for message in graph.inputs[0][0]["messages"]], + ["旧问题", "旧回答", "当前问题"], + ) + + async def test_initial_state_groups_frozen_file_snapshot_under_file_summary(self): + """新 Job 初始 State 将冻结文件四元组写入 file_summary。""" + graph = FakeGraph([ + _snapshot(), + _snapshot(values={"messages": []}), + ]) + + await _collect( + graph, + input_user_file_id=11, + input_object_id=22, + input_file_hash="a" * 64, + input_filename="data.csv", + ) + + input_data = graph.inputs[0][0] + self.assertEqual( + input_data["file_summary"], + { + "user_file_id": 11, + "object_id": 22, + "file_hash": "a" * 64, + "filename": "data.csv", + }, + ) + self.assertNotIn("input_user_file_id", input_data) + self.assertNotIn("input_object_id", input_data) + self.assertNotIn("input_file_hash", input_data) + self.assertNotIn("input_filename", input_data) + + async def test_stale_recovery_with_checkpoint_uses_none_input(self): + """stale recovery 有 checkpoint 时继续原 State,不追加原始问题。""" + graph = FakeGraph([ + SimpleNamespace( + tasks=(), + values={"messages": ["existing"]}, + created_at="2026-08-08T00:00:00Z", + metadata={"source": "loop"}, + config={"configurable": {"checkpoint_id": "cp-1"}}, + next=("agent",), + ), + _snapshot(values={"messages": []}), + ]) + + await _collect(graph, claim_kind="stale_recovery") + + self.assertIsNone(graph.inputs[0][0]) + + async def test_stale_recovery_after_resume_uses_existing_checkpoint(self): + """恢复输入执行中失联时,不能因最后一条输入是 resume 而误判失败。""" + graph = FakeGraph([ + SimpleNamespace( + tasks=(), + values={"messages": ["existing"]}, + created_at="2026-08-08T00:00:00Z", + metadata={"source": "loop"}, + config={"configurable": {"checkpoint_id": "cp-2"}}, + next=("agent",), + ), + _snapshot(values={"messages": []}), + ]) + + await _collect( + graph, + claim_kind="stale_recovery", + input_record={ + "input_type": "resume", + "runtime_value": "补充回答", + "stored_text": "补充回答", + "chat_message_id": 43, + }, + ) + + self.assertIsNone(graph.inputs[0][0]) + + async def test_stale_recovery_without_checkpoint_reuses_initial_input(self): + """stale recovery 没有 checkpoint 时只重用序号 0 的初始消息窗口。""" + history = [SimpleNamespace(type="human", content="初始问题")] + graph = FakeGraph([_snapshot(), _snapshot(values={"messages": []})]) + with patch( + "app.agent.worker.graph_runner.get_job_chat_history", + return_value=history, + ): + await _collect( + graph, + claim_kind="stale_recovery", + input_record={ + "input_type": "initial", + "runtime_value": "初始问题", + "stored_text": "初始问题", + "chat_message_id": 11, + }, + ) + + self.assertEqual(graph.inputs[0][0]["messages"], history) + + async def test_resume_without_pending_interrupt_is_rejected(self): + """没有 interrupt 时不能把 resume 答案重新当作新问题执行。""" + graph = FakeGraph([_snapshot()]) + with self.assertRaisesRegex(RuntimeError, "resume 输入没有对应"): + await _collect( + graph, + input_record={ + "input_type": "resume", + "runtime_value": "回答", + "stored_text": "回答", + "chat_message_id": 43, + }, + ) + + async def test_state_read_failure_does_not_fallback_to_new_conversation(self): + """checkpoint 读取失败时不能静默丢失同一 Job 的上下文。""" + + class BrokenGraph(FakeGraph): + async def aget_state(self, _config): + raise RuntimeError("checkpoint unavailable") + + with self.assertRaisesRegex(RuntimeError, "checkpoint unavailable"): + await _collect(BrokenGraph([])) diff --git a/tests/unit/agent/test_job_idempotency.py b/tests/unit/agent/test_job_idempotency.py index 1cfac34..81c6cce 100644 --- a/tests/unit/agent/test_job_idempotency.py +++ b/tests/unit/agent/test_job_idempotency.py @@ -26,6 +26,9 @@ ) +JOB_REQUEST_KEY = "123e4567-e89b-42d3-a456-426614174000" + + class FakeCursor: """按顺序返回 session 和幂等记录,并记录 job 创建 SQL。""" @@ -33,6 +36,7 @@ def __init__(self, fetch_results=None, rowcount=1, insert_error=None): self.fetch_results = list(fetch_results or []) self.rowcount = rowcount self.insert_error = insert_error + self.lastrowid = 17 self.statements = [] def execute(self, sql, params=None): @@ -88,7 +92,7 @@ def test_same_key_and_request_returns_existing_job_without_insert(self): "request_fingerprint": fingerprint, } connection = FakeConnection( - fetch_results=[{"id": "session-1"}, existing] + fetch_results=[existing] ) with patch("app.agent.job_service.get_write_connection", return_value=connection): @@ -96,7 +100,7 @@ def test_same_key_and_request_returns_existing_job_without_insert(self): 7, "session-1", "hello", - "job-request-123456", + JOB_REQUEST_KEY, ) self.assertEqual(job, existing) @@ -109,21 +113,27 @@ def test_same_key_with_different_request_is_rejected(self): """同一幂等键不能被改用于另一个会话或消息。""" connection = FakeConnection( fetch_results=[ - {"id": "session-1"}, {"job_id": "job-1", "request_fingerprint": "0" * 64}, ] ) with patch("app.agent.job_service.get_write_connection", return_value=connection): with self.assertRaises(IdempotencyConflictError): - create_job(7, "session-1", "hello", "job-request-123456") + create_job(7, "session-1", "hello", JOB_REQUEST_KEY) self.assertEqual(connection.commits, 0) self.assertEqual(connection.rollbacks, 1) def test_new_job_persists_key_and_fingerprint_in_same_transaction(self): """新 job 必须在同一事务中写入幂等键和请求指纹。""" - connection = FakeConnection(fetch_results=[{"id": "session-1"}, None]) + connection = FakeConnection( + fetch_results=[ + None, + None, + {"id": "session-1"}, + {"message_count": 0, "title": ""}, + ] + ) created = {"job_id": "job-new", "status": "queued"} with ( @@ -134,7 +144,7 @@ def test_new_job_persists_key_and_fingerprint_in_same_transaction(self): 7, "session-1", "hello", - "job-request-123456", + JOB_REQUEST_KEY, ) insert_params = next( @@ -144,7 +154,7 @@ def test_new_job_persists_key_and_fingerprint_in_same_transaction(self): ) self.assertEqual(job, created) self.assertFalse(was_existing) - self.assertEqual(insert_params[-2], "job-request-123456") + self.assertEqual(insert_params[-2], JOB_REQUEST_KEY) self.assertEqual(insert_params[-1], _request_fingerprint("session-1", "hello")) self.assertEqual(connection.commits, 1) self.assertEqual(connection.rollbacks, 0) @@ -160,7 +170,7 @@ def test_duplicate_key_race_returns_committed_job_after_insert_conflict(self): "request_fingerprint": fingerprint, } connection = FakeConnection( - fetch_results=[{"id": "session-1"}, None], + fetch_results=[None, None, {"id": "session-1"}], insert_error=duplicate_error, ) @@ -175,7 +185,7 @@ def test_duplicate_key_race_returns_committed_job_after_insert_conflict(self): 7, "session-1", "hello", - "job-request-123456", + JOB_REQUEST_KEY, ) self.assertEqual(job, existing) diff --git a/tests/unit/agent/test_job_input_validation.py b/tests/unit/agent/test_job_input_validation.py new file mode 100644 index 0000000..4f98563 --- /dev/null +++ b/tests/unit/agent/test_job_input_validation.py @@ -0,0 +1,105 @@ +import unittest +from unittest.mock import patch + +from app.agent.job_service import ( + _decode_input_record, + _normalize_input_value, + _validate_input_text, + get_active_jobs, +) + + +class _ActiveJobsCursor: + """记录活动 Job 公共摘要查询,不提供文件对象正文。""" + + def __init__(self): + self.sql = "" + + def execute(self, sql, _params=None): + self.sql = sql + + def fetchall(self): + return [] + + +class _ActiveJobsConnection: + """提供活动 Job 查询所需的最小连接上下文。""" + + def __init__(self, cursor): + self.cursor_value = cursor + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def cursor(self, **_kwargs): + return self.cursor_value + + +class JobInputValidationTests(unittest.TestCase): + """验证普通消息和结构化恢复回答的边界。""" + + def test_structured_resume_value_is_stored_as_json_and_keeps_runtime_value(self): + """受限对象可以进入输入账本,并在 worker 侧恢复为对象。""" + stored, runtime = _normalize_input_value({"target": "revenue", "confirmed": True}) + + self.assertEqual(stored, '{"target":"revenue","confirmed":true}') + self.assertEqual(runtime, {"target": "revenue", "confirmed": True}) + + def test_plain_multiline_question_is_allowed(self): + """普通多行问题只受通用文本长度限制。""" + text = _validate_input_text("请解释下面的问题:\n为什么相关性不代表因果?") + + self.assertEqual(text, "请解释下面的问题:\n为什么相关性不代表因果?") + + def test_structured_value_only_uses_generic_limits(self): + """结构化值中的多行文本不再做 CSV 猜测。""" + stored, runtime = _normalize_input_value({"data": "a,b\n1,2"}) + self.assertEqual(runtime, {"data": "a,b\n1,2"}) + self.assertEqual(stored, '{"data":"a,b\\n1,2"}') + + def test_initial_json_text_remains_text(self): + """初始消息即使以 JSON 形状开头,也必须保持字符串。""" + record = _decode_input_record({ + "input_type": "initial", + "input_text": '{"target":"revenue"}', + "chat_message_id": 12, + }) + self.assertEqual(record["runtime_value"], '{"target":"revenue"}') + self.assertEqual(record["stored_text"], '{"target":"revenue"}') + + def test_resume_json_object_is_decoded_only_for_runtime(self): + """只有 resume 输入才把 JSON 对象解析为 Command 的运行时值。""" + record = _decode_input_record({ + "input_type": "resume", + "input_text": '{"target":"revenue"}', + "chat_message_id": 13, + }) + self.assertEqual(record["runtime_value"], {"target": "revenue"}) + self.assertEqual(record["stored_text"], '{"target":"revenue"}') + + def test_invalid_resume_json_stays_text(self): + """非法 JSON 不猜测类型,保留原始字符串。""" + record = _decode_input_record({ + "input_type": "resume", + "input_text": '{invalid', + "chat_message_id": 14, + }) + self.assertEqual(record["runtime_value"], "{invalid") + + def test_active_job_public_summary_does_not_select_blob_object_id(self): + """刷新恢复接口不应把内部 BLOB 对象 ID 放进公开摘要。""" + cursor = _ActiveJobsCursor() + with patch( + "app.agent.job_service.get_read_connection", + return_value=_ActiveJobsConnection(cursor), + ): + get_active_jobs(7) + + self.assertNotIn("input_object_id", cursor.sql) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/agent/test_job_lifecycle.py b/tests/unit/agent/test_job_lifecycle.py index 5510b10..05c2a52 100644 --- a/tests/unit/agent/test_job_lifecycle.py +++ b/tests/unit/agent/test_job_lifecycle.py @@ -1,4 +1,5 @@ import os +import json import unittest from unittest.mock import patch @@ -29,24 +30,49 @@ class FakeCursor: """记录 SQL,并按顺序返回预设的锁定查询结果。""" - def __init__(self, fetch_results=None, rowcount=1): + def __init__(self, fetch_results=None, rowcount=1, lock_job=None, event=None): self.fetch_results = list(fetch_results or []) self.rowcount = rowcount self.lastrowid = 17 + self.lock_job = lock_job or { + "status": "running", + "worker_id": "worker-a", + "attempt_count": 2, + "lease_epoch": 1, + "user_id": 7, + "session_id": "session-1", + } + self.event = event self.statements = [] def execute(self, sql, params=None): self.statements.append((" ".join(sql.split()), params)) def fetchone(self): + statement = self.statements[-1][0] if self.statements else "" + if "FROM analysis_job_events" in statement and "event_key" in statement: + return self.event + if "SELECT status, worker_id, attempt_count, lease_epoch" in statement: + return self.lock_job + if "FROM analysis_job_inputs" in statement: + return None + if "FROM chat_messages" in statement and "source_event_id" in statement: + return None + if "FROM sessions" in statement and "SELECT id" in statement: + return {"id": "session-1"} return self.fetch_results.pop(0) if self.fetch_results else None class FakeConnection: """模拟 worker 事务连接,并记录提交与回滚次数。""" - def __init__(self, fetch_results=None, rowcount=1, fail_commit=False): - self.fake_cursor = FakeCursor(fetch_results, rowcount=rowcount) + def __init__(self, fetch_results=None, rowcount=1, fail_commit=False, lock_job=None, event=None): + self.fake_cursor = FakeCursor( + fetch_results, + rowcount=rowcount, + lock_job=lock_job, + event=event, + ) self.started_transactions = 0 self.commits = 0 self.rollbacks = 0 @@ -94,11 +120,7 @@ class JobLifecycleTests(unittest.TestCase): def test_terminal_success_commits_chat_event_and_job_together(self): """成功终态必须先设置幂等标记,再在一次事务中写完所有数据。""" connection = FakeConnection( - fetch_results=[ - {"status": "running", "worker_id": "worker-a", "attempt_count": 2}, - {"chat_saved_at": None}, - {"message_count": 0, "title": "新对话"}, - ] + fetch_results=[], ) with patch("app.agent.job_service.get_write_connection", return_value=connection): @@ -116,21 +138,16 @@ def test_terminal_success_commits_chat_event_and_job_together(self): self.assertEqual(connection.commits, 1) self.assertEqual(connection.rollbacks, 0) sql = [statement for statement, _params in connection.fake_cursor.statements] - marker_index = next(i for i, statement in enumerate(sql) if "SET chat_saved_at" in statement) chat_insert_index = next(i for i, statement in enumerate(sql) if "INSERT INTO chat_messages" in statement) event_index = next(i for i, statement in enumerate(sql) if "INSERT INTO analysis_job_events" in statement) status_index = next(i for i, statement in enumerate(sql) if "SET status = 'succeeded'" in statement) - self.assertLess(marker_index, chat_insert_index) self.assertLess(event_index, status_index) + self.assertLess(chat_insert_index, status_index) def test_terminal_commit_error_rolls_back_all_changes(self): """终态提交失败时必须回滚聊天、事件和 job 更新。""" connection = FakeConnection( - fetch_results=[ - {"status": "running", "worker_id": "worker-a", "attempt_count": 2}, - {"chat_saved_at": None}, - {"message_count": 1, "title": "existing"}, - ], + fetch_results=[], fail_commit=True, ) @@ -148,13 +165,11 @@ def test_terminal_commit_error_rolls_back_all_changes(self): self.assertEqual(connection.commits, 1) self.assertEqual(connection.rollbacks, 1) - def test_existing_chat_marker_skips_duplicate_chat_rows(self): - """重试看到已提交的聊天标记时不得再次插入消息。""" + def test_existing_terminal_event_skips_duplicate_chat_rows(self): + """同一终态事件重放时由 event_key 幂等,不再次写聊天。""" + payload = {"type": "final_result", "data": {"summary": "done"}} connection = FakeConnection( - fetch_results=[ - {"status": "running", "worker_id": "worker-a", "attempt_count": 2}, - {"chat_saved_at": "2026-08-04 12:00:00"}, - ] + event={"id": 99, "payload_json": json.dumps(payload)}, ) with patch("app.agent.job_service.get_write_connection", return_value=connection): @@ -162,7 +177,7 @@ def test_existing_chat_marker_skips_duplicate_chat_rows(self): build_job(), "worker-a", "final_result", - {"type": "final_result", "data": {"summary": "done"}}, + payload, {"type": "text", "summary": "done"}, {"summary": "done"}, ) @@ -174,7 +189,12 @@ def test_existing_chat_marker_skips_duplicate_chat_rows(self): def test_stale_worker_cannot_write_event_or_fail_new_attempt(self): """旧 worker 的事件和失败更新都必须被当前租约检查拒绝。""" event_connection = FakeConnection( - fetch_results=[{"status": "running", "worker_id": "worker-new", "attempt_count": 3}] + lock_job={ + "status": "running", + "worker_id": "worker-new", + "attempt_count": 3, + "lease_epoch": 9, + } ) with patch("app.agent.job_service.get_write_connection", return_value=event_connection): event_id = write_event("job-1", "worker-old", 2, "progress", {"value": 1}) @@ -185,9 +205,17 @@ def test_stale_worker_cannot_write_event_or_fail_new_attempt(self): self.assertFalse(any("INSERT INTO analysis_job_events" in sql for sql, _ in event_connection.fake_cursor.statements)) fail_connection = FakeConnection( - fetch_results=[{"status": "running", "worker_id": "worker-new", "attempt_count": 3}] + lock_job={ + "status": "running", + "worker_id": "worker-new", + "attempt_count": 3, + "lease_epoch": 9, + } ) - with patch("app.agent.job_service.get_write_connection", return_value=fail_connection): + with ( + patch("app.agent.job_service.get_write_connection", return_value=fail_connection), + patch("app.agent.job_service.get_job_by_id", return_value=build_job()), + ): failed = fail_job("job-1", "worker-old", 2, "old failure") self.assertFalse(failed) @@ -197,23 +225,49 @@ def test_stale_worker_cannot_write_event_or_fail_new_attempt(self): def test_complete_job_sql_requires_worker_and_attempt(self): """非聊天成功更新也必须在 SQL 层带上 worker 和 attempt fencing。""" - connection = FakeConnection(rowcount=0) - with patch("app.agent.job_service.get_write_connection", return_value=connection): + connection = FakeConnection( + rowcount=0, + lock_job={ + "status": "running", + "worker_id": "worker-new", + "attempt_count": 3, + "lease_epoch": 9, + }, + ) + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch("app.agent.job_service.get_job_by_id", return_value=build_job()), + ): completed = complete_job("job-1", "worker-old", 2, {"summary": "done"}) self.assertFalse(completed) - sql, params = connection.fake_cursor.statements[0] - self.assertIn("worker_id = %s", sql) - self.assertIn("attempt_count = %s", sql) - self.assertEqual(params[-2:], ("worker-old", 2)) + lock_sql, _params = next( + (sql, params) + for sql, params in connection.fake_cursor.statements + if "SELECT status, worker_id, attempt_count, lease_epoch" in sql + ) + self.assertIn("WHERE job_id = %s", lock_sql) + self.assertEqual(connection.fake_cursor.statements[-1][0], lock_sql) def test_claim_final_exhausted_job_as_failed_and_releases_session(self): """领取时应收敛过期且耗尽尝试次数的 running job,避免永久占用会话。""" connection = FakeConnection( - fetch_results=[{"job_id": "job-max"}, None] + fetch_results=[ + { + "job_id": "job-max", + "user_id": 7, + "session_id": "session-1", + "attempt_count": 3, + "lease_epoch": 4, + }, + None, + ] ) - with patch("app.agent.job_service.get_write_connection", return_value=connection): + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch("app.agent.job_service._get_pending_checkpoint_interrupt", return_value=None), + ): claimed = claim_next_job("worker-a", stale_after_seconds=120) self.assertIsNone(claimed) @@ -223,7 +277,8 @@ def test_claim_final_exhausted_job_as_failed_and_releases_session(self): self.assertTrue(any("SET status = 'failed'" in statement for statement in sql)) self.assertTrue(any("active_session_key = NULL" in statement for statement in sql)) event_statement = next(statement for statement in sql if "INSERT INTO analysis_job_events" in statement) - self.assertIn("VALUES (%s, 'error', %s)", event_statement) + self.assertIn("INSERT INTO analysis_job_events", event_statement) + self.assertIn("event_key", event_statement) update_params = next(params for statement, params in connection.fake_cursor.statements if "SET status = 'failed'" in statement) self.assertEqual(update_params[:2], (MAX_ATTEMPTS_ERROR, MAX_ATTEMPTS_ERROR)) diff --git a/tests/unit/agent/test_job_recovery.py b/tests/unit/agent/test_job_recovery.py new file mode 100644 index 0000000..6646ea7 --- /dev/null +++ b/tests/unit/agent/test_job_recovery.py @@ -0,0 +1,277 @@ +import os +import unittest +from unittest.mock import patch + + +TEST_ENV = { + "SECRET_KEY": "test-secret", + "API_KEY": "test-api-key", + "BASE_URL": "https://example.test", + "MODEL": "test-model", + "MYSQL_HOST": "test-mysql", + "MYSQL_USER": "test-user", + "MYSQL_PASSWORD": "test-password", + "MYSQL_DATABASE": "test-database", +} +for key, value in TEST_ENV.items(): + os.environ.setdefault(key, value) + +from app.agent.job_service import ( # noqa: E402 + JobStateConflictError, + _CheckpointRecoveryBlocked, + claim_next_job, + cancel_job, + resume_job, + write_event, +) + + +RESUME_KEY = "123e4567-e89b-42d3-a456-426614174001" +CANCEL_KEY = "123e4567-e89b-42d3-a456-426614174002" + + +class ScriptedCursor: + """按 SQL 顺序返回恢复/取消事务所需的最小数据库结果。""" + + def __init__(self, fetch_results, *, rowcount=1, lastrowid=42): + self.fetch_results = list(fetch_results) + self.rowcount = rowcount + self.lastrowid = lastrowid + self.statements = [] + + def execute(self, sql, params=None): + """记录 SQL,不模拟数据库查询规划。""" + self.statements.append((" ".join(sql.split()), params)) + + def fetchone(self): + """按预设顺序返回单行。""" + statement = self.statements[-1][0] if self.statements else "" + if "message_count" in statement: + return self.fetch_results.pop(0) if self.fetch_results else None + if "FROM sessions" in statement: + return {"id": "session-1"} + return self.fetch_results.pop(0) if self.fetch_results else None + + def fetchall(self): + """恢复测试不需要多行读取。""" + return [] + + +class ScriptedConnection: + """提供事务上下文、提交回滚和游标记录能力。""" + + def __init__(self, fetch_results, *, rowcount=1): + self.cursor_value = ScriptedCursor(fetch_results, rowcount=rowcount) + self.commits = 0 + self.rollbacks = 0 + self.started_transactions = 0 + + def __enter__(self): + """进入连接上下文。""" + return self + + def __exit__(self, exc_type, exc_value, traceback): + """保留异常给调用方。""" + return False + + def cursor(self, **_kwargs): + """返回脚本游标。""" + return self.cursor_value + + def start_transaction(self, **_kwargs): + """记录事务开始。""" + self.started_transactions += 1 + + def commit(self): + """记录提交。""" + self.commits += 1 + + def rollback(self): + """记录回滚。""" + self.rollbacks += 1 + + def close(self): + """兼容显式关闭连接的 worker 路径。""" + + +def _waiting_job(): + """构造等待用户输入的最小 Job。""" + return { + "job_id": "job-waiting", + "user_id": 7, + "session_id": "session-1", + "status": "waiting_input", + "current_question_id": "question-1", + "attempt_count": 1, + "lease_epoch": 2, + } + + +class JobRecoveryTests(unittest.TestCase): + """验证 stale recovery 与同一 Job 的恢复/取消事务边界。""" + + def test_stale_pending_interrupt_is_repaired_without_reclaiming_agent(self): + """checkpoint 仍有 interrupt 时只修复 MySQL waiting_input,不启动新 attempt。""" + stale_job = { + "job_id": "job-stale", + "user_id": 7, + "session_id": "session-1", + "status": "running", + "attempt_count": 1, + "lease_epoch": 2, + } + connection = ScriptedConnection([None, stale_job]) + pending = {"question_id": "question-2", "message": "请补充目标变量"} + + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch( + "app.agent.job_service._get_pending_checkpoint_interrupt", + return_value=pending, + ), + ): + claimed = claim_next_job("worker-new", stale_after_seconds=120) + + self.assertIsNone(claimed) + self.assertEqual(connection.commits, 1) + self.assertEqual(connection.rollbacks, 0) + sql = [statement for statement, _params in connection.cursor_value.statements] + self.assertTrue(any("SET status = 'waiting_input'" in statement for statement in sql)) + self.assertFalse(any("SET status = 'running'" in statement for statement in sql)) + self.assertTrue(any("event_key" in statement for statement in sql)) + + def test_checkpoint_unavailable_rolls_back_stale_recovery(self): + """无法确认 PostgreSQL 恢复位置时不得误重放或误标记失败。""" + stale_job = { + "job_id": "job-stale", + "user_id": 7, + "session_id": "session-1", + "status": "running", + "attempt_count": 1, + "lease_epoch": 2, + } + connection = ScriptedConnection([None, stale_job]) + + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch( + "app.agent.job_service._get_pending_checkpoint_interrupt", + side_effect=_CheckpointRecoveryBlocked("checkpoint unavailable"), + ), + ): + claimed = claim_next_job("worker-new", stale_after_seconds=120) + + self.assertIsNone(claimed) + self.assertEqual(connection.commits, 0) + self.assertEqual(connection.rollbacks, 1) + sql = [statement for statement, _params in connection.cursor_value.statements] + self.assertFalse(any("SET status = 'running'" in statement for statement in sql)) + + def test_old_worker_with_wrong_lease_epoch_cannot_write_event(self): + """同一 worker 名称重复出现时,旧 lease_epoch 也必须被拒绝。""" + connection = ScriptedConnection([ + { + "status": "running", + "worker_id": "worker-a", + "attempt_count": 2, + "lease_epoch": 9, + } + ]) + + with patch("app.agent.job_service.get_write_connection", return_value=connection): + event_id = write_event( + "job-1", + "worker-a", + 2, + "progress", + {"value": 1}, + lease_epoch=8, + ) + + self.assertIsNone(event_id) + self.assertEqual(connection.commits, 0) + self.assertEqual(connection.rollbacks, 1) + self.assertFalse( + any("INSERT INTO analysis_job_events" in sql for sql, _ in connection.cursor_value.statements) + ) + + def test_resume_writes_input_ledger_and_requeues_same_job(self): + """恢复请求应追加 resume 输入、聊天关联并保留原 Job。""" + job = _waiting_job() + refreshed = {**job, "status": "queued", "resume_count": 1} + connection = ScriptedConnection([ + job, + None, + {"next_sequence": 1}, + {"message_count": 1, "title": "分析"}, + ]) + + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch("app.agent.job_service.get_job_for_user", return_value=refreshed), + ): + result, existing = resume_job( + 7, + "job-waiting", + "question-1", + "目标变量是销售额", + RESUME_KEY, + ) + + self.assertEqual(result, refreshed) + self.assertFalse(existing) + self.assertEqual(connection.commits, 1) + sql = [statement for statement, _params in connection.cursor_value.statements] + self.assertTrue(any("input_type, input_text, question_id" in statement for statement in sql)) + self.assertTrue(any("SET status = 'queued'" in statement for statement in sql)) + + def test_resume_rejects_wrong_question_id_without_mutation(self): + """恢复必须绑定当前 interrupt 的 question_id。""" + connection = ScriptedConnection([_waiting_job(), None]) + + with patch("app.agent.job_service.get_write_connection", return_value=connection): + with self.assertRaises(JobStateConflictError): + resume_job( + 7, + "job-waiting", + "question-other", + "回答", + RESUME_KEY, + ) + + self.assertEqual(connection.commits, 0) + self.assertEqual(connection.rollbacks, 1) + + def test_cancel_writes_terminal_event_and_releases_active_session(self): + """取消等待任务应写 canceled 事件、聊天并清理 active_session_key。""" + job = _waiting_job() + refreshed = {**job, "status": "canceled"} + connection = ScriptedConnection([ + job, + None, + None, + {"input_id": 42}, + None, + {"id": "session-1"}, + ]) + + with ( + patch("app.agent.job_service.get_write_connection", return_value=connection), + patch("app.agent.job_service.get_job_for_user", return_value=refreshed), + ): + result, existing = cancel_job(7, "job-waiting", CANCEL_KEY) + + self.assertEqual(result, refreshed) + self.assertFalse(existing) + self.assertEqual(connection.commits, 1) + sql = [statement for statement, _params in connection.cursor_value.statements] + parameter_sets = [params for _statement, params in connection.cursor_value.statements] + self.assertTrue( + any(values and "terminal:canceled" in values for values in parameter_sets) + ) + self.assertTrue(any("SET status = 'canceled'" in statement for statement in sql)) + self.assertTrue(any("active_session_key = NULL" in statement for statement in sql)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/agent/test_job_session_validation.py b/tests/unit/agent/test_job_session_validation.py index b5b5f63..5b33991 100644 --- a/tests/unit/agent/test_job_session_validation.py +++ b/tests/unit/agent/test_job_session_validation.py @@ -60,13 +60,20 @@ def test_create_job_rejects_unknown_session_without_insert(self): connection = FakeConnection() with patch("app.agent.job_service.get_write_connection", return_value=connection): with self.assertRaises(PermissionError): - create_job(7, "missing-session", "hello", "job-request-123456") + create_job( + 7, + "missing-session", + "hello", + "123e4567-e89b-42d3-a456-426614174000", + ) self.assertTrue(connection.rolled_back) self.assertTrue(connection.closed) - self.assertEqual(len(connection.fake_cursor.statements), 1) - self.assertNotIn("INSERT INTO sessions", connection.fake_cursor.statements[0][0]) - self.assertIn("FOR UPDATE", connection.fake_cursor.statements[0][0]) + self.assertEqual(len(connection.fake_cursor.statements), 3) + self.assertTrue( + all("INSERT INTO sessions" not in sql for sql, _ in connection.fake_cursor.statements) + ) + self.assertIn("FOR UPDATE", connection.fake_cursor.statements[2][0]) if __name__ == "__main__": diff --git a/tests/unit/agent/test_postprocess_revision_flow.py b/tests/unit/agent/test_postprocess_revision_flow.py index 3df1378..95871da 100644 --- a/tests/unit/agent/test_postprocess_revision_flow.py +++ b/tests/unit/agent/test_postprocess_revision_flow.py @@ -23,8 +23,7 @@ def _install_import_stubs(): """隔离后处理测试不需要的数据库、绘图和向量库依赖。""" agent_connect = types.ModuleType("Database.agent_connect") - agent_connect.get_file_content = lambda *args, **kwargs: None - agent_connect.get_recent_file = lambda *args, **kwargs: None + agent_connect.require_frozen_file_for_job = lambda *args, **kwargs: None sys.modules["Database.agent_connect"] = agent_connect data_visualize = types.ModuleType("Agent.Processing.data_visualize") diff --git a/tests/unit/chat/test_chat_session_deletion.py b/tests/unit/chat/test_chat_session_deletion.py index 831a5ac..53bbd5f 100644 --- a/tests/unit/chat/test_chat_session_deletion.py +++ b/tests/unit/chat/test_chat_session_deletion.py @@ -40,7 +40,11 @@ def execute(self, sql, params=None): def fetchone(self): """返回当前 SQL 对应的预设单行结果。""" - return self.fetch_results.pop(0) + return self.fetch_results.pop(0) if self.fetch_results else None + + def fetchall(self): + """返回当前 SQL 对应的预设多行结果。""" + return self.fetch_results.pop(0) if self.fetch_results else [] class FakeConnection: @@ -60,7 +64,7 @@ def __exit__(self, exc_type, exc_value, traceback): """不吞掉路由中的异常。""" return False - def cursor(self): + def cursor(self, **_kwargs): """返回记录 SQL 的游标。""" return self.fake_cursor @@ -101,7 +105,13 @@ def _post_delete(self, connection, current_user=None): def test_delete_session_enqueues_checkpoint_cleanup_in_the_same_transaction(self): """正常删除按锁定、任务检查、outbox、业务数据的顺序提交一次。""" - connection = FakeConnection(fetch_results=[("session-1",), None, {"status": "pending"}]) + connection = FakeConnection( + fetch_results=[ + [{"job_id": "job-1", "status": "succeeded"}], + ("session-1",), + {"status": "pending"}, + ] + ) response = self._post_delete(connection) @@ -113,13 +123,20 @@ def test_delete_session_enqueues_checkpoint_cleanup_in_the_same_transaction(self self.assertEqual(len(statements), 7) self.assertEqual( statements[0], + ( + "SELECT job_id, status FROM analysis_jobs WHERE session_id = %s AND user_id = %s ORDER BY id FOR UPDATE", + ("session-1", 7), + ), + ) + self.assertEqual( + statements[1], ( "SELECT id FROM sessions WHERE id = %s AND user_id = %s FOR UPDATE", ("session-1", 7), ), ) - self.assertIn("FROM analysis_jobs", statements[1][0]) self.assertIn("INSERT INTO checkpoint_cleanup_outbox", statements[2][0]) + self.assertEqual(statements[2][1], ("job-1", None)) self.assertIn("SELECT status FROM checkpoint_cleanup_outbox", statements[3][0]) self.assertTrue(statements[4][0].startswith("DELETE ca FROM chat_attachments")) self.assertEqual( @@ -133,7 +150,12 @@ def test_delete_session_enqueues_checkpoint_cleanup_in_the_same_transaction(self def test_active_job_blocks_all_delete_statements(self): """有 queued/running job 时回滚并拒绝删除 checkpoint 和会话数据。""" - connection = FakeConnection(fetch_results=[("session-1",), (1,)]) + connection = FakeConnection( + fetch_results=[ + [{"job_id": "job-1", "status": "running"}], + ("session-1",), + ] + ) response = self._post_delete(connection) @@ -148,7 +170,10 @@ def test_active_job_blocks_all_delete_statements(self): def test_checkpoint_outbox_failure_rolls_back_the_whole_transaction(self): """outbox 写入异常时,不提交消息或会话的后续删除。""" connection = FakeConnection( - fetch_results=[("session-1",), None], + fetch_results=[ + [{"job_id": "job-1", "status": "succeeded"}], + ("session-1",), + ], raise_on_prefix="INSERT INTO checkpoint_cleanup_outbox", )