diff --git a/.gitignore b/.gitignore index 864d00c..f86694c 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ src/backend/repos/ # Optional local-only operator notes /AGENTS.md /LOCAL.md + +.github/copilot-instructions.md +**/*.log \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md index cc7f9a4..eb5bff3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,13 +1,16 @@ # HALF 架构说明 -> **对应版本**:v0.2.1 +> **对应版本**:v0.3.0(含自动派发模式) > 本文档描述当前代码已经实现的系统结构、关键设计决策和公开面。以代码为事实源头;文档与代码不一致时,代码为准。 --- ## 一、系统定位 -HALF(Human-AI Loop Framework)是一个人机协同多智能体任务管理平台。它面向同时使用多个 AI coding agent(Claude Code、Codex、Copilot、GLM、Kimi 等)的研究者和团队,通过纯人工触发的方式协调任务编排、prompt 分发、状态跟踪和结果归档:系统生成 prompt,负责人手工粘贴到 agent 执行,agent 根据 prompt 修改项目代码仓库,并把计划、任务结果等协作产物写回 HALF 协作仓库,HALF 后台只轮询协作仓库识别完成。整个闭环不调用 agent 平台的非公开接口,不做接口逆向。 +HALF(Human-AI Loop Framework)是一个人机协同多智能体任务管理平台。它面向同时使用多个 AI coding agent(Claude Code、Codex 等)的研究者和团队,提供两种任务执行模式: + +- **手动模式**:系统生成 prompt,负责人手工粘贴到 agent 执行,agent 把计划、任务结果等协作产物写回 HALF 协作仓库,HALF 后台轮询协作仓库识别完成。整个闭环不调用 agent 平台的非公开接口,不做接口逆向。 +- **自动模式**:对配置了 API 凭证的 agent 类型,HALF 后端直接调用其 REST API(Anthropic Messages 格式)触发任务执行,按 DAG 依赖顺序自动推进,无需人工介入。 --- @@ -53,8 +56,8 @@ HALF(Human-AI Loop Framework)是一个人机协同多智能体任务管理 **三层划分**: - **前端**:React 18 + TypeScript + Vite SPA,负责页面渲染、交互编辑、手工触发复制操作;通过 JWT Bearer 访问后端 REST API。 -- **后端**:FastAPI(Python 3.12)服务,负责鉴权、业务状态维护、prompt 生成、DAG 解析、Git 轮询调度。后端 **不主动调用 agent**。 -- **存储**:**SQLite 承载系统内部状态**(项目、计划、任务、用户、审计日志等);**Git 协作仓库承载项目协同产物**(每个项目配置一个协作仓库,任务输出、`result.json` 哨兵、`usage.json` 用量记录)。项目代码仓库可与协作仓库相同,也可单独配置;HALF 只轮询协作仓库。SQLite 与 Git 通过轮询桥接。 +- **后端**:FastAPI(Python 3.12)服务,负责鉴权、业务状态维护、prompt 生成、DAG 解析、Git 轮询调度,以及自动模式下直接调用外部 Agent API(通过 Agent Runner 层)。 +- **存储**:**SQLite 承载系统内部状态**(项目、计划、任务、用户、审计日志等);**Git 协作仓库承载项目协同产物**(每个项目配置一个协作仓库,任务输出、`result.json` 哨兵、`usage.json` 用量记录)。项目代码仓库可与协作仓库相同,也可单独配置;手动模式下 HALF 只轮询协作仓库。SQLite 与 Git 通过轮询桥接。 --- @@ -88,12 +91,23 @@ HALF(Human-AI Loop Framework)是一个人机协同多智能体任务管理 这样同时覆盖单文件和多文件任务,显著降低轮询误判漏判。具体状态流转参见 `task-lifecycle.md`。 -### 3.5 人工派发模式(Human-in-the-Loop) +### 3.5 人工派发模式(Human-in-the-Loop,手动模式) -MVP 所有任务分发均由项目负责人手工完成——系统生成 prompt,负责人点击"复制 Prompt 并派发"按钮把内容送到剪贴板,再手工粘贴给对应 agent。HALF 不会自动发送任何 prompt。 +**手动模式**项目中,所有任务分发均由项目负责人手工完成——系统生成 prompt,负责人点击"复制 Prompt 并派发"按钮把内容送到剪贴板,再手工粘贴给对应 agent。HALF 不会自动发送任何 prompt。 **剪贴板 user-activation 约束**:浏览器要求 `navigator.clipboard.writeText` 必须在用户点击产生的同步执行栈内调用。前端因此采用"预取 prompt + 同步写剪贴板"的编排:选中任务时后台预取最新 prompt 缓存;用户点击时 `copyText` 在任何 `await` 之前直接写剪贴板;写入失败必须显式中止,不得继续调用 `/dispatch`。这避免了"按钮显示已复制但剪贴板里残留的是上一个任务 prompt"的历史 bug。 +### 3.6 自动派发模式(Auto-Dispatch) + +**自动模式**项目中,Agent 类型配置了 `sdk_type`(目前支持 `claude`),且对应 Agent 实例配置了 `api_base_url` 和加密存储的 `api_key` 后,计划 finalize 或任务完成时,后端自动触发就绪任务的执行,无需人工操作。 + +- **DAG 并发链式执行**:`services/auto_dispatch.py::run_auto_task` 是一个异步协程,执行完成后调用 `get_ready_auto_tasks` 获取下游就绪任务,通过 `asyncio.gather` 并发运行所有解锁的下游任务,形成自动推进的 DAG 链。 +- **Git Worktree 隔离**:每个自动执行的任务获得独立的 per-task 工作目录(`{REPOS_DIR}/{project_id}/tasks/{task_id}/`),基于项目代码仓库(`{project_id}/code/`)的 git worktree 创建,任务完成后删除,避免并发任务互相干扰。 +- **两仓库布局**:后端在 `{REPOS_DIR}/{project_id}/` 下维护两个子目录:`collab/`(HALF 协作仓库克隆)和可选 `code/`(项目代码仓库克隆,与协作仓库不同时才独立存在);`tasks/{task_id}/` 为按需创建的 worktree。 +- **状态管理**:任务在真正调用 Agent API 前才在 DB 中标记为 `running`(`dispatch_mode=auto`);执行完成后由 `run_auto_task` 直接标记 `completed` 或 `needs_attention`,**不经过 polling_service**。手动模式任务的状态变更仍走轮询路径。 +- **失败处理**:API 调用失败时任务进入 `needs_attention`,记录错误原因,不自动重试。可在前端手动重新派发或人工处理。 +- **进程内去重**:`_running_auto_tasks: set[int]` 在 asyncio 单线程事件循环中保证同一任务不会被并发执行两次。 + --- ## 四、技术栈 @@ -165,11 +179,11 @@ MVP 所有任务分发均由项目负责人手工完成——系统生成 prompt |---|---| | `User` | 系统用户(admin / user);`status` 为 `active` 或 `frozen` | | `Agent` | 用户登记的 AI agent,含多模型配置(`models_json`)、订阅到期、短期/长期重置时间与间隔 | -| `AgentTypeConfig` + `ModelDefinition` + `AgentTypeModelMap` | Agent 类型目录与模型定义的全局配置,由管理员维护 | -| `Project` | 项目。含 `git_repo_url`(HALF 协作仓库)、`project_repo_url`(可选项目代码仓库)、`collaboration_dir`、`planning_mode`、`template_inputs_json`、轮询配置快照、`goal`(任务介绍) | +| `AgentTypeConfig` + `ModelDefinition` + `AgentTypeModelMap` | Agent 类型目录与模型定义的全局配置,由管理员维护;`AgentTypeConfig` 含 `sdk_type` 字段(目前仅 `claude`),用于标识自动模式;API 凭证(`api_base_url`、`api_key_encrypted`,Fernet 加密存储)存在 `Agent` 实例层,每实例独立配置 | +| `Project` | 项目。含 `git_repo_url`(HALF 协作仓库)、`project_repo_url`(可选项目代码仓库)、`collaboration_dir`、`planning_mode`、`template_inputs_json`、轮询配置快照、`goal`(任务介绍)、`is_auto`(是否为自动模式项目,默认 `False`) | | `ProjectPlan` | 计划。每个项目可有多个 `candidate` 计划和一个 `final` 计划 | | `ProcessTemplate` | 可复用的流程模版。任务中的 `assignee` 使用抽象槽位 `agent-N`,应用到项目时替换为真实 Agent | -| `Task` | 计划 finalize 后产生的执行任务;`status` 覆盖 `pending / running / completed / needs_attention / abandoned` | +| `Task` | 计划 finalize 后产生的执行任务;`status` 覆盖 `pending / running / completed / needs_attention / abandoned`;`dispatch_mode` 区分手动(`manual`)和自动(`auto`)派发 | | `TaskEvent` | 任务状态变更事件(dispatched / completed / timeout / manual_complete / abandoned / redispatched / updated / error) | | `GlobalSetting` | 全局项目参数(轮询间隔、启动延迟、默认超时)+ 规划 prompt 的同机分配引导 | | `AuditLog` | 密码修改、用户角色/状态变更的操作审计日志 | diff --git a/docs/prd/auto-dispatch-agents.md b/docs/prd/auto-dispatch-agents.md index 6cd5616..096720e 100644 --- a/docs/prd/auto-dispatch-agents.md +++ b/docs/prd/auto-dispatch-agents.md @@ -18,19 +18,19 @@ ### 智能体执行模式 -每个 agent 实例(非 agent 类型)拥有一个**执行模式**字段: +每个 agent 类型拥有一个**执行模式**字段: -| 模式 | 含义 | 默认行为 | +| 模式 | 含义 | 约束 | |---|---|---| -| **手动模式**(Manual) | 沿用现有流程,系统生成 prompt,操作人手动复制粘贴 | 默认 | -| **自动模式**(Auto) | 系统在任务就绪时自动调用外部 AI API 执行,无需人工介入 | 需配置 API 凭证后方可激活 | +| **手动**(manual) | 所有任务由操作人手动派发 | 只能使用手动模式 agent| +| **自动**(auto) | 任务就绪后自动派发,无需人工介入 | 只能使用自动模式 agent| --- ## 用户故事 **US-1 管理员/用户配置自动模式 agent** -作为一名用户,我可以在"智能体设置"页面为某个智能体类型开启自动模式,填写 API 凭证,并选择调用格式,保存后该 agent 即可被自动派发。 +作为一名用户,我可以在"智能体设置"页面为某个智能体类型开启自动模式;并在各智能体实例的配置页面填写该实例专属的 API 凭证,保存后该实例即可被自动派发。 **US-2 项目负责人查看 agent 模式** 作为项目负责人,在选择参与 agent 时,我可以看到每个 agent 的模式标签("自动"或"手动"),以便了解哪些 agent 需要人工介入。 @@ -49,13 +49,12 @@ **FR-1.1** 创建或编辑智能体类型时,提供"执行模式"选项:手动(默认)或自动。 -**FR-1.2** 当选择"自动"时,需展示并要求填写以下字段: +**FR-1.2** 当选择"自动"时,智能体类型本身不存储 API 凭证;每个属于该类型的智能体实例需在实例配置页面单独填写以下字段: | 字段 | 说明 | 必填 | |---|---|---| | API Base URL | API 服务地址,如 `https://api.openai.com/v1` | 是 | | API Key | 鉴权密钥 | 是 | -| API 格式 | 单选:OpenAI Chat Completions / Anthropic Messages | 是 | **FR-1.3** API Key 在界面上以密码框形式展示(`****`),不允许明文回显。编辑时可选择"重新设置"以覆盖,或留空以保持原值不变。 @@ -63,7 +62,7 @@ **FR-1.5** 切换模式时需要确认提示:从自动切换回手动时,提示"切换后现有 API 凭证将被清除,确认继续?"。 -**FR-1.6** API 凭证(base_url、api_key)存储在 Agent 类型层,在所有引用该智能体类型的智能体实例中共享。 +**FR-1.6** API 凭证(base_url、api_key)存储在 Agent 实例层,每个智能体实例独立配置各自的 API 凭证,互不影响。自动模式类型下的每个实例在投入使用前必须完成凭证配置。 --- @@ -71,7 +70,7 @@ **FR-2.1** 项目选择参与 agent 的界面中,在 agent 名称旁以标签展示其执行模式("自动"/"手动"),仅供查看,不可在项目页面修改。 -**FR-2.2** 无需其他改动,项目页面的 agent 选择逻辑保持不变。 +**FR-2.2** 项目创建/编辑时,只展示与项目执行模式匹配的 agent:自动模式项目只显示自动模式 agent,手动模式项目只显示手动模式 agent。尝试将不匹配模式的 agent 加入项目时,后端返回 400 错误。 --- @@ -81,7 +80,7 @@ **FR-3.2** 自动派发遵循 DAG 依赖顺序:仅当一个任务的所有前置依赖任务已 `completed` 时,该任务才会被自动派发。 -**FR-3.3** 对于混合模式项目,自动 agent 的任务正常自动执行;手动 agent 的任务继续在界面上等待人工操作,两者可并行推进。 +**FR-3.3** 不支持混合模式项目。项目必须为纯自动或纯手动模式,不允许同一项目中同时存在自动模式 agent 和手动模式 agent。 **FR-3.4** 自动派发的任务在界面上标记为"自动执行中",区别于人工派发后的"运行中"状态(视觉上可保持一致,语义上需可区分,便于后续扩展)。 @@ -91,24 +90,6 @@ --- -### FR-4 外部 API 格式支持 - -系统以 task prompt 作为输入,通过调用外部 AI 服务的 API 来执行任务。支持以下两种主流接口协议: - -**FR-4.1 OpenAI Chat Completions 格式** -- 系统向 `POST {base_url}/chat/completions` 发送请求 -- 请求体包含 `model`、`messages`(将 task prompt 作为 user message) -- 支持流式(`stream: true`)和非流式两种响应 - -**FR-4.2 Anthropic Messages 格式** -- 系统向 `POST {base_url}/messages` 发送请求 -- 请求体包含 `model`、`messages`(将 task prompt 作为 user message)、`max_tokens` -- 鉴权使用 `x-api-key` header - -**FR-4.3** 两种格式下,task prompt 作为整条 user message 发送给外部服务。系统不维护跨任务的 conversation history(每次调用均为独立对话)。 - ---- - ## 非功能需求 **NFR-1 安全性** @@ -120,14 +101,13 @@ - 自动派发为新增功能,已有工作流不变。 **NFR-3 可观测性** -- 自动派发的每次调用需记录事件日志(`TaskEvent`),包括:发起时间、API 格式、成功/失败、错误信息(不含 API Key)。 +- 自动派发的每次调用需记录事件日志(`TaskEvent`),包括:发起时间、成功/失败、错误信息(不含 API Key)。 --- ## 超出范围(Out of Scope) - 跨任务的会话上下文维护(多轮对话) -- 项目级别覆盖 agent 执行模式 - API 调用失败后的自动重试机制 - 用量统计和费用追踪(可作为后续功能扩展) - 对现有 `claude_runner.py` / `copilot_runner.py`(SDK 模式)的整合,本次仅引入 REST API 模式 @@ -139,19 +119,21 @@ ### 智能体页面(/agents) - Agent 卡片:自动模式 agent 展示 `自动` 标签 -- 创建/编辑 agent 表单:新增"执行模式"切换 + 自动模式下的凭证配置区域 +- 创建/编辑智能体类型表单:新增"执行模式"切换(手动/自动),不含凭证字段 +- 创建/编辑智能体实例表单:当所属类型为自动模式时,展示凭证配置区域(API Base URL、API Key) ### 项目创建/编辑页面 -- Agent 选择列表:在 agent 名称旁展示模式标签(只读) +- 新增"执行模式"选择(手动/自动,默认手动) +- Agent 选择列表:按项目执行模式过滤,只展示匹配模式的 agent,在 agent 名称旁展示模式标签(只读) --- ## 验收标准 -1. 创建一个自动模式 agent,填写有效的 OpenAI 兼容 API 凭证,将其加入项目并 finalize 计划后,对应任务无需人工操作即可进入执行状态,完成后任务状态变为 `completed`。 -2. 创建一个自动模式 agent,填写无效的 API Key,任务触发后进入 `needs_attention` 状态,错误信息显示鉴权失败,且响应中不包含 API Key 明文。 -3. 一个项目中同时包含手动和自动 agent:自动 agent 的任务自动执行,手动 agent 的任务在界面上等待人工派发,两者正常并行推进,互不影响。 +1. 创建一个自动模式 agent 类型,再创建一个属于该类型的智能体实例并填写有效的 API 凭证,将其加入项目并 finalize 计划后,对应任务无需人工操作即可进入执行状态,完成后任务状态变为 `completed`。 +2. 创建一个自动模式 agent 实例,填写无效的 API Key,任务触发后进入 `needs_attention` 状态,错误信息显示鉴权失败,且响应中不包含 API Key 明文。 +3. 尝试将自动模式 agent 加入手动模式项目,或将手动模式 agent 加入自动模式项目时,后端返回 400 错误,操作被拒绝。 4. 同一自动模式 agent 被分配多个就绪任务时,这些任务可并发执行,无需等待其中一个完成。 -5. 所有现有手动模式 agent 的行为与改动前完全一致。 +5. 所有现有手动模式 agent 的行为与改动前完全一致;所有存量项目默认为手动模式,现有工作流不受影响。 6. API Key 不出现在任何 API 响应、日志或页面中。 diff --git a/docs/project-structure.md b/docs/project-structure.md index 509eced..9e187f7 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,6 +1,6 @@ # 代码结构导览 -> **对应版本**:v0.2.1 +> **对应版本**:v0.3.0(含自动派发模式) > 本文档帮助想要修改 HALF 代码的贡献者快速定位对应模块。以 `main` 分支的当前真实目录结构为准。 --- @@ -46,7 +46,7 @@ backend/ ├── main.py # FastAPI app 入口;启动期校验、初始化和 polling worker 启动 ├── config.py # Settings 类 + validate_security_config(启动期弱密钥/弱密码拒启) ├── database.py # SQLAlchemy engine / SessionLocal / Base -├── models.py # 12 个 ORM 模型(User / Agent / GlobalSetting / Project / ProjectPlan / ProcessTemplate / Task / AgentTypeConfig / ModelDefinition / AgentTypeModelMap / TaskEvent / AuditLog) +├── models.py # 12 个 ORM 模型(User / Agent / GlobalSetting / Project / ProjectPlan / ProcessTemplate / Task / AgentTypeConfig / ModelDefinition / AgentTypeModelMap / TaskEvent / AuditLog);Task 含 dispatch_mode 字段;AgentTypeConfig 含 sdk_type 自动模式标识字段;Agent 含 api_base_url / api_key_encrypted 实例级 API 凭证字段;Project 含 is_auto 字段 ├── schemas.py # Pydantic 响应/请求 schema ├── auth.py # JWT 签发与校验、bcrypt 密码哈希工具 ├── access.py # get_owned_project / get_owned_task、Agent 可见性与可用性等业务隔离工具 @@ -54,7 +54,7 @@ backend/ │ ├── auth.py # /api/auth/* │ ├── agents.py # /api/agents/* │ ├── codex_usage.py # /api/codex-usage/*(Codex OAuth 登录、状态与额度刷新) -│ ├── agent_settings.py # /api/agent-settings/*(仅管理员) +│ ├── agent_settings.py # /api/agent-settings/*(仅管理员);管理 AgentTypeConfig 的 sdk_type(自动模式标识);Agent 实例的 api_base_url / api_key 通过 agents.py 管理 │ ├── projects.py # /api/projects CRUD │ ├── plans.py # /api/projects/:id/plans/* │ ├── tasks.py # /api/tasks/*(无 prefix;在 main 里 include) @@ -63,16 +63,21 @@ backend/ │ ├── settings.py # /api/settings/polling、/api/settings/prompt │ └── users.py # /api/admin/users/* + /api/admin/audit-logs(仅管理员) ├── services/ # 业务服务层 -│ ├── git_service.py # clone / fetch / pull / read_file / file_exists / _safe_join / validate_git_url +│ ├── git_service.py # 两仓库布局管理:collab/(协作仓库)、code/(代码仓库,可选)、tasks/{id}/(per-task worktree);clone / fetch / pull / read_file / file_exists / ensure_code_repo_sync / create_task_workspace / delete_task_workspace / delete_project_repo / _safe_join / validate_git_url │ ├── path_service.py # resolve_expected_output_path / normalize_expected_output_path(路径归一化 + 防越界) │ ├── prompt_service.py # generate_plan_prompt / generate_task_prompt / generate_template_prompt │ ├── prompt_settings.py # 全局 Prompt 设置(同机分配引导)读写 -│ ├── polling_service.py # polling_loop / poll_project / get_effective_task_timeout_minutes +│ ├── polling_service.py # polling_loop / poll_project / get_effective_task_timeout_minutes;只处理手动模式任务的 result.json 检测 │ ├── polling_config_service.py # 项目级轮询参数解析 │ ├── agents.py # Agent availability 状态推导、短期/长期重置续推逻辑 +│ ├── auto_dispatch.py # 自动派发编排:get_ready_auto_tasks(查询就绪任务)/ dispatch_auto_tasks(向 BackgroundTasks 注册执行)/ run_auto_task(单任务执行 + DAG 链式推进) │ ├── codex_usage_cache.py # Codex OAuth token、账号额度快照与账号级刷新冷却的内存缓存 │ ├── project_agents.py # 项目-Agent 绑定校验 -│ └── usage_limits.py # 用量相关辅助 +│ ├── usage_limits.py # 用量相关辅助 +│ └── agent_runner/ # Agent SDK 调用层(自动模式) +│ ├── base.py # AgentRunner 抽象基类 +│ ├── claude_runner.py # ClaudeRunner:调用 Anthropic Messages API +│ └── registry.py # 按 sdk_type 返回对应 Runner 实例;目前支持 claude ├── middleware/ │ └── rate_limit.py # 登录限流(5 次失败锁 15 分钟) ├── validators/ @@ -115,10 +120,15 @@ FastAPI 应用也在这里实例化,并挂载 auth、agents、projects、plans | 密码强度 / 启动期校验 | `config.py::validate_security_config` | | Agent 可用性推导、续推 | `services/agents.py` | | Git 读文件 / 同步策略 | `services/git_service.py::ensure_repo_sync / read_file / dir_has_content` | +| Git 仓库布局 / worktree 生命周期 | `services/git_service.py::ensure_code_repo_sync / create_task_workspace / delete_task_workspace` | | 路径安全 / 归一化 | `services/path_service.py` | | Prompt 模板 | `services/prompt_service.py` | -| 轮询循环 | `services/polling_service.py::polling_loop / poll_project` | +| 轮询循环(手动模式) | `services/polling_service.py::polling_loop / poll_project` | | 任务超时判定 | `services/polling_service.py::get_effective_task_timeout_minutes` | +| 自动派发调度 | `services/auto_dispatch.py::dispatch_auto_tasks / run_auto_task` | +| Agent API 调用(Claude) | `services/agent_runner/claude_runner.py` | +| Agent 类型自动模式标识(sdk_type)管理 | `routers/agent_settings.py` + `AgentTypeConfig` 模型 | +| Agent 实例 API 凭证(api_base_url / api_key)管理 | `routers/agents.py` + `Agent` 模型 | | Owner 级隔离 | `access.py` | | 登录限流 | `middleware/rate_limit.py` | | Git URL 白名单 | `validators/git_url.py` | diff --git a/docs/task-lifecycle.md b/docs/task-lifecycle.md index 77479ce..f18de7e 100644 --- a/docs/task-lifecycle.md +++ b/docs/task-lifecycle.md @@ -1,6 +1,6 @@ # 任务生命周期与运行时机制 -> **对应版本**:v0.2.1 +> **对应版本**:v0.3.0(含自动派发模式) > 本文档说明 HALF 的运行时模型:项目 / 计划 / 任务三者关系、计划生成、任务执行、状态流转、`result.json` 契约、轮询机制和 Agent 可用性跟踪。以代码为事实源头;文档与代码不一致时,代码为准。 --- @@ -76,13 +76,13 @@ Finalize 后,`/projects/:id/tasks` 页面按 DAG 展示任务,右侧选中 | 状态 | 含义 | 负责人可执行动作 | |---|---|---| -| `pending` / 待派发 | 任务已创建未派发 | 复制 Prompt 并派发、放弃任务 | -| `running` / 执行中 | 已派发,正在计时,后台轮询中 | 等待、手动刷新;也可重新派发、手动标记完成、放弃任务 | -| `completed` / 已完成 | 检测到并校验通过 `result.json`,或负责人手动标记 | 查看结果、继续后续任务 | -| `needs_attention` / 需人工处理 | 超过任务超时时间仍未检测到 `result.json`,或 `result.json` 校验失败 | 根据 `last_error` 修正结果文件、重新派发、手动标记完成、放弃任务 | +| `pending` / 待派发 | 任务已创建未派发 | 复制 Prompt 并派发(手动模式);自动模式任务由系统自动触发,一般不需要手动干预 | +| `running` / 执行中 | 已派发,正在计时(手动:后台轮询中;自动:Agent Runner 执行中) | 等待、手动刷新;也可重新派发、手动标记完成、放弃任务 | +| `completed` / 已完成 | 检测到并校验通过 `result.json`(手动模式),或 Agent Runner 执行成功(自动模式),或负责人手动标记 | 查看结果、继续后续任务 | +| `needs_attention` / 需人工处理 | 超过任务超时时间仍未检测到 `result.json`(手动),或 Agent API 调用失败(自动),或 `result.json` 校验失败 | 根据 `last_error` 修正结果文件、重新派发、手动标记完成、放弃任务 | | `abandoned` / 已放弃 | 负责人主动标记放弃 | 查看结果;也可重新派发(`abandoned` 不是终态) | -`POST /api/tasks/:taskId/redispatch` 当前接受的源状态是 `needs_attention` / `running` / `abandoned`。 +`dispatch_mode` 字段区分派发方式:`manual`(手工复制粘贴派发)和 `auto`(系统自动调用 Agent API 派发)。`POST /api/tasks/:taskId/redispatch` 当前接受的源状态是 `needs_attention` / `running` / `abandoned`。 ### 3.2 派发约束 @@ -142,6 +142,20 @@ Prompt 模板的具体填充逻辑在 `services/prompt_service.py::generate_task 若负责人刚修改了任务的 `task_name / description / expected_output`,前端必须**先完成自动保存并基于最新服务端数据重新生成 prompt**,才允许点击派发。在新 prompt 预取完成前,派发按钮保持 disabled,避免旧 prompt 与最新任务文本不一致。 +### 3.6 自动派发执行流程(Auto-Dispatch) + +当项目的 `is_auto = true`,且所使用的 Agent 类型配置了 `sdk_type`,同时对应 Agent 实例配置了 `api_base_url` + `api_key` 时,任务由系统自动触发执行: + +1. **触发时机**:计划 finalize(`POST /api/projects/:id/plans/finalize`)或任务完成/放弃(`POST /api/tasks/:taskId/mark-complete`、`abandon`)后,后端调用 `dispatch_auto_tasks(background_tasks, db, project_id=...)`。 +2. **就绪判断**:`get_ready_auto_tasks` 查询 `pending` 状态且所有前序任务均为 `completed`/`abandoned` 的自动 Agent 任务。若 Agent 缺少 API 凭证,对应任务进入 `needs_attention`(写 `last_error`)而不放入待执行列表。 +3. **执行**:`run_auto_task(task_id)` 在 FastAPI `BackgroundTasks` 中以 `asyncio.gather` 并发运行所有就绪任务。每个任务: + - 在正式调用 Agent API 前将任务状态更新为 `running`(`dispatch_mode=auto`)并提交到 DB + - 创建 per-task git worktree(基于 `{project_id}/code/` 仓库)供 Agent 使用 + - 通过 `agent_runner/claude_runner.py` 调用 Anthropic Messages API + - 执行成功 → 任务 `completed`;失败 → 任务 `needs_attention` + 写 `last_error` + - 完成后删除 worktree,再次调用 `get_ready_auto_tasks` 触发下游任务(DAG 链式推进) +4. **与轮询的关系**:自动模式任务的状态由 Agent Runner 直接管理,**不依赖 `polling_service`**;`polling_service` 仍对手动模式任务做 `result.json` 检测,但会跳过 `dispatch_mode=auto` 的 `running` 任务的 timeout 判定。 + --- ## 四、`result.json` 完成契约 diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile index c73a2f2..39f5295 100644 --- a/src/backend/Dockerfile +++ b/src/backend/Dockerfile @@ -5,6 +5,8 @@ COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /bin/uv RUN apt-get update && apt-get install -y \ curl \ git \ + bubblewrap \ + socat \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -21,4 +23,4 @@ ENV HALF_REPOS_DIR=/app/repos EXPOSE 8000 1455 -CMD ["uv", "run", "--no-dev", "--frozen", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "--no-dev", "--frozen", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/src/backend/config.py b/src/backend/config.py index e3b1727..8f5858a 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -27,6 +27,7 @@ def _truthy(value: str | None) -> bool: class Settings: SECRET_KEY: str = os.getenv("HALF_SECRET_KEY", "example-insecure-secret-placeholder") + AGENT_API_KEY_ENCRYPTION_SECRET: str | None = os.getenv("HALF_AGENT_API_KEY_ENCRYPTION_SECRET") ADMIN_PASSWORD: str = os.getenv("HALF_ADMIN_PASSWORD", "example-insecure-password-placeholder") ALLOW_REGISTER: bool = _truthy(os.getenv("HALF_ALLOW_REGISTER", "false")) DEMO_SEED_ENABLED: bool = _truthy(os.getenv("HALF_DEMO_SEED_ENABLED", "true")) diff --git a/src/backend/logging.yml b/src/backend/logging.yml new file mode 100644 index 0000000..07d7a94 --- /dev/null +++ b/src/backend/logging.yml @@ -0,0 +1,71 @@ +version: 1 +disable_existing_loggers: false + +formatters: + uvicorn_default: + "()": uvicorn.logging.DefaultFormatter + fmt: "%(levelprefix)s %(message)s" + use_colors: null + + uvicorn_access: + "()": uvicorn.logging.AccessFormatter + fmt: '%(levelprefix)s %(client_addr)s - "%(request_line)s" %(status_code)s' + + app: + format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + datefmt: "%Y-%m-%d %H:%M:%S" + +handlers: + uvicorn_default: + class: logging.StreamHandler + formatter: uvicorn_default + stream: ext://sys.stderr + + uvicorn_access: + class: logging.StreamHandler + formatter: uvicorn_access + stream: ext://sys.stdout + + app_console: + class: logging.StreamHandler + formatter: app + stream: ext://sys.stdout + + app_file: + class: logging.FileHandler + formatter: app + filename: ../../logs/app.log + encoding: utf-8 + + runner_file: + class: logging.FileHandler + formatter: app + filename: ../../logs/cc_runner.log + encoding: utf-8 + +root: + level: INFO + handlers: [app_console, app_file] + +loggers: + uvicorn: + handlers: [uvicorn_default] + level: INFO + propagate: false + + uvicorn.error: + level: INFO + + uvicorn.access: + handlers: [uvicorn_access] + level: INFO + propagate: false + + services.agent_runner: + handlers: [runner_file] + level: DEBUG + propagate: true + + watchfiles.main: + level: WARNING + propagate: false \ No newline at end of file diff --git a/src/backend/main.py b/src/backend/main.py index 81ef3bf..7e02a03 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -2,6 +2,7 @@ import json import logging from contextlib import asynccontextmanager +import os from fastapi import FastAPI from sqlalchemy import inspect, text @@ -30,7 +31,7 @@ from services.demo_seed import DEMO_AGENT_TYPE_CATALOG, DEMO_MODEL_CAPABILITIES, seed_demo_project from services.issue_review_loop import ensure_issue_review_loop_template -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG if os.getenv("HALF_DEBUG") else logging.INFO) logger = logging.getLogger("half") @@ -106,6 +107,8 @@ def ensure_schema_updates(): "long_term_reset_mode": "TEXT DEFAULT 'days'", "display_order": "INTEGER DEFAULT 0", "created_by": "INTEGER", + "api_base_url": "TEXT", + "api_key_encrypted": "TEXT", }, "users": { "role": "TEXT DEFAULT 'user'", @@ -126,6 +129,7 @@ def ensure_schema_updates(): "default_max_review_rounds": f"INTEGER DEFAULT {DEFAULT_MAX_REVIEW_ROUNDS}", "planning_mode": "TEXT DEFAULT 'balanced'", "template_inputs_json": "TEXT DEFAULT '{}'", + "is_auto": "INTEGER DEFAULT 0", }, "project_plans": { "prompt_text": "TEXT", @@ -138,8 +142,12 @@ def ensure_schema_updates(): "detected_at": "DATETIME", "last_error": "TEXT", }, + "tasks": { + "dispatch_mode": "TEXT", + }, "agent_type_configs": { "description": "TEXT", + "sdk_type": "TEXT", "display_order": "INTEGER DEFAULT 0", }, "agent_type_model_map": { diff --git a/src/backend/models.py b/src/backend/models.py index 5882a48..bae0026 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -40,6 +40,8 @@ class Agent(Base): co_located = Column(Boolean, default=False) is_active = Column(Boolean, default=True) availability_status = Column(Text, default="unknown") # online/quota_exhausted/expired/unknown + api_base_url = Column(Text, nullable=True) + api_key_encrypted = Column(Text, nullable=True) subscription_expires_at = Column(DateTime, nullable=True) short_term_reset_at = Column(DateTime, nullable=True) short_term_reset_interval_hours = Column(Integer, nullable=True) @@ -85,6 +87,7 @@ class Project(Base): default_max_review_rounds = Column(Integer, nullable=False, default=DEFAULT_MAX_REVIEW_ROUNDS) planning_mode = Column(Text, default="balanced") template_inputs_json = Column(Text, default="{}") + is_auto = Column(Boolean, default=False) # False = manual, True = auto created_by = Column(Integer, ForeignKey("users.id")) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) @@ -150,6 +153,7 @@ class Task(Base): usage_file_path = Column(Text) last_error = Column(Text) timeout_minutes = Column(Integer, nullable=True) + dispatch_mode = Column(Text, nullable=True) # manual/auto; NULL means legacy manual dispatched_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) created_at = Column(DateTime, default=utcnow) @@ -162,6 +166,8 @@ class AgentTypeConfig(Base): id = Column(Integer, primary_key=True, index=True) name = Column(Text, unique=True, nullable=False) description = Column(Text, nullable=True) + # sdk_type: "claude" — which SDK runner to use (auto mode only) + sdk_type = Column(Text, nullable=True) display_order = Column(Integer, default=0) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 37e4a5b..899e5c6 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "fastapi>=0.136.1", - "uvicorn>=0.46.0", + "uvicorn[standard]>=0.46.0", "sqlalchemy>=2.0.49", "pyjwt>=2.12.1", "passlib[bcrypt]>=1.7.4", @@ -13,10 +13,11 @@ dependencies = [ "json-repair>=0.59.5", "python-dotenv>=1.2.2", "httpx>=0.27.0", + "claude-agent-sdk>=0.1.80", + "cryptography>=48.0.0", ] [dependency-groups] dev = [ "pytest", ] - diff --git a/src/backend/routers/agent_settings.py b/src/backend/routers/agent_settings.py index a09d198..71810e9 100644 --- a/src/backend/routers/agent_settings.py +++ b/src/backend/routers/agent_settings.py @@ -6,11 +6,13 @@ from sqlalchemy.orm import Session from database import get_db -from models import AgentTypeConfig, ModelDefinition, AgentTypeModelMap, Agent +from models import AgentTypeConfig, ModelDefinition, AgentTypeModelMap, Agent, Task, TaskEvent from auth import require_admin, User router = APIRouter(prefix="/api/agent-settings", tags=["agent-settings"]) +VALID_SDK_TYPES = {"claude"} + # --- Schemas --- @@ -28,17 +30,20 @@ class AgentTypeOut(BaseModel): id: int name: str description: Optional[str] = None + sdk_type: Optional[str] = None models: list[ModelDefinitionOut] = Field(default_factory=list) class AgentTypeCreate(BaseModel): name: str description: Optional[str] = None + sdk_type: Optional[str] = None class AgentTypeUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None + sdk_type: Optional[str] = None class ModelAddToType(BaseModel): @@ -103,10 +108,20 @@ def _build_agent_type_response(db: Session, agent_type: AgentTypeConfig) -> Agen id=agent_type.id, name=agent_type.name, description=agent_type.description, + sdk_type=agent_type.sdk_type, models=[ModelDefinitionOut.model_validate(m) for m in models], ) +def _normalize_sdk_type(value: str | None) -> str | None: + sdk_type = (value or "").strip() + if not sdk_type: + return None + if sdk_type not in VALID_SDK_TYPES: + raise HTTPException(status_code=400, detail="sdk_type 不支持,有效值:claude") + return sdk_type + + # --- Agent Type Endpoints --- @router.get("/types", response_model=list[AgentTypeOut]) @@ -151,6 +166,7 @@ def create_agent_type(body: AgentTypeCreate, db: Session = Depends(get_db), _use if existing: raise HTTPException(status_code=400, detail="该 Agent 类型已存在") agent_type = AgentTypeConfig(name=name, description=body.description) + agent_type.sdk_type = _normalize_sdk_type(body.sdk_type) db.add(agent_type) db.commit() db.refresh(agent_type) @@ -179,7 +195,9 @@ def update_agent_type(type_id: int, body: AgentTypeUpdate, db: Session = Depends if body.description is not None: agent_type.description = body.description.strip() or None - agent_type.updated_at = datetime.now(timezone.utc) + if "sdk_type" in body.model_fields_set: + agent_type.sdk_type = _normalize_sdk_type(body.sdk_type) + db.commit() db.refresh(agent_type) return _build_agent_type_response(db, agent_type) diff --git a/src/backend/routers/agents.py b/src/backend/routers/agents.py index 28611f9..c1b3ecf 100644 --- a/src/backend/routers/agents.py +++ b/src/backend/routers/agents.py @@ -10,6 +10,7 @@ from database import get_db from models import Agent, Project, ProjectPlan, Task, User, AgentTypeConfig, AgentTypeModelMap, ModelDefinition from auth import get_current_user +from services.agent_credentials import encrypt_api_key from services.project_agents import agent_ids_from_assignments_json router = APIRouter(prefix="/api/agents", tags=["agents"]) @@ -36,6 +37,8 @@ class AgentCreate(BaseModel): long_term_reset_at: Optional[datetime] = None long_term_reset_interval_days: Optional[int] = None long_term_reset_mode: str = "days" + api_base_url: Optional[str] = None + api_key: Optional[str] = None class AgentUpdate(BaseModel): @@ -53,6 +56,8 @@ class AgentUpdate(BaseModel): long_term_reset_at: Optional[datetime] = None long_term_reset_interval_days: Optional[int] = None long_term_reset_mode: Optional[str] = None + api_base_url: Optional[str] = None + api_key: Optional[str] = None class StatusUpdate(BaseModel): @@ -75,6 +80,9 @@ class AgentResponse(BaseModel): is_active: bool availability_status: str display_order: int = 0 + sdk_type: Optional[str] = None # from AgentTypeConfig: currently only "claude" + api_base_url: Optional[str] = None + has_api_key: bool = False subscription_expires_at: Optional[datetime] short_term_reset_at: Optional[datetime] short_term_reset_interval_hours: Optional[int] @@ -106,6 +114,7 @@ class AgentTypeCatalogResponse(BaseModel): id: int name: str description: Optional[str] = None + sdk_type: Optional[str] = None models: list[AgentTypeCatalogModel] = Field(default_factory=list) @@ -177,6 +186,7 @@ def _build_agent_type_catalog(db: Session, agent_type: AgentTypeConfig) -> Agent id=agent_type.id, name=agent_type.name, description=agent_type.description, + sdk_type=agent_type.sdk_type, models=models, ) @@ -246,6 +256,15 @@ def _normalize_agent_input(payload: dict) -> dict: for field in ("short_term_reset_at", "long_term_reset_at"): if field in payload: payload[field] = _normalize_beijing_datetime(payload[field]) + # Handle API credentials: encrypt key, normalize base URL + api_key = payload.pop("api_key", None) + if api_key and api_key.strip(): + payload["api_key_encrypted"] = encrypt_api_key(api_key) + else: + payload.pop("api_key_encrypted", None) + if "api_base_url" in payload: + raw = (payload["api_base_url"] or "").strip().rstrip("/") + payload["api_base_url"] = raw or None return payload @@ -273,6 +292,14 @@ def _normalize_agent_update_input(payload: dict) -> dict: raw_capability = payload.get("capability") payload["capability"] = raw_capability.strip() if raw_capability else None + # Handle API credentials: only update if explicitly provided + api_key = payload.pop("api_key", None) + if api_key is not None and api_key.strip(): + payload["api_key_encrypted"] = encrypt_api_key(api_key) + if "api_base_url" in payload: + raw = (payload["api_base_url"] or "").strip().rstrip("/") + payload["api_base_url"] = raw or None + return payload @@ -349,10 +376,15 @@ def _clear_confirmation_flags_on_manual_update(agent: Agent, update_data: dict): agent.long_term_reset_needs_confirmation = False -def _build_agent_response(agent: Agent, user: User, owner_roles: dict[int, str | None] | None = None) -> AgentResponse: +def _agent_type_config(db: Session, agent: Agent) -> AgentTypeConfig | None: + return db.query(AgentTypeConfig).filter(AgentTypeConfig.name == agent.agent_type).first() + + +def _build_agent_response(agent: Agent, user: User, owner_roles: dict[int, str | None] | None = None, agent_type: AgentTypeConfig | None = None) -> AgentResponse: roles = owner_roles or {} owner_role = roles.get(agent.created_by) if agent.created_by is not None else None public = owner_role == "admin" + type_config = agent_type return AgentResponse( id=agent.id, name=agent.name, @@ -365,6 +397,9 @@ def _build_agent_response(agent: Agent, user: User, owner_roles: dict[int, str | is_active=agent.is_active, availability_status=agent.availability_status, display_order=agent.display_order or 0, + sdk_type=type_config.sdk_type if type_config else None, + api_base_url=agent.api_base_url, + has_api_key=bool(agent.api_key_encrypted), subscription_expires_at=agent.subscription_expires_at, short_term_reset_at=agent.short_term_reset_at, short_term_reset_interval_hours=agent.short_term_reset_interval_hours, @@ -395,7 +430,12 @@ def list_agents(db: Session = Depends(get_db), user: User = Depends(get_current_ for agent in agents: db.refresh(agent) owner_roles = get_agent_owner_roles(db, agents) - return [_build_agent_response(agent, user, owner_roles) for agent in agents] + type_names = {agent.agent_type for agent in agents} + type_configs = { + item.name: item + for item in db.query(AgentTypeConfig).filter(AgentTypeConfig.name.in_(type_names)).all() + } if type_names else {} + return [_build_agent_response(agent, user, owner_roles, type_configs.get(agent.agent_type)) for agent in agents] @router.get("/config/types", response_model=list[AgentTypeCatalogResponse]) @@ -424,7 +464,12 @@ def reorder_agents(body: ReorderRequest, db: Session = Depends(get_db), user: Us for agent in agents: db.refresh(agent) owner_roles = get_agent_owner_roles(db, agents) - return [_build_agent_response(agent, user, owner_roles) for agent in agents] + type_names = {agent.agent_type for agent in agents} + type_configs = { + item.name: item + for item in db.query(AgentTypeConfig).filter(AgentTypeConfig.name.in_(type_names)).all() + } if type_names else {} + return [_build_agent_response(agent, user, owner_roles, type_configs.get(agent.agent_type)) for agent in agents] @router.post("", response_model=AgentResponse, status_code=status.HTTP_201_CREATED) @@ -448,7 +493,7 @@ def create_agent(body: AgentCreate, db: Session = Depends(get_db), user: User = db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.put("/{agent_id}", response_model=AgentResponse) @@ -468,7 +513,7 @@ def update_agent(agent_id: int, body: AgentUpdate, db: Session = Depends(get_db) db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.patch("/{agent_id}/status", response_model=AgentResponse) @@ -487,7 +532,7 @@ def update_agent_status(agent_id: int, body: StatusUpdate, db: Session = Depends db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.post("/{agent_id}/short-term-reset/reset", response_model=AgentResponse) @@ -502,7 +547,7 @@ def reset_short_term(agent_id: int, db: Session = Depends(get_db), user: User = db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.post("/{agent_id}/short-term-reset/confirm", response_model=AgentResponse) @@ -513,7 +558,7 @@ def confirm_short_term(agent_id: int, db: Session = Depends(get_db), user: User db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.post("/{agent_id}/long-term-reset/reset", response_model=AgentResponse) @@ -539,7 +584,7 @@ def reset_long_term(agent_id: int, db: Session = Depends(get_db), user: User = D db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.post("/{agent_id}/long-term-reset/confirm", response_model=AgentResponse) @@ -550,7 +595,7 @@ def confirm_long_term(agent_id: int, db: Session = Depends(get_db), user: User = db.commit() db.refresh(agent) owner_roles = get_agent_owner_roles(db, [agent]) - return _build_agent_response(agent, user, owner_roles) + return _build_agent_response(agent, user, owner_roles, _agent_type_config(db, agent)) @router.delete("/{agent_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/routers/plans.py b/src/backend/routers/plans.py index f9a2e5b..f90e2db 100644 --- a/src/backend/routers/plans.py +++ b/src/backend/routers/plans.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session @@ -17,6 +17,7 @@ from services.prompt_settings import get_plan_co_location_guidance from schemas import UtcDatetimeModel from services.project_agents import agent_ids_from_assignments_json +from services.auto_dispatch import dispatch_auto_tasks router = APIRouter(prefix="/api/projects", tags=["plans"]) @@ -537,7 +538,13 @@ def finalize_plan_record( @router.post("/{project_id}/plans/finalize") -def finalize_plan(project_id: int, body: FinalizeRequest, db: Session = Depends(get_db), user: User = Depends(get_current_user)): +def finalize_plan( + project_id: int, + body: FinalizeRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): project = get_owned_project(db, project_id, user) plan = db.query(ProjectPlan).filter( @@ -547,4 +554,6 @@ def finalize_plan(project_id: int, body: FinalizeRequest, db: Session = Depends( if not plan: raise HTTPException(status_code=404, detail="Plan not found") - return finalize_plan_record(db, project, plan, user) + result = finalize_plan_record(db, project, plan, user) + dispatch_auto_tasks(background_tasks, db, project_id=project_id) + return result diff --git a/src/backend/routers/projects.py b/src/backend/routers/projects.py index 0c4b1d4..29084aa 100644 --- a/src/backend/routers/projects.py +++ b/src/backend/routers/projects.py @@ -9,12 +9,12 @@ from access import get_owned_project, load_usable_agents from database import get_db -from models import Agent, Project, ProjectPlan, Task, TaskEvent, User +from models import Agent, AgentTypeConfig, Project, ProjectPlan, Task, TaskEvent, User from auth import get_current_user from schemas import UtcDatetimeModel from config import DEFAULT_MAX_REVIEW_ROUNDS from services.polling_config_service import get_global_polling_settings -from services.git_service import validate_git_url +from services.git_service import validate_git_url, delete_project_repo from services.project_agents import ( agent_ids_from_assignments_json, parse_agent_assignments_json, @@ -71,6 +71,7 @@ class ProjectCreate(BaseModel): default_max_review_rounds: Optional[int] = DEFAULT_MAX_REVIEW_ROUNDS planning_mode: str = DEFAULT_PLANNING_MODE template_inputs: Optional[object] = None + is_auto: bool = False class ProjectUpdate(BaseModel): @@ -90,6 +91,7 @@ class ProjectUpdate(BaseModel): default_max_review_rounds: Optional[int] = None planning_mode: Optional[str] = None template_inputs: Optional[object] = None + is_auto: Optional[bool] = None class ProjectResponse(UtcDatetimeModel): @@ -112,6 +114,7 @@ class ProjectResponse(UtcDatetimeModel): default_max_review_rounds: int planning_mode: str template_inputs: dict[str, str] + is_auto: bool = False agent_assignments: list[AgentAssignment] inactive_agent_ids: list[int] = Field(default_factory=list) @@ -195,6 +198,7 @@ def _build_project_response(db: Session, project: Project, next_step: Optional[s 'default_max_review_rounds': getattr(project, 'default_max_review_rounds', None) or DEFAULT_MAX_REVIEW_ROUNDS, 'planning_mode': _normalize_planning_mode(getattr(project, 'planning_mode', None)), 'template_inputs': _parse_template_inputs_json(getattr(project, 'template_inputs_json', None)), + 'is_auto': bool(getattr(project, 'is_auto', False)), 'inactive_agent_ids': _inactive_project_agent_ids(db, project), } if next_step is not None and task_summary is not None: @@ -202,6 +206,31 @@ def _build_project_response(db: Session, project: Project, next_step: Optional[s return ProjectResponse(**payload) +def _validate_agent_mode_compatibility(db: Session, agent_ids: list[int], is_auto: bool) -> None: + """Raise 400 if any agent's type execution mode doesn't match the project's execution mode.""" + if not agent_ids: + return + agents = db.query(Agent).filter(Agent.id.in_(agent_ids)).all() + type_names = {agent.agent_type for agent in agents} + type_configs = { + cfg.name: cfg + for cfg in db.query(AgentTypeConfig).filter(AgentTypeConfig.name.in_(type_names)).all() + } if type_names else {} + for agent in agents: + cfg = type_configs.get(agent.agent_type) + agent_is_auto = bool(cfg and cfg.sdk_type is not None) + if is_auto and not agent_is_auto: + raise HTTPException( + status_code=400, + detail=f"自动模式项目只能使用自动模式的 agent(agent id={agent.id} 为手动模式)", + ) + if not is_auto and agent_is_auto: + raise HTTPException( + status_code=400, + detail=f"手动模式项目只能使用手动模式的 agent(agent id={agent.id} 为自动模式)", + ) + + def _raise_unavailable_agent_error(agent_ids: list[int]) -> None: raise HTTPException( status_code=400, @@ -435,6 +464,8 @@ def create_project(body: ProjectCreate, db: Session = Depends(get_db), user: Use body.git_repo_url = _validate_required_git_repo_url(body.git_repo_url) project_repo_url = _normalize_optional_project_repo_url(body.project_repo_url) agent_assignments = _project_assignments_from_body(db, body, user) + agent_ids = [int(a.get("id")) for a in agent_assignments] + _validate_agent_mode_compatibility(db, agent_ids, body.is_auto) _validate_polling_params( body.polling_interval_min, body.polling_interval_max, @@ -470,6 +501,7 @@ def create_project(body: ProjectCreate, db: Session = Depends(get_db), user: Use default_max_review_rounds=_validate_default_max_review_rounds(body.default_max_review_rounds), planning_mode=_normalize_planning_mode(body.planning_mode), template_inputs_json=json.dumps(_normalize_template_inputs(body.template_inputs), ensure_ascii=False), + is_auto=body.is_auto, ) if project.created_by is None: raise HTTPException(status_code=500, detail="created_by must not be None") @@ -542,6 +574,16 @@ def update_project(project_id: int, body: ProjectUpdate, db: Session = Depends(g update_data['task_timeout_minutes'] = get_global_polling_settings(db)["task_timeout_minutes"] if 'planning_mode' in update_data: update_data['planning_mode'] = _normalize_planning_mode(update_data['planning_mode']) + + # Validate agent mode compatibility against the merged is_auto + if 'agent_ids_json' in update_data or 'is_auto' in update_data: + merged_is_auto = bool(update_data.get('is_auto', getattr(project, 'is_auto', False))) + if 'agent_ids_json' in update_data: + merged_agent_ids = [int(a["id"]) for a in json.loads(update_data['agent_ids_json'])] + else: + merged_agent_ids = _project_agent_ids(project) + _validate_agent_mode_compatibility(db, merged_agent_ids, merged_is_auto) + if 'default_max_review_rounds' in update_data: update_data['default_max_review_rounds'] = _validate_default_max_review_rounds(update_data['default_max_review_rounds']) if 'template_inputs' in update_data: @@ -581,4 +623,5 @@ def delete_project(project_id: int, db: Session = Depends(get_db), user: User = db.query(ProjectPlan).filter(ProjectPlan.project_id == project_id).delete(synchronize_session=False) db.delete(project) db.commit() + delete_project_repo(project_id) return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/routers/tasks.py b/src/backend/routers/tasks.py index f773e7d..4f4c6a7 100644 --- a/src/backend/routers/tasks.py +++ b/src/backend/routers/tasks.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session @@ -14,6 +14,13 @@ from services.path_service import ExpectedOutputPathError, normalize_expected_output_path from services.prompt_service import generate_task_prompt from services import git_service +from services.auto_dispatch import ( + DISPATCH_MODE_AUTO, + DISPATCH_MODE_MANUAL, + dispatch_auto_tasks, + is_auto_task, + run_auto_task, +) from services.issue_review_loop import ( get_effective_business_state, is_business_dispatch_allowed, @@ -38,6 +45,7 @@ class TaskResponse(UtcDatetimeModel): usage_file_path: Optional[str] last_error: Optional[str] timeout_minutes: Optional[int] + dispatch_mode: Optional[str] = None dispatched_at: Optional[datetime] completed_at: Optional[datetime] created_at: Optional[datetime] @@ -379,15 +387,17 @@ def dispatch_task( if not is_loop_dispatch and task.status not in ("pending", "needs_attention"): raise HTTPException(status_code=400, detail=f"Cannot dispatch task in status: {task.status}") - if not is_loop_dispatch: - _validate_dispatch_predecessors( - db, - task, - ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, - ) + _validate_dispatch_predecessors( + db, + task, + ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, + ) + if is_auto_task(db, task): + raise HTTPException(status_code=400, detail="Auto-mode tasks are dispatched automatically") now = datetime.now(timezone.utc) task.status = "running" + task.dispatch_mode = DISPATCH_MODE_MANUAL task.dispatched_at = now task.updated_at = now db.add(TaskEvent( @@ -397,12 +407,18 @@ def dispatch_task( )) db.commit() db.refresh(task) + return task # Mark complete @router.post("/api/tasks/{task_id}/mark-complete", response_model=TaskResponse) -def mark_complete(task_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): +def mark_complete( + task_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): task = get_owned_task(db, task_id, user) if task.status not in ("running", "needs_attention"): raise HTTPException(status_code=400, detail=f"Cannot mark complete a task in status: {task.status}") @@ -427,13 +443,19 @@ def mark_complete(task_id: int, db: Session = Depends(get_db), user: User = Depe project.updated_at = now db.commit() + dispatch_auto_tasks(background_tasks, db, project_id=task.project_id) db.refresh(task) return task # Abandon task @router.post("/api/tasks/{task_id}/abandon", response_model=TaskResponse) -def abandon_task(task_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): +def abandon_task( + task_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +): task = get_owned_task(db, task_id, user) if task.status in ("completed", "abandoned"): raise HTTPException(status_code=400, detail=f"Cannot abandon a task in status: {task.status}") @@ -456,6 +478,7 @@ def abandon_task(task_id: int, db: Session = Depends(get_db), user: User = Depen project.updated_at = now db.commit() + dispatch_auto_tasks(background_tasks, db, project_id=task.project_id) db.refresh(task) return task @@ -464,6 +487,7 @@ def abandon_task(task_id: int, db: Session = Depends(get_db), user: User = Depen @router.post("/api/tasks/{task_id}/redispatch", response_model=TaskResponse) def redispatch_task( task_id: int, + background_tasks: BackgroundTasks, body: TaskDispatchRequest = TaskDispatchRequest(), db: Session = Depends(get_db), user: User = Depends(get_current_user), @@ -474,17 +498,18 @@ def redispatch_task( if not is_loop_dispatch and task.status not in ("needs_attention", "running", "abandoned"): raise HTTPException(status_code=400, detail=f"Cannot redispatch task in status: {task.status}") - if not is_loop_dispatch: - _validate_dispatch_predecessors( - db, - task, - ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, - ) + auto_task = is_auto_task(db, task) + _validate_dispatch_predecessors( + db, + task, + ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, + ) now = datetime.now(timezone.utc) prev_status = task.status prev_error = task.last_error - task.status = "running" + task.status = "pending" if auto_task else "running" + task.dispatch_mode = DISPATCH_MODE_AUTO if auto_task else DISPATCH_MODE_MANUAL task.dispatched_at = now task.last_error = None task.updated_at = now @@ -507,4 +532,8 @@ def redispatch_task( db.commit() db.refresh(task) + + if auto_task: + background_tasks.add_task(run_auto_task, task.id) + return task diff --git a/src/backend/services/agent_credentials.py b/src/backend/services/agent_credentials.py new file mode 100644 index 0000000..d43f535 --- /dev/null +++ b/src/backend/services/agent_credentials.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken + +from config import settings + + +def _fernet() -> Fernet: + secret = (settings.AGENT_API_KEY_ENCRYPTION_SECRET or settings.SECRET_KEY).encode("utf-8") + digest = hashlib.sha256(secret).digest() + key = base64.urlsafe_b64encode(digest) + return Fernet(key) + + +def encrypt_api_key(api_key: str) -> str: + value = (api_key or "").strip() + if not value: + raise ValueError("API Key cannot be empty") + return _fernet().encrypt(value.encode("utf-8")).decode("utf-8") + + +def decrypt_api_key(encrypted_api_key: str | None) -> str | None: + if not encrypted_api_key: + return None + try: + return _fernet().decrypt(encrypted_api_key.encode("utf-8")).decode("utf-8") + except InvalidToken as exc: + raise ValueError("Stored API Key cannot be decrypted") from exc diff --git a/src/backend/services/agent_runner/__init__.py b/src/backend/services/agent_runner/__init__.py new file mode 100644 index 0000000..7958477 --- /dev/null +++ b/src/backend/services/agent_runner/__init__.py @@ -0,0 +1,4 @@ +from services.agent_runner.registry import run_task_for_agent +from services.agent_runner.base import AgentRunner, AgentRunContext + +__all__ = ["run_task_for_agent", "AgentRunner", "AgentRunContext"] diff --git a/src/backend/services/agent_runner/base.py b/src/backend/services/agent_runner/base.py new file mode 100644 index 0000000..eda84e5 --- /dev/null +++ b/src/backend/services/agent_runner/base.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from models import Agent, Project, Task + +logger = logging.getLogger(__name__) + + +@dataclass +class AgentRunContext: + task: Task + project: Project + agent: Agent + prompt: str + + +class AgentRunner(ABC): + """Abstract base class for SDK-backed agent runners. + + Design patterns + --------------- + Template Method + ``run()`` is the public template: it handles logging, timeout, and + sequencing. Subclasses supply the SDK-specific hooks + ``_ensure_ready()`` and ``_dispatch()``. + + Session lifecycle + ----------------- + Tools and permissions are fixed at construction time. A runner instance + maintains a persistent SDK client and session; calling ``run()`` on the + same object multiple times reuses the existing session — no new client is + created. + """ + + def __init__( + self, + model: str, + ) -> None: + self._model = model + + # ------------------------------------------------------------------ + # Template Method — public entrypoint + # ------------------------------------------------------------------ + + async def run(self, ctx: AgentRunContext) -> None: + """Execute the task prompt. + + On the first call the SDK client and session are initialised lazily. + Subsequent calls on the *same instance* reuse the existing session, + preserving conversational context. + """ + logger.info( + "%s starting task %s (model=%s)", + type(self).__name__, + ctx.task.task_code, + self._model, + ) + await self._ensure_ready(ctx) + await self._dispatch(ctx) + logger.info("%s finished task %s", type(self).__name__, ctx.task.task_code) + + # ------------------------------------------------------------------ + # Abstract hooks — implemented by each SDK runner + # ------------------------------------------------------------------ + + @abstractmethod + async def _ensure_ready(self, ctx: AgentRunContext) -> None: + """Lazily initialise the SDK client and obtain/resume the session. + + Must be idempotent: if the client and session are already live this + must be a no-op. + """ + + @abstractmethod + async def _dispatch(self, ctx: AgentRunContext) -> None: + """Send the task prompt and block until the agent becomes idle.""" + + @abstractmethod + async def close(self) -> None: + """Tear down the client and session. Called by the registry when the + runner is evicted or the process shuts down.""" + + # ------------------------------------------------------------------ + # Async context-manager support + # ------------------------------------------------------------------ + + async def __aenter__(self) -> AgentRunner: + return self + + async def __aexit__(self, *_: object) -> None: + await self.close() diff --git a/src/backend/services/agent_runner/claude_runner.py b/src/backend/services/agent_runner/claude_runner.py new file mode 100644 index 0000000..a74cc74 --- /dev/null +++ b/src/backend/services/agent_runner/claude_runner.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging + +from services import git_service +from services.agent_runner.base import AgentRunContext, AgentRunner +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKClient, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + UserMessage, +) + +logger = logging.getLogger(__name__) + + +class ClaudeRunner(AgentRunner): + def __init__( + self, + model: str, + api_base_url: str | None = None, + api_key: str | None = None, + ) -> None: + super().__init__(model) + self._api_base_url = api_base_url + self._api_key = api_key + self._client: ClaudeSDKClient | None = None + + # ------------------------------------------------------------------ + # Template-Method hooks + # ------------------------------------------------------------------ + + async def _ensure_ready(self, ctx: AgentRunContext) -> None: + """Start the Claude client once; idempotent on subsequent calls.""" + if self._client is not None: + return + + env: dict[str, str] = {} + if self._api_base_url: + env["ANTHROPIC_BASE_URL"] = self._api_base_url + if self._api_key: + env["ANTHROPIC_API_KEY"] = self._api_key + + proj_collab_dir = git_service._collab_dir(ctx.project.id) + proj_code_dir = git_service._code_dir(ctx.project.id) if ctx.project.project_repo_url else None + task_workspace_dir = git_service._task_workspace_dir(ctx.project.id, ctx.task.id) + + options = ClaudeAgentOptions( + model=self._model, + cwd=task_workspace_dir, + permission_mode="acceptEdits", # 自动批准工作目录内的文件编辑和常见 FS 操作 + allowed_tools=["Read", "Write", "Edit", "Bash", "Glob", "Grep", "LS"], # 预批准核心工具 + add_dirs=list({d for d in [ proj_collab_dir, proj_code_dir ] if d}), + sandbox={ + "enabled": True, + "autoAllowBashIfSandboxed": True, # Sandbox 内 Bash 自动批准 + "failIfUnavailable": True, # 可选:Sandbox 不可用时失败 + "network": { + "allowedDomains": [ + "gitee.com", + "*.gitee.com", + "github.com", + "*.github.com", + ] + }, + }, + env=env, + ) + self._client = ClaudeSDKClient(options=options) + await self._client.__aenter__() + logger.debug("ClaudeRunner: client started") + + async def _dispatch(self, ctx: AgentRunContext) -> None: + """Send the prompt and consume the response stream.""" + await self._client.query(ctx.prompt) + async for message in self._client.receive_response(): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, ThinkingBlock): + logger.debug( + "ClaudeRunner task %s thinking: %s", + ctx.task.task_code, + block.thinking, + ) + elif isinstance(block, ToolUseBlock): + logger.debug( + "ClaudeRunner task %s tool_call: %s(%s)", + ctx.task.task_code, + block.name, + block.input, + ) + elif isinstance(message, UserMessage): + content = message.content if isinstance(message.content, list) else [] + for block in content: + if isinstance(block, ToolResultBlock): + logger.debug( + "ClaudeRunner task %s tool_result: error=%s content=%s", + ctx.task.task_code, + block.is_error, + block.content, + ) + if hasattr(message, "result") and message.result: + logger.debug( + "ClaudeRunner task %s result: %s", + ctx.task.task_code, + message.result, + ) + + # ------------------------------------------------------------------ + # Resource teardown + # ------------------------------------------------------------------ + + async def close(self) -> None: + if self._client is not None: + try: + await self._client.__aexit__(None, None, None) + except Exception: + logger.debug("ClaudeRunner: client close failed", exc_info=True) + self._client = None diff --git a/src/backend/services/agent_runner/registry.py b/src/backend/services/agent_runner/registry.py new file mode 100644 index 0000000..8c23444 --- /dev/null +++ b/src/backend/services/agent_runner/registry.py @@ -0,0 +1,170 @@ +"""Runner registry — per-task agent execution with isolated git worktrees.""" +from __future__ import annotations + +import logging + +from database import SessionLocal +from models import Agent, AgentTypeConfig, Project, Task, TaskEvent +from services import git_service +from services.agent_credentials import decrypt_api_key +from services.agent_runner.base import AgentRunner, AgentRunContext +from services.prompt_service import generate_task_prompt + +logger = logging.getLogger(__name__) + +_DEFAULT_MODELS: dict[str, str] = { + "claude": "deepseek-v4-flash", +} + + +def _create_runner( + agent: Agent, + sdk_type: str, + api_base_url: str | None = None, + api_key: str | None = None, +) -> AgentRunner: + """Instantiate the concrete runner for the given sdk_type.""" + from services.agent_runner.claude_runner import ClaudeRunner + + effective_model = ( + (agent.model_name or "").strip() + or _DEFAULT_MODELS.get(sdk_type, "") + ) + + if sdk_type == "claude": + return ClaudeRunner(model=effective_model, api_base_url=api_base_url, api_key=api_key) + + raise ValueError( + f"Unsupported sdk_type {sdk_type!r} for agent {agent.id}. Available: ['claude']" + ) + + +def _mark_task_error(db, task: Task, message: str) -> None: + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + task.status = "needs_attention" + task.last_error = message + task.updated_at = now + db.add(TaskEvent(task_id=task.id, event_type="error", detail=message)) + db.commit() + + +async def run_task_for_agent(task_id: int, project_id: int) -> None: + """Background coroutine: run a task in an isolated per-task git worktree. + + For each execution: + 1. Sync the collaboration repo (git_repo_url). + 2. Sync the code repo (project_repo_url) if configured. + 3. Create a per-task workspace with a dedicated worktree branch. + 4. Run the agent; clean up workspace and runner regardless of outcome. + """ + db = SessionLocal() + task_workspace_dir: str | None = None + runner: AgentRunner | None = None + try: + task = db.query(Task).filter(Task.id == task_id).first() + project = db.query(Project).filter(Project.id == project_id).first() + if not task or not project: + logger.error( + "run_task_for_agent: task %s or project %s not found", task_id, project_id + ) + return + + agent = ( + db.query(Agent).filter(Agent.id == task.assignee_agent_id).first() + if task.assignee_agent_id + else None + ) + if not agent: + logger.error( + "run_task_for_agent: agent not found for task %s", task_id + ) + return + + # Look up sdk_type from AgentTypeConfig (type-level, not per-instance) + agent_type_config = db.query(AgentTypeConfig).filter(AgentTypeConfig.name == agent.agent_type).first() + effective_sdk_type = (agent_type_config.sdk_type or "").strip() if agent_type_config else "" + + if not effective_sdk_type: + logger.error( + "run_task_for_agent: sdk_type not configured for agent %s in task %s", agent.id, task_id + ) + return + + # --- Sync collaboration repo --- + sync_status = git_service.ensure_repo_sync(project_id, project.git_repo_url) + if sync_status.error: + msg = f"Git sync failed before agent execution: {sync_status.error}" + logger.error("Task %s: %s", task.task_code, msg) + _mark_task_error(db, task, msg) + return + + # --- Sync code repo (two-repo mode) --- + if project.project_repo_url: + code_sync = git_service.ensure_code_repo_sync(project_id, project.project_repo_url) + if code_sync.error: + msg = f"Code repo sync failed before agent execution: {code_sync.error}" + logger.error("Task %s: %s", task.task_code, msg) + _mark_task_error(db, task, msg) + return + + # --- Create per-task workspace with isolated worktree --- + workspace = None + try: + workspace = git_service.create_task_workspace(project_id, task_id) + task_workspace_dir = workspace.workspace_dir + except Exception as exc: + msg = f"Failed to create task workspace: {exc}" + logger.error("Task %s: %s", task.task_code, msg) + _mark_task_error(db, task, msg) + return + + prompt = generate_task_prompt( + db, project, task, + task_branch=workspace.task_branch, + default_branch=workspace.default_branch, + ) + ctx = AgentRunContext( + task=task, + project=project, + agent=agent, + prompt=prompt, + ) + + api_base_url = agent.api_base_url + api_key = decrypt_api_key(agent.api_key_encrypted) + runner = _create_runner(agent, effective_sdk_type, api_base_url=api_base_url, api_key=api_key) + + logger.info( + "Auto-executing task %s via sdk_type=%s (runner=%s)", + task.task_code, + effective_sdk_type, + type(runner).__name__, + ) + await runner.run(ctx) + logger.info("Auto-execution finished for task %s", task.task_code) + + except Exception as exc: + logger.exception("Auto-execution failed for task %s: %s", task_id, exc) + try: + fresh_db = SessionLocal() + try: + task = fresh_db.query(Task).filter(Task.id == task_id).first() + if task and task.status == "running": + _mark_task_error(fresh_db, task, f"Agent SDK execution error: {exc}") + finally: + fresh_db.close() + except Exception: + logger.exception( + "Failed to update error state for task %s after runner failure", task_id + ) + finally: + if runner is not None: + try: + await runner.close() + except Exception: + logger.debug("Runner close failed for task %s", task_id, exc_info=True) + if task_workspace_dir is not None: + git_service.delete_task_workspace(project_id, task_id) + db.close() + diff --git a/src/backend/services/auto_dispatch.py b/src/backend/services/auto_dispatch.py new file mode 100644 index 0000000..a5b10f2 --- /dev/null +++ b/src/backend/services/auto_dispatch.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from typing import Iterable + +from fastapi import BackgroundTasks +from sqlalchemy.orm import Session + +from database import SessionLocal +from models import Agent, AgentTypeConfig, Project, Task, TaskEvent +from services.agent_runner.registry import run_task_for_agent + +logger = logging.getLogger(__name__) + +DISPATCH_MODE_AUTO = "auto" +DISPATCH_MODE_MANUAL = "manual" +_running_auto_tasks: set[int] = set() +_running_auto_tasks_lock = asyncio.Lock() + + +def _task_dependency_codes(task: Task) -> list[str]: + try: + parsed = json.loads(task.depends_on_json or "[]") + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [str(item).strip() for item in parsed if str(item).strip()] + + +def is_task_ready(db: Session, task: Task) -> bool: + codes = _task_dependency_codes(task) + if not codes: + return True + predecessors = db.query(Task).filter( + Task.project_id == task.project_id, + Task.task_code.in_(codes), + ).all() + by_code = {item.task_code: item for item in predecessors} + return all( + (predecessor := by_code.get(code)) is not None + and predecessor.status in ("completed", "abandoned") + for code in codes + ) + + +def get_agent_type_config(db: Session, agent: Agent | None) -> AgentTypeConfig | None: + if not agent: + return None + return db.query(AgentTypeConfig).filter(AgentTypeConfig.name == agent.agent_type).first() + + +def is_auto_agent_type(agent_type: AgentTypeConfig | None) -> bool: + return bool(agent_type and agent_type.sdk_type) + + +def is_auto_task(db: Session, task: Task) -> bool: + if not task.assignee_agent_id: + return False + agent = db.query(Agent).filter(Agent.id == task.assignee_agent_id).first() + return is_auto_agent_type(get_agent_type_config(db, agent)) + + +def _event_detail(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=False, sort_keys=True) + + +def get_ready_auto_tasks( + db: Session, + *, + project_id: int | None = None, + task_ids: Iterable[int] | None = None, +) -> list[int]: + """Return IDs of pending auto tasks whose dependencies are satisfied. + + May mark tasks as 'needs_attention' when agent credentials are missing, + but never marks tasks as 'running' — that is deferred to run_auto_task. + """ + query = db.query(Task).filter(Task.status == "pending") + if project_id is not None: + query = query.filter(Task.project_id == project_id) + task_id_list = list(task_ids or []) + if task_id_list: + query = query.filter(Task.id.in_(task_id_list)) + + tasks = query.all() + + # Bulk-fetch agents and their type configs to avoid N+1 queries + agent_ids = {t.assignee_agent_id for t in tasks if t.assignee_agent_id} + agents_by_id: dict[int, Agent] = {} + agent_types_by_name: dict[str, AgentTypeConfig] = {} + if agent_ids: + agents_by_id = { + a.id: a + for a in db.query(Agent).filter(Agent.id.in_(agent_ids)).all() + } + agent_type_names = {a.agent_type for a in agents_by_id.values() if a.agent_type} + if agent_type_names: + agent_types_by_name = { + atc.name: atc + for atc in db.query(AgentTypeConfig) + .filter(AgentTypeConfig.name.in_(agent_type_names)) + .all() + } + + ready: list[int] = [] + needs_attention_found = False + now = datetime.now(timezone.utc) + for task in tasks: + if not task.assignee_agent_id: + continue + agent = agents_by_id.get(task.assignee_agent_id) + agent_type = agent_types_by_name.get(agent.agent_type) if agent and agent.agent_type else None + if not is_auto_agent_type(agent_type): + continue + if not agent.api_base_url or not agent.api_key_encrypted or not agent_type.sdk_type: + task.status = "needs_attention" + task.last_error = "Auto-dispatch agent is missing API credentials" + task.updated_at = now + db.add(TaskEvent( + task_id=task.id, + event_type="auto_dispatch_failed", + detail=_event_detail({ + "sdk_type": agent_type.sdk_type, + "status": "failed", + "error": task.last_error, + }), + )) + needs_attention_found = True + continue + if is_task_ready(db, task): + ready.append(task.id) + + if needs_attention_found: + db.commit() + return ready + + +def dispatch_auto_tasks( + background_tasks: BackgroundTasks, + db: Session, + *, + project_id: int | None = None, + task_ids: Iterable[int] | None = None, +) -> list[int]: + """Schedule ready auto tasks for parallel execution via BackgroundTasks.""" + ready_ids = get_ready_auto_tasks(db, project_id=project_id, task_ids=task_ids) + if ready_ids: + async def _run() -> None: + await asyncio.gather(*[run_auto_task(tid) for tid in ready_ids]) + background_tasks.add_task(_run) + return ready_ids + + +def _mark_task_error(db: Session, task: Task, message: str, agent_type: AgentTypeConfig | None = None) -> None: + now = datetime.now(timezone.utc) + task.status = "needs_attention" + task.last_error = message + task.updated_at = now + db.add(TaskEvent( + task_id=task.id, + event_type="auto_dispatch_failed", + detail=_event_detail({ + "sdk_type": agent_type.sdk_type if agent_type else None, + "status": "failed", + "error": message, + }), + )) + db.commit() + + +def _mark_task_completed(db: Session, task: Task, agent_type: AgentTypeConfig | None = None) -> None: + now = datetime.now(timezone.utc) + task.status = "completed" + task.completed_at = now + task.last_error = None + task.updated_at = now + db.add(TaskEvent( + task_id=task.id, + event_type="auto_dispatch_completed", + detail=_event_detail({ + "sdk_type": agent_type.sdk_type if agent_type else None, + "status": "succeeded", + }), + )) + db.commit() + + +def _complete_project_if_done(db: Session, project_id: int) -> None: + project = db.query(Project).filter(Project.id == project_id).first() + if not project or project.status != "executing": + return + all_tasks = db.query(Task).filter(Task.project_id == project.id).all() + if all_tasks and all(task.status in ("completed", "abandoned") for task in all_tasks): + project.status = "completed" + project.updated_at = datetime.now(timezone.utc) + db.commit() + + +async def run_auto_task(task_id: int) -> None: + async with _running_auto_tasks_lock: + if task_id in _running_auto_tasks: + return + _running_auto_tasks.add(task_id) + db = SessionLocal() + agent_type: AgentTypeConfig | None = None + project_id: int | None = None + try: + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + logger.error("Auto-dispatch task %s not found", task_id) + return + project_id = task.project_id + if task.status != "pending": + return + project = db.query(Project).filter(Project.id == task.project_id).first() + agent = ( + db.query(Agent).filter(Agent.id == task.assignee_agent_id).first() + if task.assignee_agent_id + else None + ) + agent_type = get_agent_type_config(db, agent) + if not project or not agent or not is_auto_agent_type(agent_type): + _mark_task_error(db, task, "Auto-dispatch task is missing project or auto agent config", agent_type) + return + + # Mark running immediately before handing off to the agent runner + now = datetime.now(timezone.utc) + task.status = "running" + task.dispatch_mode = DISPATCH_MODE_AUTO + task.dispatched_at = now + task.last_error = None + task.updated_at = now + db.add(TaskEvent( + task_id=task.id, + event_type="auto_dispatch_started", + detail=_event_detail({ + "sdk_type": agent_type.sdk_type, + "status": "started", + }), + )) + db.commit() + + await run_task_for_agent(task_id, project_id) + # run_task_for_agent uses its own DB session; re-fetch to see outcome + db.expire_all() + task = db.query(Task).filter(Task.id == task_id).first() + if task and task.status == "running": + _mark_task_completed(db, task, agent_type) + if project_id is not None: + next_ids = get_ready_auto_tasks(db, project_id=project_id) + _complete_project_if_done(db, project_id) + if next_ids: + # Run all newly-unlocked tasks concurrently. Each branch will + # independently chain its own downstream tasks as they complete, + # so converging nodes are dispatched as soon as all deps finish. + await asyncio.gather(*[run_auto_task(tid) for tid in next_ids]) + except Exception as exc: + logger.exception("Auto-dispatch failed for task %s: %s", task_id, exc) + try: + task = db.query(Task).filter(Task.id == task_id).first() + if task: + _mark_task_error(db, task, str(exc), agent_type) + except Exception: + logger.exception("Failed to record auto-dispatch error for task %s", task_id) + finally: + async with _running_auto_tasks_lock: + _running_auto_tasks.discard(task_id) + db.close() diff --git a/src/backend/services/git_service.py b/src/backend/services/git_service.py index 6661fcb..b2a6fbd 100644 --- a/src/backend/services/git_service.py +++ b/src/backend/services/git_service.py @@ -1,5 +1,6 @@ import json import os +import shutil import subprocess import configparser import time @@ -17,6 +18,8 @@ _ENSURE_REPO_TTL_SECONDS = 3.0 _ensure_repo_last_run: dict[int, float] = {} _ensure_repo_locks: dict[int, threading.Lock] = {} +_ensure_code_repo_last_run: dict[int, float] = {} +_ensure_code_repo_locks: dict[int, threading.Lock] = {} import logging @@ -44,6 +47,13 @@ ) +@dataclass(frozen=True) +class TaskWorkspace: + workspace_dir: str + task_branch: str + default_branch: str | None + + @dataclass(frozen=True) class RepoSyncStatus: repo_dir: str | None @@ -65,10 +75,26 @@ def validate_git_url(url: str) -> str: return value -def _repo_dir(project_id: int) -> str: +def _project_dir(project_id: int) -> str: + """Root workspace directory for a project: {REPOS_DIR}/{project_id}/""" return os.path.join(settings.REPOS_DIR, str(project_id)) +def _collab_dir(project_id: int) -> str: + """Collaboration repo checkout: {REPOS_DIR}/{project_id}/collab/""" + return os.path.join(settings.REPOS_DIR, str(project_id), "collab") + + +def _code_dir(project_id: int) -> str: + """Code repo checkout (two-repo mode): {REPOS_DIR}/{project_id}/code/""" + return os.path.join(settings.REPOS_DIR, str(project_id), "code") + + +def _task_workspace_dir(project_id: int, task_id: int) -> str: + """Per-task isolated workspace (auto mode): {REPOS_DIR}/{project_id}/tasks/{task_id}/""" + return os.path.join(settings.REPOS_DIR, str(project_id), "tasks", str(task_id)) + + def _project_lock(project_id: int) -> threading.Lock: lock = _ensure_repo_locks.get(project_id) if lock is None: @@ -77,6 +103,14 @@ def _project_lock(project_id: int) -> threading.Lock: return lock +def _code_lock(project_id: int) -> threading.Lock: + lock = _ensure_code_repo_locks.get(project_id) + if lock is None: + lock = threading.Lock() + _ensure_code_repo_locks[project_id] = lock + return lock + + def _safe_join(base: str, relative_path: str) -> str: """Join a relative path to base and reject any traversal outside base.""" base_real = os.path.realpath(base) @@ -86,11 +120,29 @@ def _safe_join(base: str, relative_path: str) -> str: return candidate +def _migrate_legacy_repo(project_id: int) -> None: + """Move a legacy repo from {project_id}/ directly to {project_id}/collab/ if needed.""" + project = _project_dir(project_id) + collab = _collab_dir(project_id) + if os.path.isdir(collab): + return # already migrated or freshly created + if not os.path.isdir(os.path.join(project, ".git")): + return # no legacy repo present + tmp = collab + ".migrating" + os.makedirs(tmp, exist_ok=True) + for item in os.listdir(project): + if item in ("collab", "collab.migrating", "code", "tasks"): + continue + os.rename(os.path.join(project, item), os.path.join(tmp, item)) + os.rename(tmp, collab) + logger.info("Migrated legacy repo for project %s to %s", project_id, collab) + + def clone_repo(project_id: int, git_repo_url: str) -> str: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if os.path.exists(repo_dir): return repo_dir - os.makedirs(settings.REPOS_DIR, exist_ok=True) + os.makedirs(os.path.dirname(repo_dir), exist_ok=True) subprocess.run( ["git", "clone", git_repo_url, repo_dir], check=True, @@ -112,7 +164,7 @@ def _run_git(repo_dir: str, args: list[str], *, timeout: int = 60) -> subprocess def pull_repo(project_id: int) -> str: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.exists(repo_dir): raise FileNotFoundError(f"Repo directory not found: {repo_dir}") _run_git(repo_dir, ["pull", "--ff-only"]) @@ -120,7 +172,7 @@ def pull_repo(project_id: int) -> str: def fetch_repo(project_id: int) -> str: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.exists(repo_dir): raise FileNotFoundError(f"Repo directory not found: {repo_dir}") _run_git(repo_dir, ["fetch", "--prune", "origin"]) @@ -172,7 +224,7 @@ def _is_shallow_repo(repo_dir: str) -> bool: def _unshallow_repo(project_id: int) -> tuple[bool, str | None]: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not _is_shallow_repo(repo_dir): return True, None return _retry_git_operation( @@ -182,7 +234,8 @@ def _unshallow_repo(project_id: int) -> tuple[bool, str | None]: def ensure_repo_sync(project_id: int, git_repo_url: str) -> RepoSyncStatus: - repo_dir = _repo_dir(project_id) + _migrate_legacy_repo(project_id) + repo_dir = _collab_dir(project_id) now = time.monotonic() lock = _project_lock(project_id) with lock: @@ -342,7 +395,7 @@ def _workspace_path(relative_path: str, git_repo_url: str | None) -> str | None: def _remote_head_ref(project_id: int) -> str | None: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.isdir(repo_dir): return None @@ -362,7 +415,7 @@ def _remote_head_ref(project_id: int) -> str | None: def _read_remote_file(project_id: int, relative_path: str) -> str | None: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.isdir(repo_dir): return None @@ -380,7 +433,7 @@ def _read_remote_file(project_id: int, relative_path: str) -> str | None: def _list_remote_dir(project_id: int, relative_path: str) -> list[str]: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.isdir(repo_dir): return [] ref = _remote_head_ref(project_id) @@ -395,7 +448,7 @@ def _list_remote_dir(project_id: int, relative_path: str) -> list[str]: def _remote_dir_has_content(project_id: int, relative_path: str) -> bool: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if not os.path.isdir(repo_dir): return False ref = _remote_head_ref(project_id) @@ -416,7 +469,7 @@ def read_file( *, prefer_remote: bool = False, ) -> str | None: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if prefer_remote: remote_content = _read_remote_file(project_id, relative_path) if remote_content is not None: @@ -468,7 +521,7 @@ def list_dir( prefer_remote: bool = False, ) -> list[str]: """List immediate entries in a repo subdirectory. Returns [] if not a dir.""" - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if prefer_remote: remote_entries = _list_remote_dir(project_id, relative_path) if remote_entries: @@ -500,7 +553,7 @@ def dir_has_content( prefer_remote: bool = False, ) -> bool: """True if relative_path is a directory containing at least one non-empty file.""" - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if prefer_remote and _remote_dir_has_content(project_id, relative_path): return True candidates: list[str] = [] @@ -532,7 +585,7 @@ def file_exists( *, prefer_remote: bool = False, ) -> bool: - repo_dir = _repo_dir(project_id) + repo_dir = _collab_dir(project_id) if prefer_remote and _read_remote_file(project_id, relative_path) is not None: return True try: @@ -547,3 +600,147 @@ def file_exists( return True return _read_remote_file(project_id, relative_path) is not None + + +# --------------------------------------------------------------------------- +# Code repo (project_repo_url) — auto mode only +# --------------------------------------------------------------------------- + +def ensure_code_repo_sync(project_id: int, code_repo_url: str) -> RepoSyncStatus: + """Ensure the code repository (project_repo_url) is cloned and up-to-date.""" + repo_dir = _code_dir(project_id) + lock = _code_lock(project_id) + with lock: + if os.path.exists(repo_dir): + fetched, fetch_error = _retry_git_operation( + "git fetch origin (code repo)", + lambda: _run_git(repo_dir, ["fetch", "--prune", "origin"]), + ) + if not fetched: + return RepoSyncStatus(repo_dir=repo_dir, fetched=False, remote_ready=False, error=fetch_error) + warnings: list[str] = [] + pulled, pull_error = _retry_git_operation( + "git pull --ff-only (code repo)", + lambda: _run_git(repo_dir, ["pull", "--ff-only"]), + ) + if pull_error: + warnings.append(pull_error) + return RepoSyncStatus(repo_dir=repo_dir, fetched=True, pulled=pulled, remote_ready=True, warnings=warnings) + + os.makedirs(os.path.dirname(repo_dir), exist_ok=True) + cloned, clone_error = _retry_git_operation( + "git clone (code repo)", + lambda: subprocess.run( + ["git", "clone", code_repo_url, repo_dir], + check=True, capture_output=True, text=True, timeout=120, + ), + ) + if not cloned: + return RepoSyncStatus(repo_dir=None, remote_ready=False, error=clone_error) + return RepoSyncStatus(repo_dir=repo_dir, fetched=True, pulled=True, remote_ready=True) + + +# --------------------------------------------------------------------------- +# Per-task workspace — auto mode +# --------------------------------------------------------------------------- + +def _get_default_branch(repo_dir: str) -> str | None: + """Return the remote default branch name (e.g. 'main' or 'master'). + + Uses the local tracking ref ``origin/HEAD`` which is set during + ``git clone`` — no network access required. + Returns *None* when the ref is absent or unparseable. + """ + try: + result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "origin/HEAD"], + cwd=repo_dir, + check=True, + capture_output=True, + text=True, + timeout=10, + ) + ref = result.stdout.strip() # e.g. "origin/main" + if ref and "/" in ref: + return ref.split("/", 1)[1] + return ref or None + except Exception: + logger.debug("Could not determine default branch for %s", repo_dir, exc_info=True) + return None + + +def create_task_workspace(project_id: int, task_id: int) -> TaskWorkspace: + """Create an isolated workspace for auto-mode task execution. + + Structure:: + + {project_dir}/tasks/{task_id}/ + code/ <- git worktree of the code repo (or collab repo in single-repo mode) + collab -> symlink ../../collab + + Returns the task workspace directory path. + """ + task_ws = _task_workspace_dir(project_id, task_id) + code_wt = os.path.join(task_ws, "code") + collab_link = os.path.join(task_ws, "collab") + + # Use code repo if present (two-repo mode), else collab repo (single-repo mode) + code = _code_dir(project_id) + base_repo = code if os.path.isdir(os.path.join(code, ".git")) else _collab_dir(project_id) + + os.makedirs(task_ws, exist_ok=True) + branch = f"task-{task_id}" + _run_git(base_repo, ["worktree", "add", "-b", branch, code_wt]) + logger.info("Created worktree for task %s at %s (branch=%s)", task_id, code_wt, branch) + + default_branch = _get_default_branch(base_repo) + + # Symlink collab repo into task workspace so agent sees both dirs + collab_target = os.path.relpath(_collab_dir(project_id), task_ws) + os.symlink(collab_target, collab_link) + + return TaskWorkspace( + workspace_dir=task_ws, + task_branch=branch, + default_branch=default_branch, + ) + + +def delete_task_workspace(project_id: int, task_id: int) -> None: + """Remove the task worktree and workspace directory.""" + task_ws = _task_workspace_dir(project_id, task_id) + code_wt = os.path.join(task_ws, "code") + + code = _code_dir(project_id) + base_repo = code if os.path.isdir(os.path.join(code, ".git")) else _collab_dir(project_id) + + try: + _run_git(base_repo, ["worktree", "remove", "--force", code_wt]) + except Exception: + logger.warning("Failed to remove worktree at %s", code_wt, exc_info=True) + + try: + _run_git(base_repo, ["worktree", "prune"]) + except Exception: + pass + + try: + shutil.rmtree(task_ws, ignore_errors=True) + except Exception: + logger.warning("Failed to remove task workspace at %s", task_ws, exc_info=True) + + +def delete_project_repo(project_id: int) -> None: + """Remove all on-disk git checkouts for a project. + + Cleans up {REPOS_DIR}/{project_id}/ entirely so that a future project + with the same ID cannot accidentally inherit a stale checkout. + """ + project_dir = _project_dir(project_id) + if not os.path.exists(project_dir): + return + try: + shutil.rmtree(project_dir) + logger.info("Deleted repo directory for project %s: %s", project_id, project_dir) + except Exception: + logger.warning("Failed to delete repo directory for project %s at %s", project_id, project_dir, exc_info=True) diff --git a/src/backend/services/polling_service.py b/src/backend/services/polling_service.py index e5d203b..4d32ced 100644 --- a/src/backend/services/polling_service.py +++ b/src/backend/services/polling_service.py @@ -279,6 +279,8 @@ def _delay_satisfied(dispatched_at) -> bool: ) for task in running_tasks: + if getattr(task, "dispatch_mode", None) == "auto": + continue # Skip polling this task if start delay has not elapsed yet if not _delay_satisfied(task.dispatched_at): logger.debug( diff --git a/src/backend/services/prompt_service.py b/src/backend/services/prompt_service.py index ea88343..39b0fef 100644 --- a/src/backend/services/prompt_service.py +++ b/src/backend/services/prompt_service.py @@ -350,6 +350,8 @@ def generate_task_prompt( project: Project, task: Task, include_usage: bool = False, # kept for API compat, no longer used + task_branch: str | None = None, + default_branch: str | None = None, ) -> str: collab = (project.collaboration_dir or "").strip("/") task_dir = f"{collab}/{task.task_code}" if collab else task.task_code @@ -379,6 +381,43 @@ def generate_task_prompt( else: predecessor_lines = "无前序任务输出" + # Build the code-repo push instruction depending on whether we're in a worktree. + if task_branch and default_branch: + code_push_instruction = ( + f"代码修改完成后,在项目代码仓库目录依次执行:\n" + f" 1. `git add .` 并 `git commit`\n" + f" 2. `git fetch origin`\n" + f" 3. `git rebase origin/{default_branch}`(如有冲突请智能解决后执行 `git rebase --continue`)\n" + f" 4. `git push origin HEAD:{default_branch}`\n" + f" 注意:当前处于 git worktree 隔离分支 `{task_branch}`,**不能执行 `git checkout {default_branch}`**," + f"必须通过 `HEAD:{default_branch}` refspec 提交代码。" + ) + # No need for `git pull` on the code repo — worktree was freshly created from the latest commit. + pre_steps = ( + "1. 在 HALF 协作仓库目录执行 `git pull`,确保拿到最新的远端状态,否则可能读不到前序任务输出。\n" + "2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" + ) + elif task_branch and not default_branch: + code_push_instruction = ( + f"代码修改完成后,在项目代码仓库目录依次执行:\n" + f" 1. `git add .` 并 `git commit`\n" + f" 2. `git fetch origin`\n" + f" 3. `git rebase origin/HEAD`(如有冲突请智能解决后执行 `git rebase --continue`)\n" + f" 4. `git push origin HEAD:$(git rev-parse --abbrev-ref origin/HEAD | sed 's|origin/||')`\n" + f" 注意:当前处于 git worktree 隔离分支 `{task_branch}`,**不能执行 `git checkout` 切换到主分支**," + f"必须通过 refspec 提交代码。" + ) + pre_steps = ( + "1. 在 HALF 协作仓库目录执行 `git pull`,确保拿到最新的远端状态,否则可能读不到前序任务输出。\n" + "2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" + ) + else: + code_push_instruction = "代码修改在项目代码仓库执行 git add、git commit、git push。" + pre_steps = ( + "1. 在开始本任务前,必须先在项目代码仓库目录执行 `git pull`;若 HALF 协作仓库与项目代码仓库不同,也必须在 HALF 协作仓库目录执行 `git pull`,确保拿到最新的远端状态,否则可能读不到前序任务输出。\n" + "2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" + ) + sections = [f"你是项目 [{project.name}] 的执行 Agent。"] if goal_text: sections.append(f"## 项目任务介绍\n{goal_text}") @@ -401,11 +440,12 @@ def generate_task_prompt( 3. 先写入临时文件 `result.json.tmp`,确认写完并 flush 后,再原子重命名为 `result.json` 4. `result.json` 必须是合法 JSON 对象,包含 `task_code`、`summary`、`artifacts`;`task_code` 必须为 `{task_code}`,`summary` 必须为非空字符串,`artifacts` 必须是仓库根相对路径字符串数组,不得使用绝对路径、反斜杠或 `..` 越界路径 5. 后续任务默认从前序任务目录及其中的 `result.json` 读取成果,不要依赖旧的单文件输出路径约定 -6. 代码修改在项目代码仓库执行 git add、git commit、git push;协作产物在 HALF 协作仓库执行 git add、git commit、git push。""".format(task_code=task.task_code) +6. {code_push_instruction} +7. 协作产物在 HALF 协作仓库执行 git add、git commit、git push。""".format(task_code=task.task_code, code_push_instruction=code_push_instruction) predecessor_check = ( - "2. 按本流程规则读取前序任务目录、`flow-state.json` 和当前轮次产物;不要要求中间轮次一定存在前序 `result.json`。" + "3. 按本流程规则读取前序任务目录、`flow-state.json` 和当前轮次产物;不要要求中间轮次一定存在前序 `result.json`。" if issue_review_loop_task - else "2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" + else "3. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" ) completion_sentinel = ( """## 完成哨兵约束 @@ -417,7 +457,7 @@ def generate_task_prompt( - 如果本任务没有代码改动,必须在报告和 `result.json` 中明确说明 `no_code_changes: true` 以及验证依据;不得只生成 `result.json` 冒充完成。""" ) sections.append(f"""## 执行前置步骤(必须先做) -1. 在开始本任务前,必须先在项目代码仓库目录执行 `git pull`;若 HALF 协作仓库与项目代码仓库不同,也必须在 HALF 协作仓库目录执行 `git pull`,确保拿到最新的远端状态,否则可能读不到前序任务输出。 +{pre_steps} {predecessor_check} ## 任务信息 diff --git a/src/backend/tests/test_auto_dispatch.py b/src/backend/tests/test_auto_dispatch.py new file mode 100644 index 0000000..cd61d95 --- /dev/null +++ b/src/backend/tests/test_auto_dispatch.py @@ -0,0 +1,786 @@ +"""Tests covering the acceptance criteria for the auto-dispatch agents PRD. + +AC-1 After plan finalization, auto tasks execute and complete without manual + intervention; project transitions to completed when all tasks finish. +AC-2 Auto agent with missing/invalid API credentials → task enters + needs_attention; no API key is exposed in responses or event logs. +AC-3 Mode mismatch between project and agent → backend returns 400. +AC-4 Multiple ready auto tasks are all returned for concurrent dispatch; + the _running_auto_tasks guard prevents duplicate execution of the same + task. +AC-5 All existing agents/projects default to manual mode; existing workflows + are unaffected. +AC-6 API Key never appears in any API response, event log, or task detail. +""" + +import asyncio +import json +import sys +import unittest +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import AsyncMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +import database +from auth import hash_password +from models import ( + Agent, + AgentTypeConfig, + Base, + GlobalSetting, + Project, + ProjectPlan, + Task, + TaskEvent, + User, +) +from routers import agent_settings as agent_settings_router +from routers import auth as auth_router +from routers import projects as projects_router +from services.agent_credentials import encrypt_api_key +from services.auto_dispatch import ( + _complete_project_if_done, + get_ready_auto_tasks, + is_auto_agent_type, + is_task_ready, + run_auto_task, +) + + +# --------------------------------------------------------------------------- +# Helpers shared across multiple test classes +# --------------------------------------------------------------------------- + +def _make_engine(): + return create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + +def _add_global_settings(db): + db.add_all([ + GlobalSetting(key="polling_interval_min", value="15"), + GlobalSetting(key="polling_interval_max", value="30"), + GlobalSetting(key="polling_start_delay_minutes", value="0"), + GlobalSetting(key="polling_start_delay_seconds", value="0"), + ]) + + +# --------------------------------------------------------------------------- +# AC-1 / AC-2 (service layer) — DAG readiness and credential validation +# --------------------------------------------------------------------------- + +class TestIsTaskReady(unittest.TestCase): + """Unit tests for the DAG dependency resolver used by auto-dispatch.""" + + def setUp(self): + engine = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(bind=engine) + self.db = sessionmaker(bind=engine)() + user = User(id=1, username="owner", password_hash=hash_password("Owner123")) + project = Project( + id=1, name="P", + git_repo_url="https://github.com/x/y", + collaboration_dir="o/1", + status="executing", + created_by=1, + ) + plan = ProjectPlan(id=1, project_id=1, status="final") + self.db.add_all([user, project, plan]) + self.db.commit() + self.addCleanup(self.db.close) + self._next_id = 1 + + def _task(self, code: str, status: str, depends_on: list | None = None) -> Task: + t = Task( + id=self._next_id, + project_id=1, plan_id=1, + task_code=code, task_name=code, + status=status, + depends_on_json=json.dumps(depends_on or []), + expected_output_path=f"o/1/{code}/result.json", + ) + self._next_id += 1 + self.db.add(t) + self.db.commit() + return t + + def test_no_deps_is_always_ready(self): + t = self._task("T1", "pending") + self.assertTrue(is_task_ready(self.db, t)) + + def test_completed_dep_makes_task_ready(self): + self._task("T1", "completed") + t2 = self._task("T2", "pending", depends_on=["T1"]) + self.assertTrue(is_task_ready(self.db, t2)) + + def test_abandoned_dep_makes_task_ready(self): + self._task("T1", "abandoned") + t2 = self._task("T2", "pending", depends_on=["T1"]) + self.assertTrue(is_task_ready(self.db, t2)) + + def test_pending_dep_blocks_task(self): + self._task("T1", "pending") + t2 = self._task("T2", "pending", depends_on=["T1"]) + self.assertFalse(is_task_ready(self.db, t2)) + + def test_running_dep_blocks_task(self): + self._task("T1", "running") + t2 = self._task("T2", "pending", depends_on=["T1"]) + self.assertFalse(is_task_ready(self.db, t2)) + + def test_partial_deps_block_task(self): + """AC-1: all predecessors must finish before downstream is ready.""" + self._task("T1", "completed") + self._task("T2", "pending") + t3 = self._task("T3", "pending", depends_on=["T1", "T2"]) + self.assertFalse(is_task_ready(self.db, t3)) + + +# --------------------------------------------------------------------------- + +class TestGetReadyAutoTasks(unittest.TestCase): + """Tests for get_ready_auto_tasks including credential and DAG checks.""" + + _VALID_KEY = "super-secret-key-for-testing" + + def setUp(self): + engine = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(bind=engine) + self.db = sessionmaker(bind=engine)() + user = User(id=1, username="owner", password_hash=hash_password("Owner123")) + project = Project( + id=1, name="P", + git_repo_url="https://github.com/x/y", + collaboration_dir="o/1", + status="executing", + is_auto=True, + created_by=1, + ) + plan = ProjectPlan(id=1, project_id=1, status="final") + + # Auto agent type — valid credentials + self.auto_type = AgentTypeConfig( + id=1, name="gpt-auto", + sdk_type="claude", + ) + # Auto agent type — missing credentials (sdk_type set but no key/url on instance) + self.no_creds_type = AgentTypeConfig( + id=2, name="gpt-no-creds", + sdk_type="claude", + ) + # Manual agent type (no sdk_type at all) + self.manual_type = AgentTypeConfig(id=3, name="manual-type") + + self.auto_agent = Agent( + id=1, name="Auto", slug="auto-1", agent_type="gpt-auto", created_by=1, + api_base_url="https://api.example.com/v1", + api_key_encrypted=encrypt_api_key(self._VALID_KEY), + ) + self.no_creds_agent = Agent( + id=2, name="NoCreds", slug="nocreds-1", agent_type="gpt-no-creds", created_by=1, + ) + self.manual_agent = Agent( + id=3, name="Manual", slug="manual-1", agent_type="manual-type", created_by=1 + ) + + self.db.add_all([ + user, project, plan, + self.auto_type, self.no_creds_type, self.manual_type, + self.auto_agent, self.no_creds_agent, self.manual_agent, + ]) + self.db.commit() + self.addCleanup(self.db.close) + self._task_seq = 1 + + def _task(self, code: str, agent_id: int, depends_on: list | None = None) -> Task: + t = Task( + id=self._task_seq, + project_id=1, plan_id=1, + task_code=code, task_name=code, + status="pending", + assignee_agent_id=agent_id, + depends_on_json=json.dumps(depends_on or []), + expected_output_path=f"o/1/{code}/result.json", + ) + self._task_seq += 1 + self.db.add(t) + self.db.commit() + return t + + def test_ready_task_returned_when_credentials_valid(self): + """AC-1: valid auto task appears in ready list.""" + t = self._task("T1", self.auto_agent.id) + ready = get_ready_auto_tasks(self.db, project_id=1) + self.assertIn(t.id, ready) + + def test_missing_credentials_marks_task_needs_attention(self): + """AC-2: missing credentials → task becomes needs_attention, not returned.""" + t = self._task("T1", self.no_creds_agent.id) + ready = get_ready_auto_tasks(self.db, project_id=1) + self.assertNotIn(t.id, ready) + self.db.refresh(t) + self.assertEqual(t.status, "needs_attention") + self.assertIsNotNone(t.last_error) + self.assertIn("API credentials", t.last_error) + + def test_missing_credentials_records_failed_event(self): + """AC-2: a task event with type auto_dispatch_failed is created.""" + t = self._task("T1", self.no_creds_agent.id) + get_ready_auto_tasks(self.db, project_id=1) + event = ( + self.db.query(TaskEvent) + .filter(TaskEvent.task_id == t.id) + .first() + ) + self.assertIsNotNone(event) + self.assertEqual(event.event_type, "auto_dispatch_failed") + + def test_failed_event_detail_does_not_contain_api_key(self): + """AC-6: error event detail must not leak any stored API key.""" + t = self._task("T1", self.no_creds_agent.id) + get_ready_auto_tasks(self.db, project_id=1) + events = self.db.query(TaskEvent).filter(TaskEvent.task_id == t.id).all() + for event in events: + self.assertNotIn(self._VALID_KEY, event.detail or "") + + def test_manual_agent_task_not_returned(self): + """AC-5: tasks assigned to manual agents are ignored by auto-dispatch.""" + self._task("T1", self.manual_agent.id) + ready = get_ready_auto_tasks(self.db, project_id=1) + self.assertEqual(ready, []) + + def test_blocked_task_not_returned(self): + """AC-1: task with unsatisfied dependency is not dispatched.""" + dep = self._task("T1", self.auto_agent.id) # pending + t2 = self._task("T2", self.auto_agent.id, depends_on=["T1"]) + ready = get_ready_auto_tasks(self.db, project_id=1) + self.assertIn(dep.id, ready) # T1 has no deps, it's ready + self.assertNotIn(t2.id, ready) # T2 is blocked + + def test_multiple_independent_ready_tasks_all_returned(self): + """AC-4: all ready tasks are returned so they can be dispatched in parallel.""" + t1 = self._task("T1", self.auto_agent.id) + t2 = self._task("T2", self.auto_agent.id) + ready = get_ready_auto_tasks(self.db, project_id=1) + self.assertIn(t1.id, ready) + self.assertIn(t2.id, ready) + + +# --------------------------------------------------------------------------- +# AC-1, AC-2, AC-4 (service layer) — run_auto_task state transitions +# --------------------------------------------------------------------------- + +class TestRunAutoTask(unittest.TestCase): + """Tests for run_auto_task — state machine and concurrent-dispatch guard.""" + + _VALID_KEY = "super-secret-key" + + def setUp(self): + engine = _make_engine() + Base.metadata.create_all(bind=engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + with self.SessionLocal() as db: + user = User(id=1, username="owner", password_hash=hash_password("Owner123")) + project = Project( + id=1, name="P", + git_repo_url="https://github.com/x/y", + collaboration_dir="o/1", + status="executing", + is_auto=True, + created_by=1, + ) + plan = ProjectPlan(id=1, project_id=1, status="final") + auto_type = AgentTypeConfig( + id=1, name="gpt-auto", + sdk_type="claude", + ) + auto_agent = Agent( + id=1, name="Auto", slug="auto-1", agent_type="gpt-auto", created_by=1, + api_base_url="https://api.example.com/v1", + api_key_encrypted=encrypt_api_key(self._VALID_KEY), + ) + db.add_all([user, project, plan, auto_type, auto_agent]) + db.commit() + + import services.auto_dispatch as _mod + self._orig_SessionLocal = _mod.SessionLocal + _mod.SessionLocal = self.SessionLocal + # Clean the in-progress guard set before each test + _mod._running_auto_tasks.clear() + self.addCleanup(setattr, _mod, "SessionLocal", self._orig_SessionLocal) + self.addCleanup(_mod._running_auto_tasks.clear) + self._task_seq = 1 + + def _add_task(self, code: str, depends_on: list | None = None) -> int: + with self.SessionLocal() as db: + t = Task( + id=self._task_seq, + project_id=1, plan_id=1, + task_code=code, task_name=code, + status="pending", + assignee_agent_id=1, + depends_on_json=json.dumps(depends_on or []), + expected_output_path=f"o/1/{code}/result.json", + ) + self._task_seq += 1 + db.add(t) + db.commit() + return t.id + + def test_successful_run_marks_task_completed(self): + """AC-1: pending → running → completed on successful runner invocation.""" + task_id = self._add_task("T1") + with patch("services.auto_dispatch.run_task_for_agent", new_callable=AsyncMock): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + task = db.query(Task).filter(Task.id == task_id).first() + self.assertEqual(task.status, "completed") + self.assertIsNotNone(task.completed_at) + + def test_successful_run_records_started_and_completed_events(self): + """AC-1: both auto_dispatch_started and auto_dispatch_completed events logged.""" + task_id = self._add_task("T1") + with patch("services.auto_dispatch.run_task_for_agent", new_callable=AsyncMock): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + events = db.query(TaskEvent).filter(TaskEvent.task_id == task_id).all() + event_types = {e.event_type for e in events} + self.assertIn("auto_dispatch_started", event_types) + self.assertIn("auto_dispatch_completed", event_types) + + def test_event_details_do_not_contain_api_key(self): + """AC-6: task events must never leak the stored API key.""" + task_id = self._add_task("T1") + with patch("services.auto_dispatch.run_task_for_agent", new_callable=AsyncMock): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + events = db.query(TaskEvent).filter(TaskEvent.task_id == task_id).all() + for event in events: + self.assertNotIn(self._VALID_KEY, event.detail or "") + + def test_runner_exception_marks_task_needs_attention(self): + """AC-2: runner failure → task enters needs_attention with error info.""" + task_id = self._add_task("T1") + with patch( + "services.auto_dispatch.run_task_for_agent", + new_callable=AsyncMock, + side_effect=RuntimeError("auth failed: 401 Unauthorized"), + ): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + task = db.query(Task).filter(Task.id == task_id).first() + self.assertEqual(task.status, "needs_attention") + self.assertIsNotNone(task.last_error) + self.assertIn("auth failed", task.last_error) + + def test_runner_exception_records_failed_event(self): + """AC-2: auto_dispatch_failed event recorded on runner error.""" + task_id = self._add_task("T1") + with patch( + "services.auto_dispatch.run_task_for_agent", + new_callable=AsyncMock, + side_effect=RuntimeError("connection timeout"), + ): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + events = db.query(TaskEvent).filter(TaskEvent.task_id == task_id).all() + failed = [e for e in events if e.event_type == "auto_dispatch_failed"] + self.assertGreater(len(failed), 0) + + def test_project_completed_when_all_tasks_finish(self): + """AC-1: project transitions to completed once all tasks are done.""" + task_id = self._add_task("T1") + with patch("services.auto_dispatch.run_task_for_agent", new_callable=AsyncMock): + asyncio.run(run_auto_task(task_id)) + with self.SessionLocal() as db: + project = db.query(Project).filter(Project.id == 1).first() + self.assertEqual(project.status, "completed") + + def test_running_guard_prevents_duplicate_dispatch(self): + """AC-4: _running_auto_tasks guard skips re-entrant execution of the same task.""" + import services.auto_dispatch as _mod + task_id = self._add_task("T1") + _mod._running_auto_tasks.add(task_id) + with patch( + "services.auto_dispatch.run_task_for_agent", new_callable=AsyncMock + ) as mock_runner: + asyncio.run(run_auto_task(task_id)) + mock_runner.assert_not_called() + + def test_dag_chain_downstream_task_dispatched_after_upstream_completes(self): + """AC-1: T2 is automatically dispatched once T1 completes.""" + t1_id = self._add_task("T1") + t2_id = self._add_task("T2", depends_on=["T1"]) + call_order: list[int] = [] + + async def _mock_runner(task_id: int, project_id: int) -> None: + call_order.append(task_id) + + with patch( + "services.auto_dispatch.run_task_for_agent", + new=AsyncMock(side_effect=_mock_runner), + ): + asyncio.run(run_auto_task(t1_id)) + + self.assertIn(t1_id, call_order) + self.assertIn(t2_id, call_order) + # T1 must have been dispatched before T2 + self.assertLess(call_order.index(t1_id), call_order.index(t2_id)) + + +# --------------------------------------------------------------------------- +# AC-3 — project/agent mode compatibility via HTTP API +# --------------------------------------------------------------------------- + +def _build_app(session_factory): + app = FastAPI() + app.include_router(auth_router.router) + app.include_router(projects_router.router) + + def override_db(): + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[database.get_db] = override_db + return app + + +class TestProjectAgentModeCompatibility(unittest.TestCase): + """AC-3: adding a mismatched-mode agent to a project must return HTTP 400.""" + + def setUp(self): + engine = _make_engine() + Base.metadata.create_all(bind=engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + self.client = TestClient(_build_app(self.SessionLocal)) + + with self.SessionLocal() as db: + user = User( + username="owner", + password_hash=hash_password("Owner123"), + role="user", + status="active", + ) + db.add(user) + db.flush() + self._user_id = user.id + + auto_type = AgentTypeConfig( + name="gpt-auto", + sdk_type="claude", + ) + manual_type = AgentTypeConfig(name="manual-type") + db.add_all([auto_type, manual_type]) + db.flush() + + self._auto_agent_id = self._seed_agent(db, user.id, "auto-a1", "gpt-auto") + self._manual_agent_id = self._seed_agent(db, user.id, "manual-a1", "manual-type") + _add_global_settings(db) + db.commit() + + def _seed_agent(self, db, user_id: int, slug: str, agent_type: str) -> int: + agent = Agent( + name=slug, slug=slug, + agent_type=agent_type, + availability_status="available", + subscription_expires_at=datetime.utcnow() + timedelta(days=30), + created_by=user_id, + ) + db.add(agent) + db.flush() + return agent.id + + def _login(self) -> dict: + r = self.client.post( + "/api/auth/login", + json={"username": "owner", "password": "Owner123"}, + ) + return {"Authorization": f"Bearer {r.json()['token']}"} + + def test_auto_project_with_manual_agent_returns_400(self): + """AC-3: auto project cannot include a manual-mode agent.""" + r = self.client.post( + "/api/projects", + json={ + "name": "Test", + "git_repo_url": "https://github.com/x/y", + "is_auto": True, + "agent_ids": [self._manual_agent_id], + }, + headers=self._login(), + ) + self.assertEqual(r.status_code, 400) + self.assertIn("手动模式", r.json()["detail"]) + + def test_manual_project_with_auto_agent_returns_400(self): + """AC-3: manual project cannot include an auto-mode agent.""" + r = self.client.post( + "/api/projects", + json={ + "name": "Test", + "git_repo_url": "https://github.com/x/y", + "is_auto": False, + "agent_ids": [self._auto_agent_id], + }, + headers=self._login(), + ) + self.assertEqual(r.status_code, 400) + self.assertIn("自动模式", r.json()["detail"]) + + def test_auto_project_with_auto_agent_accepted(self): + """AC-3: matching auto modes are accepted (positive case).""" + r = self.client.post( + "/api/projects", + json={ + "name": "Auto Project", + "git_repo_url": "https://github.com/x/y", + "is_auto": True, + "agent_ids": [self._auto_agent_id], + }, + headers=self._login(), + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(r.json()["is_auto"]) + + def test_manual_project_with_manual_agent_accepted(self): + """AC-3: matching manual modes are accepted (positive case).""" + r = self.client.post( + "/api/projects", + json={ + "name": "Manual Project", + "git_repo_url": "https://github.com/x/y", + "is_auto": False, + "agent_ids": [self._manual_agent_id], + }, + headers=self._login(), + ) + self.assertEqual(r.status_code, 201) + self.assertFalse(r.json()["is_auto"]) + + def test_update_project_with_mismatched_agent_returns_400(self): + """AC-3: updating a project to add a mode-mismatched agent returns 400.""" + headers = self._login() + # Create a valid auto project first + create_resp = self.client.post( + "/api/projects", + json={ + "name": "Auto", + "git_repo_url": "https://github.com/x/y", + "is_auto": True, + "agent_ids": [self._auto_agent_id], + }, + headers=headers, + ) + self.assertEqual(create_resp.status_code, 201) + project_id = create_resp.json()["id"] + + # Try to swap to a manual agent + r = self.client.put( + f"/api/projects/{project_id}", + json={"agent_ids": [self._manual_agent_id]}, + headers=headers, + ) + self.assertEqual(r.status_code, 400) + + +# --------------------------------------------------------------------------- +# AC-5 — backward compatibility defaults +# --------------------------------------------------------------------------- + +class TestBackwardCompatibility(unittest.TestCase): + """AC-5: all legacy agents and projects default to manual (non-auto) mode.""" + + def setUp(self): + engine = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(bind=engine) + self.db = sessionmaker(bind=engine)() + user = User(id=1, username="owner", password_hash=hash_password("Owner123")) + self.db.add(user) + self.db.commit() + self.addCleanup(self.db.close) + + def test_project_defaults_to_manual_mode(self): + """AC-5: Project.is_auto is False by default.""" + project = Project( + name="Legacy", + git_repo_url="https://github.com/x/y", + collaboration_dir="o/legacy", + status="draft", + created_by=1, + ) + self.db.add(project) + self.db.commit() + self.db.refresh(project) + self.assertFalse(project.is_auto) + + def test_agent_type_without_sdk_type_is_manual(self): + """AC-5: AgentTypeConfig without sdk_type is treated as manual by is_auto_agent_type.""" + manual_type = AgentTypeConfig(name="legacy-type") + self.assertFalse(is_auto_agent_type(manual_type)) + + def test_agent_type_with_sdk_type_is_auto(self): + """Positive check: sdk_type present → is_auto_agent_type returns True.""" + auto_type = AgentTypeConfig(name="auto-type", sdk_type="claude") + self.assertTrue(is_auto_agent_type(auto_type)) + + def test_none_agent_type_config_is_not_auto(self): + """AC-5: None agent type config is treated as manual.""" + self.assertFalse(is_auto_agent_type(None)) + + def test_manual_agent_tasks_excluded_from_auto_dispatch(self): + """AC-5: tasks assigned to legacy manual agents are never auto-dispatched.""" + project = Project( + id=2, name="Legacy", + git_repo_url="https://github.com/x/y", + collaboration_dir="o/2", + status="executing", + created_by=1, + ) + plan = ProjectPlan(id=2, project_id=2, status="final") + manual_type = AgentTypeConfig(id=10, name="legacy-manual") # no sdk_type + agent = Agent( + id=10, name="LegacyAgent", slug="legacy-1", + agent_type="legacy-manual", created_by=1, + ) + task = Task( + project_id=2, plan_id=2, + task_code="T1", task_name="T1", + status="pending", + assignee_agent_id=10, + depends_on_json="[]", + expected_output_path="o/2/T1/result.json", + ) + self.db.add_all([project, plan, manual_type, agent, task]) + self.db.commit() + ready = get_ready_auto_tasks(self.db, project_id=2) + self.assertEqual(ready, []) + + +# --------------------------------------------------------------------------- +# AC-6 — API key never exposed in HTTP responses +# --------------------------------------------------------------------------- + +class TestApiKeyNotExposed(unittest.TestCase): + """AC-6: raw API Key must never appear in any API response payload.""" + + _SECRET = "ultra-secret-api-key-must-not-leak" + + def setUp(self): + engine = _make_engine() + Base.metadata.create_all(bind=engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + app = FastAPI() + app.include_router(auth_router.router) + app.include_router(agent_settings_router.router) + + def override_db(): + db = self.SessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[database.get_db] = override_db + self.client = TestClient(app) + + with self.SessionLocal() as db: + admin = User( + username="admin", + password_hash=hash_password("Admin123"), + role="admin", + status="active", + ) + db.add(admin) + db.commit() + + def _admin_headers(self) -> dict: + r = self.client.post( + "/api/auth/login", + json={"username": "admin", "password": "Admin123"}, + ) + return {"Authorization": f"Bearer {r.json()['token']}"} + + def _create_auto_type(self, headers: dict, name: str = "gpt-auto") -> dict: + r = self.client.post( + "/api/agent-settings/types", + json={ + "name": name, + "sdk_type": "claude", + }, + headers=headers, + ) + self.assertEqual(r.status_code, 201) + return r.json() + + def test_create_response_does_not_contain_raw_api_key(self): + """AC-6: POST /api/agent-settings/types response must not expose any key.""" + headers = self._admin_headers() + data = self._create_auto_type(headers) + self.assertNotIn(self._SECRET, json.dumps(data)) + self.assertNotIn("api_key_encrypted", data) + self.assertNotIn("api_key", data) + + def test_list_response_does_not_contain_raw_api_key(self): + """AC-6: GET /api/agent-settings/types response must not expose the key.""" + headers = self._admin_headers() + self._create_auto_type(headers) + r = self.client.get("/api/agent-settings/types", headers=headers) + self.assertEqual(r.status_code, 200) + self.assertNotIn(self._SECRET, json.dumps(r.json())) + + def test_update_response_does_not_contain_raw_api_key(self): + """AC-6: PUT /api/agent-settings/types/{id} response must not expose any key.""" + headers = self._admin_headers() + data = self._create_auto_type(headers) + type_id = data["id"] + r = self.client.put( + f"/api/agent-settings/types/{type_id}", + json={"description": "updated"}, + headers=headers, + ) + self.assertEqual(r.status_code, 200) + payload = json.dumps(r.json()) + self.assertNotIn(self._SECRET, payload) + self.assertNotIn("api_key_encrypted", r.json()) + + def test_agent_type_out_schema_exposes_only_has_api_key_flag(self): + """AC-6: AgentTypeOut has no raw key fields; has_api_key flag lives on AgentResponse.""" + from routers.agent_settings import AgentTypeOut + type_fields = set(AgentTypeOut.model_fields.keys()) + self.assertNotIn("api_key", type_fields) + self.assertNotIn("api_key_encrypted", type_fields) + self.assertNotIn("has_api_key", type_fields) + # The has_api_key flag is on the agent instance response + from routers.agents import AgentResponse + agent_fields = set(AgentResponse.model_fields.keys()) + self.assertNotIn("api_key", agent_fields) + self.assertNotIn("api_key_encrypted", agent_fields) + self.assertIn("has_api_key", agent_fields) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/backend/tests/test_git_service.py b/src/backend/tests/test_git_service.py index 0a6e81f..040e220 100644 --- a/src/backend/tests/test_git_service.py +++ b/src/backend/tests/test_git_service.py @@ -117,7 +117,7 @@ def setUp(self): self.base_dir = Path(self.temp_dir.name) self.repos_dir = self.base_dir / "repos" self.workspace_dir = self.base_dir / "workspace" - (self.repos_dir / "3").mkdir(parents=True) + (self.repos_dir / "3" / "collab").mkdir(parents=True) (self.workspace_dir / "outputs" / "proj-3").mkdir(parents=True) self.original_repos_dir = settings.REPOS_DIR @@ -176,7 +176,7 @@ def test_ensure_repo_keeps_existing_checkout_when_pull_fails(self): ) as mock_pull: repo_dir = git_service.ensure_repo(3, "git@github.com:example-org/example-repo.git") - self.assertEqual(repo_dir, str(self.repos_dir / "3")) + self.assertEqual(repo_dir, str(self.repos_dir / "3" / "collab")) mock_fetch.assert_called_once_with(3) mock_pull.assert_called_once_with(3) diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py index 1f4e773..f1b0d57 100644 --- a/src/backend/tests/test_issue_review_loop.py +++ b/src/backend/tests/test_issue_review_loop.py @@ -14,6 +14,7 @@ from auth import hash_password from database import Base from models import ProcessTemplate, Project, ProjectPlan, Task, User +from fastapi.background import BackgroundTasks from routers.tasks import TaskDispatchRequest, dispatch_task, redispatch_task from services.issue_review_loop import ( DEFAULT_REVIEW_PROMPT, @@ -304,7 +305,7 @@ def test_running_loop_task_requires_redispatch(self): self.assertIn("Cannot dispatch running task", str(ctx.exception)) with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): - updated = redispatch_task(task_3.id, TaskDispatchRequest(), self.db, self.user) + updated = redispatch_task(task_3.id, BackgroundTasks(), TaskDispatchRequest(), self.db, self.user) self.assertEqual(updated.status, "running") diff --git a/src/backend/tests/test_plan_finalize_validation.py b/src/backend/tests/test_plan_finalize_validation.py index e6e19fb..8473ef5 100644 --- a/src/backend/tests/test_plan_finalize_validation.py +++ b/src/backend/tests/test_plan_finalize_validation.py @@ -3,7 +3,7 @@ import unittest from pathlib import Path -from fastapi import HTTPException +from fastapi import BackgroundTasks, HTTPException from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -59,19 +59,18 @@ def _seed_plan(self, expected_output: str) -> tuple[Project, ProjectPlan]: def test_finalize_plan_rejects_action_phrase_expected_output(self): _project, plan = self._seed_plan("代码变更提交") with self.assertRaises(HTTPException) as ctx: - finalize_plan(20, FinalizeRequest(plan_id=plan.id), self.db, self.user) - self.assertEqual(ctx.exception.status_code, 400) + finalize_plan(20, FinalizeRequest(plan_id=plan.id), BackgroundTasks(), db=self.db, user=self.user) self.assertIn("invalid expected_output", ctx.exception.detail) self.assertIn("action phrase", ctx.exception.detail) def test_finalize_plan_accepts_path_with_trailing_human_description(self): _project, plan = self._seed_plan("outputs/proj-20/result.json,请按以下格式写入") - response = finalize_plan(20, FinalizeRequest(plan_id=plan.id), self.db, self.user) + response = finalize_plan(20, FinalizeRequest(plan_id=plan.id), BackgroundTasks(), db=self.db, user=self.user) self.assertEqual(response["tasks_created"], 1) def test_finalize_plan_writes_project_task_timeout_to_tasks(self): _project, plan = self._seed_plan("outputs/proj-20/result.json") - response = finalize_plan(20, FinalizeRequest(plan_id=plan.id), self.db, self.user) + response = finalize_plan(20, FinalizeRequest(plan_id=plan.id), BackgroundTasks(), db=self.db, user=self.user) self.assertEqual(response["tasks_created"], 1) from models import Task task = self.db.query(Task).filter(Task.project_id == 20).one() diff --git a/src/backend/tests/test_task_predecessor_status.py b/src/backend/tests/test_task_predecessor_status.py index ca4221a..3bbab85 100644 --- a/src/backend/tests/test_task_predecessor_status.py +++ b/src/backend/tests/test_task_predecessor_status.py @@ -14,6 +14,7 @@ from database import Base from auth import hash_password from models import Project, ProjectPlan, Task, TaskEvent, User +from fastapi import BackgroundTasks from routers.tasks import ( TaskDispatchRequest, _compute_predecessor_status, @@ -173,7 +174,7 @@ def test_dispatch_does_not_check_predecessor_files_on_server(self): with patch("routers.tasks.git_service.ensure_repo") as mock_ensure, patch( "routers.tasks.git_service.file_exists", return_value=False, - ): + ), patch("routers.tasks.dispatch_auto_tasks"): dispatched = dispatch_task(task.id, TaskDispatchRequest(), self.db, self.user) self.assertEqual(dispatched.status, "running") mock_ensure.assert_not_called() @@ -185,8 +186,8 @@ def test_redispatch_archives_last_error_into_event_detail(self): with patch("routers.tasks.git_service.ensure_repo"), patch( "routers.tasks.git_service.file_exists", return_value=True, - ): - updated = redispatch_task(task.id, TaskDispatchRequest(), self.db, self.user) + ), patch("routers.tasks.dispatch_auto_tasks"): + updated = redispatch_task(task.id, BackgroundTasks(), TaskDispatchRequest(), self.db, self.user) self.assertEqual(updated.status, "running") self.assertIsNone(updated.last_error) event = ( @@ -201,8 +202,8 @@ def test_redispatch_does_not_check_predecessor_files_on_server(self): with patch("routers.tasks.git_service.ensure_repo") as mock_ensure, patch( "routers.tasks.git_service.file_exists", return_value=False, - ): - updated = redispatch_task(task.id, TaskDispatchRequest(), self.db, self.user) + ), patch("routers.tasks.dispatch_auto_tasks"): + updated = redispatch_task(task.id, BackgroundTasks(), TaskDispatchRequest(), self.db, self.user) self.assertEqual(updated.status, "running") mock_ensure.assert_not_called() @@ -211,7 +212,7 @@ def test_mark_complete_clears_last_error(self): task.last_error = "boom: timeout" self.db.commit() - updated = mark_complete(task.id, self.db, self.user) + updated = mark_complete(task.id, BackgroundTasks(), self.db, self.user) self.assertEqual(updated.status, "completed") self.assertIsNone(updated.last_error) diff --git a/src/backend/uv.lock b/src/backend/uv.lock index 7a3061c..34a57fc 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "bcrypt" version = "4.0.1" @@ -61,6 +70,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.1.80" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/06/0984d8bc2f0f7b05aca2005461d587f9d04d8009fc4a2d333dec1c2f3164/claude_agent_sdk-0.1.80.tar.gz", hash = "sha256:1938d376cd6db273583266b184fc9caf53779841f131bf3fe308014707536019", size = 250299, upload-time = "2026-05-09T06:44:58.202Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4d/fc78dae356a43126d0142921a73254f371b359b0508d5af046c43bc680bf/claude_agent_sdk-0.1.80-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a26cfea92029f1e3bcc468657e9bbb464a7bf04519b528ec0182ede3415a311", size = 60909658, upload-time = "2026-05-09T06:45:01.651Z" }, + { url = "https://files.pythonhosted.org/packages/aa/08/586c98a59d30bea43d83a9db7f8468a24affd2f7d3721a0dd010bf4784c8/claude_agent_sdk-0.1.80-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:09f025305524e909c8ee190e73ad319b0d14f2c5d2d1ec567995c1eb833f4de7", size = 62949213, upload-time = "2026-05-09T06:45:04.848Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a8/e7825005610e711fdebcc5c82c5de2214bb967f1cf5a14edd50ef16e0bc0/claude_agent_sdk-0.1.80-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:be269e118cce52b638b17232f2a52ce4d0877218672261d987cc20b4e5d9c83a", size = 70625763, upload-time = "2026-05-09T06:45:07.899Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dd/a754eed2ab4f8437aac52d4d321e28c4d8bfd6ca126b5179b441aa7aeadf/claude_agent_sdk-0.1.80-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:653fb53600777c253885f9536c17da19d12f9d7fedd5e419c522854f1089449a", size = 70806172, upload-time = "2026-05-09T06:45:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a8/3b27d7aa434b471a3100d158c7e709d09e7be0179a6c34be27def3ffaa1f/claude_agent_sdk-0.1.80-py3-none-win_amd64.whl", hash = "sha256:51ecfc32257201fc2cb6c061ba4d78e27b789a736fd5ed1e6ec0af60fd5d81aa", size = 71422151, upload-time = "2026-05-09T06:45:15.043Z" }, +] + [[package]] name = "click" version = "8.3.3" @@ -82,6 +166,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + [[package]] name = "fastapi" version = "0.136.1" @@ -152,6 +289,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "bcrypt" }, + { name = "claude-agent-sdk" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, { name = "json-repair" }, @@ -160,7 +299,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-multipart" }, { name = "sqlalchemy" }, - { name = "uvicorn" }, + { name = "uvicorn", extra = ["standard"] }, ] [package.dev-dependencies] @@ -171,6 +310,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "bcrypt", specifier = "==4.0.1" }, + { name = "claude-agent-sdk", specifier = ">=0.1.80" }, + { name = "cryptography", specifier = ">=48.0.0" }, { name = "fastapi", specifier = ">=0.136.1" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "json-repair", specifier = ">=0.59.5" }, @@ -179,7 +320,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-multipart", specifier = ">=0.0.27" }, { name = "sqlalchemy", specifier = ">=2.0.49" }, - { name = "uvicorn", specifier = ">=0.46.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.46.0" }, ] [package.metadata.requires-dev] @@ -198,6 +339,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -213,6 +383,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -240,6 +419,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/aa/0529dee460b745b93f6abc97b56b7527314c5167ba29ab7a5bd5c08de01f/json_repair-0.59.5-py3-none-any.whl", hash = "sha256:6869965bd1cc1aaaa04dc85865c26fbb76d9a2d83a20010f5eae2563b1567827", size = 47282, upload-time = "2026-04-24T11:41:36.653Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -272,6 +503,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.3" @@ -362,6 +602,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/eb/9d89ad2d9b0ba8cd65393d434471621b98912abb10fbe1df08e480ba57b5/pydantic_core-2.46.3-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd35aa21299def8db7ef4fe5c4ff862941a9a158ca7b63d61e66fe67d30416b4", size = 2137657, upload-time = "2026-04-20T14:42:45.149Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -380,6 +634,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -414,6 +673,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" }, ] +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.49" @@ -460,6 +885,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/82/10cdfab4ab663a6b6bd624d33f55b2cfa41af5105be033a6d5d135a92c5f/sse_starlette-3.4.2.tar.gz", hash = "sha256:2f9a7f51ed84395a0427fb9f66cb1ec11f7899d977a72cbc9070b962a2e14489", size = 35236, upload-time = "2026-05-06T19:42:13.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/27/351c71e803c56090d8d3bf9520422debeb8ed938871fd4f7ef519805a6c5/sse_starlette-3.4.2-py3-none-any.whl", hash = "sha256:6ea5d35b7ce979a3de5a0db5f77fe886b1616e4b3e1ad93fba502bd9b5fb662f", size = 16516, upload-time = "2026-05-06T19:42:12.201Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -506,3 +944,177 @@ sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e wheels = [ { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, ] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/src/frontend/src/components/TaskDetailPanel.tsx b/src/frontend/src/components/TaskDetailPanel.tsx index 6d93958..5ce05c5 100644 --- a/src/frontend/src/components/TaskDetailPanel.tsx +++ b/src/frontend/src/components/TaskDetailPanel.tsx @@ -245,6 +245,13 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR
{assignee ? `${assignee.name} (${assignee.agent_type}${assignee.model_name ? ` / ${assignee.model_name}` : ''})` : (task.assignee_agent_id ? `Agent #${task.assignee_agent_id}` : '未指派')}
+ {assignee && ( +{isAuto ? '没有已配置自动执行 SDK 的 Agent。请先在智能体设置中为某个 Agent 类型配置自动执行。' : '没有手动执行模式的 Agent。'}
+