From ad4bd3e25f0ad4203b11e547335f9609933428dc Mon Sep 17 00:00:00 2001 From: chenjie Date: Tue, 2 Jun 2026 18:10:50 +0800 Subject: [PATCH 01/86] changed gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 344937d93..59fa2c7cc 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ tmp/ # Documentation docs/_build/ +docs/tmp site/ # Node.js (TUI) From 060e981e3bd424da914b88b107af02d9dd9132ff Mon Sep 17 00:00:00 2001 From: chenjie Date: Wed, 3 Jun 2026 13:47:53 +0800 Subject: [PATCH 02/86] fix(webui): avoid misrouting absolute paths as slash commands Reject slash command names containing path separators so absolute file paths are sent via prompt_async, and add focused tests to prevent regressions in parser and chat routing. Co-authored-by: Cursor --- .../components/common/CommandDropdown.test.ts | 17 +++++++++++ .../src/components/common/CommandDropdown.tsx | 13 ++++++--- .../src/components/common/SessionChat.test.ts | 28 ++++++++++++++++++- webui/src/components/common/SessionChat.tsx | 13 ++++++--- 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 webui/src/components/common/CommandDropdown.test.ts diff --git a/webui/src/components/common/CommandDropdown.test.ts b/webui/src/components/common/CommandDropdown.test.ts new file mode 100644 index 000000000..ab1b57a98 --- /dev/null +++ b/webui/src/components/common/CommandDropdown.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { parseSlashCommand } from './CommandDropdown'; + +describe('parseSlashCommand', () => { + it('parses normal slash commands', () => { + expect(parseSlashCommand('/help')).toEqual({ command: 'help', args: '' }); + expect(parseSlashCommand('/plan build the thing')).toEqual({ + command: 'plan', + args: 'build the thing', + }); + }); + + it('does not treat absolute filesystem paths as commands', () => { + expect(parseSlashCommand('/tmp/workspace/workflow.md')).toBeNull(); + expect(parseSlashCommand('/tmp/rex_integration_guide.md\n\nuse this')).toBeNull(); + }); +}); diff --git a/webui/src/components/common/CommandDropdown.tsx b/webui/src/components/common/CommandDropdown.tsx index 114996104..2138ce0fa 100644 --- a/webui/src/components/common/CommandDropdown.tsx +++ b/webui/src/components/common/CommandDropdown.tsx @@ -89,16 +89,21 @@ export default function CommandDropdown({ * 从输入文本中解析 slash 命令的名称和参数 * 例如 "/plan create a feature" → { command: "plan", args: "create a feature" } */ +export function isSlashCommandName(command: string): boolean { + return command.length > 0 && !/[\\/]/.test(command); +} + export function parseSlashCommand(text: string): { command: string; args: string } | null { const trimmed = text.trim(); if (!trimmed.startsWith('/')) return null; const withoutSlash = trimmed.slice(1); const spaceIndex = withoutSlash.indexOf(' '); - if (spaceIndex === -1) { - return { command: withoutSlash, args: '' }; + const command = spaceIndex === -1 ? withoutSlash : withoutSlash.slice(0, spaceIndex); + if (!isSlashCommandName(command)) { + return null; } return { - command: withoutSlash.slice(0, spaceIndex), - args: withoutSlash.slice(spaceIndex + 1).trim(), + command, + args: spaceIndex === -1 ? '' : withoutSlash.slice(spaceIndex + 1).trim(), }; } diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index e26218a32..ab9a8f6c7 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -461,6 +461,32 @@ describe('SessionChat agent mentions', () => { }); }); +describe('SessionChat slash command routing', () => { + it('sends absolute filesystem paths as normal prompts instead of slash commands', async () => { + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + })); + + const text = '/tmp/stream_alert_denoise/rex_integration_guide.md\n\nuse this file'; + const textarea = screen.getByPlaceholderText('请输入消息') as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: text } }); + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }); + + await waitFor(() => { + expect(clientPostMock).toHaveBeenCalledWith( + '/api/session/sess-1/prompt_async', + expect.objectContaining({ + parts: [{ type: 'text', text }], + }), + ); + }); + expect(clientPostMock).not.toHaveBeenCalledWith( + '/api/session/sess-1/command', + expect.anything(), + ); + }); +}); + describe('truncateToolDisplayText', () => { it('returns short text unchanged', () => { expect(truncateToolDisplayText('bash')).toBe('bash'); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index fe648e249..799f94e8f 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'; import LoadingSpinner from './LoadingSpinner'; import { QuestionTool } from './QuestionTool'; import DelegateTaskCard, { isDelegateTool, shouldRenderDelegateTaskCard } from './DelegateTaskCard'; -import CommandDropdown, { parseSlashCommand } from './CommandDropdown'; +import CommandDropdown, { isSlashCommandName, parseSlashCommand } from './CommandDropdown'; import ImageLightbox from './ImageLightbox'; import { useSessionMessages } from '@/hooks/useSessions'; import { useSSE, type SSEConnectionStatus } from '@/hooks/useSSE'; @@ -2415,15 +2415,20 @@ export default function SessionChat({ const cursor = e.target.selectionStart ?? val.length; const mention = mentionAgents.length > 0 ? findMentionTrigger(val, cursor) : null; const trimmed = val.trimStart(); + const slashQuery = trimmed.startsWith('/') ? trimmed.slice(1) : ''; if (mention && !trimmed.startsWith('/')) { setMentionRange({ start: mention.start, end: mention.end }); setMentionQuery(mention.query); setSelectedMentionIndex(0); setShowCommandDropdown(false); - } else if (trimmed.startsWith('/') && !trimmed.includes(' ') && successfulAttachments.length === 0) { + } else if ( + trimmed.startsWith('/') && + !trimmed.includes(' ') && + (slashQuery === '' || isSlashCommandName(slashQuery)) && + successfulAttachments.length === 0 + ) { void loadCommandsIfNeeded(); - const q = trimmed.slice(1); - setCommandQuery(q); + setCommandQuery(slashQuery); setSelectedCommandIndex(0); setShowCommandDropdown(true); setMentionRange(null); From 544fe6a5680b4ab34d137118f20ff5442a763bf3 Mon Sep 17 00:00:00 2001 From: luguili Date: Wed, 1 Jul 2026 16:25:44 +0800 Subject: [PATCH 03/86] =?UTF-8?q?feat:edr=E5=A2=9E=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/skills/sangfor-edr-use/SKILL.md | 15 +- .../references/cdp-workflow.md | 37 +- .../plugins/skills/skyeye-sensor-use/SKILL.md | 36 +- .../scripts/requirements.txt | 1 + .../scripts/skyeye_sensor_auth.py | 515 ++++++ .../scripts/skyeye_sensor_cli.py | 77 +- .../device/sangfor_edr_webcli/_provider.yaml | 92 + .../device/sangfor_edr_webcli/_test.yaml | 15 + .../sangfor_edr_webcli/sangfor_edr.handler.py | 670 +++++++ .../sangfor_edr_webcli/sangfor_edr_auth.yaml | 55 + pyproject.toml | 1 + uv.lock | 1609 +++++++++-------- 12 files changed, 2323 insertions(+), 800 deletions(-) create mode 100644 .flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py create mode 100644 .flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml create mode 100644 .flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml create mode 100644 .flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py create mode 100644 .flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml diff --git a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md index 10e0e956a..dcd64ab83 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md @@ -5,7 +5,18 @@ description: 用于处理深信服 EDR(终端检测与响应)相关任务, # 深信服 EDR Use -## First +## First + +### 登录态处理规则 + +当用户需要打开深信服 EDR 页面,或需要通过 Web2CLI 抓取 EDR 页面请求时,必须先确认用户是否愿意提供 EDR 设备的访问地址、用户名和密码: + +- **用户不提供账密**:沿用原有浏览器登录流程。打开 EDR 页面后由用户在可视化浏览器中手动登录,登录成功后保存完整浏览器登录态(包括 cookies、localStorage 等)。 +- **用户提供账密**:优先调用 `sangfor_edr_auth`,保存 EDR 访问地址、用户名和密码(密码必须进入 secret 存储),使用 `action=ensure_auth_state` 或 `action=refresh_auth_state` 通过 browser daemon / CDP 驱动真实登录页自动登录,并保存完整浏览器登录态 `auth-state.json`。 + +无论采用哪种方式,只要获得可用登录态,就继续原有浏览器 / Web2CLI 流程:加载登录态、打开目标 EDR 页面、按需注入 Web2CLI hook、执行页面操作并导出捕获到的请求。后续再次打开页面时,必须先校验 `auth-state.json`;若登录态失效且已保存账密,则自动重新走 CDP 登录并刷新 state。 + +若自动登录过程中出现验证码识别失败、MFA 校验、页面选择器变化、未检测到登录成功或有效 `sessionid` 等情况,立即回退到原有浏览器手动登录流程。 > ⚠️ **EDR 没有开放 API**,所有操作必须通过浏览器(CDP 直连)完成。 @@ -81,4 +92,4 @@ powershell -Command "& '\Scripts\python.exe' '\skil - ✅ 正确:`/bin/python`(Unix)或 `\Scripts\python.exe`(Windows) - ❌ 禁止:`python script.py` / `python3 script.py`(直接调用 PATH 中的 Python) -**原因**:Flocks 虚拟环境包含了所有项目依赖,系统 Python 可能缺少必要的包。完整跨平台示例见上一节"执行示例"。 \ No newline at end of file +**原因**:Flocks 虚拟环境包含了所有项目依赖,系统 Python 可能缺少必要的包。完整跨平台示例见上一节"执行示例"。 diff --git a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md index f6a59c81f..a5b30f34b 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md +++ b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md @@ -42,8 +42,11 @@ google-chrome --remote-debugging-port=9222 chromium --remote-debugging-port=9222 ``` -### 3. 登录 EDR -确保用户在 Chrome 中已登录 EDR(如需 MFA,完成认证)。 +### 3. 登录 EDR +登录态来源有两种,必须先向用户确认是否愿意提供 EDR 访问地址、用户名和密码: + +- 用户不提供账密:由用户在 Chrome 中手动登录 EDR(如需 MFA,完成认证),登录成功后保存浏览器 state。 +- 用户提供账密:调用 `sangfor_edr_auth` 保存账密,并通过 browser daemon / CDP 驱动真实 EDR 登录页自动登录。验证码图片在浏览器会话中获取,OCR 识别后填入页面;登录成功后保存浏览器 state。 --- @@ -64,13 +67,24 @@ flocks browser --doctor flocks browser --setup ``` -### Step 3:用户打开目标页面 -用户在 Chrome 中打开: -``` -{EDR_URL}/ui/#/index -``` - -### Step 4:执行抓取脚本 +### Step 3:准备登录态 +优先检查固定 state 文件: +``` +~/.flocks/browser/sangfor-edr/auth-state.json +``` + +如果 state 不存在或已失效: + +- 未保存账密:提示用户在 Chrome 中手动登录,登录成功后执行 `flocks browser state save ~/.flocks/browser/sangfor-edr/auth-state.json`。 +- 已保存账密:调用 `sangfor_edr_auth`,使用 `action=ensure_auth_state` 或 `action=refresh_auth_state` 自动打开真实登录页、识别验证码、填入账密并保存新的 state。 + +### Step 4:打开目标页面 +用户或工具在 Chrome 中打开: +``` +{EDR_URL}/ui/#/index +``` + +### Step 5:执行抓取脚本 **工具脚本路径**(位于 skill references 目录): ``` @@ -175,7 +189,8 @@ print(text_result["result"]["result"]["value"]) | `Target.getTargets` 返回空 | 浏览器未开启 remote debugging | 用户执行 `chrome.exe --remote-debugging-port=9222` | | EDR tab 未找到 | 页面未打开或 URL 不匹配 | 确保 Chrome 中打开了 EDR 首页 | | 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | -| 页面显示登录框 | 会话已失效 | 告知用户重新登录 EDR | +| 页面显示登录框 | 会话已失效 | 若已保存账密,调用 `sangfor_edr_auth` 自动刷新 state;否则提示用户手动登录 EDR | +| 自动登录失败 | 验证码识别失败、MFA、页面选择器变化或登录成功检测失败 | 回退到浏览器手动登录,登录后保存 state | --- @@ -204,4 +219,4 @@ powershell -Command "& '\Scripts\python.exe' '\skil ```bash # macOS / Linux "/bin/python" "/skills/sangfor-edr-use/references/fetch_edr_system_state.py" --url "https://edr.example.com/" -``` \ No newline at end of file +``` diff --git a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md index de4612965..fa0bdec45 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md +++ b/.flocks/plugins/skills/skyeye-sensor-use/SKILL.md @@ -48,7 +48,12 @@ browser: not connected — 请确保 Chrome / Chromium / Edge 已打开,然后 3. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 4. 只有随后 `--doctor` 通过后,才继续后面的登录或页面操作。 -打开登录页并等待用户手动完成登录(含短信验证码 / MFA 等): +登录态来源有两种,必须先确认用户是否愿意提供 SkyEye Sensor 地址、用户名和密码: + +- 用户不提供账密:沿用原有浏览器登录流程。打开登录页后由用户手动完成登录(含短信验证码 / MFA 等),登录成功后保存 state。 +- 用户提供账密:调用 `scripts/skyeye_sensor_auth.py`,通过 browser daemon / CDP 驱动真实登录页自动登录。验证码图片地址为 `{BASE_URL}/skyeye/admin/code`,脚本会在浏览器会话中获取验证码图片、OCR 识别、填入页面并保存 state。 + +手动登录时,打开登录页并等待用户完成登录: ```bash flocks browser -c ' @@ -66,6 +71,35 @@ print(page_info()) flocks browser state save ~/.flocks/browser/skyeye-sensor/auth-state.json ``` +提供账密时,优先执行自动登录: + +```bash +SKYEYE_SENSOR_BASE_URL=https:// \ +uv run python scripts/skyeye_sensor_auth.py ensure \ + --username '' \ + --password '' +``` + +脚本会将用户名/密码写入 Flocks secret,并将基础配置保存到 `~/.flocks/browser/skyeye-sensor/auth-config.json`。下次 `auth-state.json` 失效时,可直接执行: + +```bash +uv run python scripts/skyeye_sensor_auth.py ensure +``` + +也可以在查询命令前直接带账密或使用已保存账密刷新登录态: + +```bash +uv run python scripts/skyeye_sensor_cli.py \ + --base-url https:// \ + --username '' \ + --password '' \ + alarm count + +uv run python scripts/skyeye_sensor_cli.py --auto-login alarm list +``` + +如果验证码 OCR、MFA、登录页 DOM 变化或登录成功检测失败,回退到上面的手动登录流程。 + ### CLI 认证失败时的恢复流程 当 CLI 出现以下任一情况,优先判定为认证问题(**不要立刻要求用户重新登录**): diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt b/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt index d44cde4fa..5576e8c57 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/requirements.txt @@ -1,3 +1,4 @@ click>=8.0.0 requests>=2.28.0 rich>=13.0.0 +ddddocr>=1.6.1 diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py new file mode 100644 index 000000000..769d70fe5 --- /dev/null +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_auth.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""SkyEye Sensor browser auth-state helper. + +This script follows the skill's existing browser workflow and only automates +the manual login step when credentials are available: + +1. load and validate the saved browser auth-state; +2. if it is missing or expired, open the real login page with CDP; +3. wait for the async-rendered login form; +4. fetch and OCR the captcha in the browser session; +5. fill the form, submit it, and save a refreshed auth-state. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import random +import sys +import time +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin, urlparse + +from flocks.browser import helpers + +AUTH_DIR = Path.home() / ".flocks" / "browser" / "skyeye-sensor" +DEFAULT_AUTH_STATE = AUTH_DIR / "auth-state.json" +AUTH_CONFIG = AUTH_DIR / "auth-config.json" +USERNAME_SECRET_ID = "skyeye_sensor_username" +PASSWORD_SECRET_ID = "skyeye_sensor_password" +DEFAULT_LOGIN_PATH = "/login" +DEFAULT_CAPTCHA_PATH = "/skyeye/admin/code" +DEFAULT_TIMEOUT = 25 + + +def _get_secret_manager(): + from flocks.security import get_secret_manager + + return get_secret_manager() + + +def _resolve_ref(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return _get_secret_manager().get(value[len("{secret:") : -1]) + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1]) + return value + + +def _normalise_base_url(value: str) -> str: + candidate = value.strip() + if not candidate: + raise ValueError("SkyEye Sensor base_url is required.") + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + raise ValueError(f"Invalid SkyEye Sensor base_url: {value!r}") + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{host}{port}".rstrip("/") + + +def _read_config() -> dict[str, Any]: + if not AUTH_CONFIG.exists(): + return {} + try: + data = json.loads(AUTH_CONFIG.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _write_config(data: dict[str, Any]) -> None: + AUTH_DIR.mkdir(parents=True, exist_ok=True) + AUTH_CONFIG.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _persist_inputs(args: argparse.Namespace, config: dict[str, Any]) -> dict[str, Any]: + if args.base_url: + config["base_url"] = _normalise_base_url(args.base_url) + if args.auth_state: + config["auth_state_path"] = str(Path(args.auth_state).expanduser()) + if args.login_path: + config["login_path"] = args.login_path + if args.captcha_path: + config["captcha_path"] = args.captcha_path + + if args.save_credentials: + secrets = _get_secret_manager() + if args.username: + secrets.set(USERNAME_SECRET_ID, args.username) + config["username"] = f"{{secret:{USERNAME_SECRET_ID}}}" + if args.password: + secrets.set(PASSWORD_SECRET_ID, args.password) + config["password"] = f"{{secret:{PASSWORD_SECRET_ID}}}" + if args.base_url or args.auth_state or args.username or args.password: + _write_config(config) + return config + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + auth_state_path: Path, + username: str, + password: str, + login_path: str, + captcha_path: str, + timeout: int, + auto_ocr: bool, + max_captcha_retry: int, + ) -> None: + self.base_url = base_url + self.auth_state_path = auth_state_path + self.username = username + self.password = password + self.login_path = login_path + self.captcha_path = captcha_path + self.timeout = timeout + self.auto_ocr = auto_ocr + self.max_captcha_retry = max_captcha_retry + + +def _runtime_config(args: argparse.Namespace) -> RuntimeConfig: + config = _persist_inputs(args, _read_config()) + secrets = _get_secret_manager() + + base_url = _normalise_base_url( + args.base_url + or _resolve_ref(config.get("base_url")) + or os.getenv("SKYEYE_SENSOR_BASE_URL") + or "" + ) + auth_state_path = Path( + args.auth_state + or _resolve_ref(config.get("auth_state_path")) + or os.getenv("SKYEYE_SENSOR_AUTH_STATE") + or DEFAULT_AUTH_STATE + ).expanduser() + username = ( + args.username + or _resolve_ref(config.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or os.getenv("SKYEYE_SENSOR_USERNAME") + or "" + ).strip() + password = ( + args.password + or _resolve_ref(config.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or os.getenv("SKYEYE_SENSOR_PASSWORD") + or "" + ).strip() + + return RuntimeConfig( + base_url=base_url, + auth_state_path=auth_state_path, + username=username, + password=password, + login_path=args.login_path or str(config.get("login_path") or DEFAULT_LOGIN_PATH), + captcha_path=args.captcha_path or str(config.get("captcha_path") or DEFAULT_CAPTCHA_PATH), + timeout=max(5, int(args.timeout or DEFAULT_TIMEOUT)), + auto_ocr=not args.no_ocr, + max_captcha_retry=max(1, int(args.max_captcha_retry or 5)), + ) + + +def _url(cfg: RuntimeConfig, path: str) -> str: + return urljoin(cfg.base_url + "/", path.lstrip("/")) + + +def _login_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.login_path) + + +def _open_page(url: str) -> None: + try: + helpers.open_or_attach_tab(url) + except Exception: + helpers.goto_url(url) + helpers.wait_for_load(timeout=15) + + +def _page_text() -> str: + try: + return str(helpers.js("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + + +def _has_auth_cookie(cfg: RuntimeConfig) -> bool: + try: + cookies = helpers.cdp("Network.getCookies", urls=[cfg.base_url]).get("cookies", []) + except Exception: + return False + if not isinstance(cookies, list): + return False + names = {str(cookie.get("name") or "") for cookie in cookies if isinstance(cookie, dict)} + return bool(names & {"csrfToken", "sessionid", "JSESSIONID", "PHPSESSID"}) + + +def _looks_like_login_page(url: str, text: str) -> bool: + haystack = f"{url}\n{text}".lower() + return any(marker in haystack for marker in ("login", "请输入用户名", "请输入密码", "请输入验证码", "验证码")) + + +def _is_logged_in(cfg: RuntimeConfig) -> bool: + info = helpers.page_info() + current_url = str(info.get("url") or "") + if _looks_like_login_page(current_url, _page_text()): + return False + return _has_auth_cookie(cfg) or cfg.base_url.rstrip("/") in current_url + + +def validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + if not cfg.auth_state_path.exists(): + return { + "valid": False, + "reason": "auth_state_not_found", + "auth_state_path": str(cfg.auth_state_path), + } + try: + loaded = helpers.load_state(cfg.auth_state_path, url=cfg.base_url) + helpers.wait_for_load(timeout=15) + if _is_logged_in(cfg): + return { + "valid": True, + "reason": "browser_state_loaded", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + return { + "valid": False, + "reason": "auth_state_expired_or_login_page", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + except Exception as exc: + return { + "valid": False, + "reason": "auth_state_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + } + + +def _login_dom_summary() -> Any: + script = """(() => { + return Array.from(document.querySelectorAll("input,button,a,img,iframe")) + .slice(0, 80) + .map((el) => ({ + tag: el.tagName, + id: el.id || "", + name: el.getAttribute("name") || "", + type: el.getAttribute("type") || "", + placeholder: el.getAttribute("placeholder") || "", + text: (el.innerText || el.value || el.getAttribute("alt") || "").trim().slice(0, 80), + className: String(el.className || ""), + src: el.getAttribute("src") || "", + href: el.getAttribute("href") || "" + })); +})()""" + try: + return helpers.js(script) + except Exception as exc: + return {"error": str(exc)} + + +def _wait_for_login_form_ready(cfg: RuntimeConfig) -> dict[str, bool]: + script = """(() => { + const exists = (selector) => Boolean(document.querySelector(selector)); + const textButton = Array.from(document.querySelectorAll("button")) + .some((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + return { + username: exists("input[placeholder='请输入用户名']") || exists(".login-form input[type='text']"), + password: exists("input[placeholder='请输入密码']") || exists(".login-form input[type='password']"), + captcha: exists("input[placeholder='请输入验证码']") || exists(".code-input input"), + submit: exists(".login-form button.q-button--primary") || textButton + }; +})()""" + deadline = time.time() + cfg.timeout + last_state: dict[str, bool] = {} + while time.time() < deadline: + state = helpers.js(script) + last_state = state if isinstance(state, dict) else {} + if last_state.get("username") and last_state.get("password") and last_state.get("captcha") and last_state.get("submit"): + return {key: bool(value) for key, value in last_state.items()} + time.sleep(0.5) + raise RuntimeError( + "SkyEye Sensor login form was not rendered before timeout: " + + json.dumps({"state": last_state, "dom": _login_dom_summary()}, ensure_ascii=False) + ) + + +def _captcha_image_from_browser(cfg: RuntimeConfig) -> bytes: + captcha_url = _url(cfg, f"{cfg.captcha_path}?r={random.random()}") + script = f"""(async () => {{ + const response = await fetch({json.dumps(captcha_url)}, {{ + credentials: "include", + cache: "no-store" + }}); + if (!response.ok) {{ + throw new Error("captcha request failed: " + response.status); + }} + const blob = await response.blob(); + return await new Promise((resolve, reject) => {{ + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }}); +}})()""" + data_url = str(helpers.js(script) or "") + if "," not in data_url: + raise RuntimeError("SkyEye Sensor captcha fetch did not return a data URL.") + return base64.b64decode(data_url.split(",", 1)[1]) + + +def _ocr_code(image_bytes: bytes) -> str: + try: + import ddddocr + except ImportError as exc: + raise RuntimeError("ddddocr is required for automatic captcha recognition.") from exc + return str(ddddocr.DdddOcr(show_ad=False).classification(image_bytes)).strip()[:4] + + +def _fill_and_submit(cfg: RuntimeConfig, code: str) -> dict[str, Any]: + payload = { + "username": cfg.username, + "password": cfg.password, + "code": code, + } + script = f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + const username = document.querySelector("input[placeholder='请输入用户名']") + || document.querySelector(".login-form input[type='text']"); + const password = document.querySelector("input[placeholder='请输入密码']") + || document.querySelector(".login-form input[type='password']"); + const captcha = document.querySelector("input[placeholder='请输入验证码']") + || document.querySelector(".code-input input"); + const agreement = document.querySelector(".protocol input[type='checkbox']") + || document.querySelector(".q-checkbox__original"); + const submit = document.querySelector(".login-form button.q-button--primary") + || Array.from(document.querySelectorAll("button")) + .find((el) => /登\\s*录|login/i.test((el.innerText || "").trim())); + + function setValue(el, value) {{ + if (!el) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set; + el.focus(); + setter.call(el, value); + el.dispatchEvent(new Event("input", {{bubbles: true}})); + el.dispatchEvent(new Event("change", {{bubbles: true}})); + el.dispatchEvent(new KeyboardEvent("keyup", {{bubbles: true}})); + return true; + }} + + const filled = {{ + username: setValue(username, cfg.username), + password: setValue(password, cfg.password), + captcha: setValue(captcha, cfg.code), + agreement: false + }}; + if (!filled.username || !filled.password || !filled.captcha) {{ + throw new Error("missing SkyEye Sensor login input"); + }} + if (agreement) {{ + if (!agreement.checked) agreement.click(); + agreement.dispatchEvent(new Event("input", {{bubbles: true}})); + agreement.dispatchEvent(new Event("change", {{bubbles: true}})); + filled.agreement = Boolean(agreement.checked); + }} + if (!submit) {{ + throw new Error("missing SkyEye Sensor login submit button"); + }} + submit.click(); + return filled; +}})()""" + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + + +def _wait_for_login_success(cfg: RuntimeConfig) -> bool: + deadline = time.time() + cfg.timeout + while time.time() < deadline: + try: + if _is_logged_in(cfg): + return True + except Exception: + pass + time.sleep(0.5) + return False + + +def refresh_auth_state(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: + missing = [] + if not cfg.username: + missing.append("username") + if not cfg.password: + missing.append("password") + if missing: + return { + "success": False, + "status": "manual_login_required", + "reason": "missing_credentials", + "missing": missing, + "auth_state_path": str(cfg.auth_state_path), + } + + _open_page(_login_url(cfg)) + form_state = _wait_for_login_form_ready(cfg) + last_error = "" + for attempt in range(1, cfg.max_captcha_retry + 1): + try: + code = captcha_code.strip() + if not code: + if not cfg.auto_ocr: + return { + "success": False, + "status": "manual_login_required", + "reason": "captcha_code_required", + "auth_state_path": str(cfg.auth_state_path), + } + code = _ocr_code(_captcha_image_from_browser(cfg)) + filled = _fill_and_submit(cfg, code) + if _wait_for_login_success(cfg): + saved = helpers.save_state(cfg.auth_state_path, url=cfg.base_url) + return { + "success": True, + "status": "browser_cdp_login_refreshed_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "attempt": attempt, + "form": form_state, + "filled": {key: bool(value) for key, value in filled.items()}, + "saved": saved, + } + last_error = "login_success_check_timeout" + except Exception as exc: + last_error = str(exc) + + if captcha_code: + break + try: + helpers.goto_url(_login_url(cfg)) + helpers.wait_for_load(timeout=10) + form_state = _wait_for_login_form_ready(cfg) + except Exception: + pass + + return { + "success": False, + "status": "manual_login_required", + "reason": "browser_cdp_login_failed", + "last_error": last_error, + "auth_state_path": str(cfg.auth_state_path), + } + + +def ensure_auth_state(cfg: RuntimeConfig, captcha_code: str = "", force_refresh: bool = False) -> dict[str, Any]: + if not force_refresh: + validation = validate_auth_state(cfg) + if validation.get("valid"): + return {"success": True, "status": "auth_state_loaded", **validation} + return refresh_auth_state(cfg, captcha_code=captcha_code) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Ensure SkyEye Sensor browser auth-state") + parser.add_argument("action", nargs="?", choices=["ensure", "validate", "refresh"], default="ensure") + parser.add_argument("--base-url", help="SkyEye Sensor base URL, for example https://sensor.example.com") + parser.add_argument("--username", help="Username for CDP-assisted login") + parser.add_argument("--password", help="Password for CDP-assisted login") + parser.add_argument("--auth-state", help=f"Auth-state path, default: {DEFAULT_AUTH_STATE}") + parser.add_argument("--login-path", default="", help=f"Login path, default: {DEFAULT_LOGIN_PATH}") + parser.add_argument("--captcha-path", default="", help=f"Captcha path, default: {DEFAULT_CAPTCHA_PATH}") + parser.add_argument("--captcha-code", default="", help="Manual captcha code; OCR is used when omitted") + parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT) + parser.add_argument("--max-captcha-retry", type=int, default=5) + parser.add_argument("--no-ocr", action="store_true", help="Do not OCR captcha; require --captcha-code") + parser.add_argument("--no-save-credentials", dest="save_credentials", action="store_false") + parser.set_defaults(save_credentials=True) + return parser + + +def main() -> int: + args = _build_parser().parse_args() + try: + cfg = _runtime_config(args) + if args.action == "validate": + result = validate_auth_state(cfg) + elif args.action == "refresh": + result = refresh_auth_state(cfg, captcha_code=args.captcha_code) + else: + result = ensure_auth_state(cfg, captcha_code=args.captcha_code) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result.get("success") or result.get("valid") else 1 + except Exception as exc: + print(json.dumps({"success": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py index d75b8af23..3b1368741 100644 --- a/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py +++ b/.flocks/plugins/skills/skyeye-sensor-use/scripts/skyeye_sensor_cli.py @@ -5,6 +5,7 @@ import json import sys +from argparse import Namespace from datetime import datetime from pathlib import Path from typing import Any @@ -53,6 +54,37 @@ def resolve_auth_file() -> Path | None: return None +def ensure_browser_auth_state( + *, + base_url: str, + username: str | None, + password: str | None, + captcha_code: str | None, + force_refresh: bool, + debug: bool, +) -> tuple[bool, str]: + from skyeye_sensor_auth import ensure_auth_state, _runtime_config + + args = Namespace( + base_url=base_url, + username=username, + password=password, + auth_state=str(AUTH_STATE_FILE), + login_path="", + captcha_path="", + captcha_code=captcha_code or "", + timeout=25, + max_captcha_retry=5, + no_ocr=False, + save_credentials=True, + ) + cfg = _runtime_config(args) + result = ensure_auth_state(cfg, captcha_code=captcha_code or "", force_refresh=force_refresh) + if debug: + print_info(json.dumps(result, ensure_ascii=False)) + return bool(result.get("success") or result.get("valid")), cfg.base_url + + def build_alarm_filters( hazard_level: str | None = None, threat_type: str | None = None, @@ -150,22 +182,57 @@ def pick_first(item: dict, *keys: str, default: str = "-") -> str: @click.group() @click.option("--token", "-t", help="CSRF Token") @click.option("--base-url", "-u", help="Base URL") +@click.option("--username", help="Username for browser/CDP login") +@click.option("--password", help="Password for browser/CDP login") +@click.option("--auto-login", is_flag=True, help="Use saved credentials to refresh browser auth-state") +@click.option("--refresh-auth", is_flag=True, help="Force browser/CDP login before running the command") +@click.option("--captcha-code", help="Captcha code; OCR is used when omitted") @click.option("--debug", "-d", is_flag=True, help="Debug mode") @click.pass_context -def cli(ctx: click.Context, token: str | None, base_url: str | None, debug: bool) -> None: +def cli( + ctx: click.Context, + token: str | None, + base_url: str | None, + username: str | None, + password: str | None, + auto_login: bool, + refresh_auth: bool, + captcha_code: str | None, + debug: bool, +) -> None: """SkyEye Sensor CLI.""" ctx.ensure_object(dict) actual_base_url = base_url or BASE_URL - auth_file = resolve_auth_file() actual_token = token or CSRF_TOKEN - if auth_file is None and not actual_token: - print_error("未提供认证信息。请提供 auth-state.json、cookie.json 或 --token") - sys.exit(1) + wants_browser_login = bool(username or password or auto_login or refresh_auth) + if wants_browser_login: + try: + ok, resolved_base_url = ensure_browser_auth_state( + base_url=actual_base_url, + username=username, + password=password, + captcha_code=captcha_code, + force_refresh=refresh_auth, + debug=debug, + ) + actual_base_url = actual_base_url or resolved_base_url + if not ok: + print_error("SkyEye Sensor 自动登录失败,请改用浏览器手动登录后保存 auth-state。") + sys.exit(1) + except Exception as exc: + print_error(f"SkyEye Sensor 自动登录失败: {exc}") + sys.exit(1) + if not actual_base_url: print_error("未提供平台地址。请设置 SKYEYE_SENSOR_BASE_URL 或使用 --base-url。") sys.exit(1) + auth_file = resolve_auth_file() + if auth_file is None and not actual_token: + print_error("未提供认证信息。请提供 auth-state.json、cookie.json 或 --token") + sys.exit(1) + ctx.obj["client"] = SkyeyeSensorClient( base_url=actual_base_url, auth_file=auth_file, diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml new file mode 100644 index 000000000..7c346957b --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/_provider.yaml @@ -0,0 +1,92 @@ +name: Sangfor EDR WebCLI +vendor: sangfor +service_id: sangfor_edr +version: "1.0.0" +integration_type: device +description: > + Sangfor EDR WebCLI-backed integration. It can reuse an existing browser + storage state, or refresh auth-state by driving the real EDR login page + through browser daemon / CDP when credentials are configured. +description_cn: > + Sangfor EDR WebCLI device integration. It reuses full browser auth-state + from manual login, or refreshes full browser auth-state through CDP-assisted + browser login when credentials are configured. +credential_fields: + - key: base_url + label: Base URL + storage: config + config_key: base_url + input_type: url + required: true + placeholder: "https://edr.example.com" + - key: auth_state_path + label: Auth State Path + storage: config + config_key: auth_state_path + input_type: text + required: false + default: "~/.flocks/browser/sangfor-edr/auth-state.json" + - key: username + label: Username + storage: secret + config_key: username + secret_id: sangfor_edr_username + input_type: text + required: false + description: Optional. Stored for CDP-assisted browser login when auth-state is missing or expired. + - key: password + label: Password + storage: secret + config_key: password + secret_id: sangfor_edr_password + input_type: password + required: false + description: Optional. Stored as a secret and used only to refresh browser auth-state. + - key: auto_ocr_code + label: Auto OCR Captcha + storage: config + config_key: auto_ocr_code + input_type: boolean + required: false + default: true + - key: max_captcha_retry + label: Max Captcha Retry + storage: config + config_key: max_captcha_retry + input_type: number + required: false + default: 5 + - key: login_path + label: Login Path + storage: config + config_key: login_path + input_type: text + required: false + default: "/ui/login.php" + - key: index_path + label: Index Path + storage: config + config_key: index_path + input_type: text + required: false + default: "/ui/#/index" + - key: agreement_selector + label: Agreement Checkbox Selector + storage: config + config_key: agreement_selector + input_type: text + required: false + default: ".user-protocol-check input[type='checkbox']" +defaults: + timeout: 20 + category: custom + verify_ssl: false +notes: | + The existing manual-login flow saves a full browser storageState containing + cookies and localStorage. When credentials are available, the automatic flow + also drives the real EDR login page through browser daemon / CDP and then + saves the same full browser storageState format. + + If captcha OCR, MFA, page selector matching, or login success detection fails, + keep the browser/manual login recovery path and save the resulting full + auth-state with flocks browser. diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml new file mode 100644 index 000000000..f2893fcfb --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/_test.yaml @@ -0,0 +1,15 @@ +schema_version: 1 +provider: sangfor_edr +connectivity: + tool: sangfor_edr_auth + params: + action: ensure_auth_state +fixtures: + sangfor_edr_auth: + - label: "Ensure EDR auth state" + label_cn: "Ensure or refresh EDR auth state" + tags: [smoke, auth] + params: + action: ensure_auth_state + assert: + success: true diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py new file mode 100644 index 000000000..12e734459 --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py @@ -0,0 +1,670 @@ +"""Sangfor EDR browser-state authentication helper. + +EDR has no stable Open API in this integration. This handler follows the same +browser workflow used by TDP / OneSEC / SkyEye / Qingteng skills: + +1. try to load the saved browser auth-state; +2. if it is still valid, reuse it; +3. if it is missing or expired, open the real EDR login page through CDP; +4. fill username, password and captcha in the browser page; +5. after login succeeds, save the full browser auth-state again. + +The handler only replaces the "wait for the user to log in manually" step with +CDP-assisted form login. It does not implement EDR business APIs. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from pathlib import Path +from typing import Any, Optional +from urllib.parse import urljoin, urlparse + +from flocks.browser import helpers +from flocks.config.config_writer import ConfigWriter +from flocks.tool.registry import ToolContext, ToolResult + +SERVICE_ID = "sangfor_edr" +USERNAME_SECRET_ID = "sangfor_edr_username" +PASSWORD_SECRET_ID = "sangfor_edr_password" +DEFAULT_AUTH_STATE_PATH = "~/.flocks/browser/sangfor-edr/auth-state.json" +DEFAULT_LOGIN_PATH = "/ui/login.php" +DEFAULT_INDEX_PATH = "/ui/#/index" +DEFAULT_TIMEOUT = 25 + + +class RuntimeConfig: + def __init__( + self, + *, + base_url: str, + auth_state_path: Path, + username: str, + password: str, + login_path: str, + index_path: str, + timeout: int, + auto_ocr_code: bool, + max_captcha_retry: int, + username_selector: str, + password_selector: str, + captcha_selector: str, + agreement_selector: str, + submit_selector: str, + ) -> None: + self.base_url = base_url + self.auth_state_path = auth_state_path + self.username = username + self.password = password + self.login_path = login_path + self.index_path = index_path + self.timeout = timeout + self.auto_ocr_code = auto_ocr_code + self.max_captcha_retry = max_captcha_retry + self.username_selector = username_selector + self.password_selector = password_selector + self.captcha_selector = captcha_selector + self.agreement_selector = agreement_selector + self.submit_selector = submit_selector + + +# ── Config / secret helpers ────────────────────────────────────────────────── + +def _get_secret_manager(): + from flocks.security import get_secret_manager + + return get_secret_manager() + + +def _resolve_ref(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + return str(value) + if value.startswith("{secret:") and value.endswith("}"): + return _get_secret_manager().get(value[len("{secret:") : -1]) + if value.startswith("{env:") and value.endswith("}"): + return os.getenv(value[len("{env:") : -1]) + return value + + +def _coerce_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _coerce_int(value: Any, default: int) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return default + + +def _normalise_base_url(value: str) -> str: + candidate = value.strip() + if not candidate: + raise ValueError("Sangfor EDR base_url is required.") + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + raise ValueError(f"Invalid Sangfor EDR base_url: {value!r}") + host = parsed.hostname + if ":" in host and not host.startswith("["): + host = f"[{host}]" + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{host}{port}".rstrip("/") + + +def _save_params_to_service(params: dict[str, Any]) -> dict[str, Any]: + raw = ConfigWriter.get_api_service_raw(SERVICE_ID) + service = dict(raw) if isinstance(raw, dict) else {} + + for key in ( + "base_url", + "auth_state_path", + "auto_ocr_code", + "max_captcha_retry", + "login_path", + "index_path", + "username_selector", + "password_selector", + "captcha_selector", + "agreement_selector", + "submit_selector", + ): + value = params.get(key) + if value not in (None, ""): + service[key] = value + + secrets = _get_secret_manager() + username = params.get("username") + if isinstance(username, str) and username: + secrets.set(USERNAME_SECRET_ID, username) + service["username"] = f"{{secret:{USERNAME_SECRET_ID}}}" + + password = params.get("password") + if isinstance(password, str) and password: + secrets.set(PASSWORD_SECRET_ID, password) + service["password"] = f"{{secret:{PASSWORD_SECRET_ID}}}" + + if _coerce_bool(params.get("persist_credentials"), default=True) and any( + params.get(key) not in (None, "") + for key in ("base_url", "auth_state_path", "username", "password") + ): + ConfigWriter.set_api_service(SERVICE_ID, service) + + return service + + +def _resolve_runtime_config(params: dict[str, Any]) -> RuntimeConfig: + raw = _save_params_to_service(params) + secrets = _get_secret_manager() + + base_url = _normalise_base_url( + _resolve_ref(raw.get("base_url")) + or _resolve_ref(raw.get("host")) + or os.getenv("SANGFOR_EDR_BASE_URL") + or "" + ) + auth_state_path = Path( + _resolve_ref(raw.get("auth_state_path")) + or os.getenv("SANGFOR_EDR_AUTH_STATE") + or DEFAULT_AUTH_STATE_PATH + ).expanduser() + + username = ( + _resolve_ref(raw.get("username")) + or secrets.get(USERNAME_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_username") + or os.getenv("SANGFOR_EDR_USERNAME") + or "" + ).strip() + password = ( + _resolve_ref(raw.get("password")) + or secrets.get(PASSWORD_SECRET_ID) + or secrets.get(f"{SERVICE_ID}_password") + or os.getenv("SANGFOR_EDR_PASSWORD") + or "" + ).strip() + + return RuntimeConfig( + base_url=base_url, + auth_state_path=auth_state_path, + username=username, + password=password, + login_path=str(raw.get("login_path") or DEFAULT_LOGIN_PATH), + index_path=str(raw.get("index_path") or DEFAULT_INDEX_PATH), + timeout=max(5, _coerce_int(raw.get("timeout"), DEFAULT_TIMEOUT)), + auto_ocr_code=_coerce_bool(raw.get("auto_ocr_code"), default=True), + max_captcha_retry=max(1, _coerce_int(raw.get("max_captcha_retry"), 5)), + username_selector=str(raw.get("username_selector") or ""), + password_selector=str(raw.get("password_selector") or ""), + captcha_selector=str(raw.get("captcha_selector") or ""), + agreement_selector=str(raw.get("agreement_selector") or ""), + submit_selector=str(raw.get("submit_selector") or ""), + ) + + +# ── Browser state workflow ─────────────────────────────────────────────────── + +def _url(cfg: RuntimeConfig, path: str) -> str: + return urljoin(cfg.base_url + "/", path.lstrip("/")) + + +def _now_ms() -> str: + return str(int(time.time() * 1000)) + + +def _login_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.login_path) + + +def _index_url(cfg: RuntimeConfig) -> str: + return _url(cfg, cfg.index_path) + + +def _open_page(url: str) -> None: + try: + helpers.open_or_attach_tab(url) + except Exception: + helpers.goto_url(url) + helpers.wait_for_load(timeout=15) + + +def _page_text() -> str: + try: + return str(helpers.js("document.body ? document.body.innerText : ''") or "") + except Exception: + return "" + + +def _looks_like_login_page(text: str, url: str) -> bool: + haystack = f"{url}\n{text}".lower() + markers = ("login.php", "user_name", "password", "randcode", "captcha", "验证码", "登录") + return any(marker.lower() in haystack for marker in markers) + + +def _has_session_cookie(cfg: RuntimeConfig) -> bool: + try: + result = helpers.cdp("Network.getCookies", urls=[cfg.base_url]) + except Exception: + return False + cookies = result.get("cookies", []) + if not isinstance(cookies, list): + return False + return any( + str(cookie.get("name") or "").lower() == "sessionid" and cookie.get("value") + for cookie in cookies + if isinstance(cookie, dict) + ) + + +def _is_logged_in(cfg: RuntimeConfig) -> bool: + info = helpers.page_info() + current_url = str(info.get("url") or "") + if _looks_like_login_page(_page_text(), current_url): + return False + return _has_session_cookie(cfg) or cfg.base_url.rstrip("/") in current_url + + +def _validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: + if not cfg.auth_state_path.exists(): + return { + "valid": False, + "reason": "auth_state_not_found", + "auth_state_path": str(cfg.auth_state_path), + } + try: + loaded = helpers.load_state(cfg.auth_state_path, url=_index_url(cfg)) + helpers.wait_for_load(timeout=15) + if _is_logged_in(cfg): + return { + "valid": True, + "reason": "browser_state_loaded", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + return { + "valid": False, + "reason": "auth_state_expired_or_login_page", + "auth_state_path": str(cfg.auth_state_path), + "loaded": loaded, + } + except Exception as exc: + return { + "valid": False, + "reason": "auth_state_load_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + } + + +# ── CDP login workflow ─────────────────────────────────────────────────────── + +def _selector_list(custom: str, defaults: tuple[str, ...]) -> list[str]: + values = [item.strip() for item in custom.split(",") if item.strip()] if custom else [] + values.extend(defaults) + result: list[str] = [] + for value in values: + if value not in result: + result.append(value) + return result + + +def _login_selectors(cfg: RuntimeConfig) -> dict[str, list[str]]: + return { + "username": _selector_list( + cfg.username_selector, + ( + "#user", + "input[name='user']", + ".username-input", + "#user_name", + "#username", + "input[name='user_name']", + "input[name='username']", + "input[type='text']", + ), + ), + "password": _selector_list( + cfg.password_selector, + ( + "#password", + "input[name='password']", + ".input_text_password", + "input[name='pwd']", + "input[type='password']", + ), + ), + "captcha": _selector_list( + cfg.captcha_selector, + ( + "#code", + "input[name='code']", + ".code_input_text", + "#randcode", + "#captcha", + "input[name='randcode']", + "input[name='captcha']", + "input[name='verify_code']", + ), + ), + "agreement": _selector_list( + cfg.agreement_selector, + ( + ".user-protocol-check input[type='checkbox']", + ".sfedr-checkbox-input", + "input[type='checkbox'][true-value='1']", + ), + ), + "submit": _selector_list( + cfg.submit_selector, + ( + "#button", + "input[name='button']", + ".login-opr-btn", + "#login", + "#submit", + ".login-btn", + ".btn-login", + "button[type='submit']", + "input[type='submit']", + ), + ), + } + + +def _login_dom_summary() -> Any: + script = """(() => { + return Array.from(document.querySelectorAll("input,button,a,img,iframe")) + .slice(0, 80) + .map((el) => ({ + tag: el.tagName, + id: el.id || "", + name: el.getAttribute("name") || "", + type: el.getAttribute("type") || "", + placeholder: el.getAttribute("placeholder") || "", + value: el.tagName === "INPUT" && el.type !== "password" ? (el.value || "") : "", + text: (el.innerText || el.value || el.getAttribute("alt") || "").trim().slice(0, 80), + className: String(el.className || ""), + src: el.getAttribute("src") || "", + href: el.getAttribute("href") || "" + })); +})()""" + try: + return helpers.js(script) + except Exception as exc: + return {"error": str(exc)} + + +def _wait_for_login_form_ready(cfg: RuntimeConfig) -> dict[str, bool]: + selectors = _login_selectors(cfg) + payload = { + "usernameSelectors": selectors["username"], + "passwordSelectors": selectors["password"], + "captchaSelectors": selectors["captcha"], + "submitSelectors": selectors["submit"], + } + deadline = time.time() + cfg.timeout + last_state: dict[str, bool] = {} + while time.time() < deadline: + state = helpers.js( + f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + const exists = (selectors) => selectors.some((selector) => Boolean(document.querySelector(selector))); + return {{ + username: exists(cfg.usernameSelectors), + password: exists(cfg.passwordSelectors), + captcha: exists(cfg.captchaSelectors), + submit: exists(cfg.submitSelectors) + }}; +}})()""" + ) + last_state = state if isinstance(state, dict) else {} + if last_state.get("username") and last_state.get("password") and last_state.get("submit"): + return {key: bool(value) for key, value in last_state.items()} + time.sleep(0.5) + raise RuntimeError( + "EDR login form was not rendered before timeout: " + + json.dumps({"state": last_state, "dom": _login_dom_summary()}, ensure_ascii=False) + ) + + +def _captcha_image_from_browser(cfg: RuntimeConfig) -> bytes: + captcha_url = _url(cfg, f"/ui/randcode.php?{_now_ms()}") + script = f"""(async () => {{ + const response = await fetch({json.dumps(captcha_url)}, {{ + credentials: "include", + cache: "no-store" + }}); + if (!response.ok) {{ + throw new Error("captcha request failed: " + response.status); + }} + const blob = await response.blob(); + return await new Promise((resolve, reject) => {{ + const reader = new FileReader(); + reader.onerror = () => reject(reader.error || new Error("captcha read failed")); + reader.onload = () => resolve(String(reader.result)); + reader.readAsDataURL(blob); + }}); +}})()""" + data_url = str(helpers.js(script) or "") + if "," not in data_url: + raise RuntimeError("EDR captcha fetch did not return a data URL.") + return base64.b64decode(data_url.split(",", 1)[1]) + + +def _ocr_verify_code(image_bytes: bytes) -> str: + try: + import ddddocr + except ImportError as exc: + raise RuntimeError( + "ddddocr is required for automatic Sangfor EDR captcha recognition." + ) from exc + return str(ddddocr.DdddOcr(show_ad=False).classification(image_bytes)).strip()[:4] + + +def _set_login_form_values(cfg: RuntimeConfig, code: str) -> dict[str, Any]: + selectors = _login_selectors(cfg) + payload = { + "username": cfg.username, + "password": cfg.password, + "code": code, + "usernameSelectors": selectors["username"], + "passwordSelectors": selectors["password"], + "captchaSelectors": selectors["captcha"], + "agreementSelectors": selectors["agreement"], + "submitSelectors": selectors["submit"], + } + script = f"""(() => {{ + const cfg = {json.dumps(payload, ensure_ascii=False)}; + function first(selectors) {{ + for (const selector of selectors) {{ + const el = document.querySelector(selector); + if (el) return el; + }} + return null; + }} + function setValue(el, value) {{ + if (!el) return false; + const proto = el instanceof HTMLTextAreaElement + ? HTMLTextAreaElement.prototype + : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value").set; + el.focus(); + setter.call(el, value); + el.dispatchEvent(new Event("input", {{bubbles: true}})); + el.dispatchEvent(new Event("change", {{bubbles: true}})); + el.dispatchEvent(new KeyboardEvent("keyup", {{bubbles: true}})); + return true; + }} + const username = first(cfg.usernameSelectors); + const password = first(cfg.passwordSelectors); + const captcha = first(cfg.captchaSelectors); + const agreement = first(cfg.agreementSelectors); + const filled = {{ + username: setValue(username, cfg.username), + password: setValue(password, cfg.password), + captcha: captcha ? setValue(captcha, cfg.code) : false, + agreement: false + }}; + if (!filled.username || !filled.password) {{ + throw new Error("missing EDR login username/password input"); + }} + if (agreement) {{ + if (!agreement.checked) {{ + agreement.click(); + }} + agreement.dispatchEvent(new Event("input", {{bubbles: true}})); + agreement.dispatchEvent(new Event("change", {{bubbles: true}})); + filled.agreement = Boolean(agreement.checked); + }} + const submit = first(cfg.submitSelectors) + || Array.from(document.querySelectorAll("button,input[type='button'],input[type='submit'],a")) + .find((el) => /登录|登 录|login/i.test((el.innerText || el.value || el.textContent || "").trim())); + if (submit) {{ + submit.click(); + }} else {{ + const form = username.closest("form") || password.closest("form"); + if (!form) throw new Error("missing EDR login submit button"); + form.dispatchEvent(new Event("submit", {{bubbles: true, cancelable: true}})); + if (typeof form.submit === "function") form.submit(); + }} + return filled; +}})()""" + result = helpers.js(script) + return result if isinstance(result, dict) else {"result": result} + + +def _wait_for_login_success(cfg: RuntimeConfig) -> bool: + deadline = time.time() + cfg.timeout + while time.time() < deadline: + try: + if _is_logged_in(cfg): + return True + except Exception: + pass + time.sleep(0.5) + return False + + +def _missing_login_inputs(cfg: RuntimeConfig) -> list[str]: + missing = [] + if not cfg.username: + missing.append("username") + if not cfg.password: + missing.append("password") + return missing + + +def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: + missing = _missing_login_inputs(cfg) + if missing: + return { + "success": False, + "status": "manual_login_required", + "reason": "missing_cdp_login_credentials", + "missing": missing, + "auth_state_path": str(cfg.auth_state_path), + } + + _open_page(_login_url(cfg)) + form_state = _wait_for_login_form_ready(cfg) + last_error = "" + for attempt in range(1, cfg.max_captcha_retry + 1): + try: + code = captcha_code.strip() + if not code: + if not cfg.auto_ocr_code: + return { + "success": False, + "status": "manual_login_required", + "reason": "captcha_code_required", + "auth_state_path": str(cfg.auth_state_path), + } + code = _ocr_verify_code(_captcha_image_from_browser(cfg)) + + filled = _set_login_form_values(cfg, code) + if _wait_for_login_success(cfg): + saved = helpers.save_state(cfg.auth_state_path, url=_index_url(cfg)) + return { + "success": True, + "status": "browser_cdp_login_refreshed_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "attempt": attempt, + "form": form_state, + "filled": {key: bool(value) for key, value in filled.items()}, + "saved": saved, + } + last_error = "login_success_check_timeout" + except Exception as exc: + last_error = str(exc) + + if captcha_code: + break + try: + helpers.goto_url(_login_url(cfg)) + helpers.wait_for_load(timeout=10) + except Exception: + pass + + return { + "success": False, + "status": "manual_login_required", + "reason": "browser_cdp_login_failed", + "last_error": last_error, + "auth_state_path": str(cfg.auth_state_path), + } + + +# ── Tool actions ───────────────────────────────────────────────────────────── + +def _auth_state_loaded_output(validation: dict[str, Any]) -> dict[str, Any]: + return { + "success": True, + "status": "auth_state_loaded", + **validation, + } + + +async def handle(ctx: ToolContext) -> ToolResult: + params = dict(ctx.params) + action = str(params.get("action") or "ensure_auth_state").strip() + + try: + cfg = _resolve_runtime_config(params) + + if action == "validate_auth_state": + validation = _validate_auth_state(cfg) + return ToolResult(success=bool(validation.get("valid")), output=validation) + + if action not in {"ensure_auth_state", "refresh_auth_state"}: + return ToolResult( + success=False, + error="Unsupported Sangfor EDR auth action. Use ensure_auth_state, validate_auth_state, or refresh_auth_state.", + ) + + force_refresh = action == "refresh_auth_state" or _coerce_bool(params.get("force_refresh"), default=False) + if not force_refresh: + validation = _validate_auth_state(cfg) + if validation.get("valid"): + return ToolResult(success=True, output=_auth_state_loaded_output(validation)) + + result = _refresh_auth_state_with_cdp_login( + cfg, + captcha_code=str(params.get("captcha_code") or ""), + ) + return ToolResult( + success=bool(result.get("success")), + output=result, + error=None if result.get("success") else result.get("reason"), + ) + except Exception as exc: + return ToolResult(success=False, error=str(exc)) diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml new file mode 100644 index 000000000..cf2a89873 --- /dev/null +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml @@ -0,0 +1,55 @@ +name: sangfor_edr_auth +description: > + Manage Sangfor EDR browser authentication state. Use ensure_auth_state to + validate the saved auth-state and automatically refresh full browser state + through browser daemon / CDP when credentials are configured. +description_cn: > + 管理深信服 EDR 浏览器登录态。ensure_auth_state 会检查已保存 state; + 当 state 缺失或失效且已配置账密时,通过 browser daemon / CDP 驱动真实 + 登录页自动登录并重新保存完整浏览器 state。 +category: custom +enabled: true +requires_confirmation: false +provider: sangfor_edr +inputSchema: + type: object + properties: + action: + type: string + enum: [ensure_auth_state, validate_auth_state, refresh_auth_state] + default: ensure_auth_state + description: > + Authentication action. ensure_auth_state reuses existing state when it + appears valid and refreshes when needed; validate_auth_state checks only; + refresh_auth_state always performs CDP-assisted browser login. + captcha_code: + type: string + description: Optional manual captcha code. When omitted, OCR is used if enabled. + base_url: + type: string + description: Optional EDR base URL to save before running auth, for example https://edr.example.com. + username: + type: string + description: Optional username to save before running auth. + password: + type: string + description: Optional password to save as a secret before running auth. + auth_state_path: + type: string + description: Optional auth-state path. Defaults to ~/.flocks/browser/sangfor-edr/auth-state.json. + agreement_selector: + type: string + description: Optional selector for the EDR user agreement checkbox. + persist_credentials: + type: boolean + default: true + description: Persist provided base_url, username, password, and auth_state_path for future refreshes. + force_refresh: + type: boolean + default: false + description: Force CDP-assisted browser login even if auth-state exists. + required: [action] +handler: + type: script + script_file: sangfor_edr.handler.py + function: handle diff --git a/pyproject.toml b/pyproject.toml index 45d82a8ec..3d1f82234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ dependencies = [ "datasketch>=1.10.0", # Kafka ingest (workflow input/output) "aiokafka>=0.14.0", + "ddddocr>=1.6.1", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 3be641a1f..530ccb50e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 1 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -10,18 +10,18 @@ resolution-markers = [ name = "aiofiles" version = "25.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354 } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668 }, ] [[package]] name = "aiohappyeyeballs" version = "2.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, ] [[package]] @@ -37,25 +37,25 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876 }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557 }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258 }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199 }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013 }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501 }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981 }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934 }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671 }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219 }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049 }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557 }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931 }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125 }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427 }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534 }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446 }, ] [[package]] @@ -67,14 +67,14 @@ dependencies = [ { name = "packaging" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, - { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, - { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, - { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458 }, + { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057 }, + { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238 }, + { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567 }, + { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160 }, + { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897 }, ] [[package]] @@ -85,36 +85,36 @@ dependencies = [ { name = "frozenlist" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, ] [[package]] name = "aiosqlite" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, ] [[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, ] [[package]] @@ -131,9 +131,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820 } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400 }, ] [[package]] @@ -144,27 +144,27 @@ dependencies = [ { name = "idna" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622 } 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" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] [[package]] name = "asttokens" version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047 }, ] [[package]] name = "async-timeout" version = "5.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, ] [[package]] @@ -175,27 +175,27 @@ dependencies = [ { name = "cryptography" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523, upload-time = "2025-12-21T23:38:30.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/d5/957886c316466349d55c4de6a688a10a98295c0b4429deb8db1a17f3eb19/asyncssh-2.22.0.tar.gz", hash = "sha256:c3ce72b01be4f97b40e62844dd384227e5ff5a401a3793007c42f86a5c8eb537", size = 540523 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938, upload-time = "2025-12-21T23:38:28.976Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/0da2f2214fc183338af1afe5a103a2052fd03464e8eafbd827abff58a4d0/asyncssh-2.22.0-py3-none-any.whl", hash = "sha256:d16465ccdf1ed20eba1131b14415b155e047f6f5be0d19f39c2e0b61331ee0e7", size = 374938 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } 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" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] [[package]] name = "backoff" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, ] [[package]] @@ -206,9 +206,9 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721 }, ] [[package]] @@ -219,9 +219,9 @@ dependencies = [ { name = "attrs" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672 } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054 }, ] [[package]] @@ -233,18 +233,18 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961, upload-time = "2026-02-22T04:32:50.574Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/7a/c549417e8c5e4dface6d5d828cd7dc72502dcea33a99f5324abf5a853ce9/cdp_use-1.4.5.tar.gz", hash = "sha256:0da3a32df46336a03ff5a22bc6bc442cd7d2f2d50a118fd4856f29d37f6d26a0", size = 193961 } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504, upload-time = "2026-02-22T04:32:49.22Z" }, + { url = "https://files.pythonhosted.org/packages/56/12/386d8c6bf0448c43674e24d6194c3b57d62e5361e90bca3d58108819ad32/cdp_use-1.4.5-py3-none-any.whl", hash = "sha256:8f8e2435e3a20e4009d2974144192cf3c132f6c2971338e156198814d9b91ecb", size = 350504 }, ] [[package]] name = "certifi" version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684 }, ] [[package]] @@ -254,54 +254,54 @@ 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" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } 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/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, ] [[package]] name = "charset-normalizer" version = "3.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, - { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, - { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, - { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, - { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, - { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, - { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, - { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, - { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, - { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154 }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191 }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674 }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259 }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276 }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161 }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452 }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272 }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622 }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056 }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751 }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563 }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265 }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229 }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277 }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817 }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455 }, ] [[package]] name = "claude" version = "0.4.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d", size = 820, upload-time = "2025-06-10T18:49:08.992Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/0d/40d26e7074d10f946ae06f6125de511ef0318598d7c3cc27c4ce9c9eb0a8/claude-0.4.11.tar.gz", hash = "sha256:a76ecc7e28869866c4353a39d176397a3b6964b543ef176363db5ea32726868d", size = 820 } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478", size = 1167, upload-time = "2025-06-10T18:49:08.17Z" }, + { url = "https://files.pythonhosted.org/packages/63/2b/aa9b6b6fbf63e094c3dfe2ec0010ddd952934647ee51e13a97186b91a44d/claude-0.4.11-py3-none-any.whl", hash = "sha256:369b5945461154bdcb9957f375944e9268ad343e15a924af2476a05b1c167478", size = 1167 }, ] [[package]] @@ -311,18 +311,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 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" }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] [[package]] @@ -332,33 +332,33 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "humanfriendly" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018 }, ] [[package]] name = "coverage" version = "7.13.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554 }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908 }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419 }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159 }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270 }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538 }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821 }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191 }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337 }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404 }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903 }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780 }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093 }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900 }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515 }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 }, ] [[package]] @@ -368,9 +368,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422 }, ] [[package]] @@ -380,36 +380,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 }, ] [[package]] @@ -420,27 +420,43 @@ dependencies = [ { name = "numpy" }, { name = "scipy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699, upload-time = "2026-04-17T23:06:56.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/73/8e9014887f9fca2d785777a0a6186813e4fc7faa24f05fc88c6420624891/datasketch-1.10.0.tar.gz", hash = "sha256:d23aea80ce4c40790ca7a40795659848be92ecc43db80942be26f21e81d24714", size = 91699 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914, upload-time = "2026-04-17T23:06:54.39Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/a94668082e078099eb0161635649510aa887690767b779fffe4bdc479913/datasketch-1.10.0-py3-none-any.whl", hash = "sha256:303dd90cda0948a21abba3aaefc9f8528fa12b8204edc5e1ae8b1d7b750234e7", size = 99914 }, +] + +[[package]] +name = "ddddocr" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "opencv-python", marker = "sys_platform == 'darwin' or sys_platform == 'win32'" }, + { name = "opencv-python-headless", marker = "sys_platform == 'linux'" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/5f/7c06bbb594b77062e6d0d43f06dc88668aaeb699c8737c84542aaa39da8c/ddddocr-1.6.1.tar.gz", hash = "sha256:1c59d84d63d8703c6c486465a32389c9e41dd92852c794c5e4c0181a5f82d43a", size = 75943309 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/48/cbaed3981b8d8d51141b9b4779b811f4728e65d952a1e3e2e5e929539183/ddddocr-1.6.1-py3-none-any.whl", hash = "sha256:c7c70f4ae2d0335440ae8b272eea48c9f6888ecef46785fe2311f0c97a133935", size = 75983593 }, ] [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, ] [[package]] @@ -453,34 +469,34 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813 }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, ] [[package]] name = "docstring-parser" version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 }, ] [[package]] name = "executing" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, ] [[package]] @@ -494,37 +510,37 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407 }, ] [[package]] name = "fastuuid" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232, upload-time = "2025-10-19T22:19:22.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/7d/d9daedf0f2ebcacd20d599928f8913e9d2aea1d56d2d355a93bfa2b611d7/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26", size = 18232 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164, upload-time = "2025-10-19T22:31:45.635Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837, upload-time = "2025-10-19T22:38:38.53Z" }, - { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370, upload-time = "2025-10-19T22:40:26.07Z" }, - { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766, upload-time = "2025-10-19T22:37:23.779Z" }, - { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105, upload-time = "2025-10-19T22:26:56.821Z" }, - { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564, upload-time = "2025-10-19T22:30:31.604Z" }, - { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659, upload-time = "2025-10-19T22:31:32.341Z" }, - { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430, upload-time = "2025-10-19T22:26:22.962Z" }, - { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894, upload-time = "2025-10-19T22:27:01.647Z" }, - { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374, upload-time = "2025-10-19T22:29:19.879Z" }, - { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550, upload-time = "2025-10-19T22:27:49.658Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/e78fcc5df65467f0d207661b7ef86c5b7ac62eea337c0c0fcedbeee6fb13/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a", size = 510164 }, + { url = "https://files.pythonhosted.org/packages/2b/b3/c846f933f22f581f558ee63f81f29fa924acd971ce903dab1a9b6701816e/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d", size = 261837 }, + { url = "https://files.pythonhosted.org/packages/54/ea/682551030f8c4fa9a769d9825570ad28c0c71e30cf34020b85c1f7ee7382/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070", size = 251370 }, + { url = "https://files.pythonhosted.org/packages/14/dd/5927f0a523d8e6a76b70968e6004966ee7df30322f5fc9b6cdfb0276646a/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796", size = 277766 }, + { url = "https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09", size = 278105 }, + { url = "https://files.pythonhosted.org/packages/2d/b1/b9c75e03b768f61cf2e84ee193dc18601aeaf89a4684b20f2f0e9f52b62c/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8", size = 301564 }, + { url = "https://files.pythonhosted.org/packages/fc/fa/f7395fdac07c7a54f18f801744573707321ca0cee082e638e36452355a9d/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741", size = 459659 }, + { url = "https://files.pythonhosted.org/packages/66/49/c9fd06a4a0b1f0f048aacb6599e7d96e5d6bc6fa680ed0d46bf111929d1b/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057", size = 478430 }, + { url = "https://files.pythonhosted.org/packages/be/9c/909e8c95b494e8e140e8be6165d5fc3f61fdc46198c1554df7b3e1764471/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8", size = 450894 }, + { url = "https://files.pythonhosted.org/packages/90/eb/d29d17521976e673c55ef7f210d4cdd72091a9ec6755d0fd4710d9b3c871/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176", size = 154374 }, + { url = "https://files.pythonhosted.org/packages/cc/fc/f5c799a6ea6d877faec0472d0b27c079b47c86b1cdc577720a5386483b36/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397", size = 156550 }, ] [[package]] name = "filelock" version = "3.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759 }, ] [[package]] @@ -532,7 +548,7 @@ name = "flatbuffers" version = "25.12.19" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 }, ] [[package]] @@ -551,6 +567,7 @@ dependencies = [ { name = "click" }, { name = "croniter" }, { name = "datasketch" }, + { name = "ddddocr" }, { name = "defusedxml" }, { name = "dingtalk-stream" }, { name = "fastapi" }, @@ -619,6 +636,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "croniter", specifier = ">=6.0.0" }, { name = "datasketch", specifier = ">=1.10.0" }, + { name = "ddddocr", specifier = ">=1.6.1" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "dingtalk-stream", specifier = ">=0.20" }, { name = "fastapi", specifier = ">=0.109.0" }, @@ -678,34 +696,34 @@ dev = [ name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, ] [[package]] name = "fsspec" version = "2026.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505 }, ] [[package]] @@ -715,9 +733,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, ] [[package]] @@ -727,9 +745,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620 }, ] [[package]] @@ -740,9 +758,9 @@ dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737 }, ] [package.optional-dependencies] @@ -766,9 +784,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912 }, ] [[package]] @@ -778,50 +796,51 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578 }, ] [[package]] name = "greenlet" version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, - { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, - { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, - { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, - { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358 }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217 }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792 }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250 }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875 }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467 }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001 }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081 }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331 }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, ] [[package]] name = "hf-xet" version = "1.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357, upload-time = "2026-03-13T06:58:51.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/08/23c84a26716382c89151b5b447b4beb19e3345f3a93d3b73009a71a57ad3/hf_xet-1.4.2.tar.gz", hash = "sha256:b7457b6b482d9e0743bd116363239b1fa904a5e65deede350fbc0c4ea67c71ea", size = 672357 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339, upload-time = "2026-03-13T06:58:36.245Z" }, - { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664, upload-time = "2026-03-13T06:58:34.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422, upload-time = "2026-03-13T06:58:27.472Z" }, - { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847, upload-time = "2026-03-13T06:58:25.989Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843, upload-time = "2026-03-13T06:58:44.59Z" }, - { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751, upload-time = "2026-03-13T06:58:46.533Z" }, - { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149, upload-time = "2026-03-13T06:58:57.07Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426, upload-time = "2026-03-13T06:58:55.46Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/b40b83a2ff03ef05c4478d2672b1fc2b9683ff870e2b25f4f3af240f2e7b/hf_xet-1.4.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:71f02d6e4cdd07f344f6844845d78518cc7186bd2bc52d37c3b73dc26a3b0bc5", size = 3800339 }, + { url = "https://files.pythonhosted.org/packages/64/2e/af4475c32b4378b0e92a587adb1aa3ec53e3450fd3e5fe0372a874531c00/hf_xet-1.4.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9b38d876e94d4bdcf650778d6ebbaa791dd28de08db9736c43faff06ede1b5a", size = 3559664 }, + { url = "https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c", size = 4217422 }, + { url = "https://files.pythonhosted.org/packages/68/47/d6cf4a39ecf6c7705f887a46f6ef5c8455b44ad9eb0d391aa7e8a2ff7fea/hf_xet-1.4.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c3b3c6a882016b94b6c210957502ff7877802d0dbda8ad142c8595db8b944271", size = 3992847 }, + { url = "https://files.pythonhosted.org/packages/2d/ef/e80815061abff54697239803948abc665c6b1d237102c174f4f7a9a5ffc5/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d9a634cc929cfbaf2e1a50c0e532ae8c78fa98618426769480c58501e8c8ac2", size = 4193843 }, + { url = "https://files.pythonhosted.org/packages/54/75/07f6aa680575d9646c4167db6407c41340cbe2357f5654c4e72a1b01ca14/hf_xet-1.4.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6b0932eb8b10317ea78b7da6bab172b17be03bbcd7809383d8d5abd6a2233e04", size = 4432751 }, + { url = "https://files.pythonhosted.org/packages/cd/71/193eabd7e7d4b903c4aa983a215509c6114915a5a237525ec562baddb868/hf_xet-1.4.2-cp37-abi3-win_amd64.whl", hash = "sha256:ad185719fb2e8ac26f88c8100562dbf9dbdcc3d9d2add00faa94b5f106aea53f", size = 3671149 }, + { url = "https://files.pythonhosted.org/packages/b4/7e/ccf239da366b37ba7f0b36095450efae4a64980bdc7ec2f51354205fdf39/hf_xet-1.4.2-cp37-abi3-win_arm64.whl", hash = "sha256:32c012286b581f783653e718c1862aea5b9eb140631685bb0c5e7012c8719a87", size = 3533426 }, ] [[package]] @@ -832,24 +851,24 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } 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" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961 } 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/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694 }, ] [[package]] @@ -862,9 +881,9 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } 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" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] [package.optional-dependencies] @@ -876,9 +895,9 @@ socks = [ 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" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } 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" }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, ] [[package]] @@ -896,9 +915,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684, upload-time = "2026-03-20T10:36:08.767Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/15/eafc1c57bf0f8afffb243dcd4c0cceb785e956acc17bba4d9bf2ae21fc9c/huggingface_hub-1.7.2.tar.gz", hash = "sha256:7f7e294e9bbb822e025bdb2ada025fa4344d978175a7f78e824d86e35f7ab43b", size = 724684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036, upload-time = "2026-03-20T10:36:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/08/de/3ad061a05f74728927ded48c90b73521b9a9328c85d841bdefb30e01fb85/huggingface_hub-1.7.2-py3-none-any.whl", hash = "sha256:288f33a0a17b2a73a1359e2a5fd28d1becb2c121748c6173ab8643fb342c850e", size = 618036 }, ] [[package]] @@ -908,18 +927,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyreadline3", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794 }, ] [[package]] name = "idna" version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, ] [[package]] @@ -929,18 +948,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, ] [[package]] @@ -959,9 +978,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222 }, ] [[package]] @@ -971,9 +990,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, ] [[package]] @@ -983,9 +1002,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, ] [[package]] @@ -995,34 +1014,34 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] [[package]] name = "jiter" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, - { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, - { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, - { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, - { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, - { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, - { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, - { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, - { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, - { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958 }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597 }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821 }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163 }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709 }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480 }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735 }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814 }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990 }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021 }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024 }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424 }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818 }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169 }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808 }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384 }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768 }, ] [[package]] @@ -1035,9 +1054,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, ] [[package]] @@ -1047,9 +1066,9 @@ 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" } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } 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" }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, ] [[package]] @@ -1067,9 +1086,9 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749, upload-time = "2026-03-19T14:03:34.508Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/94/ab00e21fa5977d6b9c68fb3a95de2aa1a1e586964ff2af3e37405bf65d9f/langfuse-4.0.1.tar.gz", hash = "sha256:40a6daf3ab505945c314246d5b577d48fcfde0a47e8c05267ea6bd494ae9608e", size = 272749 } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049, upload-time = "2026-03-19T14:03:32.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/8f/3145ef00940f9c29d7e0200fd040f35616eac21c6ab4610a1ba14f3a04c1/langfuse-4.0.1-py3-none-any.whl", hash = "sha256:e22f49ea31304f97fc31a97c014ba63baa8802d9568295d54f06b00b43c30524", size = 465049 }, ] [[package]] @@ -1084,28 +1103,28 @@ dependencies = [ { name = "websockets" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016 }, ] [[package]] name = "librt" version = "0.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471 } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, - { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, - { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, - { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, - { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, - { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516 }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634 }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941 }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991 }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476 }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518 }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116 }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751 }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378 }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917 }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017 }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441 }, ] [[package]] @@ -1126,9 +1145,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694, upload-time = "2026-04-13T17:35:01.606Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/2b/b58bf6bbcbc3d0e55d0a84fdf9128e5b1436517f46fce89b1cd8948ebb81/litellm-1.83.7.tar.gz", hash = "sha256:e2f2cb99df2e2b2eab63f1354faa45c88dd7c8d40c18eb648afb1b349c689633", size = 17791694 } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807, upload-time = "2026-04-13T17:34:58.36Z" }, + { url = "https://files.pythonhosted.org/packages/75/80/caeb4cdcad96451ba83ad3ba2a9da08b1e1a915fa845c489f56ea044488b/litellm-1.83.7-py3-none-any.whl", hash = "sha256:5784a1d9a9a4a8acd6ca1e347003a5e2e1b3c749b4d41e7da4904577adade111", size = 16069807 }, ] [[package]] @@ -1139,9 +1158,9 @@ dependencies = [ { name = "attrs" }, { name = "cattrs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896 } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250 }, ] [[package]] @@ -1154,21 +1173,21 @@ dependencies = [ { name = "onnxruntime" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474, upload-time = "2025-10-30T15:22:25.298Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841, upload-time = "2025-10-30T15:22:27.413Z" }, - { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335, upload-time = "2025-10-30T15:22:29.907Z" }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831, upload-time = "2025-10-30T15:22:32.063Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e4/35c323beb3280482c94299d61626116856ac2d4ec16ecef50afc4fdd4291/magika-0.6.3-py3-none-any.whl", hash = "sha256:eda443d08006ee495e02083b32e51b98cb3696ab595a7d13900d8e2ef506ec9d", size = 2969474 }, + { url = "https://files.pythonhosted.org/packages/25/8f/132b0d7cd51c02c39fd52658a5896276c30c8cc2fd453270b19db8c40f7e/magika-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:86901e64b05dde5faff408c9b8245495b2e1fd4c226e3393d3d2a3fee65c504b", size = 13358841 }, + { url = "https://files.pythonhosted.org/packages/c4/03/5ed859be502903a68b7b393b17ae0283bf34195cfcca79ce2dc25b9290e7/magika-0.6.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3d9661eedbdf445ac9567e97e7ceefb93545d77a6a32858139ea966b5806fb64", size = 15367335 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8ee7d644affa3b80efdd623a3d75865c8f058f3950cb87fb0c48e3559bc/magika-0.6.3-py3-none-win_amd64.whl", hash = "sha256:e57f75674447b20cab4db928ae58ab264d7d8582b55183a0b876711c2b2787f3", size = 12692831 }, ] [[package]] name = "markdown" version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 }, ] [[package]] @@ -1178,9 +1197,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, ] [[package]] @@ -1191,9 +1210,9 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816, upload-time = "2025-11-16T19:21:18.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/bc/c8c8eea5335341306b0fa7e1cb33c5e1c8d24ef70ddd684da65f41c49c92/markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09", size = 18816 } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724, upload-time = "2025-11-16T19:21:17.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/f1e3e9d959db134cedf06825fae8d5b294bd368aacdd0831a3975b7c4d55/markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a", size = 15724 }, ] [[package]] @@ -1208,28 +1227,28 @@ dependencies = [ { name = "markdownify" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099, upload-time = "2026-02-20T19:45:23.886Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/93/3b93c291c99d09f64f7535ba74c1c6a3507cf49cffd38983a55de6f834b6/markitdown-0.1.5.tar.gz", hash = "sha256:4c956ff1528bf15e1814542035ec96e989206d19d311bb799f4df973ecafc31a", size = 45099 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402, upload-time = "2026-02-20T19:45:27.195Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8b/fd7e042455a829a1ede0bc8e9e3061aa6c7c4cf745385526ef62ff1b5a5b/markitdown-0.1.5-py3-none-any.whl", hash = "sha256:5180a9a841e20fc01c2c09dbc5d039638429bbebcdc2af1b2615c3c427840434", size = 63402 }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, ] [[package]] @@ -1239,9 +1258,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110 } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, ] [[package]] @@ -1264,54 +1283,54 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615 }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] [[package]] name = "mpmath" version = "1.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106 } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198 }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893 }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456 }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872 }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018 }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883 }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413 }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404 }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456 }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322 }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955 }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254 }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059 }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588 }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642 }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377 }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887 }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053 }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307 }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, ] [[package]] @@ -1324,52 +1343,52 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053 }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134 }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616 }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847 }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976 }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104 }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239 }, ] [[package]] name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] [[package]] name = "numpy" version = "2.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587 } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272 }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573 }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782 }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038 }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666 }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480 }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036 }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643 }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117 }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584 }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450 }, ] [[package]] name = "olefile" version = "0.47" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240 } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565 }, ] [[package]] @@ -1385,11 +1404,11 @@ dependencies = [ { name = "sympy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580, upload-time = "2024-11-21T00:49:07.029Z" }, - { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833, upload-time = "2024-11-21T00:49:10.563Z" }, - { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903, upload-time = "2024-11-21T00:49:12.984Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562, upload-time = "2024-11-21T00:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482, upload-time = "2024-11-21T00:49:19.412Z" }, + { url = "https://files.pythonhosted.org/packages/e5/39/9335e0874f68f7d27103cbffc0e235e32e26759202df6085716375c078bb/onnxruntime-1.20.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:22b0655e2bf4f2161d52706e31f517a0e54939dc393e92577df51808a7edc8c9", size = 31007580 }, + { url = "https://files.pythonhosted.org/packages/c5/9d/a42a84e10f1744dd27c6f2f9280cc3fb98f869dd19b7cd042e391ee2ab61/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f56e898815963d6dc4ee1c35fc6c36506466eff6d16f3cb9848cea4e8c8172", size = 11952833 }, + { url = "https://files.pythonhosted.org/packages/47/42/2f71f5680834688a9c81becbe5c5bb996fd33eaed5c66ae0606c3b1d6a02/onnxruntime-1.20.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb71a814f66517a65628c9e4a2bb530a6edd2cd5d87ffa0af0f6f773a027d99e", size = 13333903 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/aabfdf91d013320aa2fc46cf43c88ca0182860ff15df872b4552254a9680/onnxruntime-1.20.1-cp312-cp312-win32.whl", hash = "sha256:bd386cc9ee5f686ee8a75ba74037750aca55183085bf1941da8efcfe12d5b120", size = 9814562 }, + { url = "https://files.pythonhosted.org/packages/dd/80/76979e0b744307d488c79e41051117634b956612cc731f1028eb17ee7294/onnxruntime-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:19c2d843eb074f385e8bbb753a40df780511061a63f9def1b216bf53860223fb", size = 11331482 }, ] [[package]] @@ -1406,9 +1425,37 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656 }, +] + +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052 }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781 }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638 }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062 }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", marker = "sys_platform != 'win32'" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/21/76/9417a6aef9def70e467a5bf560579f816148a4c658b7d525581b356eda9e/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c8cfc8e87ed452b5cecb9419473ee5560a989859fe1d10d1ce11ae87b09a2cb", size = 33703709 }, + { url = "https://files.pythonhosted.org/packages/92/ce/bd17ff5772938267fd49716e94ca24f616ff4cb1ff4c6be13085108037be/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0525a3d2c0b46c611e2130b5fdebc94cf404845d8fa64d2f3a3b679572a5bd22", size = 56016764 }, + { url = "https://files.pythonhosted.org/packages/8f/b4/b7bcbf7c874665825a8c8e1097e93ea25d1f1d210a3e20d4451d01da30aa/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb60e36b237b1ebd40a912da5384b348df8ed534f6f644d8e0b4f103e272ba7d", size = 35010236 }, + { url = "https://files.pythonhosted.org/packages/4b/33/b5db29a6c00eb8f50708110d8d453747ca125c8b805bc437b289dbdcc057/opencv_python_headless-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0bd48544f77c68b2941392fcdf9bcd2b9cdf00e98cb8c29b2455d194763cf99e", size = 60391106 }, ] [[package]] @@ -1419,9 +1466,9 @@ dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676 }, ] [[package]] @@ -1431,9 +1478,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369 }, ] [[package]] @@ -1449,9 +1496,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772, upload-time = "2026-03-04T14:17:25.324Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/fa/73d50e2c15c56be4d000c98e24221d494674b0cc95524e2a8cb3856d95a4/opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c", size = 17772 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960 }, ] [[package]] @@ -1461,9 +1508,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073 }, ] [[package]] @@ -1475,9 +1522,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951 }, ] [[package]] @@ -1488,36 +1535,36 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621 }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, ] [[package]] name = "parso" version = "0.8.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894 }, ] [[package]] name = "pathspec" version = "1.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206 }, ] [[package]] @@ -1527,37 +1574,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, ] [[package]] name = "pillow" version = "12.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819 } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279 }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490 }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462 }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744 }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371 }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215 }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783 }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112 }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } 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" }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] [[package]] @@ -1567,75 +1614,75 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, ] [[package]] name = "propcache" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, ] [[package]] name = "protobuf" version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739 }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089 }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737 }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610 }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381 }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436 }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656 }, ] [[package]] name = "ptyprocess" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, ] [[package]] name = "pure-eval" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] [[package]] name = "pyasn1" version = "0.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997 }, ] [[package]] @@ -1645,37 +1692,37 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } 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" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, ] [[package]] name = "pycryptodome" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276 } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, - { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, - { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, - { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, - { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, - { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, - { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627 }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362 }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625 }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954 }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534 }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853 }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465 }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414 }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484 }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636 }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675 }, ] [[package]] @@ -1688,9 +1735,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, ] [[package]] @@ -1700,26 +1747,26 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, ] [[package]] @@ -1731,9 +1778,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929 }, ] [[package]] @@ -1745,27 +1792,27 @@ dependencies = [ { name = "cattrs" }, { name = "lsprotocol" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac", size = 54852, upload-time = "2026-03-19T09:16:56.853Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/06/de0a8db764391f1b3ab8ba4eaa6d1fe923151a1999350c15b987b46a3718/pygls-2.1.0.tar.gz", hash = "sha256:3f2247717deeda9174d9c2f76130ff4d3e0e0788a5be47212df248d163453aac", size = 54852 } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9", size = 68719, upload-time = "2026-03-19T09:16:57.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/1b/523fa1d7a9ed16d41dc1a33533def97712eb3aff660d2f124033db019461/pygls-2.1.0-py3-none-any.whl", hash = "sha256:cfa8443561488cb15b59f6ce64cabfa37d79753f7120c1bf729419246bf747f9", size = 68719 }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, ] [[package]] name = "pyjwt" version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564 } 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" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726 }, ] [package.optional-dependencies] @@ -1777,33 +1824,33 @@ crypto = [ name = "pymupdf" version = "1.27.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380, upload-time = "2026-03-20T09:47:58.386Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/32/f6b645c51d79a188a4844140c5dabca7b487ad56c4be69c4bc782d0d11a9/pymupdf-1.27.2.2.tar.gz", hash = "sha256:ea8fdc3ab6671ca98f629d5ec3032d662c8cf1796b146996b7ad306ac7ed3335", size = 85354380 } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563, upload-time = "2026-03-19T12:35:42.989Z" }, - { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089, upload-time = "2026-03-20T09:44:16.982Z" }, - { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817, upload-time = "2026-03-20T09:44:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135, upload-time = "2026-03-20T09:44:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585, upload-time = "2026-03-20T09:45:05.393Z" }, - { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861, upload-time = "2026-03-20T09:45:21.353Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032, upload-time = "2026-03-20T09:45:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/90/88/d01992a50165e22dec057a1129826846c547feb4ba07f42720ac030ce438/pymupdf-1.27.2.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:800f43e60a6f01f644343c2213b8613db02eaf4f4ba235b417b3351fa99e01c0", size = 23987563 }, + { url = "https://files.pythonhosted.org/packages/6d/0e/9f526bc1d49d8082eff0d1547a69d541a0c5a052e71da625559efaba46a6/pymupdf-1.27.2.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2e4299ef1ac0c9dff9be096cbd22783699673abecfa7c3f73173ae06421d73", size = 23263089 }, + { url = "https://files.pythonhosted.org/packages/42/be/984f0d6343935b5dd30afaed6be04fc753146bf55709e63ef28bf9ef7497/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5e3d54922db1c7da844f1208ac1db05704770988752311f81dd36694ae0a07b", size = 24318817 }, + { url = "https://files.pythonhosted.org/packages/22/8e/85e9d9f11dbf34036eb1df283805ef6b885f2005a56d6533bb58ab0b8a11/pymupdf-1.27.2.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:892698c9768457eb0991c102c96a856c0a7062539371df5e6bee0816f3ef498e", size = 24948135 }, + { url = "https://files.pythonhosted.org/packages/db/e6/386edb017e5b93f1ab0bf6653ae32f3dd8dfc834ed770212e10ca62f4af9/pymupdf-1.27.2.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b4bbfa6ef347fade678771a93f6364971c51a2cdc44cd2400dc4eeed1ddb4e6", size = 25169585 }, + { url = "https://files.pythonhosted.org/packages/ba/fd/f1ebe24fcd31aaea8b85b3a7ac4c3fc96e20388be5466ace27c9a3c546d9/pymupdf-1.27.2.2-cp310-abi3-win32.whl", hash = "sha256:0b8e924433b7e0bd46be820899300259235997d5a747638471fb2762baa8ee30", size = 18008861 }, + { url = "https://files.pythonhosted.org/packages/a8/b6/2a9a8556000199bbf80a5915dcd15d550d1e5288894316445c54726aaf53/pymupdf-1.27.2.2-cp310-abi3-win_amd64.whl", hash = "sha256:09bb53f9486ccb5297030cbc2dbdae845ba1c3c5126e96eb2d16c4f118de0b5b", size = 19238032 }, ] [[package]] name = "pypdf" version = "6.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744 }, ] [[package]] name = "pyreadline3" version = "3.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 }, ] [[package]] @@ -1817,9 +1864,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, ] [[package]] @@ -1830,9 +1877,9 @@ dependencies = [ { name = "pytest" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] @@ -1844,9 +1891,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, ] [[package]] @@ -1856,45 +1903,45 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] [[package]] name = "python-dotenv" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] [[package]] name = "python-multipart" version = "0.0.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 }, ] [[package]] name = "python-socks" version = "2.8.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909 } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087 }, ] [[package]] name = "pytz" version = "2026.1.post1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088 } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489 }, ] [[package]] @@ -1902,27 +1949,27 @@ 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/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } 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/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, ] [[package]] @@ -1934,33 +1981,33 @@ dependencies = [ { name = "rpds-py" }, { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } 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" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, ] [[package]] name = "regex" version = "2026.2.28" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184 } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, - { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, - { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, - { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, - { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, - { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, - { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, - { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, - { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574 }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426 }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200 }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765 }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093 }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455 }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037 }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113 }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194 }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846 }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516 }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278 }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068 }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416 }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297 }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408 }, ] [[package]] @@ -1973,9 +2020,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, ] [[package]] @@ -1985,9 +2032,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, ] [[package]] @@ -1998,57 +2045,57 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582 } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } 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/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, ] [[package]] name = "ruff" version = "0.15.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277 } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037 }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433 }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302 }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625 }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743 }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536 }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292 }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981 }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422 }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158 }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861 }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310 }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752 }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961 }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538 }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839 }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304 }, ] [[package]] @@ -2058,72 +2105,72 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, - { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, - { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, ] [[package]] name = "smmap" version = "5.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } 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" }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] [[package]] name = "socksio" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763 }, ] [[package]] name = "soupsieve" version = "2.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627 } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016 }, ] [[package]] @@ -2134,16 +2181,16 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, - { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, - { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, - { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, - { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, - { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737 }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020 }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983 }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690 }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738 }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546 }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484 }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202 }, ] [[package]] @@ -2154,9 +2201,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329 }, ] [[package]] @@ -2168,9 +2215,9 @@ dependencies = [ { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] [[package]] @@ -2181,18 +2228,18 @@ dependencies = [ { name = "anyio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350 }, ] [[package]] name = "striprtf" version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533, upload-time = "2025-03-27T22:55:56.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/86/7154b7c625a3ff704581dab70c05389e1de90233b7a751f79f712c2ca0e9/striprtf-0.0.29.tar.gz", hash = "sha256:5a822d075e17417934ed3add6fc79b5fc8fb544fe4370b2f894cdd28f0ddd78e", size = 7533 } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879, upload-time = "2025-03-27T22:55:55.977Z" }, + { url = "https://files.pythonhosted.org/packages/08/3e/1418afacc4aae04690cff282078f22620c89a99490499878ececc3021654/striprtf-0.0.29-py3-none-any.whl", hash = "sha256:0fc6a41999d015358d19627776b616424dd501ad698105c81d76734d1e14d91b", size = 7879 }, ] [[package]] @@ -2202,18 +2249,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353 }, ] [[package]] name = "tenacity" version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, ] [[package]] @@ -2224,15 +2271,15 @@ dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728, upload-time = "2025-10-06T20:21:52.756Z" }, - { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049, upload-time = "2025-10-06T20:21:53.782Z" }, - { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008, upload-time = "2025-10-06T20:21:54.832Z" }, - { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665, upload-time = "2025-10-06T20:21:56.129Z" }, - { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230, upload-time = "2025-10-06T20:21:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688, upload-time = "2025-10-06T20:21:58.619Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694, upload-time = "2025-10-06T20:21:59.876Z" }, + { url = "https://files.pythonhosted.org/packages/a4/85/be65d39d6b647c79800fd9d29241d081d4eeb06271f383bb87200d74cf76/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8", size = 1050728 }, + { url = "https://files.pythonhosted.org/packages/4a/42/6573e9129bc55c9bf7300b3a35bef2c6b9117018acca0dc760ac2d93dffe/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b", size = 994049 }, + { url = "https://files.pythonhosted.org/packages/66/c5/ed88504d2f4a5fd6856990b230b56d85a777feab84e6129af0822f5d0f70/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37", size = 1129008 }, + { url = "https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad", size = 1152665 }, + { url = "https://files.pythonhosted.org/packages/a3/fe/26df24ce53ffde419a42f5f53d755b995c9318908288c17ec3f3448313a3/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5", size = 1194230 }, + { url = "https://files.pythonhosted.org/packages/20/cc/b064cae1a0e9fac84b0d2c46b89f4e57051a5f41324e385d10225a984c24/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3", size = 1254688 }, + { url = "https://files.pythonhosted.org/packages/81/10/b8523105c590c5b8349f2587e2fdfe51a69544bd5a76295fc20f2374f470/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd", size = 878694 }, ] [[package]] @@ -2242,32 +2289,32 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, - { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, - { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, - { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, - { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, - { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, - { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, - { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, - { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275 }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472 }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736 }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835 }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673 }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818 }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195 }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982 }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245 }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069 }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263 }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429 }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363 }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786 }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133 }, ] [[package]] name = "toml" version = "0.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, ] [[package]] @@ -2277,33 +2324,33 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598 } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374 }, ] [[package]] name = "traitlets" version = "5.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, ] [[package]] name = "tree-sitter" version = "0.25.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961 } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, - { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, - { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, - { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, - { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, - { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941 }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699 }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125 }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418 }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250 }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156 }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984 }, ] [[package]] @@ -2316,18 +2363,18 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813 }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, ] [[package]] @@ -2337,27 +2384,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, ] [[package]] name = "ulid-py" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0", size = 22514, upload-time = "2020-09-15T15:35:09.414Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/53/d14a8ec344048e21431821cb49e9a6722384f982b889c2dd449428dbdcc1/ulid-py-1.1.0.tar.gz", hash = "sha256:dc6884be91558df077c3011b9fb0c87d1097cb8fc6534b11f310161afd5738f0", size = 22514 } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987", size = 25753, upload-time = "2020-09-15T15:35:08.075Z" }, + { url = "https://files.pythonhosted.org/packages/42/7c/a12c879fe6c2b136a718c142115ff99397fbf62b4929d970d58ae386d55f/ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987", size = 25753 }, ] [[package]] name = "urllib3" version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, ] [[package]] @@ -2368,9 +2415,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830 }, ] [package.optional-dependencies] @@ -2388,35 +2435,35 @@ standard = [ 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" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } 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/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] [[package]] @@ -2426,48 +2473,48 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 }, ] [[package]] name = "wcwidth" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684 } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189 }, ] [[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" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } 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/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, ] [[package]] @@ -2479,28 +2526,28 @@ dependencies = [ { name = "httpx" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a", size = 58176, upload-time = "2026-03-24T04:05:34.801Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f3/3809ff4562145094f29e96e8b4e0267a54941c40d26579a82d9c5928ad33/wecom_aibot_sdk-1.0.5.tar.gz", hash = "sha256:78b0d748f945fd877ca94a8e369d13033028dc3d758fe1635945df31bf984e1a", size = 58176 } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05", size = 26229, upload-time = "2026-03-24T04:05:33.298Z" }, + { url = "https://files.pythonhosted.org/packages/25/c2/e3a681e0cbaa4394fca003aa0f47a212665264ae49c343e144b458dd5ef0/wecom_aibot_sdk-1.0.5-py3-none-any.whl", hash = "sha256:3f23c7eca0bf8092244840c4563d7fda6aa6741604ff4c329f85c2e6bb47bf05", size = 26229 }, ] [[package]] name = "wrapt" version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, - { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, - { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, - { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, - { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, - { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, - { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, - { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998 }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020 }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098 }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036 }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156 }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102 }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732 }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705 }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877 }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885 }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, ] [[package]] @@ -2512,34 +2559,34 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, - { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, - { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, - { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, - { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, - { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, - { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, - { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, - { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, - { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737 }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029 }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310 }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587 }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528 }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339 }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061 }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132 }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289 }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950 }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960 }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703 }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325 }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067 }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285 }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359 }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674 }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879 }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288 }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, ] From 1a244b70a7fbb3389b5c86d05d480ff3e239c332 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Thu, 2 Jul 2026 16:56:58 +0800 Subject: [PATCH 04/86] feat: add project management to session sidebar --- flocks/project/project.py | 33 ++ flocks/server/routes/project.py | 45 ++ flocks/server/routes/session.py | 17 + tests/server/routes/test_session_routes.py | 26 + webui/src/api/session.ts | 2 +- webui/src/locales/en-US/session.json | 16 + webui/src/locales/zh-CN/session.json | 16 + webui/src/pages/Session/index.test.tsx | 120 +++++ webui/src/pages/Session/index.tsx | 598 +++++++++++++++------ 9 files changed, 698 insertions(+), 175 deletions(-) diff --git a/flocks/project/project.py b/flocks/project/project.py index 4be59d220..204025406 100644 --- a/flocks/project/project.py +++ b/flocks/project/project.py @@ -46,6 +46,39 @@ class Project: """Project management namespace""" _current: Optional[ProjectInfo] = None + + @classmethod + async def create( + cls, + *, + name: str, + worktree: str, + icon: Optional[ProjectIcon] = None, + ) -> ProjectInfo: + """ + Create a user-managed project grouping. + + User-managed projects share the provided worktree but get their own + project ID so sessions can be organized independently from the default + auto-discovered project. + """ + now = int(datetime.now().timestamp() * 1000) + project_id = Identifier.ascending("slug").replace("slg_", "prj_", 1) + project = ProjectInfo( + id=project_id, + worktree=worktree, + vcs=None, + name=name, + icon=icon, + sandboxes=[], + time=ProjectTime( + created=now, + updated=now, + ), + ) + await Storage.write(["project", project_id], project) + log.info("project.created", {"id": project_id}) + return project @classmethod async def from_directory(cls, directory: str) -> Dict[str, Any]: diff --git a/flocks/server/routes/project.py b/flocks/server/routes/project.py index e03f5dd45..95e2bd20e 100644 --- a/flocks/server/routes/project.py +++ b/flocks/server/routes/project.py @@ -21,6 +21,14 @@ class ProjectUpdateRequest(BaseModel): icon: Optional[ProjectIcon] = None +class ProjectCreateRequest(BaseModel): + """Project create request""" + name: str + worktree: Optional[str] = None + icon: Optional[ProjectIcon] = None + + +@router.get("", response_model=List[ProjectInfo], include_in_schema=False) @router.get("/", response_model=List[ProjectInfo], summary="List all projects") async def list_projects(): """ @@ -36,6 +44,43 @@ async def list_projects(): raise HTTPException(status_code=500, detail=str(e)) +@router.post("", response_model=ProjectInfo, include_in_schema=False) +@router.post("/", response_model=ProjectInfo, summary="Create project") +async def create_project(request: ProjectCreateRequest): + """ + Create a user-managed project. + + User-managed projects organize sessions without changing the current + workspace execution directory. + """ + name = request.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Project name cannot be empty") + + try: + import os + from flocks.project.instance import Instance + + worktree = request.worktree + if not worktree: + try: + worktree = Instance.directory + except Exception: + worktree = os.getcwd() + + project = await Project.create( + name=name, + worktree=str(worktree), + icon=request.icon, + ) + return project + except HTTPException: + raise + except Exception as e: + log.error("project.create.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + @router.get("/current", response_model=ProjectInfo, summary="Get current project") async def get_current_project(): """ diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 7d7858b2d..e63c47dfe 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -72,6 +72,7 @@ class SessionCreateRequest(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="ignore") parentID: Optional[str] = Field(None, alias="parent_id", description="Parent session ID") + projectID: Optional[str] = Field(None, alias="project_id", description="Project ID") title: Optional[str] = Field(None, description="Session title") permission: Optional[List[PermissionRule]] = Field(None, description="Permission rules") category: Optional[str] = Field(None, description="Session category (e.g. 'user', 'workflow')") @@ -143,6 +144,8 @@ class SessionListItem(BaseModel): model_config = ConfigDict(populate_by_name=True, by_alias=True) id: str + projectID: str + directory: str title: str time: SessionTime category: str = "user" @@ -198,6 +201,8 @@ def _session_to_list_item(session: SessionModel) -> SessionListItem: current_user = get_current_auth_user() return SessionListItem( id=session.id, + projectID=session.project_id, + directory=session.directory, title=session.title, time=SessionTime( created=session.time.created, @@ -485,6 +490,18 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR except Exception: directory = os.getcwd() project_id = "default" + + if request.projectID: + from flocks.project.project import Project + + project = await Project.get(request.projectID) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project {request.projectID} not found", + ) + project_id = project.id + directory = project.worktree # Trigger command:new hook if creating from parent (like /new command) if request.parentID: diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 5aa2e8994..cf2307db9 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -161,6 +161,8 @@ async def test_list_sessions_light_manager_filters_and_omits_heavy_fields(self, row = next(item for item in data if item["id"] == user_id) assert set(row) == { "id", + "projectID", + "directory", "title", "time", "category", @@ -172,9 +174,33 @@ async def test_list_sessions_light_manager_filters_and_omits_heavy_fields(self, "canDelete", "isShared", } + assert row["projectID"] + assert row["directory"] assert "goal" not in row assert "summary" not in row + @pytest.mark.asyncio + async def test_create_session_in_user_managed_project(self, client: AsyncClient): + """Session creation can target a user-managed project.""" + project_resp = await client.post("/api/project", json={"name": "Labs"}) + assert project_resp.status_code == status.HTTP_200_OK + project = project_resp.json() + + session_resp = await client.post( + "/api/session", + json={"title": "Project Session", "projectID": project["id"]}, + ) + assert session_resp.status_code == status.HTTP_200_OK + assert session_resp.json()["projectID"] == project["id"] + + list_resp = await client.get( + "/api/session", + params={"view": "list", "manager": "true", "roots": "true", "limit": "100"}, + ) + row = next(item for item in list_resp.json() if item["id"] == session_resp.json()["id"]) + assert row["projectID"] == project["id"] + assert row["directory"] == project["worktree"] + @pytest.mark.asyncio async def test_get_session(self, client: AsyncClient, session_id: str): """GET /api/session/{id} returns the specific session.""" diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index eee2df3a0..b07678af5 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -125,7 +125,7 @@ export const sessionApi = { /** * 创建会话 */ - create: async (data?: { title?: string; parentID?: string }) => { + create: async (data?: { title?: string; parentID?: string; projectID?: string }) => { const response = await client.post('/api/session', data || {}); return response.data; }, diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index b3711c26e..4cef0ac10 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -23,6 +23,8 @@ "moreActions": "More actions", "selectMode": "Batch select", "cancelSelect": "Cancel", + "cancel": "Cancel", + "save": "Save", "selectAll": "Select All", "deselectAll": "Deselect All", "deleteSelected": "Delete ({{count}})", @@ -248,6 +250,20 @@ "groupThisWeek": "This Week", "groupLastWeek": "Last Week", "groupEarlier": "Earlier", + "projectsSection": "Projects", + "toggleProject": "Expand or collapse project {{project}}", + "selectProject": "Select project {{project}}", + "defaultProject": "Default project", + "defaultProjectName": "Default", + "noProjectSessions": "No conversations", + "projectDialog": { + "createTitle": "New Project", + "renameTitle": "Rename Project", + "nameLabel": "Project name", + "namePlaceholder": "Enter project name", + "nameEmpty": "Project name cannot be empty", + "saveFailed": "Failed to save project" + }, "filterConversations": "Filter conversations...", "noResults": "No conversations found", "showMore": "Show {{count}} more", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index fc8748cd0..f80e49eb6 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -23,6 +23,8 @@ "moreActions": "更多操作", "selectMode": "批量选择", "cancelSelect": "取消", + "cancel": "取消", + "save": "保存", "selectAll": "全选", "deselectAll": "取消全选", "deleteSelected": "删除 ({{count}})", @@ -248,6 +250,20 @@ "groupThisWeek": "本周", "groupLastWeek": "上周", "groupEarlier": "更早", + "projectsSection": "项目", + "toggleProject": "展开或收起项目 {{project}}", + "selectProject": "选择项目 {{project}}", + "defaultProject": "默认项目", + "defaultProjectName": "默认", + "noProjectSessions": "暂无会话", + "projectDialog": { + "createTitle": "新建项目", + "renameTitle": "重命名项目", + "nameLabel": "项目名称", + "namePlaceholder": "输入项目名称", + "nameEmpty": "项目名称不能为空", + "saveFailed": "保存项目失败" + }, "filterConversations": "搜索会话...", "noResults": "没有匹配的会话", "showMore": "显示更多 {{count}} 条", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 4ae8932e0..8216b8054 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -21,6 +21,8 @@ const { toast, } = vi.hoisted(() => ({ client: { + get: vi.fn(), + patch: vi.fn(), post: vi.fn(), }, sessionApi: { @@ -248,8 +250,19 @@ describe('SessionPage session actions menu', () => { }); defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: '', model_id: '' } }); modelV2API.listDefinitions.mockResolvedValue({ data: { models: [] } }); + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') { + return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' } }); + } + return Promise.resolve({ + data: [ + { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }, + ], + }); + }); sessionApi.update.mockResolvedValue({ ...session, title: 'Renamed Session' }); + client.patch.mockResolvedValue({ data: { id: 'project-1', worktree: '/tmp/project', name: 'Renamed Project' } }); client.post.mockResolvedValue({ data: secondSession }); sessionApi.get.mockResolvedValue(session); sessionApi.getMessages.mockResolvedValue([ @@ -268,6 +281,113 @@ describe('SessionPage session actions menu', () => { vi.stubGlobal('confirm', vi.fn(() => true)); }); + it('groups sidebar sessions under their project', async () => { + const user = userEvent.setup(); + + renderSessionPage(); + + const projectLabel = await screen.findByText('Security Project'); + expect(screen.getByText('Original Session')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'toggleProject' })); + + expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); + }); + + it('merges legacy default sessions into the current project group', async () => { + useSessions.mockReturnValue({ + sessions: [ + session, + { + ...secondSession, + projectID: 'default', + directory: '/tmp/project', + title: 'Legacy Default Session', + }, + ], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + + renderSessionPage(); + + await screen.findByText('Security Project'); + expect(screen.getByText('Original Session')).toBeInTheDocument(); + expect(screen.getByText('Legacy Default Session')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('project')).not.toBeInTheDocument(); + }); + + it('labels the unnamed current project as default', async () => { + client.get.mockImplementation((url: string) => { + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: null }; + if (url === '/api/project/current') { + return Promise.resolve({ data: currentProject }); + } + return Promise.resolve({ data: [currentProject] }); + }); + + renderSessionPage(); + + expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); + expect(screen.queryByText('project')).not.toBeInTheDocument(); + }); + + it('creates a user-managed project from the sidebar', async () => { + const user = userEvent.setup(); + let projectRows = [ + { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }, + ]; + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') { + return Promise.resolve({ data: projectRows[0] }); + } + return Promise.resolve({ data: projectRows }); + }); + client.post.mockImplementation((url: string, payload: Record) => { + if (url === '/api/project') { + const created = { id: 'project-2', worktree: '/tmp/project', name: payload.name as string }; + projectRows = [projectRows[0], created]; + return Promise.resolve({ data: created }); + } + return Promise.resolve({ data: secondSession }); + }); + + renderSessionPage(); + + await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); + await user.type(screen.getByLabelText('projectDialog.nameLabel'), 'Labs'); + await user.click(screen.getByRole('button', { name: 'save' })); + + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/api/project', { name: 'Labs' }); + expect(screen.getByText('Labs')).toBeInTheDocument(); + }); + }); + + it('renames a project from the sidebar', async () => { + const user = userEvent.setup(); + + renderSessionPage(); + + await screen.findByText('Security Project'); + await user.click(screen.getByRole('button', { name: 'projectDialog.renameTitle' })); + const input = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(input); + await user.type(input, 'Renamed Project'); + await user.click(screen.getByRole('button', { name: 'save' })); + + await waitFor(() => { + expect(client.patch).toHaveBeenCalledWith('/api/project/project-1', { name: 'Renamed Project' }); + }); + }); + it('opens the actions menu for a session item', async () => { const user = userEvent.setup(); diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index dca793107..d2d1f85ca 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { MessageSquare, Plus, Trash2, - ChevronDown, Sparkles, Shield, Search, AlertTriangle, + ChevronDown, ChevronRight, Sparkles, Shield, Search, AlertTriangle, PanelLeftClose, PanelLeft, Bot, Loader2, Workflow as WorkflowIcon, Settings2, CheckSquare, MoreHorizontal, PencilLine, Download, Share2, Cpu, Info, + FolderGit2, FolderPlus, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import i18n from '@/i18n'; @@ -37,6 +38,18 @@ function sanitizeSessionExportName(value: string) { const LAST_SELECTED_SESSION_STORAGE_KEY = 'flocks:last-selected-session'; const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; +type ProjectSummary = { + id: string; + worktree: string; + name?: string | null; +}; +type ProjectDialogMode = 'create' | 'rename'; +type ProjectSessionGroup = { + id: string; + label: string; + worktree: string; + sessions: Session[]; +}; type ChatModelOption = { key: string; providerID: string; @@ -113,6 +126,27 @@ function makeModelKey(providerID: string, modelID: string): string { return `${providerID}::${modelID}`; } +function getPathBasename(path: string): string { + const normalized = path.replace(/[\\/]+$/g, ''); + const parts = normalized.split(/[\\/]/).filter(Boolean); + return parts[parts.length - 1] || path || 'Project'; +} + +function getProjectLabel(project?: ProjectSummary, fallbackDirectory?: string): string { + const explicitName = project?.name?.trim(); + if (explicitName) return explicitName; + const directory = project?.worktree || fallbackDirectory || ''; + return getPathBasename(directory); +} + +function normalizeProjectPath(path?: string): string { + return (path || '').replace(/[\\/]+$/g, ''); +} + +function isLegacyDefaultProjectId(projectId?: string): boolean { + return !projectId || projectId === 'default' || projectId === 'global'; +} + export default function SessionPage() { const { t, i18n } = useTranslation('session'); const location = useLocation(); @@ -131,6 +165,14 @@ export default function SessionPage() { const [pendingInitialMessage, setPendingInitialMessage] = useState(null); const [selectMode, setSelectMode] = useState(false); const [checkedIds, setCheckedIds] = useState>(new Set()); + const [projects, setProjects] = useState([]); + const [currentProjectId, setCurrentProjectId] = useState(null); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [collapsedProjectIds, setCollapsedProjectIds] = useState>(new Set()); + const [projectDialogMode, setProjectDialogMode] = useState(null); + const [editingProjectId, setEditingProjectId] = useState(null); + const [projectNameValue, setProjectNameValue] = useState(''); + const [projectSubmitting, setProjectSubmitting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); const [openMenuSessionId, setOpenMenuSessionId] = useState(null); const [menuAnchor, setMenuAnchor] = useState<{ top: number; right: number } | null>(null); @@ -259,55 +301,78 @@ export default function SessionPage() { const selectedSession = listedSelectedSession ?? (selectedSessionFallback?.id === selectedSessionId ? selectedSessionFallback : null); - // 今天/昨天不限制;本周/上周/更早默认只显示 5 条 - const GROUP_DEFAULT_LIMIT: Record = { - today: Infinity, - yesterday: Infinity, - thisWeek: 5, - lastWeek: 5, - earlier: 5, - }; - - const [expandedGroups, setExpandedGroups] = useState>(new Set()); - const toggleGroupExpand = useCallback((key: string) => { - setExpandedGroups(prev => { + const toggleProjectCollapsed = useCallback((projectId: string) => { + setCollapsedProjectIds(prev => { const next = new Set(prev); - next.has(key) ? next.delete(key) : next.add(key); + next.has(projectId) ? next.delete(projectId) : next.add(projectId); return next; }); }, []); - const groupedSessions = useMemo(() => { - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const yesterdayStart = todayStart - 86400000; - // Week starts on Monday - const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); - const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; - const lastWeekStart = thisWeekStart - 7 * 86400000; - - const q = searchQuery.toLowerCase().trim(); - const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; - - const buckets: { key: string; labelKey: string; items: typeof sessions }[] = [ - { key: 'today', labelKey: 'groupToday', items: [] }, - { key: 'yesterday', labelKey: 'groupYesterday', items: [] }, - { key: 'thisWeek', labelKey: 'groupThisWeek', items: [] }, - { key: 'lastWeek', labelKey: 'groupLastWeek', items: [] }, - { key: 'earlier', labelKey: 'groupEarlier', items: [] }, - ]; - - for (const s of filtered) { - const ts = s.time?.updated ?? 0; - if (ts >= todayStart) buckets[0].items.push(s); - else if (ts >= yesterdayStart) buckets[1].items.push(s); - else if (ts >= thisWeekStart) buckets[2].items.push(s); - else if (ts >= lastWeekStart) buckets[3].items.push(s); - else buckets[4].items.push(s); + const projectSessionGroups = useMemo(() => { + const projectMap = new Map(); + const currentProject = currentProjectId + ? projects.find((project) => project.id === currentProjectId) + : undefined; + const currentProjectWorktree = normalizeProjectPath(currentProject?.worktree); + + projects.forEach((project) => { + const isCurrentProject = currentProjectId === project.id; + const hasExplicitName = Boolean(project.name?.trim()); + projectMap.set(project.id, { + id: project.id, + label: isCurrentProject && !hasExplicitName ? t('defaultProjectName') : getProjectLabel(project), + worktree: project.worktree, + sessions: [], + }); + }); + + sessions.forEach((session) => { + const sessionDirectory = normalizeProjectPath(session.directory); + const projectId = isLegacyDefaultProjectId(session.projectID) && currentProjectId && sessionDirectory === currentProjectWorktree + ? currentProjectId + : session.projectID || session.directory || 'global'; + const existing = projectMap.get(projectId); + if (existing) { + existing.sessions.push(session); + return; + } + + projectMap.set(projectId, { + id: projectId, + label: getProjectLabel(undefined, session.directory), + worktree: session.directory, + sessions: [session], + }); + }); + + return Array.from(projectMap.values()) + .filter((group) => group.sessions.length > 0 || !searchQuery.trim()) + .sort((a, b) => { + const aLatest = a.sessions[0]?.time?.updated ?? 0; + const bLatest = b.sessions[0]?.time?.updated ?? 0; + if (aLatest !== bLatest) return bLatest - aLatest; + return a.label.localeCompare(b.label); + }); + }, [currentProjectId, projects, searchQuery, sessions, t]); + + const selectedProjectIDForCreate = selectedProjectId ?? currentProjectId ?? projectSessionGroups[0]?.id ?? null; + + useEffect(() => { + if (projectSessionGroups.length === 0) { + setSelectedProjectId(null); + return; + } + + if (selectedProjectId && projectSessionGroups.some((group) => group.id === selectedProjectId)) { + return; } - return buckets.filter(b => b.items.length > 0); - }, [sessions, searchQuery]); + const fallbackProjectId = currentProjectId && projectSessionGroups.some((group) => group.id === currentProjectId) + ? currentProjectId + : projectSessionGroups[0].id; + setSelectedProjectId(fallbackProjectId); + }, [currentProjectId, projectSessionGroups, selectedProjectId]); // Handle SSE events for session-level updates (title changes, etc.) const handleChatError = useCallback((msg: string) => { @@ -328,6 +393,28 @@ export default function SessionPage() { } }, [updateSessionTitle, refetchSessions]); + const fetchProjects = useCallback(async () => { + const [listResult, currentResult] = await Promise.allSettled([ + client.get('/api/project'), + client.get('/api/project/current'), + ]); + + const nextProjects = listResult.status === 'fulfilled' && Array.isArray(listResult.value.data) + ? listResult.value.data + : []; + const nextCurrentProjectId = currentResult.status === 'fulfilled' + ? currentResult.value.data?.id ?? null + : null; + + setProjects(nextProjects); + setCurrentProjectId(nextCurrentProjectId); + setSelectedProjectId((current) => current ?? nextCurrentProjectId ?? nextProjects[0]?.id ?? null); + }, []); + + useEffect(() => { + void fetchProjects(); + }, [fetchProjects]); + // Keep the selected session in sync with URL query params (e.g. onboarding // or other in-app navigation to `/sessions?session=...`). Clear the params // after consuming them so refreshes don't re-send the initial message. @@ -518,7 +605,10 @@ export default function SessionPage() { if (creating) return; setCreating(true); try { - const response = await client.post('/api/session', { title: 'New Session' }); + const response = await client.post('/api/session', { + title: 'New Session', + ...(selectedProjectIDForCreate ? { projectID: selectedProjectIDForCreate } : {}), + }); addSession(response.data); setSelectedAgent('rex'); setSelectedModelKey(null); @@ -528,7 +618,7 @@ export default function SessionPage() { } finally { setCreating(false); } - }, [creating, addSession, toast, t]); + }, [creating, selectedProjectIDForCreate, addSession, toast, t]); const handleSelectModel = useCallback(async (option: ChatModelOption) => { setSelectedModelKey(option.key); @@ -554,7 +644,10 @@ export default function SessionPage() { modelOverride?: { providerID: string; modelID: string } | null, ) => { try { - const response = await client.post('/api/session', { title: 'New Session' }); + const response = await client.post('/api/session', { + title: 'New Session', + ...(selectedProjectIDForCreate ? { projectID: selectedProjectIDForCreate } : {}), + }); const newSessionId = response.data.id; addSession(response.data); @@ -573,7 +666,7 @@ export default function SessionPage() { } catch (err: any) { toast.error(t('createFailed'), err.message); } - }, [addSession, selectedAgent, toast, t]); + }, [addSession, selectedAgent, selectedProjectIDForCreate, toast, t]); const showSelectorTooltip = useCallback((target: HTMLElement, title: string, lines: string[]) => { const rect = target.getBoundingClientRect(); @@ -645,6 +738,57 @@ export default function SessionPage() { } }, [renameValue, sessions, t, toast, updateSessionTitle]); + const handleOpenCreateProject = useCallback(() => { + setProjectDialogMode('create'); + setEditingProjectId(null); + setProjectNameValue(''); + }, []); + + const handleOpenRenameProject = useCallback((project: ProjectSessionGroup) => { + setProjectDialogMode('rename'); + setEditingProjectId(project.id); + setProjectNameValue(project.label); + }, []); + + const handleCloseProjectDialog = useCallback(() => { + if (projectSubmitting) return; + setProjectDialogMode(null); + setEditingProjectId(null); + setProjectNameValue(''); + }, [projectSubmitting]); + + const handleSubmitProject = useCallback(async () => { + if (!projectDialogMode || projectSubmitting) return; + const nextName = projectNameValue.trim(); + if (!nextName) { + toast.error(t('projectDialog.saveFailed'), t('projectDialog.nameEmpty')); + return; + } + + setProjectSubmitting(true); + try { + if (projectDialogMode === 'create') { + const response = await client.post('/api/project', { name: nextName }); + setSelectedProjectId(response.data.id); + setCollapsedProjectIds(prev => { + const next = new Set(prev); + next.delete(response.data.id); + return next; + }); + } else if (editingProjectId) { + await client.patch(`/api/project/${editingProjectId}`, { name: nextName }); + } + await fetchProjects(); + setProjectDialogMode(null); + setEditingProjectId(null); + setProjectNameValue(''); + } catch (err: any) { + toast.error(t('projectDialog.saveFailed'), err.message); + } finally { + setProjectSubmitting(false); + } + }, [editingProjectId, fetchProjects, projectDialogMode, projectNameValue, projectSubmitting, t, toast]); + const handleDownloadSession = useCallback(async (sessionId: string, title: string) => { setOpenMenuSessionId(null); setDownloadingSessionId(sessionId); @@ -744,6 +888,100 @@ export default function SessionPage() { setBatchDeleting(false); }, [checkedIds, batchDeleting, removeSessions, selectedSessionId, toast, t]); + const renderSessionListItem = (session: Session) => ( +
selectMode ? handleToggleCheck(session.id) : setSelectedSessionId(session.id)} + className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ + !selectMode && selectedSessionId === session.id + ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' + : selectMode && checkedIds.has(session.id) + ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' + : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' + }`} + > +
+ {selectMode && ( + handleToggleCheck(session.id)} + onClick={(e) => e.stopPropagation()} + className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" + /> + )} + {session.category === 'workflow' && ( + + + + )} + {session.category === 'entity-config' && ( + + + + )} + {renamingSessionId === session.id ? ( + setRenameValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => void handleSubmitRename(session.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); void handleSubmitRename(session.id); } + if (e.key === 'Escape') { e.preventDefault(); handleCancelRename(); } + }} + placeholder={t('renamePlaceholder')} + disabled={renameSubmitting} + className="w-full min-w-0 rounded border border-blue-300 bg-white px-1.5 py-0.5 text-sm text-gray-900 outline-none focus:border-blue-400 dark:border-blue-500/50 dark:bg-zinc-950 dark:text-zinc-100" + aria-label={t('rename')} + data-session-rename-input + /> + ) : ( +

+ {session.title} + {session.isShared && ( + + {t('sharedTag')} + + )} +

+ )} +
+ {session.time?.updated && renamingSessionId !== session.id && ( +

+ {formatSessionDate(session.time.updated)} +

+ )} + + {!selectMode && ( +
+ +
+ )} +
+ ); + if (loadingSessions) { return (
@@ -787,148 +1025,106 @@ export default function SessionPage() { {/* Session list */}
- {sessions.length === 0 ? ( + {sessions.length === 0 && projectSessionGroups.length === 0 ? (

{t('noSessions')}

- ) : groupedSessions.length === 0 ? ( + ) : projectSessionGroups.length === 0 ? (

{t('noResults', 'No conversations found')}

) : ( - groupedSessions.map(({ key, labelKey, items }) => { - const isSearching = searchQuery.trim().length > 0; - const limit = isSearching ? Infinity : (GROUP_DEFAULT_LIMIT[key] ?? 5); - const isExpanded = expandedGroups.has(key); - const visibleItems = (isSearching || isExpanded || items.length <= limit) - ? items - : items.slice(0, limit); - const hiddenCount = items.length - visibleItems.length; - - return ( -
-
- {t(labelKey, labelKey)} -
- {visibleItems.map((session) => ( -
selectMode ? handleToggleCheck(session.id) : setSelectedSessionId(session.id)} - className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ - !selectMode && selectedSessionId === session.id - ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' - : selectMode && checkedIds.has(session.id) - ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' - : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' - }`} - > - {/* Title row */} -
- {selectMode && ( - handleToggleCheck(session.id)} - onClick={(e) => e.stopPropagation()} - className="flex-shrink-0 w-3.5 h-3.5 accent-blue-500 cursor-pointer rounded" - /> - )} - {session.category === 'workflow' && ( - - - - )} - {session.category === 'entity-config' && ( - - - - )} - {renamingSessionId === session.id ? ( - setRenameValue(e.target.value)} - onClick={(e) => e.stopPropagation()} - onBlur={() => void handleSubmitRename(session.id)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); void handleSubmitRename(session.id); } - if (e.key === 'Escape') { e.preventDefault(); handleCancelRename(); } +
+
+ {t('projectsSection')} + +
+
+ {projectSessionGroups.map((group) => { + const collapsed = collapsedProjectIds.has(group.id); + const isCurrentProject = currentProjectId === group.id; + const isSelectedProject = selectedProjectId === group.id; + return ( +
+
+
- {/* Timestamp row */} - {session.time?.updated && renamingSessionId !== session.id && ( -

- {formatSessionDate(session.time.updated)} -

- )} - - {/* Three-dot menu trigger */} - {!selectMode && ( -
+ className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200" + aria-label={t('toggleProject', { project: group.label })} + > + {collapsed ? : } + + + + {group.sessions.length} +
- )} -
- ))} - {/* 展开/收起按钮 */} - {!isSearching && hiddenCount > 0 && ( - - )} - {!isSearching && isExpanded && items.length > (GROUP_DEFAULT_LIMIT[key] ?? 5) && ( - - )} + {!collapsed && ( +
+ {group.sessions.length > 0 ? ( + group.sessions.map(renderSessionListItem) + ) : ( +
+ {t('noProjectSessions')} +
+ )} +
+ )} +
+ ); + })}
- ); - }) +
)} {hasMoreSessions && (
@@ -1244,6 +1440,60 @@ export default function SessionPage() { />
+ {projectDialogMode && ( +
+
+
+

+ {projectDialogMode === 'create' ? t('projectDialog.createTitle') : t('projectDialog.renameTitle')} +

+
+
+ + setProjectNameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleSubmitProject(); + } + if (event.key === 'Escape') { + event.preventDefault(); + handleCloseProjectDialog(); + } + }} + disabled={projectSubmitting} + placeholder={t('projectDialog.namePlaceholder')} + className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-blue-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-blue-500" + autoFocus + /> +
+
+ + +
+
+
+ )} + {selectorTooltip && (
Date: Fri, 10 Jul 2026 15:51:42 +0800 Subject: [PATCH 05/86] feat: add slack channel support --- flocks/channel/builtin/slack/__init__.py | 5 + flocks/channel/builtin/slack/channel.py | 276 ++++++++++++++++++ flocks/channel/builtin/slack/config.py | 75 +++++ flocks/channel/builtin/slack/format.py | 44 +++ flocks/channel/builtin/slack/inbound.py | 126 ++++++++ flocks/channel/registry.py | 2 + flocks/security/channel_secrets.py | 3 + flocks/tool/channel/channel_message.py | 9 +- flocks/tool/channel/im_send_message.py | 7 +- pyproject.toml | 3 + tests/channel/test_e2e_file_roundtrip.py | 4 +- tests/channel/test_slack.py | 354 +++++++++++++++++++++++ tests/tool/test_channel_message.py | 8 +- tests/tool/test_im_send_message.py | 5 + uv.lock | 25 ++ webui/src/locales/en-US/channel.json | 36 +++ webui/src/locales/zh-CN/channel.json | 36 +++ webui/src/pages/Channel/index.tsx | 145 +++++++++- 18 files changed, 1152 insertions(+), 11 deletions(-) create mode 100644 flocks/channel/builtin/slack/__init__.py create mode 100644 flocks/channel/builtin/slack/channel.py create mode 100644 flocks/channel/builtin/slack/config.py create mode 100644 flocks/channel/builtin/slack/format.py create mode 100644 flocks/channel/builtin/slack/inbound.py create mode 100644 tests/channel/test_slack.py diff --git a/flocks/channel/builtin/slack/__init__.py b/flocks/channel/builtin/slack/__init__.py new file mode 100644 index 000000000..bc4986df2 --- /dev/null +++ b/flocks/channel/builtin/slack/__init__.py @@ -0,0 +1,5 @@ +"""Slack channel integration.""" + +from flocks.channel.builtin.slack.channel import SlackChannel + +__all__ = ["SlackChannel"] diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py new file mode 100644 index 000000000..bc09b6ec6 --- /dev/null +++ b/flocks/channel/builtin/slack/channel.py @@ -0,0 +1,276 @@ +"""Slack ChannelPlugin using Socket Mode.""" + +from __future__ import annotations + +import asyncio +from collections import OrderedDict +from typing import Any, Awaitable, Callable, Optional + +from flocks.channel.base import ( + ChannelCapabilities, + ChannelMeta, + ChannelPlugin, + ChatType, + DeliveryResult, + InboundMessage, + OutboundContext, +) +from flocks.utils.log import Log + +from .config import ( + normalize_target, + resolve_app_token, + resolve_bot_token, + resolve_home_channel, + should_reply_broadcast, + should_reply_in_thread, +) +from .format import markdown_to_slack_mrkdwn +from .inbound import build_inbound_message + +try: + from slack_bolt.async_app import AsyncApp + from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + + SLACK_AVAILABLE = True +except ImportError: # pragma: no cover - exercised by validate_config tests + AsyncApp = None # type: ignore[assignment] + AsyncSocketModeHandler = None # type: ignore[assignment] + SLACK_AVAILABLE = False + + +log = Log.create(service="channel.slack") + + +class SlackChannel(ChannelPlugin): + """Slack bot channel — Socket Mode inbound and Web API outbound.""" + + MAX_MESSAGE_LENGTH = 39000 + + def __init__(self) -> None: + super().__init__() + self._app: Any = None + self._handler: Any = None + self._socket_task: Optional[asyncio.Task] = None + self._bot_user_id: Optional[str] = None + self._known_thread_ids: OrderedDict[str, None] = OrderedDict() + self._known_thread_ids_max = 5000 + + def meta(self) -> ChannelMeta: + return ChannelMeta(id="slack", label="Slack", aliases=["sl"], order=50) + + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT, ChatType.GROUP, ChatType.CHANNEL], + media=True, + threads=True, + reactions=False, + edit=False, + rich_text=True, + ) + + def validate_config(self, config: dict) -> Optional[str]: + if not SLACK_AVAILABLE: + return "Missing Python dependency: slack-bolt" + if not resolve_bot_token(config): + return "Missing required config: botToken" + if not resolve_app_token(config): + return "Missing required config: appToken" + return None + + @property + def text_chunk_limit(self) -> int: + return self.MAX_MESSAGE_LENGTH + + @property + def rate_limit(self) -> tuple[float, int]: + return (1.0, 5) + + def normalize_target(self, raw: str) -> Optional[str]: + target = normalize_target(raw, fallback=resolve_home_channel(self._config)) + return target or None + + def target_hint(self) -> str: + return " (C..., G..., or D...)" + + def format_message(self, text: str, format_hint: str = "markdown") -> str: + if format_hint == "plain": + return text + return markdown_to_slack_mrkdwn(text) + + async def start( + self, + config: dict, + on_message: Callable[[InboundMessage], Awaitable[None]], + abort_event: Optional[asyncio.Event] = None, + ) -> None: + self._config = config + self._on_message = on_message + + error = self.validate_config(config) + if error: + self.mark_disconnected(error) + log.error("slack.start.invalid_config", {"error": error}) + return + + bot_token = resolve_bot_token(config) + app_token = resolve_app_token(config) + assert AsyncApp is not None + assert AsyncSocketModeHandler is not None + + self._app = AsyncApp(token=bot_token) + abort = abort_event or asyncio.Event() + try: + auth = await self._app.client.auth_test() + self._bot_user_id = str(auth.get("user_id") or "") + team_id = str(auth.get("team_id") or "default") + + self._register_handlers() + await self._connect_socket_mode(app_token) + log.info("slack.connected", {"team_id": team_id, "bot_user_id": self._bot_user_id}) + await abort.wait() + except asyncio.CancelledError: + raise + except Exception as exc: + self.mark_disconnected(str(exc)) + log.warning("slack.socket.stopped", {"error": str(exc)}) + finally: + await self.stop() + + async def _connect_socket_mode(self, app_token: str) -> None: + """Connect Socket Mode once and mark connected only after success.""" + assert AsyncSocketModeHandler is not None + self._handler = AsyncSocketModeHandler(self._app, app_token) + timeout_seconds = float(self._config.get("socketConnectTimeoutSeconds") or 15.0) + try: + await asyncio.wait_for( + self._handler.connect_async(), + timeout=max(timeout_seconds, 1.0), + ) + except asyncio.TimeoutError as exc: + message = f"Slack Socket Mode connection timed out after {timeout_seconds:.1f}s" + self.mark_disconnected(message) + raise TimeoutError(message) from exc + except Exception as exc: + self.mark_disconnected(str(exc)) + raise + self.mark_connected() + + async def stop(self) -> None: + handler = self._handler + self._handler = None + if handler is not None: + try: + await handler.close_async() + except Exception as exc: + log.warning("slack.close.failed", {"error": str(exc)}) + + task = self._socket_task + self._socket_task = None + if task is not None and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception: + pass + + self.mark_disconnected() + + async def send_text(self, ctx: OutboundContext) -> DeliveryResult: + if not self._app: + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error="Slack channel is not connected", + retryable=True, + ) + + target = normalize_target(ctx.to, fallback=resolve_home_channel(self._config)) + if not target: + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error="Slack send requires 'to' (channel ID C..., private channel G..., or DM D...)", + ) + + try: + payload: dict[str, Any] = { + "channel": target, + "text": ctx.text, + "mrkdwn": ctx.format_hint != "plain", + } + thread_ts = self._resolve_thread_ts(ctx) + if thread_ts: + payload["thread_ts"] = thread_ts + if should_reply_broadcast(self._config): + payload["reply_broadcast"] = True + + result = await self._app.client.chat_postMessage(**payload) + message_id = str(result.get("ts") or "") + if message_id: + self._remember_thread(message_id) + if thread_ts: + self._remember_thread(thread_ts) + self.record_message() + return DeliveryResult( + channel_id="slack", + message_id=message_id, + chat_id=target, + success=True, + ) + except Exception as exc: + message = str(exc) + retryable = any(term in message.lower() for term in ("timeout", "rate", "temporarily", "connection")) + return DeliveryResult( + channel_id="slack", + message_id="", + success=False, + error=f"Slack send failed: {message}", + retryable=retryable, + ) + + def _register_handlers(self) -> None: + if not self._app: + return + + @self._app.event("message") + async def handle_message_event(event, say): + await self._handle_slack_event(event) + + @self._app.event("app_mention") + async def handle_app_mention_event(event, say): + await self._handle_slack_event(event) + + async def _handle_slack_event(self, event: dict[str, Any]) -> None: + if not self._on_message: + return + inbound = build_inbound_message( + event, + bot_user_id=self._bot_user_id, + config=self._config, + known_thread_ids=set(self._known_thread_ids.keys()), + ) + if inbound is None: + return + self.record_message() + await self._on_message(inbound) + + def _resolve_thread_ts(self, ctx: OutboundContext) -> Optional[str]: + if ctx.thread_id: + return ctx.thread_id + if should_reply_in_thread(self._config): + return ctx.reply_to_id + return None + + def _remember_thread(self, thread_ts: str) -> None: + if not thread_ts: + return + self._known_thread_ids[thread_ts] = None + self._known_thread_ids.move_to_end(thread_ts) + if len(self._known_thread_ids) > self._known_thread_ids_max: + while len(self._known_thread_ids) > self._known_thread_ids_max // 2: + self._known_thread_ids.popitem(last=False) diff --git a/flocks/channel/builtin/slack/config.py b/flocks/channel/builtin/slack/config.py new file mode 100644 index 000000000..7029d752d --- /dev/null +++ b/flocks/channel/builtin/slack/config.py @@ -0,0 +1,75 @@ +"""Configuration helpers for the Slack channel.""" + +from __future__ import annotations + +import re +from typing import Any, Optional + + +_TARGET_PREFIX_RE = re.compile(r"^(?:slack|channel|dm|user):", re.IGNORECASE) + + +def coerce_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def coerce_bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return default + + +def strip_target_prefix(raw: str) -> str: + value = raw.strip() + while value: + next_value = _TARGET_PREFIX_RE.sub("", value, count=1).strip() + if next_value == value: + return value + value = next_value + return value + + +def resolve_bot_token(config: dict[str, Any]) -> str: + return coerce_str(config.get("botToken") or config.get("slackBotToken")) + + +def resolve_app_token(config: dict[str, Any]) -> str: + return coerce_str(config.get("appToken") or config.get("slackAppToken")) + + +def resolve_home_channel(config: dict[str, Any]) -> str: + return coerce_str(config.get("homeChannel") or config.get("home_channel")) + + +def should_reply_in_thread(config: dict[str, Any]) -> bool: + return coerce_bool(config.get("replyInThread"), True) + + +def should_reply_broadcast(config: dict[str, Any]) -> bool: + return coerce_bool(config.get("replyBroadcast"), False) + + +def allow_bot_messages(config: dict[str, Any]) -> str: + value = coerce_str(config.get("allowBots")).lower() + if value in {"none", "mentions", "all"}: + return value + return "none" + + +def normalize_target(raw: str, *, fallback: Optional[str] = None) -> str: + target = strip_target_prefix(raw or "") + if target: + return target + return fallback or "" diff --git a/flocks/channel/builtin/slack/format.py b/flocks/channel/builtin/slack/format.py new file mode 100644 index 000000000..7c6c77c59 --- /dev/null +++ b/flocks/channel/builtin/slack/format.py @@ -0,0 +1,44 @@ +"""Markdown to Slack mrkdwn helpers.""" + +from __future__ import annotations + +import html +import re + + +_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") + + +def markdown_to_slack_mrkdwn(text: str) -> str: + """Convert common Markdown to Slack mrkdwn without touching code fences.""" + if not text: + return "" + + placeholders: list[str] = [] + + def protect(match: re.Match[str]) -> str: + placeholders.append(match.group(0)) + return f"\x01SLACK_CODE_{len(placeholders) - 1}\x01" + + result = re.sub(r"```.*?```", protect, text, flags=re.DOTALL) + result = re.sub(r"`[^`\n]+`", protect, result) + + result = html.unescape(result) + result = _LINK_RE.sub(r"<\2|\1>", result) + result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result, flags=re.DOTALL) + result = re.sub(r"__([^_\n]+)__", r"*\1*", result) + result = re.sub(r"~~(.+?)~~", r"~\1~", result, flags=re.DOTALL) + result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + + for index, fragment in enumerate(placeholders): + result = result.replace(f"\x01SLACK_CODE_{index}\x01", fragment) + + return result + + +def strip_bot_mention(text: str, bot_user_id: str) -> str: + if not bot_user_id: + return text.strip() + pattern = re.compile(rf"<@{re.escape(bot_user_id)}(?:\|[^>]+)?>") + cleaned = pattern.sub(" ", text) + return re.sub(r"\s+", " ", cleaned).strip() diff --git a/flocks/channel/builtin/slack/inbound.py b/flocks/channel/builtin/slack/inbound.py new file mode 100644 index 000000000..674ac4697 --- /dev/null +++ b/flocks/channel/builtin/slack/inbound.py @@ -0,0 +1,126 @@ +"""Inbound Slack event parsing.""" + +from __future__ import annotations + +from typing import Any, Optional + +from flocks.channel.base import ChatType, InboundMessage + +from .config import allow_bot_messages +from .format import strip_bot_mention + + +def extract_text(event: dict[str, Any]) -> str: + text = event.get("text") + if isinstance(text, str) and text.strip(): + return text + + files = event.get("files") + if isinstance(files, list) and files: + names = [ + str(item.get("name") or item.get("title") or item.get("id") or "file") + for item in files + if isinstance(item, dict) + ] + if names: + return "[Slack files: " + ", ".join(names) + "]" + + return "" + + +def is_own_or_ignored_bot_message( + event: dict[str, Any], + *, + bot_user_id: Optional[str], + config: dict[str, Any], +) -> bool: + user_id = str(event.get("user") or event.get("bot_id") or "") + if bot_user_id and user_id == bot_user_id: + return True + + if not (event.get("bot_id") or event.get("subtype") == "bot_message"): + return False + + policy = allow_bot_messages(config) + if policy == "all": + return False + if policy == "mentions": + return not bool(bot_user_id and f"<@{bot_user_id}>" in str(event.get("text") or "")) + return True + + +def build_inbound_message( + event: dict[str, Any], + *, + bot_user_id: Optional[str], + config: dict[str, Any], + known_thread_ids: set[str], +) -> Optional[InboundMessage]: + """Convert a Slack message/app_mention event into a Flocks InboundMessage.""" + subtype = event.get("subtype") + if subtype in {"message_changed", "message_deleted"}: + return None + + if is_own_or_ignored_bot_message(event, bot_user_id=bot_user_id, config=config): + return None + + channel_id = str(event.get("channel") or "") + user_id = str(event.get("user") or event.get("bot_id") or "") + ts = str(event.get("ts") or event.get("event_ts") or "") + if not channel_id or not user_id or not ts: + return None + + text = extract_text(event) + if not text: + return None + + channel_type = str(event.get("channel_type") or "").lower() + if not channel_type and channel_id.startswith("D"): + channel_type = "im" + + if channel_type == "im": + chat_type = ChatType.DIRECT + elif channel_type == "mpim": + chat_type = ChatType.GROUP + else: + # Slack public/private channels are not one-to-one chats. Treat them + # as channel surfaces while preserving group-trigger behavior in the + # dispatcher (all non-DIRECT chats use the same trigger policy). + chat_type = ChatType.CHANNEL + thread_ts = str(event.get("thread_ts") or "") + is_thread_reply = bool(thread_ts and thread_ts != ts) + mentioned = bool(bot_user_id and f"<@{bot_user_id}>" in text) + continues_bot_thread = bool( + is_thread_reply + and ( + thread_ts in known_thread_ids + or (bot_user_id and str(event.get("parent_user_id") or "") == bot_user_id) + ) + ) + mention_text = strip_bot_mention(text, bot_user_id or "") if mentioned else "" + + if chat_type == ChatType.DIRECT: + mentioned = False + mention_text = "" + elif continues_bot_thread: + mentioned = True + if not mention_text: + mention_text = text + + sender_name = str(event.get("username") or event.get("user_name") or "") or None + + return InboundMessage( + channel_id="slack", + account_id=str(event.get("team") or event.get("team_id") or "default"), + message_id=ts, + sender_id=user_id, + sender_name=sender_name, + chat_id=channel_id, + chat_type=chat_type, + text=text, + reply_to_id=str(event.get("parent_user_id") or "") or None, + thread_id=thread_ts or None, + mentioned=mentioned, + mention_text=mention_text, + raw=event, + ) diff --git a/flocks/channel/registry.py b/flocks/channel/registry.py index c782f937a..10ebd5e5a 100644 --- a/flocks/channel/registry.py +++ b/flocks/channel/registry.py @@ -92,6 +92,7 @@ def reset(self) -> None: def _register_builtin_channels(self) -> None: from flocks.channel.builtin.dingtalk.channel import DingTalkChannel from flocks.channel.builtin.feishu.channel import FeishuChannel + from flocks.channel.builtin.slack.channel import SlackChannel from flocks.channel.builtin.telegram.channel import TelegramChannel from flocks.channel.builtin.wecom.channel import WeComChannel from flocks.channel.builtin.weixin.channel import WeixinChannel @@ -100,6 +101,7 @@ def _register_builtin_channels(self) -> None: self.register(TelegramChannel()) self.register(DingTalkChannel()) self.register(WeixinChannel()) + self.register(SlackChannel()) def _register_plugin_extension_point(self) -> None: from flocks.plugin import PluginLoader, ExtensionPoint diff --git a/flocks/security/channel_secrets.py b/flocks/security/channel_secrets.py index 48e0f48cf..99feb4107 100644 --- a/flocks/security/channel_secrets.py +++ b/flocks/security/channel_secrets.py @@ -18,6 +18,9 @@ SENSITIVE_FIELD_NAMES: Set[str] = { "appSecret", "botToken", + "appToken", + "slackAppToken", + "slackBotToken", "secret", "clientSecret", "apiKey", diff --git a/flocks/tool/channel/channel_message.py b/flocks/tool/channel/channel_message.py index acc5e1c20..29e90418b 100644 --- a/flocks/tool/channel/channel_message.py +++ b/flocks/tool/channel/channel_message.py @@ -2,7 +2,7 @@ channel_message tool — sends a message to the IM channel bound to a given session. Looks up the SessionBinding for the given session_id to automatically resolve -the target channel (WeCom / Feishu / DingTalk) and chat_id, so the caller +the target channel (WeCom / Feishu / DingTalk / Slack) and chat_id, so the caller does not need to specify them manually. The optional channel_type parameter selects a specific channel when a session @@ -25,6 +25,7 @@ "weixin": ["weixin", "微信", "wechat", "wx"], "feishu": ["feishu", "飞书", "lark"], "dingtalk": ["dingtalk", "钉钉", "dingding", "dingtalk-connector"], + "slack": ["slack", "sl"], } @@ -131,7 +132,7 @@ async def _http_session_send( name="channel_message", description=( "Send a message to the IM channel bound to a session. " - "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk. " + "Channel types: WeCom/企业微信=wecom, Weixin/微信=weixin, Feishu=feishu, DingTalk=dingtalk, Slack=slack. " "Resolves the target channel and chat automatically from session_id. " "Use channel_type to target a specific channel when the session has multiple bindings." ), @@ -153,9 +154,9 @@ async def _http_session_send( name="channel_type", type=ParameterType.STRING, required=False, - enum=["wecom", "weixin", "feishu", "dingtalk", "企微", "企业微信", "微信", "飞书", "钉钉"], + enum=["wecom", "weixin", "feishu", "dingtalk", "slack", "企微", "企业微信", "微信", "飞书", "钉钉"], description=( - "Target channel: wecom=企业微信, weixin=微信, feishu=飞书, or dingtalk=钉钉. " + "Target channel: wecom=企业微信, weixin=微信, feishu=飞书, dingtalk=钉钉, or slack=Slack. " "Chinese aliases are accepted. " "If omitted and the session has only one binding, that channel is used automatically. " "If omitted and the session has multiple bindings, the message is sent to all of them." diff --git a/flocks/tool/channel/im_send_message.py b/flocks/tool/channel/im_send_message.py index 68fbc1078..ac349d18c 100644 --- a/flocks/tool/channel/im_send_message.py +++ b/flocks/tool/channel/im_send_message.py @@ -20,6 +20,7 @@ "weixin": ["weixin", "微信", "wechat", "wx"], "feishu": ["feishu", "飞书", "lark"], "dingtalk": ["dingtalk", "钉钉", "dingding", "dingtalk-connector"], + "slack": ["slack", "sl"], } @@ -201,8 +202,8 @@ async def _resolve_target( name="im_send_message", description=( "Resolve an IM target session and optionally send a message. " - "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, or custom channel sessions when the user asks to send an IM message. " - "Use channel_type=wecom for 企业微信 and channel_type=weixin for 微信. " + "Use this for WeCom/企业微信, Weixin/微信, Feishu, DingTalk, Slack, or custom channel sessions when the user asks to send an IM message. " + "Use channel_type=wecom for 企业微信, channel_type=weixin for 微信, and channel_type=slack for Slack. " "If session_id is omitted, it uses the current IM session when available, otherwise asks the user to pick one." ), category=ToolCategory.SYSTEM, @@ -225,7 +226,7 @@ async def _resolve_target( required=False, description=( "Optional channel filter, such as wecom=企业微信, weixin=微信, " - "feishu, dingtalk, telegram, or a custom channel id." + "feishu, dingtalk, slack, telegram, or a custom channel id." ), ), ToolParameter( diff --git a/pyproject.toml b/pyproject.toml index e68732a07..0ccac9ab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,9 @@ dependencies = [ "lark-oapi>=1.3.0", # DingTalk Stream Mode (WebSocket long-connection) "dingtalk-stream>=0.20", + # Slack Socket Mode + "slack-bolt>=1.26.0", + "slack-sdk>=3.38.0", "python-socks>=2.8.1", "pytz>=2026.1.post1", # docs parser diff --git a/tests/channel/test_e2e_file_roundtrip.py b/tests/channel/test_e2e_file_roundtrip.py index f35441146..ab4a71e6b 100644 --- a/tests/channel/test_e2e_file_roundtrip.py +++ b/tests/channel/test_e2e_file_roundtrip.py @@ -342,8 +342,8 @@ async def fake_get_http_client(): # _download_file to use the server directly. async def fake_get_file_path(*, bot_token, api_base, file_id, timeout): return server.file_id_to_path[file_id], file_id - async def fake_download(*, bot_token, file_path, max_bytes, timeout): - return server.get(f"https://api.telegram.org/file/bot{bot_token}/{file_path}")._body + async def fake_download(*, download_base, file_path, max_bytes, timeout): + return server.get(f"{download_base.rstrip('/')}/{file_path}")._body monkeypatch.setattr(inbound_media, "_get_file_path", fake_get_file_path) monkeypatch.setattr(inbound_media, "_download_file", fake_download) diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py new file mode 100644 index 000000000..9919af3e1 --- /dev/null +++ b/tests/channel/test_slack.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from flocks.channel.base import ChatType, OutboundContext +import flocks.channel.builtin.slack.channel as slack_mod +from flocks.channel.builtin.slack.channel import SlackChannel +from flocks.channel.builtin.slack.format import markdown_to_slack_mrkdwn +from flocks.channel.builtin.slack.inbound import build_inbound_message + + +def test_plugin_exports_slack_channel(): + plugin = SlackChannel() + assert plugin.meta().id == "slack" + assert plugin.meta().label == "Slack" + assert "sl" in plugin.meta().aliases + + +def test_validate_config_requires_tokens(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + assert "botToken" in (plugin.validate_config({}) or "") + assert "appToken" in (plugin.validate_config({"botToken": "xoxb-1"}) or "") + assert plugin.validate_config({"botToken": "xoxb-1", "appToken": "xapp-1"}) is None + + +def test_validate_config_reports_missing_dependency(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", False) + plugin = SlackChannel() + + assert "slack-bolt" in (plugin.validate_config({"botToken": "x", "appToken": "y"}) or "") + + +def test_markdown_to_slack_mrkdwn_preserves_code_and_links(): + text = "# Title\n\n**bold** and [OpenAI](https://openai.com)\n\n```py\n**not bold**\n```" + + rendered = markdown_to_slack_mrkdwn(text) + + assert "*Title*" in rendered + assert "*bold*" in rendered + assert "" in rendered + assert "```py\n**not bold**\n```" in rendered + + +def test_build_inbound_direct_message(): + msg = build_inbound_message( + { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.1", + "text": "hello", + "team": "T1", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.channel_id == "slack" + assert msg.account_id == "T1" + assert msg.chat_type == ChatType.DIRECT + assert msg.chat_id == "D123" + assert msg.message_id == "171.1" + assert msg.text == "hello" + assert msg.mentioned is False + assert msg.mention_text == "" + + +def test_build_inbound_group_mention_strips_bot_mention(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.2", + "text": "<@UBOT> summarize this", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.chat_type == ChatType.CHANNEL + assert msg.mentioned is True + assert msg.mention_text == "summarize this" + + +def test_build_inbound_group_dm_uses_group_chat_type(): + msg = build_inbound_message( + { + "channel": "G123", + "channel_type": "mpim", + "user": "U123", + "ts": "171.21", + "text": "<@UBOT> summarize this", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.chat_type == ChatType.GROUP + assert msg.mentioned is True + + +def test_build_inbound_thread_reply_to_known_bot_thread_triggers_without_mention(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.3", + "thread_ts": "171.2", + "text": "continue", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids={"171.2"}, + ) + + assert msg is not None + assert msg.mentioned is True + assert msg.mention_text == "continue" + assert msg.thread_id == "171.2" + + +def test_build_inbound_thread_reply_to_parent_bot_user_triggers_after_restart(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.4", + "thread_ts": "171.2", + "parent_user_id": "UBOT", + "text": "continue after restart", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.mentioned is True + assert msg.mention_text == "continue after restart" + assert msg.thread_id == "171.2" + + +def test_build_inbound_allowed_bot_message_without_user_uses_bot_id(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.5", + "text": "automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "all"}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.sender_id == "B_OTHER" + assert msg.text == "automation says hi" + + +def test_build_inbound_bot_message_mentions_policy_requires_bot_mention(): + blocked = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.6", + "text": "automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "mentions"}, + known_thread_ids=set(), + ) + allowed = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "bot_id": "B_OTHER", + "subtype": "bot_message", + "ts": "171.7", + "text": "<@UBOT> automation says hi", + }, + bot_user_id="UBOT", + config={"allowBots": "mentions"}, + known_thread_ids=set(), + ) + + assert blocked is None + assert allowed is not None + assert allowed.sender_id == "B_OTHER" + assert allowed.mention_text == "automation says hi" + + +def test_build_inbound_ignores_own_and_edit_messages(): + own = build_inbound_message( + {"channel": "C1", "user": "UBOT", "ts": "1", "text": "echo"}, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + edited = build_inbound_message( + {"channel": "C1", "user": "U1", "ts": "2", "text": "x", "subtype": "message_changed"}, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert own is None + assert edited is None + + +@pytest.mark.asyncio +async def test_send_text_posts_thread_reply_and_remembers_thread(): + plugin = SlackChannel() + plugin._config = {"replyInThread": True, "replyBroadcast": True} + fake_client = SimpleNamespace( + chat_postMessage=AsyncMock(return_value={"ts": "172.1"}) + ) + plugin._app = SimpleNamespace(client=fake_client) + + result = await plugin.send_text( + OutboundContext( + channel_id="slack", + to="slack:C123", + text="hello", + reply_to_id="171.1", + ) + ) + + assert result.success is True + assert result.message_id == "172.1" + fake_client.chat_postMessage.assert_awaited_once_with( + channel="C123", + text="hello", + mrkdwn=True, + thread_ts="171.1", + reply_broadcast=True, + ) + assert "171.1" in plugin._known_thread_ids + assert "172.1" in plugin._known_thread_ids + + +@pytest.mark.asyncio +async def test_connect_socket_mode_marks_connected_after_connect(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + self.app = app + self.token = token + self.connected = False + + async def connect_async(self): + self.connected = True + + async def close_async(self): + pass + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + await plugin._connect_socket_mode("xapp-ok") + + assert plugin.status.connected is True + assert plugin._handler.connected is True + + +@pytest.mark.asyncio +async def test_connect_socket_mode_failure_marks_disconnected(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + raise RuntimeError("invalid app token") + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(RuntimeError): + await plugin._connect_socket_mode("xapp-bad") + + assert plugin.status.connected is False + assert plugin.status.last_error == "invalid app token" + + +@pytest.mark.asyncio +async def test_connect_socket_mode_timeout_marks_disconnected(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + await asyncio.sleep(2) + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(TimeoutError): + await plugin._connect_socket_mode("xapp-slow") + + assert plugin.status.connected is False + assert "timed out" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_handle_slack_event_dispatches_inbound_message(): + plugin = SlackChannel() + plugin._config = {} + plugin._bot_user_id = "UBOT" + dispatched = [] + + async def on_message(msg): + dispatched.append(msg) + + plugin._on_message = on_message + + await plugin._handle_slack_event( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.2", + "text": "<@UBOT> ping", + } + ) + + assert len(dispatched) == 1 + assert dispatched[0].mention_text == "ping" diff --git a/tests/tool/test_channel_message.py b/tests/tool/test_channel_message.py index add4aca2a..fe51aa48f 100644 --- a/tests/tool/test_channel_message.py +++ b/tests/tool/test_channel_message.py @@ -26,7 +26,12 @@ def test_channel_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" -def test_channel_message_schema_includes_weixin() -> None: +def test_channel_message_normalizes_slack_aliases() -> None: + assert _normalize_channel_type("slack") == "slack" + assert _normalize_channel_type("sl") == "slack" + + +def test_channel_message_schema_includes_builtin_channels() -> None: schema = ToolRegistry.get_schema("channel_message") assert schema is not None @@ -34,6 +39,7 @@ def test_channel_message_schema_includes_weixin() -> None: assert "企业微信" in schema.properties["channel_type"]["enum"] assert "weixin" in schema.properties["channel_type"]["enum"] assert "微信" in schema.properties["channel_type"]["enum"] + assert "slack" in schema.properties["channel_type"]["enum"] @pytest.mark.asyncio diff --git a/tests/tool/test_im_send_message.py b/tests/tool/test_im_send_message.py index 72df25a42..ea75972f1 100644 --- a/tests/tool/test_im_send_message.py +++ b/tests/tool/test_im_send_message.py @@ -51,6 +51,11 @@ def test_im_send_message_normalizes_wecom_aliases() -> None: assert _normalize_channel_type("wxwork") == "wecom" +def test_im_send_message_normalizes_slack_aliases() -> None: + assert _normalize_channel_type("slack") == "slack" + assert _normalize_channel_type("sl") == "slack" + + @pytest.mark.asyncio async def test_im_send_message_requires_message_unless_resolve_only() -> None: result = await im_send_message(_ctx(), session_id="ses_target") diff --git a/uv.lock b/uv.lock index 95cf9d106..f49b43c82 100644 --- a/uv.lock +++ b/uv.lock @@ -598,6 +598,8 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, { name = "rich" }, + { name = "slack-bolt" }, + { name = "slack-sdk" }, { name = "sqlalchemy" }, { name = "sse-starlette" }, { name = "starlette" }, @@ -667,6 +669,8 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.1" }, { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=13.7.0" }, + { name = "slack-bolt", specifier = ">=1.26.0" }, + { name = "slack-sdk", specifier = ">=3.38.0" }, { name = "sqlalchemy", specifier = ">=2.0.25" }, { name = "sse-starlette", specifier = ">=1.8.2" }, { name = "starlette", specifier = ">=1.0.1" }, @@ -2136,6 +2140,27 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" }, ] +[[package]] +name = "slack-bolt" +version = "1.29.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/3c/2c/7434f5d8c52eafb1e702012e9f291b013bd05985a5b32bde568b2279a28a/slack_bolt-1.29.0.tar.gz", hash = "sha256:b6271ba0a9b71e319c86b40632e6cb6240aacd0433773615b76b890b9a574762" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/2f/cc/e5f78a80a1775a4cbb2cc41e8ef433602d5e755ff0d829bd43145015740a/slack_bolt-1.29.0-py2.py3-none-any.whl", hash = "sha256:1835b66b778158f3af0da77603aa18d7dfd82fd9b9a985e25c752f95050ab826" }, +] + +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585" }, +] + [[package]] name = "smmap" version = "5.0.3" diff --git a/webui/src/locales/en-US/channel.json b/webui/src/locales/en-US/channel.json index fa058f8f2..05f746df0 100644 --- a/webui/src/locales/en-US/channel.json +++ b/webui/src/locales/en-US/channel.json @@ -12,6 +12,7 @@ "wecom": "WeCom", "dingtalk": "DingTalk", "telegram": "Telegram", + "slack": "Slack", "weixin": "WeChat" }, "status": { @@ -234,6 +235,41 @@ "dedupTtlSecondsHint": "Retention time for message deduplication records (default 86400s = 24h)", "optional": "Optional" }, + "slack": { + "credentials": "Slack App Credentials", + "credentialsDesc": "Create a Slack app with Socket Mode enabled. Required events: message.im, message.mpim, message.channels, message.groups, app_mention. Required scopes include connections:write on the app token and chat:write, app_mentions:read, channels:history, groups:history, im:history, mpim:history on the bot token.", + "botTokenHint": "Bot User OAuth Token from OAuth & Permissions (starts with xoxb-)", + "appTokenHint": "App-level token with connections:write scope (starts with xapp-)", + "homeChannel": "Home Channel", + "homeChannelHint": "Default Slack channel ID for proactive sends, for example C0123456789", + "homeChannelName": "Home Channel Name", + "homeChannelNameHint": "Optional display name shown in configuration and notes", + "behavior": "Message Behavior", + "behaviorDesc": "Configure message routing and who can trigger the Agent.", + "defaultAgent": "Default Agent", + "defaultAgentHint": "Default Agent ID used when no Agent is specified", + "groupTrigger": "Channel Trigger", + "groupTriggerHint": "When to trigger Agent response in Slack channels and group DMs", + "triggerMention": "Only when @mentioned", + "triggerAll": "All messages", + "allowFrom": "Allowed Member IDs", + "allowFromHint": "Slack member IDs allowed to trigger the Agent (leave empty to allow everyone)", + "allowFromPlaceholder": "Enter Slack member ID and press Enter", + "advanced": "Advanced Settings", + "advancedDesc": "Thread reply behavior and bot-message handling.", + "replyInThread": "Reply in Threads", + "replyInThreadHint": "When enabled, replies to channel messages stay in Slack threads.", + "replyInThreadLabel": "Reply in Slack thread", + "replyBroadcast": "Broadcast Thread Replies", + "replyBroadcastHint": "Also post the first thread reply into the parent channel.", + "replyBroadcastLabel": "Broadcast replies to channel", + "allowBots": "Bot Messages", + "allowBotsHint": "Controls whether messages from other bots can trigger the Agent.", + "allowBotsNone": "Ignore bot messages", + "allowBotsMentions": "Only bot messages that mention this bot", + "allowBotsAll": "Allow all bot messages", + "optional": "Optional" + }, "weixin": { "enableTitle": "Enable WeChat Channel", "enableDesc": "When enabled, the bot connects to WeChat personal accounts via the iLink Bot long-poll API.", diff --git a/webui/src/locales/zh-CN/channel.json b/webui/src/locales/zh-CN/channel.json index fbd4c2cbc..efde3027f 100644 --- a/webui/src/locales/zh-CN/channel.json +++ b/webui/src/locales/zh-CN/channel.json @@ -12,6 +12,7 @@ "wecom": "企业微信", "dingtalk": "钉钉", "telegram": "Telegram", + "slack": "Slack", "weixin": "微信" }, "status": { @@ -236,6 +237,41 @@ "dedupTtlSecondsHint": "消息去重记录的保留时间(默认 86400 秒 = 24 小时)", "optional": "选填" }, + "slack": { + "credentials": "Slack 应用凭证", + "credentialsDesc": "创建启用 Socket Mode 的 Slack App。必需事件:message.im、message.mpim、message.channels、message.groups、app_mention。必需权限包括 App Token 的 connections:write,以及 Bot Token 的 chat:write、app_mentions:read、channels:history、groups:history、im:history、mpim:history。", + "botTokenHint": "OAuth & Permissions 页面中的 Bot User OAuth Token(xoxb- 开头)", + "appTokenHint": "具备 connections:write 权限的 App-Level Token(xapp- 开头)", + "homeChannel": "默认频道", + "homeChannelHint": "主动发送消息时使用的默认 Slack 频道 ID,例如 C0123456789", + "homeChannelName": "默认频道名称", + "homeChannelNameHint": "选填,仅用于配置展示和备注", + "behavior": "消息行为", + "behaviorDesc": "配置消息路由和触发条件。", + "defaultAgent": "默认 Agent", + "defaultAgentHint": "未指定 Agent 时使用的默认 Agent ID", + "groupTrigger": "频道触发方式", + "groupTriggerHint": "Slack 频道和多人私信中什么情况下触发 Agent 响应", + "triggerMention": "仅 @ 提及时触发", + "triggerAll": "所有消息都触发", + "allowFrom": "允许的成员 ID", + "allowFromHint": "仅允许这些 Slack member ID 触发 Agent(留空则允许所有人)", + "allowFromPlaceholder": "输入 Slack member ID 后按回车添加", + "advanced": "高级设置", + "advancedDesc": "线程回复和 Bot 消息处理策略。", + "replyInThread": "线程回复", + "replyInThreadHint": "启用后,频道消息的回复会发送到 Slack thread 中。", + "replyInThreadLabel": "在 Slack thread 中回复", + "replyBroadcast": "广播线程回复", + "replyBroadcastHint": "将第一条 thread 回复同时广播到父频道。", + "replyBroadcastLabel": "将回复广播到频道", + "allowBots": "Bot 消息", + "allowBotsHint": "控制其他 Bot 发出的消息是否可以触发 Agent。", + "allowBotsNone": "忽略 Bot 消息", + "allowBotsMentions": "仅允许 @ 本 Bot 的 Bot 消息", + "allowBotsAll": "允许所有 Bot 消息", + "optional": "选填" + }, "weixin": { "enableTitle": "启用微信通道", "enableDesc": "启用后,机器人将通过 iLink Bot 长轮询接入微信,并开始接收和回复消息。", diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index 963a16693..49375cd59 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -129,6 +129,20 @@ interface TelegramChannelConfig { streamingCoalesceMs?: number; } +interface SlackChannelConfig { + enabled: boolean; + botToken?: string; + appToken?: string; + homeChannel?: string; + homeChannelName?: string; + defaultAgent?: string; + groupTrigger?: string; + allowFrom?: string[]; + replyInThread?: boolean; + replyBroadcast?: boolean; + allowBots?: 'none' | 'mentions' | 'all'; +} + interface WeixinChannelConfig { enabled: boolean; token?: string; @@ -144,7 +158,7 @@ interface WeixinChannelConfig { dataDir?: string; } -type ChannelConfig = FeishuChannelConfig | WeComChannelConfig | DingTalkChannelConfig | TelegramChannelConfig | WeixinChannelConfig; +type ChannelConfig = FeishuChannelConfig | WeComChannelConfig | DingTalkChannelConfig | TelegramChannelConfig | SlackChannelConfig | WeixinChannelConfig; function defaultFeishuConfig(): FeishuChannelConfig { return { @@ -191,6 +205,17 @@ function defaultTelegramConfig(): TelegramChannelConfig { }; } +function defaultSlackConfig(): SlackChannelConfig { + return { + enabled: false, + groupTrigger: 'mention', + allowFrom: [], + replyInThread: true, + replyBroadcast: false, + allowBots: 'none', + }; +} + function defaultWeixinConfig(): WeixinChannelConfig { return { enabled: false, @@ -644,6 +669,7 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa {channelId === 'dingtalk' && 'Stream'} {channelId === 'weixin' && 'Long-Poll'} {channelId === 'telegram' && ((config as TelegramChannelConfig).mode === 'webhook' ? 'Webhook' : 'Polling')} + {channelId === 'slack' && 'Socket Mode'}
@@ -1396,6 +1422,115 @@ function TelegramPanel({ config, onChange, onRefresh }: TelegramPanelProps) { ); } +// ============================================================================ +// Slack Config Panel +// ============================================================================ + +function SlackPanel({ + config, + onChange, +}: { + config: SlackChannelConfig; + onChange: (c: SlackChannelConfig) => void; +}) { + const { t } = useTranslation('channel'); + const set = useCallback( + (key: K, value: SlackChannelConfig[K]) => + onChange({ ...config, [key]: value }), + [config, onChange] + ); + + return ( + <> +
+ + set('botToken', v || undefined)} + placeholder="xoxb-..." + /> + + + set('appToken', v || undefined)} + placeholder="xapp-..." + /> + + + set('homeChannel', v || undefined)} + placeholder="C0123456789" + /> + + + set('homeChannelName', v || undefined)} + placeholder="general" + /> + +
+ +
+ + set('defaultAgent', v || undefined)} + placeholder={t('slack.optional')} + /> + + + set('allowBots', v as 'none' | 'mentions' | 'all')} + options={[ + { value: 'none', label: t('slack.allowBotsNone') }, + { value: 'mentions', label: t('slack.allowBotsMentions') }, + { value: 'all', label: t('slack.allowBotsAll') }, + ]} + /> + +
+ + ); +} + // ============================================================================ // Weixin Config Panel // ============================================================================ @@ -1956,6 +2091,8 @@ export default function ChannelPage() { configs[ch.id] = { ...defaultDingTalkConfig(), ...saved }; } else if (ch.id === 'telegram') { configs[ch.id] = { ...defaultTelegramConfig(), ...saved }; + } else if (ch.id === 'slack') { + configs[ch.id] = { ...defaultSlackConfig(), ...saved }; } else if (ch.id === 'weixin') { configs[ch.id] = { ...defaultWeixinConfig(), ...saved }; } else { @@ -2281,6 +2418,12 @@ export default function ChannelPage() { onRefresh={fetchAll} /> )} + {selectedId === 'slack' && ( + handleChannelConfigChange('slack', cfg)} + /> + )} {selectedId === 'weixin' && ( Date: Wed, 15 Jul 2026 16:06:30 +0800 Subject: [PATCH 06/86] feat: complete project management actions --- tui/flocks/project/project.ts | 35 ++++ tui/flocks/server/routes/project.ts | 36 ++++ webui/src/locales/en-US/session.json | 1 + webui/src/locales/zh-CN/session.json | 1 + webui/src/pages/Session/index.test.tsx | 244 ++++++++++++++++++++++++- webui/src/pages/Session/index.tsx | 149 ++++++++++++--- 6 files changed, 427 insertions(+), 39 deletions(-) diff --git a/tui/flocks/project/project.ts b/tui/flocks/project/project.ts index 4c59840fa..db920f020 100644 --- a/tui/flocks/project/project.ts +++ b/tui/flocks/project/project.ts @@ -13,6 +13,7 @@ import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" +import { randomBytes } from "crypto" export namespace Project { const log = Log.create({ service: "project" }) @@ -45,6 +46,10 @@ export namespace Project { Updated: BusEvent.define("project.updated", Info), } + function createProjectID() { + return `prj_${Date.now().toString(36)}${randomBytes(8).toString("hex")}` + } + export async function fromDirectory(directory: string) { log.info("fromDirectory", { directory }) @@ -282,6 +287,36 @@ export namespace Project { })) } + export const create = fn( + z.object({ + name: z.string().trim().min(1), + worktree: z.string(), + icon: Info.shape.icon.optional(), + }), + async (input) => { + const now = Date.now() + const result: Info = { + id: createProjectID(), + worktree: input.worktree, + name: input.name, + icon: input.icon, + sandboxes: [], + time: { + created: now, + updated: now, + }, + } + await Storage.write(["project", result.id], result) + GlobalBus.emit("event", { + payload: { + type: Event.Updated.type, + properties: result, + }, + }) + return result + }, + ) + export const update = fn( z.object({ projectID: z.string(), diff --git a/tui/flocks/server/routes/project.ts b/tui/flocks/server/routes/project.ts index ce3763e1a..eaa7afff2 100644 --- a/tui/flocks/server/routes/project.ts +++ b/tui/flocks/server/routes/project.ts @@ -52,6 +52,42 @@ export const ProjectRoutes = lazy(() => return c.json(Instance.project) }, ) + .post( + "/", + describeRoute({ + summary: "Create project", + description: "Create a user-managed project for organizing sessions.", + operationId: "project.create", + responses: { + 200: { + description: "Created project information", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "json", + z.object({ + name: z.string().trim().min(1), + worktree: z.string().optional(), + icon: Project.Info.shape.icon.optional(), + }), + ), + async (c) => { + const body = c.req.valid("json") + const project = await Project.create({ + name: body.name, + worktree: body.worktree ?? Instance.directory, + icon: body.icon, + }) + return c.json(project) + }, + ) .patch( "/:projectID", describeRoute({ diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 1b16d2fc4..03171888d 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -265,6 +265,7 @@ "projectsSection": "Projects", "toggleProject": "Expand or collapse project {{project}}", "selectProject": "Select project {{project}}", + "createSessionInProject": "New session in project {{project}}", "defaultProject": "Default project", "defaultProjectName": "Default", "noProjectSessions": "No conversations", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 69eb8f3a2..d8ff92ac7 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -265,6 +265,7 @@ "projectsSection": "项目", "toggleProject": "展开或收起项目 {{project}}", "selectProject": "选择项目 {{project}}", + "createSessionInProject": "在项目 {{project}} 中新建会话", "defaultProject": "默认项目", "defaultProjectName": "默认", "noProjectSessions": "暂无会话", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index a6ff6af55..f6636ec11 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; @@ -401,7 +401,7 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); - const projectLabel = await screen.findByText('Security Project'); + const projectLabel = await screen.findByText('defaultProjectName'); expect(screen.getByText('Original Session')).toBeInTheDocument(); expect(screen.getByText('1')).toBeInTheDocument(); @@ -432,16 +432,16 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); - await screen.findByText('Security Project'); + await screen.findByText('defaultProjectName'); expect(screen.getByText('Original Session')).toBeInTheDocument(); expect(screen.getByText('Legacy Default Session')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.queryByText('project')).not.toBeInTheDocument(); }); - it('labels the unnamed current project as default', async () => { + it('labels the current project as default even when the stored name is the worktree name', async () => { client.get.mockImplementation((url: string) => { - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: null }; + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; if (url === '/api/project/current') { return Promise.resolve({ data: currentProject }); } @@ -454,6 +454,148 @@ describe('SessionPage session actions menu', () => { expect(screen.queryByText('project')).not.toBeInTheDocument(); }); + it('merges historical project rows for the current worktree into default', async () => { + client.get.mockImplementation((url: string) => { + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; + if (url === '/api/project/current') { + return Promise.resolve({ data: currentProject }); + } + return Promise.resolve({ + data: [ + currentProject, + { id: 'old-project-id', worktree: '/tmp/project', name: 'project' }, + ], + }); + }); + useSessions.mockReturnValue({ + sessions: [ + session, + { + ...secondSession, + projectID: 'old-project-id', + directory: '/tmp/project', + title: 'Historical Project Session', + }, + ], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + + renderSessionPage(); + + expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); + expect(screen.getByText('Historical Project Session')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('project')).not.toBeInTheDocument(); + }); + + it('falls back to the first automatic project as default when current project is missing', async () => { + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') { + return Promise.reject({ response: { status: 404 } }); + } + return Promise.resolve({ + data: [ + { id: 'project-1', worktree: '/tmp/project', name: 'flocks' }, + { id: 'old-project-id', worktree: '/tmp/project', name: 'flocks' }, + ], + }); + }); + useSessions.mockReturnValue({ + sessions: [ + session, + { + ...secondSession, + projectID: 'old-project-id', + directory: '/tmp/project', + title: 'Old Flocks Project Session', + }, + ], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + + renderSessionPage(); + + expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); + expect(screen.getByText('Old Flocks Project Session')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('flocks')).not.toBeInTheDocument(); + }); + + it('uses the current project as default when the project list is empty', async () => { + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') { + return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/flocks', name: null } }); + } + return Promise.resolve({ data: [] }); + }); + useSessions.mockReturnValue({ + sessions: [ + { ...session, projectID: 'project-1', directory: '/tmp/flocks' }, + { + ...secondSession, + projectID: 'old-project-id', + directory: '/tmp/flocks', + title: 'Old Same Worktree Session', + }, + ], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + + renderSessionPage(); + + expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); + expect(screen.getByText('Old Same Worktree Session')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('flocks')).not.toBeInTheDocument(); + }); + + it('infers the default project from sessions when project metadata is unavailable', async () => { + client.get.mockRejectedValue({ response: { status: 404 } }); + useSessions.mockReturnValue({ + sessions: [ + { ...session, projectID: 'project-1', directory: '/tmp/flocks' }, + { + ...secondSession, + projectID: 'old-project-id', + directory: '/tmp/flocks', + title: 'Fallback Same Worktree Session', + }, + ], + loading: false, + error: null, + refetch: refetchSessions, + updateSessionTitle, + removeSession, + removeSessions, + addSession, + }); + + renderSessionPage(); + + expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); + expect(screen.getByText('Fallback Same Worktree Session')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('flocks')).not.toBeInTheDocument(); + }); + it('creates a user-managed project from the sidebar', async () => { const user = userEvent.setup(); let projectRows = [ @@ -467,7 +609,7 @@ describe('SessionPage session actions menu', () => { }); client.post.mockImplementation((url: string, payload: Record) => { if (url === '/api/project') { - const created = { id: 'project-2', worktree: '/tmp/project', name: payload.name as string }; + const created = { id: 'prj_project2', worktree: '/tmp/project', name: payload.name as string }; projectRows = [projectRows[0], created]; return Promise.resolve({ data: created }); } @@ -486,21 +628,105 @@ describe('SessionPage session actions menu', () => { }); }); + it('keeps a newly created empty project visible while search is active', async () => { + const user = userEvent.setup(); + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }; + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') { + return Promise.resolve({ data: currentProject }); + } + return Promise.resolve({ data: [currentProject] }); + }); + client.post.mockImplementation((url: string, payload: Record) => { + if (url === '/api/project') { + const created = { id: 'prj_project2', worktree: '/tmp/project', name: payload.name as string }; + return Promise.resolve({ data: created }); + } + return Promise.resolve({ data: secondSession }); + }); + + renderSessionPage(); + + await user.type(screen.getByPlaceholderText('filterConversations'), 'nothing matches'); + await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); + await user.type(screen.getByLabelText('projectDialog.nameLabel'), 'Labs'); + await user.click(screen.getByRole('button', { name: 'save' })); + + expect(await screen.findByText('Labs')).toBeInTheDocument(); + expect(screen.getByText('noProjectSessions')).toBeInTheDocument(); + }); + it('renames a project from the sidebar', async () => { const user = userEvent.setup(); + client.get.mockImplementation((url: string) => { + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; + if (url === '/api/project/current') { + return Promise.resolve({ data: currentProject }); + } + return Promise.resolve({ + data: [ + currentProject, + { id: 'historical-project-id', worktree: '/tmp/labs', name: 'Labs' }, + ], + }); + }); renderSessionPage(); - await screen.findByText('Security Project'); - await user.click(screen.getByRole('button', { name: 'projectDialog.renameTitle' })); + const projectLabel = await screen.findByText('Labs'); + const projectRow = projectLabel.closest('[class*="group/project"]'); + expect(projectRow).not.toBeNull(); + await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectDialog.renameTitle' })); const input = screen.getByLabelText('projectDialog.nameLabel'); await user.clear(input); await user.type(input, 'Renamed Project'); await user.click(screen.getByRole('button', { name: 'save' })); await waitFor(() => { - expect(client.patch).toHaveBeenCalledWith('/api/project/project-1', { name: 'Renamed Project' }); + expect(client.patch).toHaveBeenCalledWith('/api/project/historical-project-id', { name: 'Renamed Project' }); + }); + }); + + it('creates a session from a specific project row', async () => { + const user = userEvent.setup(); + client.get.mockImplementation((url: string) => { + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; + if (url === '/api/project/current') { + return Promise.resolve({ data: currentProject }); + } + return Promise.resolve({ + data: [ + currentProject, + { id: 'prj_project2', worktree: '/tmp/project', name: 'Labs' }, + ], + }); + }); + client.post.mockResolvedValue({ + data: { + ...secondSession, + id: 'session-labs', + projectID: 'prj_project2', + title: 'New Session', + }, + }); + + renderSessionPage(); + + const projectLabel = await screen.findByText('Labs'); + const projectRow = projectLabel.closest('[class*="group/project"]'); + expect(projectRow).not.toBeNull(); + await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'createSessionInProject' })); + + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/api/session', { + title: 'New Session', + projectID: 'prj_project2', + }); }); + expect(addSession).toHaveBeenCalledWith(expect.objectContaining({ + id: 'session-labs', + projectID: 'prj_project2', + })); }); it('opens the actions menu for a session item', async () => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index b9a8248ec..f2bb9a3ea 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -51,6 +51,7 @@ type AgentSourceFilter = 'all' | 'builtin' | 'custom'; type ProjectSummary = { id: string; worktree: string; + vcs?: string | null; name?: string | null; }; type ProjectDialogMode = 'create' | 'rename'; @@ -370,6 +371,14 @@ const SessionSidebarItem = memo(SessionSidebarItemInner, (prev, next) => ( prev.t === next.t )); +function isUserManagedProjectId(projectId?: string | null): boolean { + return Boolean(projectId?.startsWith('prj_')); +} + +function isUserManagedProject(project?: ProjectSummary): boolean { + return isUserManagedProjectId(project?.id); +} + export default function SessionPage() { const { t, i18n } = useTranslation('session'); const location = useLocation(); @@ -548,19 +557,46 @@ export default function SessionPage() { }); }, []); + const defaultProjectId = useMemo(() => { + if (currentProjectId) return currentProjectId; + const firstAutoProject = projects.find((project) => !isUserManagedProject(project)); + const firstAutoSession = sessions.find((session) => ( + session.projectID + && !isLegacyDefaultProjectId(session.projectID) + && !isUserManagedProjectId(session.projectID) + )); + return firstAutoProject?.id ?? firstAutoSession?.projectID ?? projects[0]?.id ?? null; + }, [currentProjectId, projects, sessions]); + const projectSessionGroups = useMemo(() => { const projectMap = new Map(); - const currentProject = currentProjectId - ? projects.find((project) => project.id === currentProjectId) + const defaultProject = defaultProjectId + ? projects.find((project) => project.id === defaultProjectId) + : undefined; + const defaultSession = defaultProjectId + ? sessions.find((session) => session.projectID === defaultProjectId) : undefined; - const currentProjectWorktree = normalizeProjectPath(currentProject?.worktree); + const defaultProjectWorktree = normalizeProjectPath(defaultProject?.worktree ?? defaultSession?.directory); + const userManagedProjectIds = new Set( + [ + ...projects.filter(isUserManagedProject).map((project) => project.id), + ...sessions.map((session) => session.projectID).filter(isUserManagedProjectId), + ], + ); projects.forEach((project) => { - const isCurrentProject = currentProjectId === project.id; - const hasExplicitName = Boolean(project.name?.trim()); + const isDefaultProject = defaultProjectId === project.id; + const isSameWorktreeAsDefault = Boolean( + defaultProjectWorktree + && normalizeProjectPath(project.worktree) === defaultProjectWorktree, + ); + if (!isDefaultProject && isSameWorktreeAsDefault && !isUserManagedProject(project)) { + return; + } + projectMap.set(project.id, { id: project.id, - label: isCurrentProject && !hasExplicitName ? t('defaultProjectName') : getProjectLabel(project), + label: isDefaultProject ? t('defaultProjectName') : getProjectLabel(project), worktree: project.worktree, sessions: [], }); @@ -568,8 +604,12 @@ export default function SessionPage() { sessions.forEach((session) => { const sessionDirectory = normalizeProjectPath(session.directory); - const projectId = isLegacyDefaultProjectId(session.projectID) && currentProjectId && sessionDirectory === currentProjectWorktree - ? currentProjectId + const shouldUseDefaultProject = defaultProjectId + && defaultProjectWorktree + && sessionDirectory === defaultProjectWorktree + && !userManagedProjectIds.has(session.projectID); + const projectId = (isLegacyDefaultProjectId(session.projectID) || shouldUseDefaultProject) && defaultProjectId + ? defaultProjectId : session.projectID || session.directory || 'global'; const existing = projectMap.get(projectId); if (existing) { @@ -579,23 +619,23 @@ export default function SessionPage() { projectMap.set(projectId, { id: projectId, - label: getProjectLabel(undefined, session.directory), + label: projectId === defaultProjectId ? t('defaultProjectName') : getProjectLabel(undefined, session.directory), worktree: session.directory, sessions: [session], }); }); return Array.from(projectMap.values()) - .filter((group) => group.sessions.length > 0 || !searchQuery.trim()) + .filter((group) => group.sessions.length > 0 || !searchQuery.trim() || group.id === selectedProjectId) .sort((a, b) => { const aLatest = a.sessions[0]?.time?.updated ?? 0; const bLatest = b.sessions[0]?.time?.updated ?? 0; if (aLatest !== bLatest) return bLatest - aLatest; return a.label.localeCompare(b.label); }); - }, [currentProjectId, projects, searchQuery, sessions, t]); + }, [defaultProjectId, projects, searchQuery, selectedProjectId, sessions, t]); - const selectedProjectIDForCreate = selectedProjectId ?? currentProjectId ?? projectSessionGroups[0]?.id ?? null; + const selectedProjectIDForCreate = selectedProjectId ?? defaultProjectId ?? projectSessionGroups[0]?.id ?? null; useEffect(() => { if (projectSessionGroups.length === 0) { @@ -607,11 +647,11 @@ export default function SessionPage() { return; } - const fallbackProjectId = currentProjectId && projectSessionGroups.some((group) => group.id === currentProjectId) - ? currentProjectId + const fallbackProjectId = defaultProjectId && projectSessionGroups.some((group) => group.id === defaultProjectId) + ? defaultProjectId : projectSessionGroups[0].id; setSelectedProjectId(fallbackProjectId); - }, [currentProjectId, projectSessionGroups, selectedProjectId]); + }, [defaultProjectId, projectSessionGroups, selectedProjectId]); // Handle SSE events for session-level updates (title changes, etc.) const handleChatError = useCallback((msg: string) => { @@ -646,15 +686,24 @@ export default function SessionPage() { } }, [scheduleSessionListRefetch, updateSessionTitle]); - const fetchProjects = useCallback(async () => { + const fetchProjects = useCallback(async (ensureProject?: ProjectSummary) => { const [listResult, currentResult] = await Promise.allSettled([ client.get('/api/project'), client.get('/api/project/current'), ]); - const nextProjects = listResult.status === 'fulfilled' && Array.isArray(listResult.value.data) + let nextProjects = listResult.status === 'fulfilled' && Array.isArray(listResult.value.data) ? listResult.value.data : []; + const currentProject = currentResult.status === 'fulfilled' + ? currentResult.value.data as ProjectSummary | undefined + : undefined; + if (currentProject?.id && !nextProjects.some((project) => project.id === currentProject.id)) { + nextProjects = [currentProject, ...nextProjects]; + } + if (ensureProject?.id && !nextProjects.some((project) => project.id === ensureProject.id)) { + nextProjects = [ensureProject, ...nextProjects]; + } const nextCurrentProjectId = currentResult.status === 'fulfilled' ? currentResult.value.data?.id ?? null : null; @@ -833,16 +882,25 @@ export default function SessionPage() { setRenameValue(''); }, [selectMode]); - const handleCreateSession = useCallback(async () => { + const handleCreateSession = useCallback(async (projectIdOverride?: string) => { if (creating) return; + const projectID = projectIdOverride ?? selectedProjectIDForCreate; setCreating(true); try { const response = await client.post('/api/session', { title: 'New Session', - ...(selectedProjectIDForCreate ? { projectID: selectedProjectIDForCreate } : {}), + ...(projectID ? { projectID } : {}), }); addSession(response.data); setSelectedSessionFallback(response.data); + if (projectID) { + setSelectedProjectId(projectID); + setCollapsedProjectIds(prev => { + const next = new Set(prev); + next.delete(projectID); + return next; + }); + } setSelectedAgent('rex'); setSelectedModelKey(null); setSelectedSessionId(response.data.id); @@ -853,6 +911,10 @@ export default function SessionPage() { } }, [creating, selectedProjectIDForCreate, addSession, toast, t]); + const handleCreateSessionInProject = useCallback((projectId: string) => { + void handleCreateSession(projectId); + }, [handleCreateSession]); + const handleSelectModel = useCallback(async (option: ChatModelOption) => { setSelectedModelKey(option.key); setShowModelOptions(false); @@ -1056,16 +1118,23 @@ export default function SessionPage() { try { if (projectDialogMode === 'create') { const response = await client.post('/api/project', { name: nextName }); - setSelectedProjectId(response.data.id); + const createdProject = response.data as ProjectSummary; + setProjects(prev => ( + prev.some((project) => project.id === createdProject.id) + ? prev.map((project) => (project.id === createdProject.id ? createdProject : project)) + : [createdProject, ...prev] + )); + setSelectedProjectId(createdProject.id); setCollapsedProjectIds(prev => { const next = new Set(prev); - next.delete(response.data.id); + next.delete(createdProject.id); return next; }); + await fetchProjects(createdProject); } else if (editingProjectId) { await client.patch(`/api/project/${editingProjectId}`, { name: nextName }); + await fetchProjects(); } - await fetchProjects(); setProjectDialogMode(null); setEditingProjectId(null); setProjectNameValue(''); @@ -1310,7 +1379,7 @@ export default function SessionPage() { ? : } + {canRenameProject && ( + + )} {group.sessions.length} From c575fcd71e9e1ad8d134138650473f7a1bdb3679 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Wed, 15 Jul 2026 17:38:02 +0800 Subject: [PATCH 07/86] fix(hub): isolate plugin installation failures --- .flocks/flockshub/index.json | 2 +- .../chaitin_safeline_waf_v1_0_0/manifest.json | 2 +- flocks/hub/catalog.py | 62 ++++--- flocks/hub/installer.py | 17 +- flocks/hub/security.py | 26 +++ flocks/tool/registry.py | 67 +++++-- scripts/validate_flockshub.py | 24 +++ tests/hub/test_bundled_tools.py | 31 +++- tests/hub/test_hub_catalog.py | 63 +++++-- tests/tool/test_tool_refresh_outcomes.py | 164 +++++++++++++++++- 10 files changed, 390 insertions(+), 68 deletions(-) diff --git a/.flocks/flockshub/index.json b/.flocks/flockshub/index.json index 226009aef..7ac3a1117 100644 --- a/.flocks/flockshub/index.json +++ b/.flocks/flockshub/index.json @@ -14573,7 +14573,7 @@ "type": "device", "name": "Chaitin SafeLine WAF", "description": "Chaitin SafeLine WAF OpenAPI integration.", - "version": "1.0.0", + "version": "1.0.1", "category": "integration", "tags": [ "waf", diff --git a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json index 065286060..11ec79673 100644 --- a/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json +++ b/.flocks/flockshub/plugins/tools/device/chaitin_safeline_waf_v1_0_0/manifest.json @@ -5,7 +5,7 @@ "name": "Chaitin SafeLine WAF", "description": "Chaitin SafeLine WAF OpenAPI integration.", "descriptionCn": "长亭雷池 WAF OpenAPI 接入。", - "version": "1.0.0", + "version": "1.0.1", "author": "Flocks Team", "license": "MIT", "category": "integration", diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index d82e001f5..830c943a6 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -740,6 +740,23 @@ def _version_tuple(value: str) -> tuple[int, ...]: return tuple(int(part) for part in parts) if parts else (0,) +def _catalog_install_state( + install_path: Optional[Path], + record: Optional[local.InstalledPluginRecord], + available_version: str, +) -> tuple[str, Optional[str]]: + if install_path is None: + return "available", None + if record is None: + # An inferred install has no trustworthy package version because Hub + # manifests are not copied into the install directory. Reconcile it + # through the update path instead of assuming it is already current. + return "updateAvailable", None + if _version_tuple(record.version) < _version_tuple(available_version): + return "updateAvailable", record.version + return "installed", record.version + + def _os_compatible(manifest: HubPluginManifest) -> bool: allowed = {item.lower() for item in manifest.compatibility.os} if not allowed: @@ -753,13 +770,11 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: record = local.get_record(manifest.type, manifest.id) install_path = _resolve_install_path(manifest.type, manifest.id, record, local.infer_local_installs()) - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else manifest.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(manifest.version): - state = "updateAvailable" + state, installed_version = _catalog_install_state( + install_path, + record, + manifest.version, + ) if not _os_compatible(manifest): state = "incompatible" @@ -811,13 +826,11 @@ def _entry_from_index( record = records.get(f"{item.type}:{item.id}") install_path = _resolve_install_path(item.type, item.id, record, inferred_installs) - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else item.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(item.version): - state = "updateAvailable" + state, installed_version = _catalog_install_state( + install_path, + record, + item.version, + ) return HubCatalogEntry( id=item.id, @@ -875,11 +888,10 @@ def _entry_from_bundled_tool( """Catalog entry for a tool bundled inside flockshub. State is computed exactly like :func:`_entry_from_index`: - * ``installed`` — the user installed it under - ``~/.flocks/plugins/tools///`` - (record or on-disk payload found). - * ``updateAvailable`` — installed copy is older than the bundled - version. + * ``installed`` — an installed record confirms the local copy is + current. + * ``updateAvailable`` — the installed copy is older, or an unrecorded + on-disk payload needs reconciliation. * ``available`` — bundled in flockshub, no local install. The runtime ``ToolRegistry`` only picks up installed tools, so an "available" entry @@ -891,13 +903,11 @@ def _entry_from_bundled_tool( record = records.get(f"{manifest.type}:{manifest.id}") install_path = _resolve_install_path(manifest.type, manifest.id, record, inferred_installs) - state = "available" - installed_version: Optional[str] = None - if install_path: - installed_version = record.version if record else manifest.version - state = "installed" - if _version_tuple(installed_version) < _version_tuple(manifest.version): - state = "updateAvailable" + state, installed_version = _catalog_install_state( + install_path, + record, + manifest.version, + ) if not _os_compatible(manifest): state = "incompatible" diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index bcbbf2407..e60d29f00 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -196,7 +196,10 @@ def _copy_webui_package_with_build(plugin_id: str, src: Path, dst: Path) -> None raise -async def _refresh_runtime(plugin_type: PluginType) -> None: +async def _refresh_runtime( + plugin_type: PluginType, + changed_path: Path | None = None, +) -> None: if plugin_type == "skill": from flocks.skill.skill import Skill @@ -223,7 +226,13 @@ async def _refresh_runtime(plugin_type: PluginType) -> None: from flocks.tool.registry import ToolRegistry await ToolRegistry.init_async() - await asyncio.to_thread(ToolRegistry.refresh_plugin_tools) + if changed_path is None: + await asyncio.to_thread(ToolRegistry.refresh_plugin_tools) + else: + await asyncio.to_thread( + ToolRegistry.refresh_plugin_tools, + changed_path=changed_path, + ) clear_device_template_cache() # Drop the descriptor cache so freshly installed/uninstalled # API plugins surface in ``_load_provider_yaml_metadata`` (and @@ -518,7 +527,7 @@ async def install_plugin( ) local.save_installed_record(record) clear_catalog_caches() - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, dst) if plugin_type == "component": await _emit_component_progress(progress, manifest, "complete", record=record, message="Installed") return record @@ -618,5 +627,5 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: local.remove_installed_record(plugin_type, plugin_id) clear_catalog_caches() _cleanup_orphan_api_services(orphan_keys) - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, install_path) return True diff --git a/flocks/hub/security.py b/flocks/hub/security.py index c630fdf41..3707add24 100644 --- a/flocks/hub/security.py +++ b/flocks/hub/security.py @@ -23,6 +23,30 @@ def _sha256(path: Path) -> str: return f"sha256:{digest.hexdigest()}" +def _validate_python_syntax(package_dir: Path, manifest: HubPluginManifest) -> None: + python_files = { + package_dir / entrypoint + for entrypoint in manifest.entrypoints + if Path(entrypoint).suffix == ".py" + } + if manifest.type in {"tool", "device"}: + python_files.update( + path + for path in package_dir.rglob("*.py") + if not any(part in SKIP_NAMES for part in path.relative_to(package_dir).parts) + ) + + for path in sorted(python_files): + if not path.is_file(): + continue + relative_path = path.relative_to(package_dir).as_posix() + try: + source = path.read_text(encoding="utf-8") + compile(source, str(path), "exec") + except (SyntaxError, UnicodeDecodeError) as exc: + raise ValueError(f"Invalid Python source in package: {relative_path}: {exc}") from exc + + def validate_package(package_dir: Path, manifest: HubPluginManifest) -> None: base = package_dir.resolve() if not base.is_dir(): @@ -41,6 +65,8 @@ def validate_package(package_dir: Path, manifest: HubPluginManifest) -> None: if not entry.exists(): raise ValueError(f"Missing entrypoint: {entrypoint}") + _validate_python_syntax(base, manifest) + for rel_path, expected in manifest.checksums.items(): if not expected: continue diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index d348a8387..4bc594796 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -607,6 +607,9 @@ class ToolRegistry: _dynamic_modules: Dict[str, str] = {} _dynamic_tools_by_module: Dict[str, List[str]] = {} _plugin_tool_names: List[str] = [] + # Source-level plugin failures observed by the last load. They remain + # diagnostic only because the plugin loader already isolates each source. + _plugin_load_errors: List[str] = [] _revision: int = 0 _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 @@ -1052,7 +1055,7 @@ def init(cls) -> None: cls._register_builtin_tools() cls._register_dynamic_tools() cls._register_plugin_extension_point() - cls._load_plugin_tools() + cls._plugin_load_errors = cls._load_plugin_tools() cls._initialized = True log.debug("tool_registry.initialized", {"count": len(cls._tools)}) finally: @@ -1064,7 +1067,7 @@ async def init_async(cls) -> None: await asyncio.to_thread(cls.init) @classmethod - def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: + def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> List[str]: """Load plugin tools from both user-level and project-level plugin dirs on init. Without this, YAML/Python plugin tools only appear after an explicit @@ -1077,18 +1080,23 @@ def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: Tracks which tool names were added so that ``refresh_plugin_tools`` can accurately unregister stale entries (regardless of the ``ToolInfo.source`` value). + + Returns every source-level load error for diagnostics and scoped Hub + refresh decisions. """ before = set(cls._tools.keys()) + load_errors: List[str] = [] try: from flocks.plugin import PluginLoader - load_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) - if errors is not None: - errors.extend(load_errors) + reported_errors = PluginLoader.load_extension("TOOLS", load_entry_points=True) + if reported_errors: + load_errors.extend(reported_errors) except Exception as e: log.warn("tool_registry.plugin_load_failed", {"error": str(e)}) - if errors is not None: - errors.append(f"plugin loader: {e}") + load_errors.append(f"plugin loader: {e}") + if errors is not None: + errors.extend(load_errors) after = set(cls._tools.keys()) new_plugin_tools = sorted(after - before) python_tool_sources: Dict[str, Path] = {} @@ -1131,8 +1139,14 @@ def _load_plugin_tools(cls, errors: Optional[List[str]] = None) -> None: except ValueError: tool.info.native = True cls._plugin_tool_names = sorted(set(new_plugin_tools) | python_plugin_names) - if errors: - return + if errors is not None and load_errors: + return load_errors + cls._finalize_plugin_tools_load() + return load_errors + + @classmethod + def _finalize_plugin_tools_load(cls) -> None: + """Apply configuration overlays after an accepted plugin load.""" cls._bootstrap_user_api_services() # Defence-in-depth: ``register()`` is the canonical writer for # ``_enabled_defaults`` but this catches any tool that landed in @@ -1612,11 +1626,13 @@ def start_watcher(cls) -> None: cls._watcher.start() @classmethod - def refresh_plugin_tools(cls) -> List[str]: + def refresh_plugin_tools(cls, changed_path: Optional[Path] = None) -> List[str]: """Reload plugin tools (YAML + Python) from disk. Unregisters stale plugin tools first so that deleted files are - correctly removed from the registry. + correctly removed from the registry. When a Hub operation supplies + ``changed_path``, source failures outside that path are isolated and + do not block the operation; failures inside it still roll back. """ cls._ensure_initialized() with cls._refresh_lock: @@ -1635,14 +1651,39 @@ def refresh_plugin_tools(cls) -> List[str]: cls._enabled_defaults.update(defaults_before) cls._plugin_tool_names = plugin_names_before raise - if load_errors: + changed_root = changed_path.resolve() if changed_path is not None else None + fatal_errors: List[str] = [] + for error in load_errors: + if changed_root is None or error.startswith( + ("plugin loader:", "extension point not found:") + ): + fatal_errors.append(error) + continue + if error.startswith("entry point "): + continue + source_text, separator, _detail = error.partition(": ") + if not separator: + fatal_errors.append(error) + continue + source_path = Path(source_text) + if not source_path.is_absolute(): + fatal_errors.append(error) + continue + source_path = source_path.resolve() + if source_path == changed_root or changed_root in source_path.parents: + fatal_errors.append(error) + if fatal_errors: cls._tools.clear() cls._tools.update(tools_before) cls._enabled_defaults.clear() cls._enabled_defaults.update(defaults_before) cls._plugin_tool_names = plugin_names_before - raise ToolRefreshError("plugin", load_errors) + raise ToolRefreshError("plugin", fatal_errors) + + if load_errors: + cls._finalize_plugin_tools_load() + cls._plugin_load_errors = load_errors cls._bump_revision("plugin_refresh") return cls.all_tool_ids() diff --git a/scripts/validate_flockshub.py b/scripts/validate_flockshub.py index 5aa356aaf..7068f7cdc 100644 --- a/scripts/validate_flockshub.py +++ b/scripts/validate_flockshub.py @@ -80,6 +80,30 @@ def main() -> int: if not (package_dir / entrypoint).exists(): fail(f"Missing entrypoint {entrypoint} in {manifest_rel}") + python_files = { + package_dir / entrypoint + for entrypoint in manifest.get("entrypoints", []) + if Path(entrypoint).suffix == ".py" + } + if plugin_type in {"tool", "device"}: + python_files.update( + path + for path in package_dir.rglob("*.py") + if not any( + part in {".git", "__pycache__"} + for part in path.relative_to(package_dir).parts + ) + ) + for path in sorted(python_files): + if not path.is_file(): + continue + try: + source = path.read_text(encoding="utf-8") + compile(source, str(path), "exec") + except (SyntaxError, UnicodeDecodeError) as exc: + relative_path = path.relative_to(package_dir).as_posix() + fail(f"Invalid Python source {relative_path} in {manifest_rel}: {exc}") + for path in package_dir.rglob("*"): rel = path.relative_to(package_dir).as_posix() ensure_relative(rel) diff --git a/tests/hub/test_bundled_tools.py b/tests/hub/test_bundled_tools.py index e2fe2c483..2e86c2e73 100644 --- a/tests/hub/test_bundled_tools.py +++ b/tests/hub/test_bundled_tools.py @@ -134,7 +134,10 @@ def _write_bundled_tool( @pytest.mark.asyncio -async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): +async def test_tool_runtime_refresh_scopes_errors_and_clears_device_template_cache( + monkeypatch, + tmp_path, +): from flocks.config import api_versioning from flocks.tool.device import plugin_index from flocks.tool.registry import ToolRegistry @@ -144,7 +147,9 @@ async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): monkeypatch.setattr( ToolRegistry, "refresh_plugin_tools", - classmethod(lambda cls: calls.append("refresh")), + classmethod( + lambda cls, changed_path=None: calls.append(f"refresh:{changed_path}") + ), ) monkeypatch.setattr( api_versioning, @@ -153,10 +158,11 @@ async def test_tool_runtime_refresh_clears_device_template_cache(monkeypatch): ) plugin_index._template_cache = [] - await _refresh_runtime("device") + changed_path = tmp_path / "device" + await _refresh_runtime("device", changed_path) assert plugin_index._template_cache is None - assert calls == ["init", "refresh", "discover:True"] + assert calls == ["init", f"refresh:{changed_path}", "discover:True"] @pytest.mark.asyncio @@ -777,6 +783,23 @@ def test_path_escape_still_rejected(self, tmp_path): with pytest.raises(ValueError, match="escapes"): validate_package(package, self._manifest()) + def test_python_syntax_error_is_rejected_before_install(self, tmp_path): + package = tmp_path / "demo" + package.mkdir() + (package / "broken.handler.py").write_text("def broken(:\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"Invalid Python source.*broken\.handler\.py"): + validate_package(package, self._manifest()) + + def test_nested_python_syntax_error_is_rejected_before_install(self, tmp_path): + package = tmp_path / "demo" + scripts = package / "scripts" + scripts.mkdir(parents=True) + (scripts / "broken.py").write_text("def broken(:\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"Invalid Python source.*scripts/broken\.py"): + validate_package(package, self._manifest()) + # --------------------------------------------------------------------------- # API-service summary name disambiguation diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index f26ec863e..5ca585bb4 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -9,7 +9,7 @@ from flocks.hub import local from flocks.hub.catalog import list_catalog, load_manifest, load_taxonomy from flocks.hub.files import file_tree, read_file_content -from flocks.hub.installer import install_plugin, uninstall_plugin +from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin from flocks.plugin.loader import PluginLoader @@ -114,6 +114,41 @@ def test_soc_workspace_component_exposes_chinese_name(): assert entries["soc-workspace"].nameCn == "SOC 工作区场景套件" +async def test_chaitin_safeline_repair_release_replaces_broken_handler( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def noop_refresh(_plugin_type, _changed_path=None): + return None + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) + plugin_id = "chaitin_safeline_waf_v1_0_0" + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "device" + / plugin_id + ) + install_dir.mkdir(parents=True) + legacy_handler = install_dir / "chaitin_leichi_waf.handler.py" + legacy_handler.write_text("def broken(:\n", encoding="utf-8") + + entries = {entry.id: entry for entry in list_catalog(plugin_type="device")} + assert entries[plugin_id].state == "updateAvailable" + assert entries[plugin_id].version == "1.0.1" + assert entries[plugin_id].installedVersion is None + + record = await update_plugin("device", plugin_id) + + repaired_handler = install_dir / "chaitin_safeline_waf.handler.py" + assert record.version == "1.0.1" + assert local.get_record("device", plugin_id) == record + assert not legacy_handler.exists() + compile(repaired_handler.read_text(encoding="utf-8"), str(repaired_handler), "exec") + + def test_pentest_agents_are_listed_in_agent_catalog(): entries = list_catalog(plugin_type="agent") ids = {entry.id for entry in entries} @@ -214,7 +249,7 @@ async def test_hub_installs_pentest_subagent(isolated_hub_env): async def test_hub_installs_soc_webui_package(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -246,7 +281,7 @@ async def test_hub_webui_install_fails_when_bundle_build_fails( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None def fail_build(_self, _page_id: str): @@ -269,7 +304,7 @@ async def test_hub_installed_soc_webui_registers_alert_access_contract( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -296,7 +331,7 @@ async def test_hub_installed_soc_webui_serves_alert_access_operation( isolated_hub_env, monkeypatch: pytest.MonkeyPatch, ): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -392,7 +427,7 @@ async def noop_refresh(_plugin_type): async def test_hub_installs_soc_workspace_component_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -421,7 +456,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_preserves_existing_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -448,7 +483,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_adopts_existing_soc_workspace_tool(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -473,7 +508,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_cleans_adoptable_legacy_tool_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -496,7 +531,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_uninstall_cleans_legacy_unrecorded_webui(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -521,7 +556,7 @@ async def noop_refresh(_plugin_type): async def test_hub_component_install_failure_rolls_back_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def fail_tool_refresh(plugin_type): + async def fail_tool_refresh(plugin_type, _changed_path=None): if plugin_type == "tool": raise RuntimeError("tool refresh failed") return None @@ -548,7 +583,7 @@ async def fail_tool_refresh(plugin_type): async def test_hub_component_uninstall_cleans_orphan_children(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -582,7 +617,7 @@ async def noop_refresh(_plugin_type): async def test_hub_uninstalls_python_tool_without_record(isolated_hub_env, monkeypatch: pytest.MonkeyPatch): - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) @@ -758,7 +793,7 @@ def test_hub_component_install_stream_reports_child_progress(isolated_hub_env, m from flocks.server.auth import require_admin from flocks.server.routes.hub import router - async def noop_refresh(_plugin_type): + async def noop_refresh(_plugin_type, _changed_path=None): return None monkeypatch.setattr("flocks.hub.installer._refresh_runtime", noop_refresh) diff --git a/tests/tool/test_tool_refresh_outcomes.py b/tests/tool/test_tool_refresh_outcomes.py index 12f826f47..435d064c1 100644 --- a/tests/tool/test_tool_refresh_outcomes.py +++ b/tests/tool/test_tool_refresh_outcomes.py @@ -2,6 +2,7 @@ from collections.abc import Iterator from contextlib import contextmanager +from pathlib import Path import threading import pytest @@ -34,6 +35,7 @@ def _isolated_registry() -> Iterator[None]: "tools": ToolRegistry._tools, "defaults": ToolRegistry._enabled_defaults, "plugin_names": ToolRegistry._plugin_tool_names, + "plugin_errors": ToolRegistry._plugin_load_errors, "dynamic_modules": ToolRegistry._dynamic_modules, "dynamic_tools": ToolRegistry._dynamic_tools_by_module, } @@ -42,6 +44,7 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._tools = {} ToolRegistry._enabled_defaults = {} ToolRegistry._plugin_tool_names = [] + ToolRegistry._plugin_load_errors = [] ToolRegistry._dynamic_modules = {} ToolRegistry._dynamic_tools_by_module = {} yield @@ -50,11 +53,12 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._tools = state["tools"] ToolRegistry._enabled_defaults = state["defaults"] ToolRegistry._plugin_tool_names = state["plugin_names"] + ToolRegistry._plugin_load_errors = state["plugin_errors"] ToolRegistry._dynamic_modules = state["dynamic_modules"] ToolRegistry._dynamic_tools_by_module = state["dynamic_tools"] -def test_plugin_refresh_restores_last_known_good_on_load_error( +def test_plugin_refresh_restores_last_known_good_on_loader_failure( monkeypatch: pytest.MonkeyPatch, ): with _isolated_registry(): @@ -66,17 +70,167 @@ def _failed_load(cls, errors): replacement = _tool("partial_replacement", "plugin_py") cls._tools[replacement.info.name] = replacement cls._plugin_tool_names = [replacement.info.name] - errors.append("broken plugin") + errors.append("plugin loader: unavailable") monkeypatch.setattr(ToolRegistry, "_load_plugin_tools", classmethod(_failed_load)) - with pytest.raises(ToolRefreshError, match="broken plugin"): + with pytest.raises(ToolRefreshError, match="plugin loader: unavailable"): ToolRegistry.refresh_plugin_tools() assert ToolRegistry._tools == {previous.info.name: previous} assert ToolRegistry._plugin_tool_names == [previous.info.name] +def test_plugin_refresh_accepts_later_unrelated_syntax_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +): + from flocks.plugin import PluginLoader + + with _isolated_registry(): + project_dir = tmp_path / "project" + plugin_root = tmp_path / "plugins" + plugin_dir = plugin_root / "tools" / "device" / "legacy_device" + project_dir.mkdir() + plugin_dir.mkdir(parents=True) + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(PluginLoader, "_plugin_root", plugin_root) + monkeypatch.setattr( + ToolRegistry, + "_finalize_plugin_tools_load", + classmethod(lambda cls: None), + ) + monkeypatch.setattr(ToolRegistry, "_bump_revision", lambda _reason: None) + extension_points_before = PluginLoader._extension_points.copy() + try: + ToolRegistry._register_plugin_extension_point() + startup_errors = ToolRegistry._load_plugin_tools() + assert startup_errors == [] + ToolRegistry._plugin_load_errors = startup_errors + + (plugin_dir / "broken.handler.py").write_text( + "def broken(:\n", + encoding="utf-8", + ) + + (plugin_root / "tools" / "newly_installed.py").write_text( + "async def handle(ctx):\n" + " return None\n\n" + "TOOLS = [{\n" + " 'name': 'newly_installed',\n" + " 'description': 'newly installed tool',\n" + " 'handler': handle,\n" + "}]\n", + encoding="utf-8", + ) + + refreshed = ToolRegistry.refresh_plugin_tools( + changed_path=plugin_root / "tools" / "newly_installed.py", + ) + + assert "newly_installed" in refreshed + assert ToolRegistry.get("newly_installed") is not None + assert any("SyntaxError" in error for error in ToolRegistry._plugin_load_errors) + finally: + PluginLoader.clear_extension_points() + PluginLoader._extension_points.update(extension_points_before) + + +def test_registry_init_records_plugin_load_errors( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + ToolRegistry._initialized = False + monkeypatch.setattr( + ToolRegistry, + "_register_builtin_tools", + classmethod(lambda cls: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_dynamic_tools", + classmethod(lambda cls, errors=None: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_register_plugin_extension_point", + classmethod(lambda cls: None), + ) + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(lambda cls: ["broken legacy plugin"]), + ) + + ToolRegistry.init() + + assert ToolRegistry._plugin_load_errors == ["broken legacy plugin"] + + +def test_plugin_refresh_commits_valid_sources_alongside_new_source_error( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + ToolRegistry._plugin_load_errors = ["/plugins/legacy.py: broken legacy plugin"] + + def _load_with_new_error(cls, errors): + replacement = _tool("partial_replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.extend( + [ + "/plugins/legacy.py: broken legacy plugin", + "/plugins/unrelated.py: new broken plugin", + ] + ) + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_new_error), + ) + + refreshed = ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/newly_installed.py"), + ) + + assert refreshed == ["partial_replacement"] + assert set(ToolRegistry._tools) == {"partial_replacement"} + assert ToolRegistry._plugin_tool_names == ["partial_replacement"] + assert ToolRegistry._plugin_load_errors == [ + "/plugins/legacy.py: broken legacy plugin", + "/plugins/unrelated.py: new broken plugin", + ] + + +def test_plugin_refresh_rejects_error_inside_changed_path( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _load_target_error(cls, errors): + errors.append("/plugins/new/broken.py: ImportError: missing dependency") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_target_error), + ) + + with pytest.raises(ToolRefreshError, match="missing dependency"): + ToolRegistry.refresh_plugin_tools(changed_path=Path("/plugins/new")) + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + def test_dynamic_refresh_restores_last_known_good_on_load_error( monkeypatch: pytest.MonkeyPatch, ): @@ -121,7 +275,7 @@ def test_refresh_transactions_are_serialized_across_stages( def _failed_plugin_load(cls, errors): plugin_load_entered.set() assert allow_plugin_failure.wait(timeout=2) - errors.append("broken plugin") + errors.append("plugin loader: unavailable") def _successful_dynamic_load(cls, _errors): dynamic_load_entered.set() @@ -199,7 +353,7 @@ def test_registry_readers_do_not_observe_an_in_progress_refresh( def _failed_plugin_load(cls, errors): load_entered.set() assert allow_failure.wait(timeout=2) - errors.append("broken plugin") + errors.append("plugin loader: unavailable") monkeypatch.setattr( ToolRegistry, From a9eda82af5a5bc99428f7af269a7599f2e074782 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 16 Jul 2026 15:11:48 +0800 Subject: [PATCH 08/86] fix(hub): roll back failed plugin changes --- flocks/hub/catalog.py | 30 ++++- flocks/hub/installer.py | 158 ++++++++++++++++------- flocks/tool/registry.py | 92 +++++++------ tests/hub/test_bundled_tools.py | 39 +++++- tests/hub/test_hub_catalog.py | 121 +++++++++++++++++ tests/tool/test_tool_refresh_outcomes.py | 93 +++++++++++++ 6 files changed, 438 insertions(+), 95 deletions(-) diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index 830c943a6..87a45ce51 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -768,7 +768,12 @@ def _os_compatible(manifest: HubPluginManifest) -> bool: def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: record = local.get_record(manifest.type, manifest.id) - install_path = _resolve_install_path(manifest.type, manifest.id, record, local.infer_local_installs()) + install_path, record = _resolve_install_path( + manifest.type, + manifest.id, + record, + local.infer_local_installs(), + ) state, installed_version = _catalog_install_state( install_path, @@ -807,15 +812,16 @@ def _resolve_install_path( plugin_id: str, record: Optional[local.InstalledPluginRecord], inferred_installs: Optional[dict[tuple[PluginType, str], Path]] = None, -) -> Optional[Path]: +) -> tuple[Optional[Path], Optional[local.InstalledPluginRecord]]: if record and record.installPath: path = Path(record.installPath) if local.has_install_payload(plugin_type, path): - return path + return path, record local.remove_installed_record(plugin_type, plugin_id) + record = None if inferred_installs is not None: - return inferred_installs.get((plugin_type, plugin_id)) - return local.infer_local_install(plugin_type, plugin_id) + return inferred_installs.get((plugin_type, plugin_id)), record + return local.infer_local_install(plugin_type, plugin_id), record def _entry_from_index( @@ -824,7 +830,12 @@ def _entry_from_index( inferred_installs: dict[tuple[PluginType, str], Path], ) -> HubCatalogEntry: record = records.get(f"{item.type}:{item.id}") - install_path = _resolve_install_path(item.type, item.id, record, inferred_installs) + install_path, record = _resolve_install_path( + item.type, + item.id, + record, + inferred_installs, + ) state, installed_version = _catalog_install_state( install_path, @@ -901,7 +912,12 @@ def _entry_from_bundled_tool( * ``incompatible`` — manifest declares an incompatible OS. """ record = records.get(f"{manifest.type}:{manifest.id}") - install_path = _resolve_install_path(manifest.type, manifest.id, record, inferred_installs) + install_path, record = _resolve_install_path( + manifest.type, + manifest.id, + record, + inferred_installs, + ) state, installed_version = _catalog_install_state( install_path, diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index e60d29f00..07b9a30b0 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -90,21 +90,54 @@ def _resolve_install_destination( return local.install_dir(plugin_type, plugin_id, scope) -def _copy_package(src: Path, dst: Path) -> None: +def _remove_path(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + + +def _replace_prepared_path(prepared: Path, dst: Path) -> Path | None: + backup: Path | None = None + if dst.exists() or dst.is_symlink(): + backup = dst.parent / f".{dst.name}.bak" + _remove_path(backup) + dst.replace(backup) + try: + prepared.replace(dst) + except Exception: + if backup is not None and (backup.exists() or backup.is_symlink()): + backup.replace(dst) + raise + return backup + + +def _commit_replacement(backup: Path | None) -> None: + if backup is None: + return + try: + _remove_path(backup) + except OSError: + pass + + +def _rollback_replacement(dst: Path, backup: Path | None) -> None: + _remove_path(dst) + if backup is not None and (backup.exists() or backup.is_symlink()): + backup.replace(dst) + + +def _copy_package(src: Path, dst: Path, *, retain_backup: bool = False) -> Path | None: parent = dst.parent parent.mkdir(parents=True, exist_ok=True) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: _copy_package_contents(src, tmp) - backup = None - if dst.exists(): - backup = parent / f".{dst.name}.bak" - if backup.exists(): - shutil.rmtree(backup) - dst.replace(backup) - tmp.replace(dst) - if backup and backup.exists(): - shutil.rmtree(backup) + backup = _replace_prepared_path(tmp, dst) + if not retain_backup: + _commit_replacement(backup) + return None + return backup except Exception: if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) @@ -122,32 +155,26 @@ def _copy_package_contents(src: Path, dst: Path) -> None: shutil.copy2(item, target) -def _replace_dir(src: Path, dst: Path) -> None: - parent = dst.parent - backup = None - if dst.exists(): - backup = parent / f".{dst.name}.bak" - if backup.exists(): - shutil.rmtree(backup) - dst.replace(backup) - src.replace(dst) - if backup and backup.exists(): - shutil.rmtree(backup) - - def _contracts_access_dir(plugin_id: str, scope: str) -> Path: return local.install_root("webui", scope).parent / "access" / plugin_id -def _copy_attached_access_contracts(plugin_type: PluginType, plugin_id: str, src: Path, scope: str) -> Path | None: +def _copy_attached_access_contracts( + plugin_type: PluginType, + plugin_id: str, + src: Path, + scope: str, + *, + retain_backup: bool = False, +) -> tuple[Path, Path | None] | None: if plugin_type != "webui": return None access_src = src / "access" if not access_src.is_dir(): return None access_dst = _contracts_access_dir(plugin_id, scope) - _copy_package(access_src, access_dst) - return access_dst + backup = _copy_package(access_src, access_dst, retain_backup=retain_backup) + return access_dst, backup def _remove_attached_access_contracts(plugin_type: PluginType, plugin_id: str, scope: str) -> None: @@ -182,14 +209,24 @@ def _build_webui_pages(plugin_id: str, install_dir: Path) -> None: raise RuntimeError(f"Failed to build WebUI page bundle for {plugin_id}/{page.id}{detail}") -def _copy_webui_package_with_build(plugin_id: str, src: Path, dst: Path) -> None: +def _copy_webui_package_with_build( + plugin_id: str, + src: Path, + dst: Path, + *, + retain_backup: bool = False, +) -> Path | None: parent = dst.parent parent.mkdir(parents=True, exist_ok=True) tmp = Path(tempfile.mkdtemp(prefix=f".{dst.name}.", dir=str(parent))) try: _copy_package_contents(src, tmp) _build_webui_pages(plugin_id, tmp) - _replace_dir(tmp, dst) + backup = _replace_prepared_path(tmp, dst) + if not retain_backup: + _commit_replacement(backup) + return None + return backup except Exception: if tmp.exists(): shutil.rmtree(tmp, ignore_errors=True) @@ -340,16 +377,6 @@ async def _rollback_component_ref_installs( continue -def _rollback_install_path(plugin_type: PluginType, plugin_id: str, install_path: Path, scope: str) -> None: - if ".flocks/plugins" in install_path.as_posix(): - if install_path.is_dir(): - shutil.rmtree(install_path, ignore_errors=True) - elif install_path.exists(): - install_path.unlink() - _remove_attached_access_contracts(plugin_type, plugin_id, scope) - local.remove_installed_record(plugin_type, plugin_id) - - def _is_project_install_path(plugin_type: PluginType, install_path: Path) -> bool: try: project_root = local.install_root(plugin_type, "project").resolve() @@ -505,16 +532,31 @@ async def install_plugin( validate_package(src, manifest) dst = _resolve_install_destination(plugin_type, plugin_id, src, scope) component_key = f"component:{plugin_id}" - component_had_install = plugin_type == "component" and local.infer_local_install(plugin_type, plugin_id) is not None component_ref_installs: list[tuple[PluginType, str]] = [] + previous_record = local.get_record(plugin_type, plugin_id) + package_backup: Path | None = None + package_replaced = False + access_replacement: tuple[Path, Path | None] | None = None try: if plugin_type == "component": component_ref_installs = await _install_component_refs(manifest, scope=scope, progress=progress) if plugin_type == "webui": - _copy_webui_package_with_build(plugin_id, src, dst) + package_backup = _copy_webui_package_with_build( + plugin_id, + src, + dst, + retain_backup=True, + ) else: - _copy_package(src, dst) - _copy_attached_access_contracts(plugin_type, plugin_id, src, scope) + package_backup = _copy_package(src, dst, retain_backup=True) + package_replaced = True + access_replacement = _copy_attached_access_contracts( + plugin_type, + plugin_id, + src, + scope, + retain_backup=True, + ) record = local.make_record( plugin_type=plugin_type, plugin_id=plugin_id, @@ -530,12 +572,27 @@ async def install_plugin( await _refresh_runtime(plugin_type, dst) if plugin_type == "component": await _emit_component_progress(progress, manifest, "complete", record=record, message="Installed") + if access_replacement is not None: + _commit_replacement(access_replacement[1]) + _commit_replacement(package_backup) return record except Exception: + if access_replacement is not None: + _rollback_replacement(*access_replacement) + if package_replaced: + _rollback_replacement(dst, package_backup) + if previous_record is None: + local.remove_installed_record(plugin_type, plugin_id) + else: + local.save_installed_record(previous_record) + clear_catalog_caches() if plugin_type == "component": - if not component_had_install: - _rollback_install_path(plugin_type, plugin_id, dst, scope) await _rollback_component_ref_installs(component_ref_installs, component_key) + if package_replaced: + try: + await _refresh_runtime(plugin_type, dst) + except Exception: + pass raise @@ -593,13 +650,24 @@ async def uninstall_plugin(plugin_type: PluginType, plugin_id: str) -> bool: record = local.get_record(plugin_type, plugin_id) install_path = Path(record.installPath) if record and record.installPath else local.infer_local_install(plugin_type, plugin_id) if install_path is None or not install_path.exists(): + changed_path = install_path + if changed_path is None and record is not None: + try: + changed_path = _resolve_install_destination( + plugin_type, + plugin_id, + plugin_root(plugin_type, plugin_id), + record.scope, + ) + except Exception: + changed_path = local.install_dir(plugin_type, plugin_id, record.scope) children_removed = await _uninstall_component_refs(manifest) if manifest is not None else False had_record = record is not None local.remove_installed_record(plugin_type, plugin_id) clear_catalog_caches() _clear_device_template_cache_if_needed(plugin_type) if children_removed or had_record: - await _refresh_runtime(plugin_type) + await _refresh_runtime(plugin_type, changed_path) return children_removed or had_record project_root = local.install_root(plugin_type, "project").resolve() resolved_install_path = install_path.resolve() diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 4bc594796..d2bff0d7a 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -1639,53 +1639,67 @@ def refresh_plugin_tools(cls, changed_path: Optional[Path] = None) -> List[str]: tools_before = cls._tools.copy() defaults_before = cls._enabled_defaults.copy() plugin_names_before = list(cls._plugin_tool_names) + plugin_errors_before = list(cls._plugin_load_errors) + enabled_before = { + name: tool.info.enabled + for name, tool in tools_before.items() + } + revision_before = cls._revision load_errors: List[str] = [] - try: - cls._unregister_plugin_tools() - cls._load_plugin_tools(load_errors) - except Exception: - cls._tools.clear() - cls._tools.update(tools_before) - cls._enabled_defaults.clear() - cls._enabled_defaults.update(defaults_before) - cls._plugin_tool_names = plugin_names_before - raise - changed_root = changed_path.resolve() if changed_path is not None else None - fatal_errors: List[str] = [] - for error in load_errors: - if changed_root is None or error.startswith( - ("plugin loader:", "extension point not found:") - ): - fatal_errors.append(error) - continue - if error.startswith("entry point "): - continue - source_text, separator, _detail = error.partition(": ") - if not separator: - fatal_errors.append(error) - continue - source_path = Path(source_text) - if not source_path.is_absolute(): - fatal_errors.append(error) - continue - source_path = source_path.resolve() - if source_path == changed_root or changed_root in source_path.parents: - fatal_errors.append(error) - if fatal_errors: + def _restore_snapshot() -> None: + for name, enabled in enabled_before.items(): + tool = tools_before.get(name) + if tool is not None: + tool.info.enabled = enabled cls._tools.clear() cls._tools.update(tools_before) cls._enabled_defaults.clear() cls._enabled_defaults.update(defaults_before) cls._plugin_tool_names = plugin_names_before - raise ToolRefreshError("plugin", fatal_errors) - - if load_errors: - cls._finalize_plugin_tools_load() + cls._plugin_load_errors = plugin_errors_before + cls._revision = revision_before - cls._plugin_load_errors = load_errors - cls._bump_revision("plugin_refresh") - return cls.all_tool_ids() + try: + cls._unregister_plugin_tools() + cls._load_plugin_tools(load_errors) + changed_root = changed_path.resolve() if changed_path is not None else None + fatal_errors: List[str] = [] + for error in load_errors: + if changed_root is None or error.startswith( + ( + "plugin loader:", + "extension point not found:", + "entry point scan:", + ) + ): + fatal_errors.append(error) + continue + if error.startswith("entry point "): + continue + source_text, separator, _detail = error.partition(": ") + if not separator: + fatal_errors.append(error) + continue + source_path = Path(source_text) + if not source_path.is_absolute(): + fatal_errors.append(error) + continue + source_path = source_path.resolve() + if source_path == changed_root or changed_root in source_path.parents: + fatal_errors.append(error) + if fatal_errors: + raise ToolRefreshError("plugin", fatal_errors) + + if load_errors: + cls._finalize_plugin_tools_load() + + cls._plugin_load_errors = load_errors + cls._bump_revision("plugin_refresh") + return cls.all_tool_ids() + except Exception: + _restore_snapshot() + raise @classmethod def refresh_dynamic_tools(cls) -> List[str]: diff --git a/tests/hub/test_bundled_tools.py b/tests/hub/test_bundled_tools.py index 2e86c2e73..93cec8f5d 100644 --- a/tests/hub/test_bundled_tools.py +++ b/tests/hub/test_bundled_tools.py @@ -168,6 +168,7 @@ async def test_tool_runtime_refresh_scopes_errors_and_clears_device_template_cac @pytest.mark.asyncio async def test_uninstall_missing_tool_record_clears_device_template_cache(isolated_hub, monkeypatch): calls: list[str] = [] + missing_path = isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "api" / "ghost_tool" local.save_installed_record( InstalledPluginRecord( id="ghost_tool", @@ -175,7 +176,7 @@ async def test_uninstall_missing_tool_record_clears_device_template_cache(isolat version="1.0", source="bundled", installedAt=1, - installPath=str(isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "api" / "ghost_tool"), + installPath=str(missing_path), ) ) monkeypatch.setattr("flocks.hub.installer.clear_catalog_caches", lambda: calls.append("catalog")) @@ -184,8 +185,8 @@ async def test_uninstall_missing_tool_record_clears_device_template_cache(isolat lambda plugin_type: calls.append(f"device:{plugin_type}"), ) - async def fake_refresh_runtime(plugin_type): - calls.append(f"refresh:{plugin_type}") + async def fake_refresh_runtime(plugin_type, changed_path=None): + calls.append(f"refresh:{plugin_type}:{changed_path}") monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fake_refresh_runtime) @@ -193,7 +194,37 @@ async def fake_refresh_runtime(plugin_type): assert removed is True assert local.get_record("tool", "ghost_tool") is None - assert calls == ["catalog", "device:tool", "refresh:tool"] + assert calls == ["catalog", "device:tool", f"refresh:tool:{missing_path}"] + + +@pytest.mark.asyncio +async def test_uninstall_missing_tool_record_without_path_uses_canonical_path( + isolated_hub, + monkeypatch, +): + local.save_installed_record( + InstalledPluginRecord( + id="ghost_tool", + type="tool", + version="1.0", + source="bundled", + installedAt=1, + installPath=None, + ) + ) + refresh_paths: list[Path | None] = [] + + async def fake_refresh_runtime(_plugin_type, changed_path=None): + refresh_paths.append(changed_path) + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fake_refresh_runtime) + + removed = await uninstall_plugin("tool", "ghost_tool") + + assert removed is True + assert refresh_paths == [ + isolated_hub["home"] / ".flocks" / "plugins" / "tools" / "ghost_tool" + ] class TestBundledToolRoots: diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index 5ca585bb4..bdb479b2e 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -149,6 +149,127 @@ async def noop_refresh(_plugin_type, _changed_path=None): compile(repaired_handler.read_text(encoding="utf-8"), str(repaired_handler), "exec") +async def test_hub_install_runtime_failure_rolls_back_new_plugin( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def fail_refresh(_plugin_type, _changed_path=None): + raise RuntimeError("import failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_refresh) + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "python" + / "soc_workspace_query" + ) + + with pytest.raises(RuntimeError, match="import failed"): + await install_plugin("tool", "soc_workspace_query") + + assert not install_dir.exists() + assert local.get_record("tool", "soc_workspace_query") is None + + +async def test_hub_update_runtime_failure_restores_previous_plugin( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + install_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "python" + / "soc_workspace_query" + ) + install_dir.mkdir(parents=True) + old_handler = install_dir / "old_tool.py" + old_handler.write_text("OLD = True\n", encoding="utf-8") + previous_record = local.make_record( + plugin_type="tool", + plugin_id="soc_workspace_query", + version="0.9.0", + source="bundled:old", + install_path=install_dir, + scope="global", + ) + local.save_installed_record(previous_record) + refresh_calls = 0 + + async def fail_then_recover(_plugin_type, _changed_path=None): + nonlocal refresh_calls + refresh_calls += 1 + if refresh_calls == 1: + raise RuntimeError("import failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_then_recover) + + with pytest.raises(RuntimeError, match="import failed"): + await update_plugin("tool", "soc_workspace_query") + + assert refresh_calls == 2 + assert old_handler.read_text(encoding="utf-8") == "OLD = True\n" + assert not (install_dir / "soc_workspace_query.py").exists() + assert local.get_record("tool", "soc_workspace_query") == previous_record + + +async def test_hub_webui_runtime_failure_rolls_back_package_and_access_contracts( + isolated_hub_env, + monkeypatch: pytest.MonkeyPatch, +): + async def fail_refresh(_plugin_type, _changed_path=None): + raise RuntimeError("reconcile failed") + + monkeypatch.setattr("flocks.hub.installer._refresh_runtime", fail_refresh) + _patch_webui_bundle_build(monkeypatch) + home_plugins = isolated_hub_env["home"] / ".flocks" / "plugins" + + with pytest.raises(RuntimeError, match="reconcile failed"): + await install_plugin("webui", "soc_ui") + + assert not (home_plugins / "contracts" / "webui" / "soc_ui").exists() + assert not (home_plugins / "contracts" / "access" / "soc_ui").exists() + assert local.get_record("webui", "soc_ui") is None + + +def test_catalog_ignores_stale_record_when_legacy_install_is_inferred( + isolated_hub_env, +): + plugin_id = "chaitin_safeline_waf_v1_0_0" + legacy_dir = ( + isolated_hub_env["home"] + / ".flocks" + / "plugins" + / "tools" + / "device" + / plugin_id + ) + legacy_dir.mkdir(parents=True) + (legacy_dir / "legacy.yaml").write_text( + "name: legacy\nhandler:\n type: http\n", + encoding="utf-8", + ) + stale_record = local.make_record( + plugin_type="device", + plugin_id=plugin_id, + version="1.0.1", + source="bundled:stale", + install_path=isolated_hub_env["home"] / "missing", + scope="global", + ) + local.save_installed_record(stale_record) + + entries = {entry.id: entry for entry in list_catalog(plugin_type="device")} + + assert entries[plugin_id].state == "updateAvailable" + assert entries[plugin_id].installedVersion is None + assert entries[plugin_id].installPath == str(legacy_dir) + assert local.get_record("device", plugin_id) is None + + def test_pentest_agents_are_listed_in_agent_catalog(): entries = list_catalog(plugin_type="agent") ids = {entry.id for entry in entries} diff --git a/tests/tool/test_tool_refresh_outcomes.py b/tests/tool/test_tool_refresh_outcomes.py index 435d064c1..4efd7544b 100644 --- a/tests/tool/test_tool_refresh_outcomes.py +++ b/tests/tool/test_tool_refresh_outcomes.py @@ -36,6 +36,7 @@ def _isolated_registry() -> Iterator[None]: "defaults": ToolRegistry._enabled_defaults, "plugin_names": ToolRegistry._plugin_tool_names, "plugin_errors": ToolRegistry._plugin_load_errors, + "revision": ToolRegistry._revision, "dynamic_modules": ToolRegistry._dynamic_modules, "dynamic_tools": ToolRegistry._dynamic_tools_by_module, } @@ -45,6 +46,7 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._enabled_defaults = {} ToolRegistry._plugin_tool_names = [] ToolRegistry._plugin_load_errors = [] + ToolRegistry._revision = 0 ToolRegistry._dynamic_modules = {} ToolRegistry._dynamic_tools_by_module = {} yield @@ -54,6 +56,7 @@ def _isolated_registry() -> Iterator[None]: ToolRegistry._enabled_defaults = state["defaults"] ToolRegistry._plugin_tool_names = state["plugin_names"] ToolRegistry._plugin_load_errors = state["plugin_errors"] + ToolRegistry._revision = state["revision"] ToolRegistry._dynamic_modules = state["dynamic_modules"] ToolRegistry._dynamic_tools_by_module = state["dynamic_tools"] @@ -185,6 +188,7 @@ def _load_with_new_error(cls, errors): [ "/plugins/legacy.py: broken legacy plugin", "/plugins/unrelated.py: new broken plugin", + "entry point legacy_package: RuntimeError: broken", ] ) @@ -204,6 +208,7 @@ def _load_with_new_error(cls, errors): assert ToolRegistry._plugin_load_errors == [ "/plugins/legacy.py: broken legacy plugin", "/plugins/unrelated.py: new broken plugin", + "entry point legacy_package: RuntimeError: broken", ] @@ -231,6 +236,94 @@ def _load_target_error(cls, errors): assert ToolRegistry._plugin_tool_names == [previous.info.name] +def test_plugin_refresh_restores_snapshot_when_finalize_fails( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + builtin = _tool("builtin", "builtin") + previous = _tool("previous_plugin", "plugin_py") + ToolRegistry._tools = { + builtin.info.name: builtin, + previous.info.name: previous, + } + ToolRegistry._enabled_defaults = { + builtin.info.name: True, + previous.info.name: True, + } + ToolRegistry._plugin_tool_names = [previous.info.name] + ToolRegistry._plugin_load_errors = ["old error"] + ToolRegistry._revision = 7 + + def _load_with_unrelated_error(cls, errors): + replacement = _tool("replacement", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._enabled_defaults[replacement.info.name] = True + cls._plugin_tool_names = [replacement.info.name] + errors.append("/plugins/unrelated.py: SyntaxError: broken") + + def _failed_finalize(cls): + cls._tools[builtin.info.name].info.enabled = False + raise RuntimeError("finalize failed") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_unrelated_error), + ) + monkeypatch.setattr( + ToolRegistry, + "_finalize_plugin_tools_load", + classmethod(_failed_finalize), + ) + + with pytest.raises(RuntimeError, match="finalize failed"): + ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/target.py"), + ) + + assert ToolRegistry._tools == { + builtin.info.name: builtin, + previous.info.name: previous, + } + assert builtin.info.enabled is True + assert ToolRegistry._enabled_defaults == { + builtin.info.name: True, + previous.info.name: True, + } + assert ToolRegistry._plugin_tool_names == [previous.info.name] + assert ToolRegistry._plugin_load_errors == ["old error"] + assert ToolRegistry._revision == 7 + + +def test_plugin_refresh_rejects_entry_point_scan_failure( + monkeypatch: pytest.MonkeyPatch, +): + with _isolated_registry(): + previous = _tool("entrypoint_old", "plugin_py") + ToolRegistry._tools[previous.info.name] = previous + ToolRegistry._plugin_tool_names = [previous.info.name] + + def _load_with_scan_failure(cls, errors): + replacement = _tool("hub_target", "plugin_py") + cls._tools[replacement.info.name] = replacement + cls._plugin_tool_names = [replacement.info.name] + errors.append("entry point scan: RuntimeError: metadata unavailable") + + monkeypatch.setattr( + ToolRegistry, + "_load_plugin_tools", + classmethod(_load_with_scan_failure), + ) + + with pytest.raises(ToolRefreshError, match="metadata unavailable"): + ToolRegistry.refresh_plugin_tools( + changed_path=Path("/plugins/hub_target.py"), + ) + + assert ToolRegistry._tools == {previous.info.name: previous} + assert ToolRegistry._plugin_tool_names == [previous.info.name] + + def test_dynamic_refresh_restores_last_known_good_on_load_error( monkeypatch: pytest.MonkeyPatch, ): From 0364cf96996a07ad5a73c5a8b99fe1f1440ab067 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Thu, 16 Jul 2026 15:41:06 +0800 Subject: [PATCH 09/86] fix(model): reveal provider API keys on demand --- flocks/server/routes/provider.py | 125 ++++++++++++------ .../routes/test_global_mutation_auth.py | 20 ++- webui/src/api/provider.test.ts | 16 +++ webui/src/api/provider.ts | 3 + webui/src/pages/Model/index.test.tsx | 37 +++++- webui/src/pages/Model/index.tsx | 26 ++-- webui/src/types/index.ts | 6 +- 7 files changed, 174 insertions(+), 59 deletions(-) diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index 79e4d7e37..f05768049 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -13,13 +13,15 @@ import time from pathlib import Path from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status from pydantic import BaseModel, Field, ConfigDict +from flocks.audit import emit_audit_event +from flocks.auth.context import AuthUser from flocks.utils.log import Log from flocks.provider.provider import Provider, ModelInfo as ProviderModelInfo from flocks.security.secrets import SecretManager -from flocks.server.auth import require_admin +from flocks.server.auth import get_request_ip, get_request_user_agent, require_admin from flocks.config.config import Config from flocks.config.config_writer import ConfigWriter from flocks.storage.storage import Storage @@ -1656,6 +1658,43 @@ class ProviderCredentialResponse(BaseModel): has_credential: bool +def _load_llm_provider_credentials( + provider_id: str, + *, + reveal_api_key: bool = False, +) -> ProviderCredentialResponse: + from flocks.security import get_secret_manager + + secrets = get_secret_manager() + + secret_id = f"{provider_id}_llm_key" + api_key = secrets.get(secret_id) + if not api_key: + secret_id = f"{provider_id}_api_key" + api_key = secrets.get(secret_id) + if not api_key: + api_key = _get_inline_provider_api_key(provider_id) + + base_url = None + raw_provider = ConfigWriter.get_provider_raw(provider_id) + if raw_provider: + options = raw_provider.get("options", {}) + base_url = options.get("baseURL") or options.get("base_url") + if not base_url: + base_url = secrets.get(f"{provider_id}_base_url") + + is_placeholder = _is_placeholder_api_key(api_key) + ui_api_key = None if is_placeholder else api_key + + return ProviderCredentialResponse( + secret_id=secret_id if api_key else None, + api_key=ui_api_key if reveal_api_key else None, + api_key_masked=SecretManager.mask(ui_api_key) if ui_api_key else None, + base_url=base_url, + has_credential=bool(api_key), + ) + + @router.get( "/{provider_id}/credentials", response_model=ProviderCredentialResponse, @@ -1673,51 +1712,53 @@ async def get_provider_credentials( For API services use GET /{provider_id}/service-credentials instead. """ - from flocks.security import get_secret_manager - try: - secrets = get_secret_manager() - - # LLM provider convention: _llm_key first, fall back to legacy _api_key - secret_id = f"{provider_id}_llm_key" - api_key = secrets.get(secret_id) - if not api_key: - secret_id = f"{provider_id}_api_key" - api_key = secrets.get(secret_id) - if not api_key: - api_key = _get_inline_provider_api_key(provider_id) + return _load_llm_provider_credentials(provider_id) + except Exception as e: + log.error("provider.credentials.get.error", {"provider_id": provider_id, "error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) - # base_url from flocks.json raw (not resolved) - base_url = None - raw_provider = ConfigWriter.get_provider_raw(provider_id) - if raw_provider: - options = raw_provider.get("options", {}) - base_url = options.get("baseURL") or options.get("base_url") - # Fallback: check legacy .secret.json entry (migration may not have run yet) - if not base_url: - base_url = secrets.get(f"{provider_id}_base_url") - # When the stored value is the no-auth sentinel, hide it from the - # response so the UI doesn't pre-fill the password input with the - # literal string "not-needed". We still report ``has_credential=True`` - # because a credential record DOES exist; the user just chose to leave - # the key blank for an internal/no-auth gateway. - is_placeholder = _is_placeholder_api_key(api_key) - ui_api_key = None if is_placeholder else api_key +@router.post( + "/{provider_id}/credentials/reveal", + response_model=ProviderCredentialResponse, + summary="Reveal provider credentials", + description="Reveal credential information for a registered LLM provider to an administrator.", +) +async def reveal_provider_credentials( + provider_id: str, + request: Request, + response: Response, + admin: AuthUser = Depends(require_admin), +): + response.headers["Cache-Control"] = "no-store" + response.headers["Pragma"] = "no-cache" - return ProviderCredentialResponse( - secret_id=secret_id if api_key else None, - api_key=None, - api_key_masked=( - SecretManager.mask(ui_api_key) - if ui_api_key - else None - ), - base_url=base_url, - has_credential=bool(api_key), - ) + try: + credentials = _load_llm_provider_credentials(provider_id, reveal_api_key=True) + try: + await emit_audit_event( + "provider.credentials_reveal", + { + "action": "credentials_reveal", + "actor_id": admin.id, + "actor_name": admin.username, + "user_id": admin.id, + "username": admin.username, + "provider_id": provider_id, + "secret_id": credentials.secret_id, + "ip": get_request_ip(request), + "user_agent": get_request_user_agent(request), + }, + ) + except Exception as audit_error: + log.warn( + "provider.credentials.reveal.audit_failed", + {"provider_id": provider_id, "error": str(audit_error)}, + ) + return credentials except Exception as e: - log.error("provider.credentials.get.error", {"provider_id": provider_id, "error": str(e)}) + log.error("provider.credentials.reveal.error", {"provider_id": provider_id, "error": str(e)}) raise HTTPException(status_code=500, detail=str(e)) diff --git a/tests/server/routes/test_global_mutation_auth.py b/tests/server/routes/test_global_mutation_auth.py index 2c2bd8d69..40cdb9f67 100644 --- a/tests/server/routes/test_global_mutation_auth.py +++ b/tests/server/routes/test_global_mutation_auth.py @@ -40,6 +40,7 @@ def test_provider_global_configuration_credentials_and_tests_require_admin(): client.post("/api/provider/example/test"), client.put("/api/provider/example", json={}), client.get("/api/provider/example/credentials"), + client.post("/api/provider/example/credentials/reveal"), client.post("/api/provider/example/credentials", json={"api_key": "secret"}), client.delete("/api/provider/example/credentials"), client.get("/api/provider/example/service-credentials"), @@ -53,7 +54,9 @@ def test_provider_global_configuration_credentials_and_tests_require_admin(): assert [response.status_code for response in responses] == [403] * len(responses) -def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest.MonkeyPatch): +def test_admin_reveal_returns_raw_llm_key_with_audit_and_no_store( + monkeypatch: pytest.MonkeyPatch, +): from flocks.server.routes import provider as provider_routes raw_llm_key = "sk-raw-llm-secret-1234" @@ -63,7 +66,9 @@ def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest. "llm-example_llm_key": raw_llm_key, "service-example_api_key": raw_service_key, }.get(key) + audit = AsyncMock() monkeypatch.setattr("flocks.security.get_secret_manager", lambda: secrets) + monkeypatch.setattr(provider_routes, "emit_audit_event", audit) monkeypatch.setattr( provider_routes.ConfigWriter, "get_provider_raw", @@ -100,20 +105,33 @@ def test_admin_credential_routes_return_only_masked_secrets(monkeypatch: pytest. client = _user_client(provider_routes.router, prefix="/api/provider", role="admin") llm_response = client.get("/api/provider/llm-example/credentials") + reveal_response = client.post("/api/provider/llm-example/credentials/reveal") service_response = client.get("/api/provider/service-example/service-credentials") assert llm_response.status_code == 200 + assert reveal_response.status_code == 200 assert service_response.status_code == 200 llm_payload = llm_response.json() + reveal_payload = reveal_response.json() service_payload = service_response.json() assert llm_payload["api_key"] is None assert llm_payload["api_key_masked"] + assert reveal_payload["api_key"] == raw_llm_key + assert reveal_response.headers["Cache-Control"] == "no-store" + assert reveal_response.headers["Pragma"] == "no-cache" assert service_payload["api_key"] is None assert service_payload["api_key_masked"] assert service_payload["fields"]["api_key"] != raw_service_key assert service_payload["fields"]["base_url"] == "https://service.example.test" assert raw_llm_key not in llm_response.text + assert raw_llm_key in reveal_response.text assert raw_service_key not in service_response.text + audit.assert_awaited_once() + event_type, audit_payload = audit.await_args.args + assert event_type == "provider.credentials_reveal" + assert audit_payload["provider_id"] == "llm-example" + assert audit_payload["username"] == "admin-test" + assert raw_llm_key not in repr(audit_payload) def test_admin_provider_update_never_writes_raw_api_key_to_storage(monkeypatch: pytest.MonkeyPatch): diff --git a/webui/src/api/provider.test.ts b/webui/src/api/provider.test.ts index 1e2cda5ce..2a63e4f57 100644 --- a/webui/src/api/provider.test.ts +++ b/webui/src/api/provider.test.ts @@ -136,3 +136,19 @@ describe('providerAPI.listApiServices', () => { expect(response.data[0].name).toBe('Forced Service'); }); }); + +describe('providerAPI.revealCredentials', () => { + afterEach(() => { + vi.resetModules(); + mockPost.mockReset(); + }); + + it('uses the explicit credential reveal endpoint', async () => { + mockPost.mockResolvedValue({ data: { api_key: 'sk-revealed', has_credential: true } }); + + const { providerAPI } = await import('./provider'); + await providerAPI.revealCredentials('openai'); + + expect(mockPost).toHaveBeenCalledWith('/api/provider/openai/credentials/reveal'); + }); +}); diff --git a/webui/src/api/provider.ts b/webui/src/api/provider.ts index a35235f42..e0f3062ef 100644 --- a/webui/src/api/provider.ts +++ b/webui/src/api/provider.ts @@ -111,6 +111,9 @@ export const providerAPI = { getCredentials: (id: string) => client.get(`/api/provider/${id}/credentials`), + revealCredentials: (id: string) => + client.post(`/api/provider/${id}/credentials/reveal`), + setCredentials: (id: string, credentials: ProviderCredentialInput) => client.post<{ success: boolean; message: string }>(`/api/provider/${id}/credentials`, credentials), diff --git a/webui/src/pages/Model/index.test.tsx b/webui/src/pages/Model/index.test.tsx index 1515819c6..cbd42e1d7 100644 --- a/webui/src/pages/Model/index.test.tsx +++ b/webui/src/pages/Model/index.test.tsx @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({ catalogList: vi.fn(), createProvider: vi.fn(), getCredentials: vi.fn(), + revealCredentials: vi.fn(), setCredentials: vi.fn(), testCredentials: vi.fn(), })); @@ -113,6 +114,7 @@ vi.mock('@/components/common/EntitySheet', () => ({ vi.mock('@/api/provider', () => ({ providerAPI: { getCredentials: mocks.getCredentials, + revealCredentials: mocks.revealCredentials, setCredentials: mocks.setCredentials, testCredentials: mocks.testCredentials, }, @@ -277,6 +279,15 @@ describe('ModelPage configure provider dialog', () => { has_credential: true, }, }); + mocks.revealCredentials.mockResolvedValue({ + data: { + secret_id: 'openai_llm_key', + api_key: 'sk-existing-secret-1234', + api_key_masked: 'sk-***1234', + base_url: 'https://old.example.com/v1', + has_credential: true, + }, + }); mocks.setCredentials.mockResolvedValue({ data: { success: true } }); mocks.testCredentials.mockResolvedValue({ data: { @@ -294,15 +305,18 @@ describe('ModelPage configure provider dialog', () => { renderWithRouter(); await user.click(await screen.findByTitle('Configure')); expect(await screen.findByTestId('entity-sheet')).toBeInTheDocument(); + expect(mocks.revealCredentials).toHaveBeenCalledWith('openai'); } - it('preserves an existing secret when saving a new base URL with a blank key', async () => { + it('displays and preserves the existing API key when saving a new base URL', async () => { const user = userEvent.setup(); await openConfigureDialog(user); - const apiKeyInput = screen.getByPlaceholderText('Leave blank to keep the existing API key'); - expect(apiKeyInput).toHaveValue(''); + const apiKeyInput = screen.getByDisplayValue('sk-existing-secret-1234'); + expect(apiKeyInput).toHaveAttribute('type', 'password'); expect(apiKeyInput).not.toHaveValue('sk-***1234'); + await user.click(screen.getByTitle('form.show')); + expect(apiKeyInput).toHaveAttribute('type', 'text'); const baseUrlInput = screen.getByPlaceholderText('https://api.example.com/v1'); await user.clear(baseUrlInput); @@ -317,7 +331,7 @@ describe('ModelPage configure provider dialog', () => { expect(payload).not.toHaveProperty('api_key'); }); - it('persists a base URL change without a key before testing the connection', async () => { + it('persists the displayed API key and a base URL change before testing the connection', async () => { const user = userEvent.setup(); await openConfigureDialog(user); mocks.setCredentials.mockClear(); @@ -338,4 +352,19 @@ describe('ModelPage configure provider dialog', () => { expect(mocks.testCredentials).toHaveBeenCalledWith('openai', 'gpt-4o'); }); }); + + it('submits a replacement API key when the displayed value changes', async () => { + const user = userEvent.setup(); + await openConfigureDialog(user); + + const apiKeyInput = screen.getByDisplayValue('sk-existing-secret-1234'); + await user.clear(apiKeyInput); + await user.type(apiKeyInput, 'sk-replacement-secret'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mocks.setCredentials).toHaveBeenCalled()); + expect(mocks.setCredentials.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({ + api_key: 'sk-replacement-secret', + })); + }); }); diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 3a8e1b4c8..298bad220 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -505,7 +505,15 @@ export default function ModelPage() { onConfigure={async () => { await handleSelectProvider(provider); if (selectedProviderRef.current?.id === provider.id) { - setShowConfigDialog(true); + try { + const res = await providerAPI.revealCredentials(provider.id); + if (selectedProviderRef.current?.id === provider.id) { + setCredentials(res.data); + setShowConfigDialog(true); + } + } catch (err: any) { + toast.error(t('configFailed'), err.message); + } } }} /> @@ -554,7 +562,10 @@ export default function ModelPage() { provider={selectedProvider} existingCredentials={credentials} models={providerModels} - onClose={() => setShowConfigDialog(false)} + onClose={() => { + setShowConfigDialog(false); + setCredentials(null); + }} onConfigured={async () => { if (selectedProvider) { const res = await providerAPI.getCredentials(selectedProvider.id).catch(() => ({ data: null })); @@ -2192,10 +2203,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const toast = useToast(); const { t } = useTranslation('model'); const hasExisting = existingCredentials?.has_credential ?? false; - // Credential reads intentionally never return the raw key. Keep the input - // empty and use hasExisting to express "leave blank to preserve"; in - // particular, never seed this field with api_key_masked. - const existingKey = ''; + const existingKey = existingCredentials?.api_key ?? ''; const existingBaseUrl = existingCredentials?.base_url ?? ''; const [baseUrl, setBaseUrl] = useState(existingCredentials?.base_url ?? ''); @@ -2273,6 +2281,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos const handleSubmit = async () => { const nextApiKey = apiKey.trim(); + const apiKeyChanged = nextApiKey !== existingKey.trim(); if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key'); return; @@ -2285,7 +2294,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; - if (nextApiKey) payload.api_key = nextApiKey; + if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); // Sync catalog model list: add newly selected, delete deselected @@ -2326,6 +2335,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ); const nextApiKey = apiKey.trim(); + const apiKeyChanged = nextApiKey !== existingKey.trim(); if (!nextApiKey && !hasExisting && !providerAllowsEmptyApiKey(provider.id)) { toast.warning('Please enter API Key first'); return; @@ -2341,7 +2351,7 @@ function ConfigureProviderDialog({ provider, existingCredentials, models, onClos ? (providerName.trim() || undefined) : undefined, }; - if (nextApiKey) payload.api_key = nextApiKey; + if (nextApiKey && apiKeyChanged) payload.api_key = nextApiKey; await providerAPI.setCredentials(provider.id, payload); } catch (err: any) { toast.error(t('deleteFailed'), err.message); diff --git a/webui/src/types/index.ts b/webui/src/types/index.ts index e78dbfd28..84dd9a17f 100644 --- a/webui/src/types/index.ts +++ b/webui/src/types/index.ts @@ -397,10 +397,8 @@ export interface MCPCatalogStats { export interface ProviderCredentials { secret_id?: string | null; /** - * Write-only compatibility field. Credential reads never return the raw key; - * they return ``api_key: null`` plus ``api_key_masked`` and - * ``has_credential``. UI code must never submit the masked value as a new - * secret. Leave ``api_key`` out of an update to preserve the stored key. + * Raw API key returned only by the admin-only reveal endpoint. Ordinary LLM + * and API-service credential reads return null and expose only masked values. */ api_key?: string | null; api_key_masked?: string | null; From 5530814f6a62fcf2a0ec89628e69a09e0462b96c Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 17 Jul 2026 10:21:29 +0800 Subject: [PATCH 10/86] feat(tdp): align threat tool and action names --- .flocks/plugins/skills/tdp-use/SKILL.md | 5 +- .../tdp-use/references/api-reference.md | 48 ++++---- .../tools/device/tdp_v3_3_10/_test.yaml | 26 ++--- .../tools/device/tdp_v3_3_10/tdp.handler.py | 52 +++++---- .../device/tdp_v3_3_10/tdp_incident_list.yaml | 109 ------------------ ...t_list.yaml => tdp_threat_alert_host.yaml} | 28 ++--- ...k.yaml => tdp_threat_external_attack.yaml} | 2 +- .../tdp_threat_intelligent_aggregation.yaml | 107 +++++++++++++++++ .../tdp_v3_3_10/tdp_threat_monitor_list.yaml | 2 +- tests/tool/test_tdp_api_tools.py | 8 +- tests/tool/test_tdp_skyeye_api_plugins.py | 83 +++++++++---- 11 files changed, 259 insertions(+), 211 deletions(-) delete mode 100644 .flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml rename .flocks/plugins/tools/device/tdp_v3_3_10/{tdp_host_threat_list.yaml => tdp_threat_alert_host.yaml} (67%) rename .flocks/plugins/tools/device/tdp_v3_3_10/{tdp_threat_inbound_attack.yaml => tdp_threat_external_attack.yaml} (98%) create mode 100644 .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml diff --git a/.flocks/plugins/skills/tdp-use/SKILL.md b/.flocks/plugins/skills/tdp-use/SKILL.md index bcf4bab74..4ee84ed40 100644 --- a/.flocks/plugins/skills/tdp-use/SKILL.md +++ b/.flocks/plugins/skills/tdp-use/SKILL.md @@ -38,9 +38,10 @@ API 参数和适用场景见 [references/api-reference.md](references/api-refere - API 调用必须以当前 tool schema 为准,优先使用 schema 暴露的顶层语义化参数;列表类工具常见 `keyword`、`severity`、`cur_page`、`page_size`、`sort_by`,但 `tdp_log_search.sql` 是过滤表达式不是完整 SQL,禁止 `SELECT/FROM`,控制返回数量用 `size`,`terms` 可不传 `sql`,外部攻击结果筛选用 `result_list`。 - 用户说“告警”“告警记录”“告警日志”“明细记录”“查某 IP 的告警”时,默认走 `tdp_log_search` - 用户说“看板”“概览”“趋势”“统计”时,先用 `tdp_dashboard_status` -- 用户说“威胁事件”“外部攻击”“攻击事件”“事件总览”“事件趋势”时,优先用 `tdp_incident_list` 或 `tdp_threat_inbound_attack` +- 用户说“智能聚合”“威胁事件”“攻击事件”“事件总览”“事件趋势”时,优先用 `tdp_threat_intelligent_aggregation` +- 用户说“外部攻击”“外部攻击严重性分布”时,优先用 `tdp_threat_external_attack` - 用户说“实时监控”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 -- 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_host_threat_list` +- 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_threat_alert_host` - 用户说“系统状态”“核心服务状态”“数据库状态”时,优先用 `tdp_system_status` - 用户说“MDR”“研判结果”“研判统计”时,优先用 `tdp_mdr_alert_list` - 用户说“脆弱性”“弱口令”“登录入口”“上传接口”“API 风险”“隐私数据”时,走对应资产或风险类工具 diff --git a/.flocks/plugins/skills/tdp-use/references/api-reference.md b/.flocks/plugins/skills/tdp-use/references/api-reference.md index 3bd3bef99..2c6e13073 100644 --- a/.flocks/plugins/skills/tdp-use/references/api-reference.md +++ b/.flocks/plugins/skills/tdp-use/references/api-reference.md @@ -9,11 +9,11 @@ | 看安全态势、趋势、TOP 统计 | `tdp_dashboard_status` | `status` / `security` / `threat_event` / `alert_level_trend` | 通常可空参;列表类 action 可补 `machine_type`、`severity`、分页参数 | | 查原始告警日志 | `tdp_log_search` | `search` | `time_from`、`time_to`、`sql` | | 查字段聚合统计 | `tdp_log_search` | `terms` | `time_from`、`time_to`、`term` | -| 查威胁事件列表 | `tdp_incident_list` | `search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | +| 查威胁事件列表 | `tdp_threat_intelligent_aggregation` | `incident_search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | | 查威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | -| 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_incident_list` | `timeline` / `result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | -| 查外部攻击严重性分布 | `tdp_threat_inbound_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | -| 查告警主机汇总 / 主机下事件 | `tdp_host_threat_list` | `summary` / `events` | `summary` 可补 `severity`、`direction`、`threat_type`、`keyword`;`events` 至少要 `asset_machine` | +| 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_threat_intelligent_aggregation` | `attack_timeline` / `attack_result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | +| 查外部攻击严重性分布 | `tdp_threat_external_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | +| 查告警主机列表 / 指定主机威胁列表 | `tdp_threat_alert_host` | `alert_host_list` / `host_threat_list` | `alert_host_list` 可补 `severity`、`direction`、`threat_type`、`keyword`;`host_threat_list` 至少要 `asset_machine` | | 查脆弱性 | `tdp_vulnerability_list` | 默认 | 常见补 `time_from`、`time_to`、`severity`、`status`、`keyword`、分页参数 | | 查弱口令 | `tdp_login_weakpwd_list` | 默认 | 常见补 `time_from`、`time_to`、`data`、`result`、`app_class`、`keyword` | | 查服务 / 主机 / 框架资产 | `tdp_machine_asset_list` | `service_list` / `host_asset_list` / `web_app_framework_list` | 可空参;常见补 `service`、`service_class`、`is_public`、`keyword` | @@ -90,8 +90,8 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | `tdp_log_search` | `size` | `page_size`、`cur_page` | `/api/v1/log/searchBySql` 只支持返回数量控制,不支持分页参数 | | `tdp_log_search.sql` | 过滤表达式 | `SELECT * FROM alert` | TDP 只接受类似 WHERE 条件的表达式,不接受完整 SQL 查询 | | `tdp_log_search(action="terms")` | 可只传 `term` | 强制补 `sql` | `/api/v1/log/terms` 的 `sql` 是可选过滤条件 | -| `tdp_threat_inbound_attack` | `result_list` | `result` | API 文档字段为 `condition.result_list` | -| `tdp_incident_list` | `result` | `result_list` | 事件搜索 API 文档字段为 `condition.result` | +| `tdp_threat_external_attack` | `result_list` | `result` | API 文档字段为 `condition.result_list` | +| `tdp_threat_intelligent_aggregation` | `result` | `result_list` | 事件搜索 API 文档字段为 `condition.result` | 示例: @@ -109,18 +109,20 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | 用户实际要查什么 | 推荐 tool | 说明 | |---|---|---| -| 威胁事件 / 攻击事件 / 事件总览 | `tdp_incident_list` / `tdp_threat_inbound_attack` | 事件维度,平台已聚合 | +| 威胁事件 / 攻击事件 / 事件总览 | `tdp_threat_intelligent_aggregation` | 事件维度,平台已聚合 | +| 外部攻击严重性分布 | `tdp_threat_external_attack` | 外部攻击统计维度 | | 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 实时威胁监控列表,单次时间范围不得超过 24 小时 | | 告警 / 告警日志 / 原始检测记录 | `tdp_log_search` | 告警维度,一条就是一条原始记录 | -| 告警主机 / 受害主机 / 主机下事件 | `tdp_host_threat_list` | 主机维度,按主机聚合 | +| 告警主机 / 受害主机 / 主机下事件 | `tdp_threat_alert_host` | 主机维度,按主机聚合 | | 漏洞、弱口令、登录入口、API 风险等 | 对应资产或风险类 tool | 资产/风险维度,不要混进日志查询 | 建议按下面的用词来路由: - 提到“告警”“最近一小时告警”“查某 IP 的告警”时,默认优先 `tdp_log_search` -- 提到“威胁事件”“攻击事件”“看下最近有什么事件”时,优先 `tdp_incident_list` +- 提到“智能聚合”“威胁事件”“攻击事件”“看下最近有什么事件”时,优先 `tdp_threat_intelligent_aggregation` +- 提到“外部攻击”“外部攻击严重性分布”时,优先 `tdp_threat_external_attack` - 提到“实时监控”“威胁监控列表”时,优先 `tdp_threat_monitor_list` -- 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_host_threat_list` +- 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_threat_alert_host` - 用户没说清时,默认把“明细”理解为告警日志,把“总览/聚合”理解为事件 ## 高频场景 @@ -283,14 +285,14 @@ machine = '192.168.1.100' 推荐: -- `tdp_incident_list` -- `action=search` +- `tdp_threat_intelligent_aggregation` +- `action=incident_search` 最小可运行参数集: ```json { - "action": "search", + "action": "incident_search", "time_from": 1741536000, "time_to": 1741622400, "severity": [3, 4], @@ -308,12 +310,12 @@ machine = '192.168.1.100' 高频 action: -- `search`: 事件列表 +- `incident_search`: 智能聚合威胁事件列表 - `top_attacked_entity`: 受攻击实体 -- `result`: 事件研判结果 -- `timeline`: 时间线 -- `alert_search`: 事件下告警列表 -- `result_distribution`: 结果分布 +- `attack_success`: 攻击成功统计 +- `attack_timeline`: 攻击过程时间线 +- `attack_alert_list`: 攻击过程告警详情 +- `attack_result_distribution`: 成功、失败、尝试的数量分布 - `attacker_ip_list`: 攻击者 IP 列表 - `attacker_ip_detail`: 攻击者 IP 详情 @@ -321,14 +323,14 @@ machine = '192.168.1.100' ```json { - "action": "timeline", + "action": "attack_timeline", "incident_id": "b62899499fec914d6246137eed3b6ec4-1777334454", "time_from": 1777305600, "time_to": 1777391999 } ``` -`timeline` 会默认传 `show_attack=true`。如果 TDP 仍返回 `show_attack is false`,说明该事件当前不支持攻击过程展开,改用 `result`、`alert_search` 或回退浏览器查看事件详情。 +`attack_timeline` 会默认传 `show_attack=true`。如果 TDP 仍返回 `show_attack is false`,说明该事件当前不支持攻击过程展开,改用 `attack_success`、`attack_alert_list` 或回退浏览器查看事件详情。 返回结果重点关注: @@ -363,7 +365,7 @@ machine = '192.168.1.100' ```json { - "action": "summary", + "action": "alert_host_list", "time_from": 1741536000, "time_to": 1741622400, "severity": [3, 4], @@ -374,13 +376,13 @@ machine = '192.168.1.100' } ``` -`summary` 默认覆盖全部常见威胁类型,且 `threat_characters` 默认不限制。用户明确要“失陷主机”时再传 `threat_characters=["is_compromised"]`。 +`alert_host_list` 默认覆盖全部常见威胁类型,且 `threat_characters` 默认不限制。用户明确要“失陷主机”时再传 `threat_characters=["is_compromised"]`。 某主机下事件: ```json { - "action": "events", + "action": "host_threat_list", "asset_machine": "asset-123", "time_from": 1741536000, "time_to": 1741622400 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml index 1eae486e1..97439e990 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/_test.yaml @@ -49,12 +49,12 @@ fixtures: assert: success: true - tdp_incident_list: - - label: "Search incidents (page 1)" - label_cn: "查询威胁事件列表(第 1 页)" + tdp_threat_intelligent_aggregation: + - label: "Search intelligent aggregation incidents (page 1)" + label_cn: "查询智能聚合威胁事件(第 1 页)" tags: [smoke, incidents] params: - action: search + action: incident_search cur_page: 1 page_size: 20 assert: @@ -66,12 +66,12 @@ fixtures: params: action: top_attacked_entity - tdp_host_threat_list: - - label: "Get threat host summary" - label_cn: "查询威胁主机摘要" + tdp_threat_alert_host: + - label: "List alert hosts" + label_cn: "查询告警主机列表" tags: [smoke, threats] params: - action: summary + action: alert_host_list cur_page: 1 page_size: 20 assert: @@ -147,15 +147,15 @@ fixtures: action: search size: 20 - tdp_threat_inbound_attack: - - label: "Get inbound attack overview (last 24 hours)" - label_cn: "查询外部入侵攻击概览(最近 24 小时)" + tdp_threat_external_attack: + - label: "Get external attack severity distribution (last 24 hours)" + label_cn: "查询外部攻击严重性分布(最近 24 小时)" tags: [smoke, threats] params: {} tdp_threat_monitor_list: - - label: "List real-time threats (last 24 hours)" - label_cn: "查询威胁实时监控列表(最近 24 小时)" + - label: "List monitored threats (last 24 hours)" + label_cn: "查询威胁监控列表(最近 24 小时)" tags: [smoke, threats] params: cur_page: 1 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py index 1e39d02b2..a2ce7f45a 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp.handler.py @@ -845,17 +845,17 @@ def _passthrough_body(body: Any) -> Any: } THREAT_HOST_ACTIONS: JsonActionMap = { - "summary": ("/api/v1/host/getFallHostSumList", _threat_host_summary_body, "host_get_fall_host_sum_list"), - "events": ("/api/v1/host/threat/list", _threat_host_event_body, "host_threat_list"), + "alert_host_list": ("/api/v1/host/getFallHostSumList", _threat_host_summary_body, "host_get_fall_host_sum_list"), + "host_threat_list": ("/api/v1/host/threat/list", _threat_host_event_body, "host_threat_list"), } INCIDENT_ACTIONS: JsonActionMap = { - "search": ("/api/v1/incident/search", _incident_search_body, "incident_search"), + "incident_search": ("/api/v1/incident/search", _incident_search_body, "incident_search"), "top_attacked_entity": ("/api/v1/incident/topAttackedEntity", _condition_time_body, "incident_top_attacked_entity"), - "result": ("/api/v1/incident/result", _dashboard_body, "incident_result"), - "timeline": ("/api/v1/incident/timeline", _dashboard_body, "incident_timeline"), - "alert_search": ("/api/v1/alert/search", _condition_time_body, "incident_alert_search"), - "result_distribution": ( + "attack_success": ("/api/v1/incident/result", _dashboard_body, "incident_result"), + "attack_timeline": ("/api/v1/incident/timeline", _dashboard_body, "incident_timeline"), + "attack_alert_list": ("/api/v1/alert/search", _condition_time_body, "incident_alert_search"), + "attack_result_distribution": ( "/api/v1/incident/result/distribution", _condition_time_body, "incident_result_distribution", @@ -1267,7 +1267,7 @@ async def dashboard_status( async def threat_host_list( context: ToolContext, - action: str = "summary", + action: str = "alert_host_list", condition: dict[str, Any] | None = None, page: dict[str, Any] | None = None, time_from: int | None = None, @@ -1289,7 +1289,7 @@ async def threat_host_list( sort_order: str | None = None, ) -> ToolResult: del context - selected_action = _normalize_action(action, "summary") + selected_action = _normalize_action(action, "alert_host_list") body = _compose_payload(condition=condition, page=page) condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) @@ -1306,16 +1306,21 @@ async def threat_host_list( _set_list_if_present(condition_dict, "host_type", host_type) _set_keyword_fuzzy(condition_dict, keyword, HOST_THREAT_FUZZY_FIELDS) _set_page_overrides(body, cur_page=cur_page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) - if selected_action == "events": + if selected_action == "host_threat_list": validation_error = _validate_required_body_fields(selected_action, body, "condition.asset_machine") if validation_error: return validation_error - return await _run_action_json_tool(THREAT_HOST_ACTIONS, default_action="summary", action=selected_action, body=body) + return await _run_action_json_tool( + THREAT_HOST_ACTIONS, + default_action="alert_host_list", + action=selected_action, + body=body, + ) async def incident_list( context: ToolContext, - action: str = "search", + action: str = "incident_search", condition: dict[str, Any] | None = None, page: dict[str, Any] | None = None, incident_id: str | None = None, @@ -1339,9 +1344,9 @@ async def incident_list( sort_order: str | None = None, ) -> ToolResult: del context - selected_action = _normalize_action(action, "search") + selected_action = _normalize_action(action, "incident_search") body = _compose_payload(condition=condition, page=page) - if selected_action == "search": + if selected_action == "incident_search": condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) _set_if_present(condition_dict, "time_to", time_to) @@ -1354,7 +1359,7 @@ async def incident_list( _set_if_present(duration, "begin_duration", begin_duration) _set_if_present(duration, "end_duration", end_duration) _set_keyword_fuzzy(condition_dict, keyword, INCIDENT_SEARCH_FUZZY_FIELDS) - elif selected_action == "alert_search": + elif selected_action == "attack_alert_list": condition_dict = body.setdefault("condition", {}) _set_if_present(condition_dict, "time_from", time_from) _set_if_present(condition_dict, "time_to", time_to) @@ -1372,17 +1377,17 @@ async def incident_list( _set_if_present(body, "time_from", time_from) _set_if_present(body, "time_to", time_to) _set_if_present(body, "include_risk", include_risk) - if selected_action == "timeline": + if selected_action == "attack_timeline": body["show_attack"] = True if show_attack is None else show_attack if attacker is not None: body["attacker"] = list(attacker) _set_page_overrides(body, cur_page=cur_page, page_size=page_size, sort_by=sort_by, sort_order=sort_order) validation_map: dict[str, tuple[str, ...]] = { "top_attacked_entity": ("incident_id",), - "result": ("incident_id",), - "timeline": ("incident_id",), - "alert_search": ("condition.id", "page"), - "result_distribution": ("incident_id",), + "attack_success": ("incident_id",), + "attack_timeline": ("incident_id",), + "attack_alert_list": ("condition.id", "page"), + "attack_result_distribution": ("incident_id",), "attacker_ip_list": ("condition.incident_id",), "attacker_ip_detail": ("incident_id", "attacker"), } @@ -1391,7 +1396,12 @@ async def incident_list( validation_error = _validate_required_body_fields(selected_action, body, *required_fields) if validation_error: return validation_error - return await _run_action_json_tool(INCIDENT_ACTIONS, default_action="search", action=selected_action, body=body) + return await _run_action_json_tool( + INCIDENT_ACTIONS, + default_action="incident_search", + action=selected_action, + body=body, + ) async def service_asset_list( diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml deleted file mode 100644 index b724a7106..000000000 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml +++ /dev/null @@ -1,109 +0,0 @@ -name: tdp_incident_list -description: > - 查询 TDP 智能聚合相关接口。默认返回事件列表(注意不是告警),也可通过 action 切换到时间线、 - 结果分布、攻击实体、攻击者 IP 明细等子接口。优先使用顶层语义化参数,不要手写 - `condition.id`、`condition.duration`、`condition.fuzzy` 这类嵌套字段;只有在显式参数未覆盖时才使用 `condition` / `page`。 -category: custom -enabled: true -requires_confirmation: false -provider: tdp_api - -inputSchema: - type: object - properties: - action: - type: string - description: > - 智能聚合子接口动作。`search` 会自动补齐 condition.time_from、 - condition.time_to、duration 和分页默认值;`top_attacked_entity`、 - `result`、`timeline`、`result_distribution` 需要传 - `incident_id`;`alert_search` 需要传 `alert_ids` 或 `condition.id`, - 且应同时提供 `page`;`attacker_ip_list` 需要传 `condition.incident_id`; - `attacker_ip_detail` 需要传 `incident_id` 和 `attacker`。 - enum: [search, top_attacked_entity, result, timeline, alert_search, result_distribution, attacker_ip_list, attacker_ip_detail] - default: search - incident_id: - type: string - description: 事件 ID,适用于 `top_attacked_entity`、`result`、`timeline`、`result_distribution`、`attacker_ip_detail`;应先从 `search` 返回结果中获取。 - time_from: - type: integer - description: 开始时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_from`。可以使用python datetime动态计算。 - time_to: - type: integer - description: 结束时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_to`。可以使用python datetime动态计算。 - include_risk: - type: boolean - description: 是否包含风险告警;`timeline` 等动作写入根级,`alert_search` 写入 `condition.include_risk`。 - show_attack: - type: boolean - description: > - 是否展示攻击过程,适用于 `timeline`。handler 默认传 `true`; - 若显式传 `false`,TDP 可能返回 `show_attack is false`。 - include_action: - type: boolean - description: 是否包含事件告警,适用于 `alert_search`,会写入 `condition.include_action`。 - alert_ids: - type: array - items: - type: string - description: 告警 ID 列表,适用于 `alert_search`;会转换写入 `condition.id`。 - attacker: - type: array - items: - type: string - description: 攻击者 IP 列表,适用于 `attacker_ip_detail`;通常先从 `attacker_ip_list` 返回结果中获取。 - severity: - type: array - items: - type: integer - enum: [0, 1, 2, 3, 4] - description: 严重级别列表,适用于 `search`。 - phase: - type: array - items: - type: string - description: 攻击阶段列表,适用于 `search`,例如 `recon`、`exploit`、`control`、`attack_out`、`post_exploit`。 - result: - type: array - items: - type: string - enum: [success, failed, unknown] - description: 攻击结果列表,适用于 `search`。 - is_target_attack: - type: boolean - description: 是否只看针对性攻击事件,适用于 `search`。 - begin_duration: - type: integer - description: 持续时间起始小时数,适用于 `search`,会写入 `condition.duration.begin_duration`。 - end_duration: - type: integer - description: 持续时间结束小时数,适用于 `search`,会写入 `condition.duration.end_duration`。 - keyword: - type: string - description: > - 关键词搜索,适用于 `search`。handler 会自动组装 - `condition.fuzzy.fieldlist=["attacker_ip","host_ip","attack_name","attack_tool","incident_id"]`。 - cur_page: - type: integer - description: 页码,适用于 `search`、`alert_search`、`attacker_ip_list`。 - page_size: - type: integer - description: 每页条数,适用于 `search`、`alert_search`、`attacker_ip_list`。 - sort_by: - type: string - description: 排序字段,适用于列表类 action。 - sort_order: - type: string - enum: [asc, desc] - description: 排序方向,适用于列表类 action。 - condition: - type: object - description: 高级兼容参数。仅在顶层参数未覆盖目标字段时使用。 - page: - type: object - description: 高级兼容分页参数。`alert_search` 如需复杂排序可直接透传。 - required: [] -handler: - type: script - script_file: tdp.handler.py - function: incident_list diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml similarity index 67% rename from .flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml rename to .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml index 84829590f..ca11f5091 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_host_threat_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_alert_host.yaml @@ -1,7 +1,7 @@ -name: tdp_host_threat_list +name: tdp_threat_alert_host description: > - 查询 TDP 告警主机相关数据。`summary` 返回告警主机列表,适合按严重级别、威胁性质、威胁类型、业务组和关键词筛选;`events` - 返回某台告警主机下的威胁事件列表,通常需要先从 `summary` 结果里拿到 `assets_machine` 再继续查询。优先使用顶层语义化参数,不要手写 + 查询 TDP 告警主机相关数据。`alert_host_list` 返回告警主机列表;`host_threat_list` + 返回指定告警主机的威胁列表,通常需要先从 `alert_host_list` 结果里取得 `assets_machine`。优先使用顶层语义化参数,不要手写 `condition` / `page`,只有在需要透传尚未覆盖的底层字段时才使用这两个高级兼容参数。 category: custom enabled: true @@ -14,10 +14,10 @@ inputSchema: action: type: string description: > - 告警主机子接口动作。`summary` 会自动补齐默认时间范围、全部告警主机的威胁类型、空的威胁特征和默认分页; - `events` 用于查看单台主机下的威胁事件,要求提供 `asset_machine`,级联场景可额外传 `device_id`。 - enum: [summary, events] - default: summary + `alert_host_list` 查询告警主机列表,可按严重级别、威胁性质、威胁类型、业务组和关键词筛选; + `host_threat_list` 查询指定 `asset_machine` 的威胁列表,级联场景可额外传 `device_id`。 + enum: [alert_host_list, host_threat_list] + default: alert_host_list time_from: type: integer description: 开始时间戳,Unix 秒级时间戳,会写入 `condition.time_from`。可以使用python datetime动态计算。 @@ -26,7 +26,7 @@ inputSchema: description: 结束时间戳,Unix 秒级时间戳,会写入 `condition.time_to`。可以使用python datetime动态计算。 asset_machine: type: string - description: 资产机器标识,`events` 动作必填;应从 `summary` 结果中的主机标识字段获取。 + description: 资产机器标识,`host_threat_list` 必填;应从 `alert_host_list` 结果中的主机标识字段获取。 device_id: type: string description: 设备 ID,级联平台场景使用;非级联平台通常可省略。 @@ -52,7 +52,7 @@ inputSchema: type: array items: type: string - description: 威胁类型列表。`summary` 默认已覆盖全部常见威胁类型;仅在需要缩小范围时传入。 + description: 威胁类型列表。`alert_host_list` 默认已覆盖全部常见威胁类型;仅在需要缩小范围时传入。 disposal_status: type: array items: @@ -64,21 +64,21 @@ inputSchema: type: array items: type: string - description: 主机类型列表,适用于 `summary`,常见值对应服务器或终端视角。 + description: 主机类型列表,适用于 `alert_host_list`,常见值对应服务器或终端视角。 assets_group: type: array items: type: integer - description: 业务组 ID 列表,适用于 `summary`。 + description: 业务组 ID 列表,适用于 `alert_host_list`。 host_type: type: array items: type: string - description: 资产类型列表,适用于 `summary`。 + description: 资产类型列表,适用于 `alert_host_list`。 keyword: type: string description: > - 关键词搜索,适用于 `summary` 和 `events`。handler 会自动组装固定的 + 关键词搜索,适用于 `alert_host_list` 和 `host_threat_list`。handler 会自动组装固定的 `condition.fuzzy.fieldlist=["threat.name","external_ip","machine","assets.name","data","machine_name"]`。 cur_page: type: integer @@ -88,7 +88,7 @@ inputSchema: description: 每页条数,默认 20。 sort_by: type: string - description: 排序字段。`summary` 默认按 `severity`,`events` 默认按 `max_severity`。 + description: 排序字段。`alert_host_list` 默认按 `severity`,`host_threat_list` 默认按 `max_severity`。 sort_order: type: string enum: [asc, desc] diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml similarity index 98% rename from .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml rename to .flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml index 3f1e11611..b982f2bb5 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_inbound_attack.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_external_attack.yaml @@ -1,4 +1,4 @@ -name: tdp_threat_inbound_attack +name: tdp_threat_external_attack description: > 查询 TDP 外部攻击相关数据,当前提供外部攻击严重性分布统计。优先使用顶层语义化参数, 例如严重级别、攻击结果、级联资产组和关键词;`condition` 仅作为高级兼容入口。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml new file mode 100644 index 000000000..a68280506 --- /dev/null +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml @@ -0,0 +1,107 @@ +name: tdp_threat_intelligent_aggregation +description: > + 查询 TDP 智能聚合相关接口。默认返回聚合威胁事件列表(不是原始告警),也可查询攻击成功统计、攻击时间线、 + 攻击过程告警、结果分布、受攻击实体和攻击者 IP 信息。优先使用顶层语义化参数,不要手写 + `condition.id`、`condition.duration`、`condition.fuzzy` 这类嵌套字段;只有在显式参数未覆盖时才使用 `condition` / `page`。 +category: custom +enabled: true +requires_confirmation: false +provider: tdp_api + +inputSchema: + type: object + properties: + action: + type: string + description: > + `incident_search` 查询聚合威胁事件列表;`top_attacked_entity` 查询单个事件 TOP 受攻击域名或主机; + `attack_success` 查询单个事件的攻击成功统计;`attack_timeline` 查询单个事件的攻击过程时间线; + `attack_alert_list` 根据攻击过程中的告警 ID 查询告警详情;`attack_result_distribution` 查询成功、失败、尝试的数量分布; + `attacker_ip_list` 查询单个事件的攻击来源 IP;`attacker_ip_detail` 查询指定攻击 IP 的情报和活跃时间段。 + enum: [incident_search, top_attacked_entity, attack_success, attack_timeline, attack_alert_list, attack_result_distribution, attacker_ip_list, attacker_ip_detail] + default: incident_search + incident_id: + type: string + description: 事件 ID,适用于除 `incident_search` 和 `attack_alert_list` 外的事件详情动作;应先从 `incident_search` 返回结果中获取。 + time_from: + type: integer + description: 开始时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_from`。可以使用python datetime动态计算。 + time_to: + type: integer + description: 结束时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_to`。可以使用python datetime动态计算。 + include_risk: + type: boolean + description: 是否包含风险告警;`attack_timeline` 写入根级,`attack_alert_list` 写入 `condition.include_risk`。 + show_attack: + type: boolean + description: > + 是否展示攻击过程,适用于 `attack_timeline`。handler 默认传 `true`; + 若显式传 `false`,TDP 可能返回 `show_attack is false`。 + include_action: + type: boolean + description: 是否包含敏感行为,适用于 `attack_alert_list`,会写入 `condition.include_action`。 + alert_ids: + type: array + items: + type: string + description: 攻击过程返回的告警 ID 列表,适用于 `attack_alert_list`;会转换写入 `condition.id`。 + attacker: + type: array + items: + type: string + description: 攻击者 IP 列表,适用于 `attacker_ip_detail`;通常先从 `attacker_ip_list` 返回结果中获取。 + severity: + type: array + items: + type: integer + enum: [0, 1, 2, 3, 4] + description: 严重级别列表,适用于 `incident_search`。 + phase: + type: array + items: + type: string + description: 攻击阶段列表,适用于 `incident_search`,例如 `recon`、`exploit`、`control`、`attack_out`、`post_exploit`。 + result: + type: array + items: + type: string + enum: [success, failed, unknown] + description: 攻击结果列表,适用于 `incident_search`。 + is_target_attack: + type: boolean + description: 是否只看针对性攻击事件,适用于 `incident_search`。 + begin_duration: + type: integer + description: 持续时间起始小时数,适用于 `incident_search`,会写入 `condition.duration.begin_duration`。 + end_duration: + type: integer + description: 持续时间结束小时数,适用于 `incident_search`,会写入 `condition.duration.end_duration`。 + keyword: + type: string + description: > + 关键词搜索,适用于 `incident_search`。handler 会自动组装 + `condition.fuzzy.fieldlist=["attacker_ip","host_ip","attack_name","attack_tool","incident_id"]`。 + cur_page: + type: integer + description: 页码,适用于 `incident_search`、`attack_alert_list`、`attacker_ip_list`。 + page_size: + type: integer + description: 每页条数,适用于 `incident_search`、`attack_alert_list`、`attacker_ip_list`。 + sort_by: + type: string + description: 排序字段,适用于列表类 action。 + sort_order: + type: string + enum: [asc, desc] + description: 排序方向,适用于列表类 action。 + condition: + type: object + description: 高级兼容参数。仅在顶层参数未覆盖目标字段时使用。 + page: + type: object + description: 高级兼容分页参数。`attack_alert_list` 如需复杂排序可直接透传。 + required: [] +handler: + type: script + script_file: tdp.handler.py + function: incident_list diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml index 2830e3a1f..0d832d2be 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml @@ -1,6 +1,6 @@ name: tdp_threat_monitor_list description: > - 查询 TDP 威胁-实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 + 查询 TDP 威胁监控列表,对应“威胁-实时监控”功能及正式 API `/api/v1/monitor/threat/list`。 查询时间范围不得超过 24 小时;未传时间时 handler 动态使用最近 24 小时。 `sql` 是 TDP 过滤表达式,不是包含 SELECT/FROM 的完整 SQL。 category: custom diff --git a/tests/tool/test_tdp_api_tools.py b/tests/tool/test_tdp_api_tools.py index 232ae407e..38049a634 100644 --- a/tests/tool/test_tdp_api_tools.py +++ b/tests/tool/test_tdp_api_tools.py @@ -186,8 +186,8 @@ async def test_tdp_dashboard_status_uses_non_deprecated_threat_topic_path(): @pytest.mark.asyncio -async def test_tdp_incident_list_can_use_secret_manager_credentials_and_default_filters(): - tool = _load_tool("tdp_incident_list.yaml") +async def test_tdp_threat_intelligent_aggregation_can_use_secret_manager_credentials_and_default_filters(): + tool = _load_tool("tdp_threat_intelligent_aggregation.yaml") mock_secret_manager = MagicMock() mock_secret_manager.get.side_effect = lambda key: { "tdp_api_key": "secret-api", @@ -252,8 +252,8 @@ async def test_tdp_machine_asset_list_can_switch_to_web_app_framework_action(): @pytest.mark.asyncio -async def test_tdp_threat_inbound_attack_uses_severity_distribution_endpoint(): - tool = _load_tool("tdp_threat_inbound_attack.yaml") +async def test_tdp_threat_external_attack_uses_severity_distribution_endpoint(): + tool = _load_tool("tdp_threat_external_attack.yaml") fake_session = _FakeSession([_FakeResponse(json_payload={"response_code": 0, "data": [{"key": "4", "value": 2}]})]) with ( diff --git a/tests/tool/test_tdp_skyeye_api_plugins.py b/tests/tool/test_tdp_skyeye_api_plugins.py index d3cf573f6..19013757c 100644 --- a/tests/tool/test_tdp_skyeye_api_plugins.py +++ b/tests/tool/test_tdp_skyeye_api_plugins.py @@ -28,23 +28,23 @@ def _built_json_payload(mock_run): return api_name, path, body_builder(body) -async def test_tdp_incident_timeline_requires_incident_id(): +async def test_tdp_attack_timeline_requires_incident_id(): module = _load_module("test_tdp_handler_incident", _TDP_HANDLER) - result = await module.incident_list(_ctx(), action="timeline") + result = await module.incident_list(_ctx(), action="attack_timeline") assert result.success is False assert "incident_id" in result.error -async def test_tdp_incident_timeline_defaults_show_attack_true(): +async def test_tdp_attack_timeline_defaults_show_attack_true(): module = _load_module("test_tdp_handler_incident_timeline_show_attack", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_action_json_tool", AsyncMock(return_value=mock_result)) as mock_run: result = await module.incident_list( _ctx(), - action="timeline", + action="attack_timeline", incident_id="incident-1", time_from=1700000000, time_to=1700003600, @@ -56,30 +56,30 @@ async def test_tdp_incident_timeline_defaults_show_attack_true(): assert body["show_attack"] is True -async def test_tdp_incident_alert_search_requires_page(): +async def test_tdp_attack_alert_list_requires_page(): module = _load_module("test_tdp_handler_incident_alert_page", _TDP_HANDLER) - result = await module.incident_list(_ctx(), action="alert_search", alert_ids=["alert-1"]) + result = await module.incident_list(_ctx(), action="attack_alert_list", alert_ids=["alert-1"]) assert result.success is False assert "page" in result.error -async def test_tdp_alert_host_events_requires_asset_machine(): +async def test_tdp_host_threat_list_requires_asset_machine(): module = _load_module("test_tdp_handler_alert_host", _TDP_HANDLER) - result = await module.threat_host_list(_ctx(), action="events", condition={}) + result = await module.threat_host_list(_ctx(), action="host_threat_list", condition={}) assert result.success is False assert "condition.asset_machine" in result.error -async def test_tdp_alert_host_summary_uses_all_threat_types_defaults(): - module = _load_module("test_tdp_handler_alert_host_summary_defaults", _TDP_HANDLER) +async def test_tdp_alert_host_list_uses_all_threat_types_defaults(): + module = _load_module("test_tdp_handler_alert_host_list_defaults", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_json_tool", AsyncMock(return_value=mock_result)) as mock_run: - result = await module.threat_host_list(_ctx(), action="summary") + result = await module.threat_host_list(_ctx(), action="alert_host_list") assert result.success is True mock_run.assert_awaited_once() @@ -304,14 +304,14 @@ async def test_tdp_policy_resolve_host_requires_assets_machine_status_and_sub_st assert "sub_status" in result_missing_sub_status.error -async def test_tdp_incident_alert_search_maps_explicit_params_to_condition(): +async def test_tdp_attack_alert_list_maps_explicit_params_to_condition(): module = _load_module("test_tdp_handler_incident_mapping", _TDP_HANDLER) mock_result = ToolResult(success=True, output={"status": 200}) with patch.object(module, "_run_action_json_tool", AsyncMock(return_value=mock_result)) as mock_run: result = await module.incident_list( _ctx(), - action="alert_search", + action="attack_alert_list", alert_ids=["alert-1"], include_risk=True, include_action=False, @@ -325,8 +325,8 @@ async def test_tdp_incident_alert_search_maps_explicit_params_to_condition(): default_action = mock_run.await_args.kwargs["default_action"] action = mock_run.await_args.kwargs["action"] body = mock_run.await_args.kwargs["body"] - assert default_action == "search" - assert action == "alert_search" + assert default_action == "incident_search" + assert action == "attack_alert_list" assert body["condition"]["id"] == ["alert-1"] assert body["condition"]["include_risk"] is True assert body["condition"]["include_action"] is False @@ -752,7 +752,7 @@ async def test_tdp_medium_query_tools_map_semantic_filters(): mock_run.reset_mock() result = await module.incident_list( _ctx(), - action="search", + action="incident_search", severity=[4], phase=["exploit"], result=["success"], @@ -821,13 +821,50 @@ async def test_skyeye_alarm_list_forwards_extended_filters(): assert params["limit"] == 10 -def test_tdp_incident_yaml_loads_with_provider(): - yaml_path = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10/tdp_incident_list.yaml" +def test_tdp_threat_tool_names_match_document_feature_groups(): + tool_dir = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10" + expected_names = [ + "tdp_threat_alert_host", + "tdp_threat_monitor_list", + "tdp_threat_external_attack", + "tdp_threat_intelligent_aggregation", + ] + for expected_name in expected_names: + yaml_path = tool_dir / f"{expected_name}.yaml" + raw = _read_yaml_raw(yaml_path) + tool = yaml_to_tool(raw, yaml_path) + assert tool.info.name == expected_name + assert tool.info.provider == "tdp_api_v3_3_10" + + +def test_tdp_threat_action_names_describe_returned_data(): + tool_dir = _WORKSPACE_ROOT / ".flocks/plugins/tools/device/tdp_v3_3_10" + alert_host = _read_yaml_raw(tool_dir / "tdp_threat_alert_host.yaml") + intelligent_aggregation = _read_yaml_raw(tool_dir / "tdp_threat_intelligent_aggregation.yaml") + + assert alert_host["inputSchema"]["properties"]["action"]["enum"] == [ + "alert_host_list", + "host_threat_list", + ] + assert intelligent_aggregation["inputSchema"]["properties"]["action"]["enum"] == [ + "incident_search", + "top_attacked_entity", + "attack_success", + "attack_timeline", + "attack_alert_list", + "attack_result_distribution", + "attacker_ip_list", + "attacker_ip_detail", + ] + + +def test_tdp_threat_intelligent_aggregation_promotes_semantic_fields(): + yaml_path = ( + _WORKSPACE_ROOT + / ".flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml" + ) raw = _read_yaml_raw(yaml_path) - tool = yaml_to_tool(raw, yaml_path) - assert tool.info.name == "tdp_incident_list" - assert tool.info.provider == "tdp_api_v3_3_10" assert "body" not in raw["inputSchema"]["properties"] assert "condition" in raw["inputSchema"]["properties"] assert "severity" in raw["inputSchema"]["properties"] @@ -837,14 +874,14 @@ def test_tdp_incident_yaml_loads_with_provider(): def test_tdp_query_yaml_promotes_semantic_top_level_fields(): expected_fields = { - "tdp_host_threat_list.yaml": {"severity", "threat_characters", "keyword", "cur_page"}, + "tdp_threat_alert_host.yaml": {"severity", "threat_characters", "keyword", "cur_page"}, "tdp_vulnerability_list.yaml": {"severity", "status", "keyword", "sort_by"}, "tdp_interface_list.yaml": {"host", "methods", "privacy_tags", "keyword"}, "tdp_interface_risk_list.yaml": {"api_risk_type", "keyword", "sort_by"}, "tdp_login_weakpwd_list.yaml": {"data", "result", "app_class", "keyword"}, "tdp_assets_domain_list.yaml": {"domain_name_or_ip", "has_login_api", "second_level_domain"}, "tdp_privacy_diagram.yaml": {"itag", "methods", "fuzzy_url_host"}, - "tdp_threat_inbound_attack.yaml": {"severity", "result_list", "keyword"}, + "tdp_threat_external_attack.yaml": {"severity", "result_list", "keyword"}, "tdp_threat_monitor_list.yaml": {"time_from", "time_to", "sql", "assets_group", "cur_page"}, "tdp_machine_asset_list.yaml": {"time_from", "time_to", "service", "service_class", "application", "keyword"}, "tdp_mdr_alert_list.yaml": {"section_list", "threat_severity", "keyword"}, From 4a9da106df2bf871da8d503383ff9d6c4a20bd09 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 17 Jul 2026 10:37:01 +0800 Subject: [PATCH 11/86] Add project rename and deletion safeguards --- flocks/project/project.py | 92 +++++++++++++-- flocks/server/routes/project.py | 31 ++++- tests/project/test_project.py | 106 +++++++++++++++++ webui/src/locales/en-US/session.json | 9 ++ webui/src/locales/zh-CN/session.json | 9 ++ webui/src/pages/Session/index.test.tsx | 85 +++++++++++++- webui/src/pages/Session/index.tsx | 153 ++++++++++++++++++++++--- 7 files changed, 448 insertions(+), 37 deletions(-) create mode 100644 tests/project/test_project.py diff --git a/flocks/project/project.py b/flocks/project/project.py index 204025406..9df480651 100644 --- a/flocks/project/project.py +++ b/flocks/project/project.py @@ -17,6 +17,14 @@ log = Log.create(service="project") +class ProjectNameConflictError(ValueError): + """Raised when a project name is already used in the same worktree.""" + + +class ProjectDeletionError(ValueError): + """Raised when a project cannot be deleted safely.""" + + class ProjectIcon(BaseModel): """Project icon configuration""" url: Optional[str] = None @@ -47,6 +55,33 @@ class Project: _current: Optional[ProjectInfo] = None + @staticmethod + def _normalized_worktree(worktree: str) -> str: + return os.path.normcase(os.path.realpath(worktree)) + + @classmethod + async def _ensure_unique_name( + cls, + *, + name: str, + worktree: str, + exclude_project_id: Optional[str] = None, + ) -> None: + entries = await Storage.list_entries(prefix="project/", model=ProjectInfo) + normalized_name = name.casefold() + normalized_worktree = cls._normalized_worktree(worktree) + + for _, project in entries: + if project.id == exclude_project_id or not project.name: + continue + if ( + project.name.strip().casefold() == normalized_name + and cls._normalized_worktree(project.worktree) == normalized_worktree + ): + raise ProjectNameConflictError( + f"Project name '{name}' already exists in this workspace" + ) + @classmethod async def create( cls, @@ -62,13 +97,19 @@ async def create( project ID so sessions can be organized independently from the default auto-discovered project. """ + normalized_name = name.strip() + if not normalized_name: + raise ValueError("Project name cannot be empty") + + await cls._ensure_unique_name(name=normalized_name, worktree=worktree) + now = int(datetime.now().timestamp() * 1000) project_id = Identifier.ascending("slug").replace("slg_", "prj_", 1) project = ProjectInfo( id=project_id, worktree=worktree, vcs=None, - name=name, + name=normalized_name, icon=icon, sandboxes=[], time=ProjectTime( @@ -268,16 +309,8 @@ async def list(cls) -> List[ProjectInfo]: List of project info """ try: - projects = await Storage.list_by_prefix(["project"]) - result = [] - - for key, value in projects.items(): - if isinstance(value, dict): - try: - project = ProjectInfo(**value) - result.append(project) - except Exception as e: - log.warn("project.parse.error", {"key": key, "error": str(e)}) + entries = await Storage.list_entries(prefix="project/", model=ProjectInfo) + result = [project for _, project in entries] # Sort by updated time (newest first) result.sort(key=lambda p: p.time.updated, reverse=True) @@ -318,6 +351,17 @@ async def update(cls, project_id: str, **kwargs) -> ProjectInfo: project = await cls.get(project_id) if not project: raise ValueError(f"Project {project_id} not found") + + if "name" in kwargs and kwargs["name"] is not None: + normalized_name = str(kwargs["name"]).strip() + if not normalized_name: + raise ValueError("Project name cannot be empty") + await cls._ensure_unique_name( + name=normalized_name, + worktree=project.worktree, + exclude_project_id=project_id, + ) + kwargs["name"] = normalized_name # Update fields update_data = project.model_dump() @@ -341,6 +385,32 @@ async def update(cls, project_id: str, **kwargs) -> ProjectInfo: log.info("project.updated", {"id": project_id}) return updated_project + + @classmethod + async def delete(cls, project_id: str) -> bool: + """Delete an empty user-managed project.""" + project = await cls.get(project_id) + if not project: + raise ValueError(f"Project {project_id} not found") + if not project_id.startswith("prj_"): + raise ProjectDeletionError("The default project cannot be deleted") + + from flocks.session.session import SessionInfo + + entries = await Storage.list_entries( + prefix=f"session:{project_id}:", + model=SessionInfo, + ) + if any(session.status != "deleted" for _, session in entries): + raise ProjectDeletionError( + "Delete all conversations in this project before deleting it" + ) + + deleted = await Storage.delete(f"project/{project_id}") + if cls._current and cls._current.id == project_id: + cls._current = None + log.info("project.deleted", {"id": project_id}) + return deleted @classmethod def current(cls) -> Optional[ProjectInfo]: diff --git a/flocks/server/routes/project.py b/flocks/server/routes/project.py index 95e2bd20e..7eb0f9dde 100644 --- a/flocks/server/routes/project.py +++ b/flocks/server/routes/project.py @@ -8,7 +8,13 @@ from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from flocks.project.project import Project, ProjectInfo, ProjectIcon +from flocks.project.project import ( + Project, + ProjectDeletionError, + ProjectIcon, + ProjectInfo, + ProjectNameConflictError, +) from flocks.utils.log import Log router = APIRouter() @@ -74,6 +80,8 @@ async def create_project(request: ProjectCreateRequest): icon=request.icon, ) return project + except ProjectNameConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) from e except HTTPException: raise except Exception as e: @@ -111,7 +119,10 @@ async def update_project(project_id: str, update: ProjectUpdateRequest): # Update fields update_data = {} if update.name is not None: - update_data["name"] = update.name + name = update.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Project name cannot be empty") + update_data["name"] = name if update.icon is not None: update_data["icon"] = update.icon @@ -120,6 +131,8 @@ async def update_project(project_id: str, update: ProjectUpdateRequest): log.info("project.updated", {"id": project_id}) return updated_project + except ProjectNameConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) from e except HTTPException: raise except Exception as e: @@ -127,6 +140,20 @@ async def update_project(project_id: str, update: ProjectUpdateRequest): raise HTTPException(status_code=400, detail=str(e)) +@router.delete("/{project_id}", response_model=bool, summary="Delete project") +async def delete_project(project_id: str): + """Delete an empty user-managed project.""" + try: + return await Project.delete(project_id) + except ProjectDeletionError as e: + raise HTTPException(status_code=409, detail=str(e)) from e + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except Exception as e: + log.error("project.delete.error", {"error": str(e), "id": project_id}) + raise HTTPException(status_code=400, detail=str(e)) from e + + @router.get("/{project_id}", response_model=ProjectInfo, summary="Get project") async def get_project(project_id: str): """ diff --git a/tests/project/test_project.py b/tests/project/test_project.py new file mode 100644 index 000000000..4fc559429 --- /dev/null +++ b/tests/project/test_project.py @@ -0,0 +1,106 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from flocks.project.project import ( + Project, + ProjectDeletionError, + ProjectInfo, + ProjectNameConflictError, + ProjectTime, +) +from flocks.storage.storage import Storage + + +@pytest.mark.asyncio +async def test_list_projects_reads_project_storage_entries(): + project = ProjectInfo( + id="prj_labs", + worktree="/tmp/flocks", + name="Labs", + time=ProjectTime(created=1, updated=2), + ) + + with patch.object( + Storage, + "list_entries", + new=AsyncMock(return_value=[("project/prj_labs", project)]), + ) as list_entries: + result = await Project.list() + + assert result == [project] + list_entries.assert_awaited_once_with(prefix="project/", model=ProjectInfo) + + +@pytest.mark.asyncio +async def test_create_rejects_duplicate_name_in_same_worktree(): + existing = ProjectInfo( + id="project-existing", + worktree="/tmp/workspace", + name="Test", + time=ProjectTime(created=1, updated=1), + ) + + with patch.object( + Storage, + "list_entries", + new=AsyncMock(return_value=[("project/project-existing", existing)]), + ), patch.object(Storage, "write", new=AsyncMock()) as write: + with pytest.raises(ProjectNameConflictError): + await Project.create(name=" test ", worktree="/tmp/workspace") + + write.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_update_allows_same_name_for_current_project(): + existing = ProjectInfo( + id="project-existing", + worktree="/tmp/workspace", + name="Test", + time=ProjectTime(created=1, updated=1), + ) + + with patch.object(Project, "get", new=AsyncMock(return_value=existing)), patch.object( + Storage, + "list_entries", + new=AsyncMock(return_value=[("project/project-existing", existing)]), + ), patch.object(Storage, "write", new=AsyncMock()) as write: + updated = await Project.update("project-existing", name=" Test ") + + assert updated.name == "Test" + write.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_delete_empty_user_managed_project(): + project = ProjectInfo( + id="prj_labs", + worktree="/tmp/workspace", + name="Labs", + time=ProjectTime(created=1, updated=1), + ) + + with patch.object(Project, "get", new=AsyncMock(return_value=project)), patch.object( + Storage, + "list_entries", + new=AsyncMock(return_value=[]), + ), patch.object(Storage, "delete", new=AsyncMock(return_value=True)) as delete: + result = await Project.delete(project.id) + + assert result is True + delete.assert_awaited_once_with("project/prj_labs") + + +@pytest.mark.asyncio +async def test_delete_rejects_default_project(): + project = ProjectInfo( + id="project-default", + worktree="/tmp/workspace", + name=None, + time=ProjectTime(created=1, updated=1), + ) + + with patch.object(Project, "get", new=AsyncMock(return_value=project)): + with pytest.raises(ProjectDeletionError): + await Project.delete(project.id) diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 03171888d..dcef00f54 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -266,12 +266,21 @@ "toggleProject": "Expand or collapse project {{project}}", "selectProject": "Select project {{project}}", "createSessionInProject": "New session in project {{project}}", + "projectActions": "Project actions", "defaultProject": "Default project", "defaultProjectName": "Default", "noProjectSessions": "No conversations", "projectDialog": { "createTitle": "New Project", "renameTitle": "Rename Project", + "renameAction": "Rename", + "deleteAction": "Delete", + "deleteTitle": "Delete Project", + "deleteDescription": "Delete project “{{project}}”? Only projects without conversations can be deleted.", + "confirmDelete": "Delete", + "defaultDeleteDisabled": "The default project cannot be deleted", + "deleteSuccess": "Project deleted", + "deleteFailed": "Failed to delete project", "nameLabel": "Project name", "namePlaceholder": "Enter project name", "nameEmpty": "Project name cannot be empty", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index d8ff92ac7..59826a3e0 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -266,12 +266,21 @@ "toggleProject": "展开或收起项目 {{project}}", "selectProject": "选择项目 {{project}}", "createSessionInProject": "在项目 {{project}} 中新建会话", + "projectActions": "项目操作", "defaultProject": "默认项目", "defaultProjectName": "默认", "noProjectSessions": "暂无会话", "projectDialog": { "createTitle": "新建项目", "renameTitle": "重命名项目", + "renameAction": "重命名", + "deleteAction": "删除", + "deleteTitle": "删除项目", + "deleteDescription": "确定删除项目“{{project}}”吗?仅可删除不包含会话的项目。", + "confirmDelete": "删除", + "defaultDeleteDisabled": "默认项目不能删除", + "deleteSuccess": "项目已删除", + "deleteFailed": "删除项目失败", "nameLabel": "项目名称", "namePlaceholder": "输入项目名称", "nameEmpty": "项目名称不能为空", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index f6636ec11..9678acf1d 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -23,6 +23,7 @@ const { toast, } = vi.hoisted(() => ({ client: { + delete: vi.fn(), get: vi.fn(), patch: vi.fn(), post: vi.fn(), @@ -362,11 +363,11 @@ describe('SessionPage session actions menu', () => { modelV2API.listDefinitions.mockResolvedValue({ data: { models: [] } }); client.get.mockImplementation((url: string) => { if (url === '/api/project/current') { - return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' } }); + return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/project', name: 'project' } }); } return Promise.resolve({ data: [ - { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }, + { id: 'project-1', worktree: '/tmp/project', name: 'project' }, ], }); }); @@ -410,6 +411,25 @@ describe('SessionPage session actions menu', () => { expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); }); + it('toggles project sessions when clicking the selected project row', async () => { + const user = userEvent.setup(); + + renderSessionPage(); + + await screen.findByText('defaultProjectName'); + const toggle = screen.getByRole('button', { name: 'toggleProject' }); + const projectRow = screen.getByRole('button', { name: 'selectProject' }); + + await user.click(toggle); + expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); + + await user.click(projectRow); + expect(screen.getByText('Original Session')).toBeInTheDocument(); + + await user.click(projectRow); + expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); + }); + it('merges legacy default sessions into the current project group', async () => { useSessions.mockReturnValue({ sessions: [ @@ -501,8 +521,8 @@ describe('SessionPage session actions menu', () => { } return Promise.resolve({ data: [ - { id: 'project-1', worktree: '/tmp/project', name: 'flocks' }, - { id: 'old-project-id', worktree: '/tmp/project', name: 'flocks' }, + { id: 'project-1', worktree: '/tmp/project', name: 'project' }, + { id: 'old-project-id', worktree: '/tmp/project', name: 'project' }, ], }); }); @@ -676,7 +696,8 @@ describe('SessionPage session actions menu', () => { const projectLabel = await screen.findByText('Labs'); const projectRow = projectLabel.closest('[class*="group/project"]'); expect(projectRow).not.toBeNull(); - await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectDialog.renameTitle' })); + await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectActions' })); + await user.click(within(projectRow as HTMLElement).getByRole('menuitem', { name: 'projectDialog.renameAction' })); const input = screen.getByLabelText('projectDialog.nameLabel'); await user.clear(input); await user.type(input, 'Renamed Project'); @@ -687,6 +708,60 @@ describe('SessionPage session actions menu', () => { }); }); + it('opens project actions and renames the default project', async () => { + const user = userEvent.setup(); + client.patch.mockResolvedValue({ + data: { id: 'project-1', worktree: '/tmp/project', name: 'Main Project' }, + }); + + renderSessionPage(); + + const projectLabel = await screen.findByText('defaultProjectName'); + const projectRow = projectLabel.closest('[class*="group/project"]'); + expect(projectRow).not.toBeNull(); + await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectActions' })); + await user.click(within(projectRow as HTMLElement).getByRole('menuitem', { name: 'projectDialog.renameAction' })); + + const input = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(input); + await user.type(input, 'Main Project'); + await user.click(screen.getByRole('button', { name: 'save' })); + + await waitFor(() => { + expect(client.patch).toHaveBeenCalledWith('/api/project/project-1', { name: 'Main Project' }); + }); + expect(await screen.findByText('Main Project')).toBeInTheDocument(); + }); + + it('deletes an empty user-managed project after confirmation', async () => { + const user = userEvent.setup(); + const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; + client.get.mockImplementation((url: string) => { + if (url === '/api/project/current') return Promise.resolve({ data: currentProject }); + return Promise.resolve({ + data: [ + currentProject, + { id: 'prj_project2', worktree: '/tmp/project', name: 'Labs' }, + ], + }); + }); + client.delete.mockResolvedValue({ data: true }); + + renderSessionPage(); + + const projectLabel = await screen.findByText('Labs'); + const projectRow = projectLabel.closest('[class*="group/project"]'); + expect(projectRow).not.toBeNull(); + await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectActions' })); + await user.click(within(projectRow as HTMLElement).getByRole('menuitem', { name: 'projectDialog.deleteAction' })); + await user.click(screen.getByRole('button', { name: 'projectDialog.confirmDelete' })); + + await waitFor(() => { + expect(client.delete).toHaveBeenCalledWith('/api/project/prj_project2'); + expect(screen.queryByText('Labs')).not.toBeInTheDocument(); + }); + }); + it('creates a session from a specific project row', async () => { const user = userEvent.setup(); client.get.mockImplementation((url: string) => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index f2bb9a3ea..eb0563fb1 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -403,6 +403,9 @@ export default function SessionPage() { const [selectedProjectId, setSelectedProjectId] = useState(null); const [collapsedProjectIds, setCollapsedProjectIds] = useState>(new Set()); const [projectDialogMode, setProjectDialogMode] = useState(null); + const [openProjectMenuId, setOpenProjectMenuId] = useState(null); + const [projectPendingDelete, setProjectPendingDelete] = useState(null); + const [projectDeleting, setProjectDeleting] = useState(false); const [editingProjectId, setEditingProjectId] = useState(null); const [projectNameValue, setProjectNameValue] = useState(''); const [projectSubmitting, setProjectSubmitting] = useState(false); @@ -596,7 +599,10 @@ export default function SessionPage() { projectMap.set(project.id, { id: project.id, - label: isDefaultProject ? t('defaultProjectName') : getProjectLabel(project), + label: isDefaultProject + && (!project.name?.trim() || project.name.trim() === getPathBasename(project.worktree)) + ? t('defaultProjectName') + : getProjectLabel(project), worktree: project.worktree, sessions: [], }); @@ -701,8 +707,10 @@ export default function SessionPage() { if (currentProject?.id && !nextProjects.some((project) => project.id === currentProject.id)) { nextProjects = [currentProject, ...nextProjects]; } - if (ensureProject?.id && !nextProjects.some((project) => project.id === ensureProject.id)) { - nextProjects = [ensureProject, ...nextProjects]; + if (ensureProject?.id) { + nextProjects = nextProjects.some((project) => project.id === ensureProject.id) + ? nextProjects.map((project) => (project.id === ensureProject.id ? ensureProject : project)) + : [ensureProject, ...nextProjects]; } const nextCurrentProjectId = currentResult.status === 'fulfilled' ? currentResult.value.data?.id ?? null @@ -717,6 +725,13 @@ export default function SessionPage() { void fetchProjects(); }, [fetchProjects]); + useEffect(() => { + if (!openProjectMenuId) return; + const closeMenu = () => setOpenProjectMenuId(null); + window.addEventListener('click', closeMenu); + return () => window.removeEventListener('click', closeMenu); + }, [openProjectMenuId]); + // Keep the selected session in sync with URL query params (e.g. onboarding // or other in-app navigation to `/sessions?session=...`). Clear the params // after consuming them so refreshes don't re-send the initial message. @@ -1094,10 +1109,14 @@ export default function SessionPage() { }, []); const handleOpenRenameProject = useCallback((project: ProjectSessionGroup) => { + const persistedName = projects.find((item) => item.id === project.id)?.name?.trim(); + setOpenProjectMenuId(null); setProjectDialogMode('rename'); setEditingProjectId(project.id); - setProjectNameValue(project.label); - }, []); + setProjectNameValue(persistedName && persistedName !== getPathBasename(project.worktree) + ? persistedName + : project.label); + }, [projects]); const handleCloseProjectDialog = useCallback(() => { if (projectSubmitting) return; @@ -1106,6 +1125,35 @@ export default function SessionPage() { setProjectNameValue(''); }, [projectSubmitting]); + const handleOpenDeleteProject = useCallback((project: ProjectSessionGroup) => { + setOpenProjectMenuId(null); + setProjectPendingDelete(project); + }, []); + + const handleDeleteProject = useCallback(async () => { + if (!projectPendingDelete || projectDeleting) return; + + setProjectDeleting(true); + try { + await client.delete(`/api/project/${projectPendingDelete.id}`); + setProjects((current) => current.filter((project) => project.id !== projectPendingDelete.id)); + setCollapsedProjectIds((current) => { + const next = new Set(current); + next.delete(projectPendingDelete.id); + return next; + }); + setSelectedProjectId((current) => ( + current === projectPendingDelete.id ? defaultProjectId : current + )); + setProjectPendingDelete(null); + toast.success(t('projectDialog.deleteSuccess')); + } catch (err: any) { + toast.error(t('projectDialog.deleteFailed'), err.message); + } finally { + setProjectDeleting(false); + } + }, [defaultProjectId, projectDeleting, projectPendingDelete, t, toast]); + const handleSubmitProject = useCallback(async () => { if (!projectDialogMode || projectSubmitting) return; const nextName = projectNameValue.trim(); @@ -1132,8 +1180,8 @@ export default function SessionPage() { }); await fetchProjects(createdProject); } else if (editingProjectId) { - await client.patch(`/api/project/${editingProjectId}`, { name: nextName }); - await fetchProjects(); + const response = await client.patch(`/api/project/${editingProjectId}`, { name: nextName }); + await fetchProjects(response.data as ProjectSummary); } setProjectDialogMode(null); setEditingProjectId(null); @@ -1428,9 +1476,8 @@ export default function SessionPage() { const isDefaultProject = defaultProjectId === group.id; const isSelectedProject = selectedProjectId === group.id; const persistedProject = projects.find((project) => project.id === group.id); - const canRenameProject = Boolean(persistedProject && !isDefaultProject); return ( -
+
{ setSelectedProjectId(group.id); - setCollapsedProjectIds(prev => { - const next = new Set(prev); - next.delete(group.id); - return next; - }); + if (isSelectedProject) { + toggleProjectCollapsed(group.id); + } else { + setCollapsedProjectIds(prev => { + const next = new Set(prev); + next.delete(group.id); + return next; + }); + } }} className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-0.5 text-left" aria-label={t('selectProject', { project: group.label })} @@ -1485,24 +1536,54 @@ export default function SessionPage() { > {creating && isSelectedProject ? : } - {canRenameProject && ( + {persistedProject && ( )} {group.sessions.length}
+ {persistedProject && openProjectMenuId === group.id && ( +
event.stopPropagation()} + > + +
+ +
+ )} {!collapsed && (
{group.sessions.length > 0 ? ( @@ -1925,6 +2006,40 @@ export default function SessionPage() {
)} + {projectPendingDelete && ( +
+
+
+

+ {t('projectDialog.deleteTitle')} +

+
+
+ {t('projectDialog.deleteDescription', { project: projectPendingDelete.label })} +
+
+ + +
+
+
+ )} + {selectorTooltip && (
Date: Fri, 17 Jul 2026 10:41:07 +0800 Subject: [PATCH 12/86] docs(tdp): clarify threat query routing --- .flocks/plugins/skills/tdp-use/SKILL.md | 4 ++-- .../tdp-use/references/api-reference.md | 20 +++++++++---------- .../tdp_threat_intelligent_aggregation.yaml | 16 ++++++++------- .../tdp_v3_3_10/tdp_threat_monitor_list.yaml | 2 +- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/.flocks/plugins/skills/tdp-use/SKILL.md b/.flocks/plugins/skills/tdp-use/SKILL.md index 4ee84ed40..15f556540 100644 --- a/.flocks/plugins/skills/tdp-use/SKILL.md +++ b/.flocks/plugins/skills/tdp-use/SKILL.md @@ -38,9 +38,9 @@ API 参数和适用场景见 [references/api-reference.md](references/api-refere - API 调用必须以当前 tool schema 为准,优先使用 schema 暴露的顶层语义化参数;列表类工具常见 `keyword`、`severity`、`cur_page`、`page_size`、`sort_by`,但 `tdp_log_search.sql` 是过滤表达式不是完整 SQL,禁止 `SELECT/FROM`,控制返回数量用 `size`,`terms` 可不传 `sql`,外部攻击结果筛选用 `result_list`。 - 用户说“告警”“告警记录”“告警日志”“明细记录”“查某 IP 的告警”时,默认走 `tdp_log_search` - 用户说“看板”“概览”“趋势”“统计”时,先用 `tdp_dashboard_status` -- 用户说“智能聚合”“威胁事件”“攻击事件”“事件总览”“事件趋势”时,优先用 `tdp_threat_intelligent_aggregation` +- 用户明确说“智能聚合”“聚合攻击事件”“智能聚合事件”“攻击过程”“事件攻击时间线”时,优先用 `tdp_threat_intelligent_aggregation` - 用户说“外部攻击”“外部攻击严重性分布”时,优先用 `tdp_threat_external_attack` -- 用户说“实时监控”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 +- 用户说“威胁事件列表”“实时监控”“威胁实时监控列表”“威胁监控列表”时,优先用正式 API 工具 `tdp_threat_monitor_list`;不要用 Cookie 认证的旧 Web CLI 代替 - 用户说“告警主机”“受害主机”“主机下的事件”时,优先用 `tdp_threat_alert_host` - 用户说“系统状态”“核心服务状态”“数据库状态”时,优先用 `tdp_system_status` - 用户说“MDR”“研判结果”“研判统计”时,优先用 `tdp_mdr_alert_list` diff --git a/.flocks/plugins/skills/tdp-use/references/api-reference.md b/.flocks/plugins/skills/tdp-use/references/api-reference.md index 2c6e13073..9aa51339b 100644 --- a/.flocks/plugins/skills/tdp-use/references/api-reference.md +++ b/.flocks/plugins/skills/tdp-use/references/api-reference.md @@ -9,8 +9,8 @@ | 看安全态势、趋势、TOP 统计 | `tdp_dashboard_status` | `status` / `security` / `threat_event` / `alert_level_trend` | 通常可空参;列表类 action 可补 `machine_type`、`severity`、分页参数 | | 查原始告警日志 | `tdp_log_search` | `search` | `time_from`、`time_to`、`sql` | | 查字段聚合统计 | `tdp_log_search` | `terms` | `time_from`、`time_to`、`term` | -| 查威胁事件列表 | `tdp_threat_intelligent_aggregation` | `incident_search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | -| 查威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | +| 查智能聚合事件 / 聚合攻击事件 | `tdp_threat_intelligent_aggregation` | `incident_search` | `time_from`、`time_to`;可补 `severity`、`phase`、`result`、`keyword`、分页参数 | +| 查威胁事件列表 / 威胁实时监控列表 | `tdp_threat_monitor_list` | 默认 | 时间范围不超过 24 小时;可补 `sql`、`net_data_type`、`assets_group`、分页参数 | | 看事件时间线 / 结果分布 / 攻击者明细 | `tdp_threat_intelligent_aggregation` | `attack_timeline` / `attack_result_distribution` / `attacker_ip_detail` | 通常先要 `incident_id` | | 查外部攻击严重性分布 | `tdp_threat_external_attack` | 默认 | `time_from`、`time_to`;可补 `severity`、`result_list`、`keyword` | | 查告警主机列表 / 指定主机威胁列表 | `tdp_threat_alert_host` | `alert_host_list` / `host_threat_list` | `alert_host_list` 可补 `severity`、`direction`、`threat_type`、`keyword`;`host_threat_list` 至少要 `asset_machine` | @@ -109,9 +109,9 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") | 用户实际要查什么 | 推荐 tool | 说明 | |---|---|---| -| 威胁事件 / 攻击事件 / 事件总览 | `tdp_threat_intelligent_aggregation` | 事件维度,平台已聚合 | +| 智能聚合事件 / 聚合攻击事件 / 攻击过程 | `tdp_threat_intelligent_aggregation` | 智能聚合维度,一条事件可关联多条告警 | | 外部攻击严重性分布 | `tdp_threat_external_attack` | 外部攻击统计维度 | -| 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 实时威胁监控列表,单次时间范围不得超过 24 小时 | +| 威胁事件列表 / 实时监控 / 威胁监控列表 | `tdp_threat_monitor_list` | 威胁实时监控列表,单次时间范围不得超过 24 小时 | | 告警 / 告警日志 / 原始检测记录 | `tdp_log_search` | 告警维度,一条就是一条原始记录 | | 告警主机 / 受害主机 / 主机下事件 | `tdp_threat_alert_host` | 主机维度,按主机聚合 | | 漏洞、弱口令、登录入口、API 风险等 | 对应资产或风险类 tool | 资产/风险维度,不要混进日志查询 | @@ -119,11 +119,11 @@ tdp_log_search(time_from=today_start, time_to=today_end, sql="...") 建议按下面的用词来路由: - 提到“告警”“最近一小时告警”“查某 IP 的告警”时,默认优先 `tdp_log_search` -- 提到“智能聚合”“威胁事件”“攻击事件”“看下最近有什么事件”时,优先 `tdp_threat_intelligent_aggregation` +- 明确提到“智能聚合”“聚合攻击事件”“智能聚合事件”“攻击过程”时,优先 `tdp_threat_intelligent_aggregation` - 提到“外部攻击”“外部攻击严重性分布”时,优先 `tdp_threat_external_attack` -- 提到“实时监控”“威胁监控列表”时,优先 `tdp_threat_monitor_list` +- 提到“威胁事件列表”“实时监控”“威胁实时监控列表”“威胁监控列表”时,优先 `tdp_threat_monitor_list` - 提到“哪些主机被打了”“告警主机”“受害主机”时,优先 `tdp_threat_alert_host` -- 用户没说清时,默认把“明细”理解为告警日志,把“总览/聚合”理解为事件 +- 用户没说清时,默认把“事件列表/实时监控”理解为威胁监控列表,把“总览/聚合”理解为智能聚合事件 ## 高频场景 @@ -281,7 +281,7 @@ machine = '192.168.1.100' - 统计某时间段内最多的威胁名称 - 聚合源 IP、目的 IP、URL、威胁类型 -### 4. 威胁事件列表 +### 4. 智能聚合事件查询 推荐: @@ -310,7 +310,7 @@ machine = '192.168.1.100' 高频 action: -- `incident_search`: 智能聚合威胁事件列表 +- `incident_search`: 查询智能聚合后的攻击事件列表;不是“威胁-实时监控”中的威胁事件列表 - `top_attacked_entity`: 受攻击实体 - `attack_success`: 攻击成功统计 - `attack_timeline`: 攻击过程时间线 @@ -359,7 +359,7 @@ machine = '192.168.1.100' } ``` -注意:这里的攻击结果字段是 `result_list`,来自 API 文档的 `condition.result_list`;不要使用事件列表里的 `result` 参数名。 +注意:这里的攻击结果字段是 `result_list`,来自 API 文档的 `condition.result_list`;不要使用智能聚合事件查询里的 `result` 参数名。 告警主机汇总: diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml index a68280506..889c6f9f6 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_intelligent_aggregation.yaml @@ -1,7 +1,8 @@ name: tdp_threat_intelligent_aggregation description: > - 查询 TDP 智能聚合相关接口。默认返回聚合威胁事件列表(不是原始告警),也可查询攻击成功统计、攻击时间线、 - 攻击过程告警、结果分布、受攻击实体和攻击者 IP 信息。优先使用顶层语义化参数,不要手写 + 查询 TDP“威胁-智能聚合”中的聚合攻击事件及其详情。默认查询智能聚合后的攻击事件,也可查询指定聚合事件的 + 攻击成功统计、攻击时间线、攻击过程告警、结果分布、受攻击实体和攻击者 IP 信息。本 tool 不用于查询 + “威胁-实时监控”中的威胁事件列表;该意图应使用 `tdp_threat_monitor_list`。优先使用顶层语义化参数,不要手写 `condition.id`、`condition.duration`、`condition.fuzzy` 这类嵌套字段;只有在显式参数未覆盖时才使用 `condition` / `page`。 category: custom enabled: true @@ -14,15 +15,16 @@ inputSchema: action: type: string description: > - `incident_search` 查询聚合威胁事件列表;`top_attacked_entity` 查询单个事件 TOP 受攻击域名或主机; - `attack_success` 查询单个事件的攻击成功统计;`attack_timeline` 查询单个事件的攻击过程时间线; - `attack_alert_list` 根据攻击过程中的告警 ID 查询告警详情;`attack_result_distribution` 查询成功、失败、尝试的数量分布; - `attacker_ip_list` 查询单个事件的攻击来源 IP;`attacker_ip_detail` 查询指定攻击 IP 的情报和活跃时间段。 + `incident_search` 查询智能聚合后的攻击事件列表;`top_attacked_entity` 查询指定聚合事件 TOP 受攻击域名或主机; + `attack_success` 查询指定聚合事件的攻击成功统计;`attack_timeline` 查询指定聚合事件的攻击过程时间线; + `attack_alert_list` 根据攻击过程返回的告警 ID 查询对应告警详情;`attack_result_distribution` 查询指定聚合事件中 + 成功、失败、尝试的数量分布;`attacker_ip_list` 查询指定聚合事件的攻击来源 IP; + `attacker_ip_detail` 查询指定聚合事件中指定攻击 IP 的情报和活跃时间段。 enum: [incident_search, top_attacked_entity, attack_success, attack_timeline, attack_alert_list, attack_result_distribution, attacker_ip_list, attacker_ip_detail] default: incident_search incident_id: type: string - description: 事件 ID,适用于除 `incident_search` 和 `attack_alert_list` 外的事件详情动作;应先从 `incident_search` 返回结果中获取。 + description: 智能聚合事件 ID,适用于除 `incident_search` 和 `attack_alert_list` 外的详情动作;应先从 `incident_search` 返回结果中获取。 time_from: type: integer description: 开始时间戳,Unix 秒级时间戳,按动作写入根级或 `condition.time_from`。可以使用python datetime动态计算。 diff --git a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml index 0d832d2be..0409fa813 100644 --- a/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml +++ b/.flocks/plugins/tools/device/tdp_v3_3_10/tdp_threat_monitor_list.yaml @@ -1,6 +1,6 @@ name: tdp_threat_monitor_list description: > - 查询 TDP 威胁监控列表,对应“威胁-实时监控”功能及正式 API `/api/v1/monitor/threat/list`。 + 查询 TDP 威胁事件列表,即“威胁-实时监控”中的威胁实时监控列表,对应正式 API `/api/v1/monitor/threat/list`。 查询时间范围不得超过 24 小时;未传时间时 handler 动态使用最近 24 小时。 `sql` 是 TDP 过滤表达式,不是包含 SELECT/FROM 的完整 SQL。 category: custom From a5105457946c46b27d24cf75aa226f063b552406 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 17 Jul 2026 14:21:18 +0800 Subject: [PATCH 13/86] fix(tui): select first provider with models --- tui/flocks/cli/cmd/tui/context/local.tsx | 12 +--- .../cmd/tui/context/model-selection.test.ts | 61 +++++++++++++++++++ .../cli/cmd/tui/context/model-selection.ts | 21 +++++++ 3 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 tui/flocks/cli/cmd/tui/context/model-selection.test.ts create mode 100644 tui/flocks/cli/cmd/tui/context/model-selection.ts diff --git a/tui/flocks/cli/cmd/tui/context/local.tsx b/tui/flocks/cli/cmd/tui/context/local.tsx index b0d569018..0cd77695b 100644 --- a/tui/flocks/cli/cmd/tui/context/local.tsx +++ b/tui/flocks/cli/cmd/tui/context/local.tsx @@ -12,6 +12,7 @@ import { Provider } from "@/provider/provider" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" +import { selectFallbackModel } from "./model-selection" export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", @@ -174,16 +175,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - const provider = sync.data.provider[0] - if (!provider) return undefined - const defaultModel = sync.data.provider_default[provider.id] - const firstModel = Object.values(provider.models)[0] - const model = defaultModel ?? firstModel?.id - if (!model) return undefined - return { - providerID: provider.id, - modelID: model, - } + return selectFallbackModel(sync.data.provider, sync.data.provider_default) }) const currentModel = createMemo(() => { diff --git a/tui/flocks/cli/cmd/tui/context/model-selection.test.ts b/tui/flocks/cli/cmd/tui/context/model-selection.test.ts new file mode 100644 index 000000000..3830009e2 --- /dev/null +++ b/tui/flocks/cli/cmd/tui/context/model-selection.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { selectFallbackModel } from "./model-selection" + +describe("selectFallbackModel", () => { + test("skips an empty provider when a later provider has a model", () => { + expect( + selectFallbackModel( + [ + { id: "openai", models: {} }, + { + id: "threatbook-cn-llm", + models: { + "minimax-m3": { id: "minimax-m3" }, + }, + }, + ], + { "threatbook-cn-llm": "minimax-m3" }, + ), + ).toEqual({ + providerID: "threatbook-cn-llm", + modelID: "minimax-m3", + }) + }) + + test("uses the provider default when it is available", () => { + expect( + selectFallbackModel( + [ + { + id: "openai", + models: { + "gpt-4.1": { id: "gpt-4.1" }, + "gpt-5": { id: "gpt-5" }, + }, + }, + ], + { openai: "gpt-5" }, + ), + ).toEqual({ providerID: "openai", modelID: "gpt-5" }) + }) + + test("uses the first model when the provider default is unavailable", () => { + expect( + selectFallbackModel( + [ + { + id: "openai", + models: { + "gpt-4.1": { id: "gpt-4.1" }, + }, + }, + ], + { openai: "removed-model" }, + ), + ).toEqual({ providerID: "openai", modelID: "gpt-4.1" }) + }) + + test("returns undefined when no provider has a model", () => { + expect(selectFallbackModel([{ id: "openai", models: {} }], {})).toBeUndefined() + }) +}) diff --git a/tui/flocks/cli/cmd/tui/context/model-selection.ts b/tui/flocks/cli/cmd/tui/context/model-selection.ts new file mode 100644 index 000000000..1c8ac86cc --- /dev/null +++ b/tui/flocks/cli/cmd/tui/context/model-selection.ts @@ -0,0 +1,21 @@ +type Model = { + id: string +} + +type Provider = { + id: string + models: Record +} + +export function selectFallbackModel(providers: Provider[], defaults: Record) { + const provider = providers.find((item) => Object.keys(item.models).length > 0) + if (!provider) return undefined + const defaultModel = defaults[provider.id] + const firstModel = Object.values(provider.models)[0] + const modelID = defaultModel && provider.models[defaultModel] ? defaultModel : firstModel?.id + if (!modelID) return undefined + return { + providerID: provider.id, + modelID, + } +} From 02e603e2a8421d012c123228622be5c9003948e3 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 17 Jul 2026 16:51:48 +0800 Subject: [PATCH 14/86] fix(docker): use supervised service startup --- README.md | 4 +- README_zh.md | 4 +- docker/Dockerfile | 8 +- scripts/container-start.sh | 86 ++----------------- tests/docker/test_container_start.py | 42 +++++++++ .../test_dockerfile_runtime_requirements.py | 10 +++ 6 files changed, 67 insertions(+), 87 deletions(-) create mode 100644 tests/docker/test_container_start.py diff --git a/README.md b/README.md index 32e0acd68..5f5b0d6c7 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,6 @@ macOS / Linux ```bash docker run -d \ --name flocks \ - -p 8000:8000 \ -p 5173:5173 \ --shm-size 4gb \ -v "${HOME}/.flocks:/home/flocks/.flocks" \ @@ -160,14 +159,13 @@ Windows PowerShell ```powershell docker run -d ` --name flocks ` - -p 8000:8000 ` -p 5173:5173 ` --shm-size 4gb ` -v "${env:USERPROFILE}\.flocks:/home/flocks/.flocks" ` ghcr.io/agentflocks/flocks:latest ``` -`EXPOSE` in the image only documents container ports. You still need `-p 8000:8000 -p 5173:5173` to access the service from the host browser. +`EXPOSE` in the image only documents container ports. You still need `-p 5173:5173` to access the service from the host browser. ## 4. FAQ diff --git a/README_zh.md b/README_zh.md index 84ef87929..7ebba572f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -149,7 +149,6 @@ macOS / Linux docker run -d \ --name flocks \ -e TZ=Asia/Shanghai \ - -p 8000:8000 \ -p 5173:5173 \ --shm-size 4gb \ -v "${HOME}/.flocks:/home/flocks/.flocks" \ @@ -161,14 +160,13 @@ Windows PowerShell docker run -d ` --name flocks ` -e TZ=Asia/Shanghai ` - -p 8000:8000 ` -p 5173:5173 ` --shm-size 4gb ` -v "${env:USERPROFILE}\.flocks:/home/flocks/.flocks" ` ghcr.io/agentflocks/flocks:latest ``` -镜像中的 `EXPOSE` 仅用于声明容器端口;要从宿主机浏览器访问服务,仍需使用 `-p 8000:8000 -p 5173:5173` 映射端口。 +镜像中的 `EXPOSE` 仅用于声明容器端口;要从宿主机浏览器访问服务,仍需使用 `-p 5173:5173` 映射端口。 ## 4. 常见问题 diff --git a/docker/Dockerfile b/docker/Dockerfile index 1b0384f12..ebd64ddd1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -9,8 +9,8 @@ ARG VITE_APP_VERSION=docker ENV DEBIAN_FRONTEND=noninteractive \ FLOCKS_DEPLOY_MODE=docker \ - FLOCKS_BACKEND_HOST=0.0.0.0 \ - FLOCKS_FRONTEND_HOST=0.0.0.0 \ + FLOCKS_HOST=0.0.0.0 \ + FLOCKS_PORT=5173 \ FLOCKS_FRONTEND_DIST_DIR=/opt/flocks/webui/dist \ FLOCKS_ROOT=/home/flocks/.flocks \ FLOCKS_CONFIG_DIR=/home/flocks/.flocks/config \ @@ -88,9 +88,9 @@ RUN sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen \ RUN mkdir -p "$HOME/.flocks" "$HOME/.flocks/config" "$HOME/.agent-browser" \ && chown -R flocks:flocks /opt/flocks "$HOME" -EXPOSE 8000 5173 +EXPOSE 5173 USER flocks -ENTRYPOINT ["/usr/bin/tini", "--"] +ENTRYPOINT ["/usr/bin/tini", "-g", "--"] CMD ["bash", "./scripts/container-start.sh"] diff --git a/scripts/container-start.sh b/scripts/container-start.sh index bca3dcf1e..752570a94 100644 --- a/scripts/container-start.sh +++ b/scripts/container-start.sh @@ -5,86 +5,18 @@ set -euo pipefail ROOT_DIR="${ROOT_DIR:-/opt/flocks}" -BACKEND_HOST="${FLOCKS_BACKEND_HOST:-0.0.0.0}" -BACKEND_PORT="${FLOCKS_BACKEND_PORT:-8000}" -FRONTEND_HOST="${FLOCKS_FRONTEND_HOST:-0.0.0.0}" -FRONTEND_PORT="${FLOCKS_FRONTEND_PORT:-5173}" -FRONTEND_DIST_DIR="${FLOCKS_FRONTEND_DIST_DIR:-$ROOT_DIR/webui/dist}" -FRONTEND_PROXY_TARGET="${FLOCKS_FRONTEND_PROXY_TARGET:-http://127.0.0.1:${BACKEND_PORT}}" - -backend_pid="" -frontend_pid="" -ready_hint_pid="" +service_started=false cleanup() { - local pid - for pid in "$backend_pid" "$frontend_pid" "$ready_hint_pid"; do - if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then - kill -TERM "$pid" 2>/dev/null || true - fi - done - - for pid in "$backend_pid" "$frontend_pid" "$ready_hint_pid"; do - if [[ -n "$pid" ]]; then - wait "$pid" 2>/dev/null || true - fi - done + trap - EXIT INT TERM + if [[ "$service_started" == true ]]; then + flocks stop || true + fi } -trap cleanup EXIT +trap cleanup EXIT INT TERM cd "$ROOT_DIR" -[[ -f "$FRONTEND_DIST_DIR/index.html" ]] || { - printf '[flocks] error: WebUI 构建产物不存在: %s\n' "$FRONTEND_DIST_DIR/index.html" >&2 - exit 1 -} -[[ -f "$ROOT_DIR/scripts/serve_webui.py" ]] || { - printf '[flocks] error: 缺少前端静态服务脚本: %s\n' "$ROOT_DIR/scripts/serve_webui.py" >&2 - exit 1 -} - -printf '[flocks] publish ports to access from host: -p %s:%s -p %s:%s\n' \ - "$BACKEND_PORT" "$BACKEND_PORT" "$FRONTEND_PORT" "$FRONTEND_PORT" - -printf '[flocks] starting backend on %s:%s\n' "$BACKEND_HOST" "$BACKEND_PORT" -python -m uvicorn flocks.server.app:app \ - --host "$BACKEND_HOST" \ - --port "$BACKEND_PORT" & -backend_pid=$! - -printf '[flocks] starting frontend on %s:%s (proxy -> %s)\n' \ - "$FRONTEND_HOST" "$FRONTEND_PORT" "$FRONTEND_PROXY_TARGET" -( exec python "$ROOT_DIR/scripts/serve_webui.py" \ - --directory "$FRONTEND_DIST_DIR" \ - --host "$FRONTEND_HOST" \ - --port "$FRONTEND_PORT" \ - --proxy-target "$FRONTEND_PROXY_TARGET" ) & -frontend_pid=$! - -( - while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do - if python - "$BACKEND_PORT" <<'PY' -import sys -import urllib.request - -port = sys.argv[1] -url = f"http://127.0.0.1:{port}/api/health" - -try: - with urllib.request.urlopen(url, timeout=1) as response: - sys.exit(0 if response.status == 200 else 1) -except Exception: - sys.exit(1) -PY - then - printf '[flocks] open WebUI in your browser: http://127.0.0.1:%s\n' "$FRONTEND_PORT" - exit 0 - fi - - sleep 1 - done -) & -ready_hint_pid=$! - -wait -n "$backend_pid" "$frontend_pid" -exit $? +flocks start --no-browser --skip-webui-build +service_started=true +flocks logs --follow diff --git a/tests/docker/test_container_start.py b/tests/docker/test_container_start.py new file mode 100644 index 000000000..900067300 --- /dev/null +++ b/tests/docker/test_container_start.py @@ -0,0 +1,42 @@ +import os +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CONTAINER_START = REPO_ROOT / "scripts" / "container-start.sh" + + +def test_container_start_uses_supervised_service_commands(tmp_path: Path) -> None: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + call_log = tmp_path / "calls.log" + flocks = bin_dir / "flocks" + flocks.write_text( + '#!/usr/bin/env bash\nprintf \'%s\\n\' "$*" >> "$CALL_LOG"\n', + encoding="utf-8", + ) + flocks.chmod(0o755) + env = os.environ.copy() + env.update( + { + "CALL_LOG": str(call_log), + "PATH": f"{bin_dir}:{env['PATH']}", + "ROOT_DIR": str(tmp_path), + } + ) + + completed = subprocess.run( + ["bash", str(CONTAINER_START)], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert completed.returncode == 0 + assert call_log.read_text(encoding="utf-8").splitlines() == [ + "start --no-browser --skip-webui-build", + "logs --follow", + "stop", + ] diff --git a/tests/docker/test_dockerfile_runtime_requirements.py b/tests/docker/test_dockerfile_runtime_requirements.py index bbb61e1c8..9360d5fc7 100644 --- a/tests/docker/test_dockerfile_runtime_requirements.py +++ b/tests/docker/test_dockerfile_runtime_requirements.py @@ -10,3 +10,13 @@ def test_runtime_image_no_longer_bundles_system_chromium() -> None: assert "AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium" not in dockerfile assert " chromium \\" not in dockerfile + + +def test_runtime_image_uses_unified_supervised_service_port() -> None: + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + + assert "FLOCKS_HOST=0.0.0.0" in dockerfile + assert "FLOCKS_PORT=5173" in dockerfile + assert "EXPOSE 5173" in dockerfile + assert "EXPOSE 8000" not in dockerfile + assert 'ENTRYPOINT ["/usr/bin/tini", "-g", "--"]' in dockerfile From 85b7625e871b3e923a75c3b2cc55c3a647d5ac4b Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Fri, 17 Jul 2026 17:07:59 +0800 Subject: [PATCH 15/86] feat: complete project-based session management --- flocks/project/project.py | 841 ++++++++++-------- flocks/server/app.py | 4 +- flocks/server/routes/project.py | 367 +++++--- flocks/server/routes/session.py | 195 ++-- flocks/session/session.py | 22 +- tests/project/test_project.py | 241 +++-- tests/server/conftest.py | 9 + tests/server/routes/test_project_routes.py | 160 ++++ tests/server/routes/test_session_routes.py | 83 +- webui/src/api/session.ts | 1 + .../src/components/common/SessionChat.test.ts | 2 +- webui/src/components/common/SessionChat.tsx | 78 +- .../common/sessionChatRenderEquality.ts | 72 ++ webui/src/hooks/useSessions.test.ts | 129 +++ webui/src/hooks/useSessions.ts | 124 ++- webui/src/locales/en-US/session.json | 18 +- webui/src/locales/zh-CN/session.json | 18 +- webui/src/pages/Session/index.test.tsx | 457 ++++------ webui/src/pages/Session/index.tsx | 559 +++++++----- webui/src/types/index.ts | 1 + 20 files changed, 2120 insertions(+), 1261 deletions(-) create mode 100644 tests/server/routes/test_project_routes.py create mode 100644 webui/src/components/common/sessionChatRenderEquality.ts diff --git a/flocks/project/project.py b/flocks/project/project.py index 9df480651..132655a8f 100644 --- a/flocks/project/project.py +++ b/flocks/project/project.py @@ -1,433 +1,558 @@ -""" -Project management module +"""User-scoped project registry backed by JSON files. -Handles project discovery, metadata, and lifecycle +Projects are UI/session groupings bound to existing directories. Legacy project +records in Storage are intentionally ignored; sessions that do not reference a +registered project are presented under the virtual default project. """ +from __future__ import annotations + +import asyncio +import hashlib +import json import os +import shutil import subprocess -from typing import Optional, Dict, Any, List +import tempfile +import time +import uuid from datetime import datetime -from pydantic import BaseModel, Field +from pathlib import Path +from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple + +from pydantic import BaseModel, ConfigDict, Field -from flocks.storage.storage import Storage from flocks.utils.log import Log -from flocks.utils.id import Identifier log = Log.create(service="project") +DEFAULT_PROJECT_ID = "default" +DEFAULT_PROJECT_NAME = "默认" +_RESERVED_PROJECT_NAMES = {"default", DEFAULT_PROJECT_NAME.casefold()} + class ProjectNameConflictError(ValueError): - """Raised when a project name is already used in the same worktree.""" + """Raised when a project name is already registered for the user.""" + + +class ProjectPathConflictError(ValueError): + """Raised when a project directory is already registered.""" + + def __init__(self, project: "ProjectInfo") -> None: + super().__init__(f"Project directory is already registered as '{project.name}'") + self.project = project class ProjectDeletionError(ValueError): - """Raised when a project cannot be deleted safely.""" + """Raised when a protected project cannot be deleted.""" class ProjectIcon(BaseModel): - """Project icon configuration""" + """Project icon configuration.""" + url: Optional[str] = None override: Optional[str] = None color: Optional[str] = None class ProjectTime(BaseModel): - """Project time metadata""" + """Project time metadata.""" + created: int updated: int initialized: Optional[int] = None class ProjectInfo(BaseModel): - """Project information""" + """Project information returned by project and session APIs.""" + + model_config = ConfigDict(populate_by_name=True) + id: str worktree: str - vcs: Optional[str] = None # "git" or None + vcs: Optional[str] = None name: Optional[str] = None icon: Optional[ProjectIcon] = None time: ProjectTime sandboxes: List[str] = Field(default_factory=list) + is_default: bool = Field(False, alias="isDefault") + path_status: Literal["available", "missing", "unreadable"] = Field( + "available", + alias="pathStatus", + ) + session_count: int = Field(0, alias="sessionCount") + matched_session_count: int = Field(0, alias="matchedSessionCount") + last_activity_at: Optional[int] = Field(None, alias="lastActivityAt") + + +class ProjectRegistryEntry(BaseModel): + """Persisted project entry.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str + name: str + worktree: str + created_at: int = Field(alias="createdAt") + updated_at: int = Field(alias="updatedAt") + + +class ProjectRegistry(BaseModel): + """Per-user project registry file.""" + + model_config = ConfigDict(populate_by_name=True) + + default_worktree: str = Field(alias="defaultWorktree") + projects: List[ProjectRegistryEntry] = Field(default_factory=list) class Project: - """Project management namespace""" - - _current: Optional[ProjectInfo] = None + """Project registry and virtual default-project operations.""" + + _lock = asyncio.Lock() + _session_stats_cache: ClassVar[ + Dict[Tuple[str, str], Tuple[float, Dict[str, Tuple[int, int, Optional[int]]]]] + ] = {} + _session_stats_cache_ttl_seconds = 30.0 + + @staticmethod + def _now_ms() -> int: + return int(datetime.now().timestamp() * 1000) + + @staticmethod + def _flocks_root() -> Path: + return Path(os.getenv("FLOCKS_ROOT", str(Path.home() / ".flocks"))).expanduser() + + @classmethod + def _owner_hash(cls, owner_id: str) -> str: + return hashlib.sha256(owner_id.encode("utf-8")).hexdigest()[:24] + + @classmethod + def registry_path(cls, owner_id: str) -> Path: + """Return the project registry path for a user.""" + + return cls._flocks_root() / "projects" / f"{cls._owner_hash(owner_id)}.json" @staticmethod def _normalized_worktree(worktree: str) -> str: - return os.path.normcase(os.path.realpath(worktree)) + return os.path.normcase(str(Path(worktree).expanduser().resolve(strict=True))) + + @staticmethod + def _path_status(worktree: str) -> Literal["available", "missing", "unreadable"]: + path = Path(worktree) + if not path.is_dir(): + return "missing" + if not os.access(path, os.R_OK | os.X_OK): + return "unreadable" + return "available" @classmethod - async def _ensure_unique_name( - cls, - *, - name: str, - worktree: str, - exclude_project_id: Optional[str] = None, - ) -> None: - entries = await Storage.list_entries(prefix="project/", model=ProjectInfo) - normalized_name = name.casefold() - normalized_worktree = cls._normalized_worktree(worktree) + def default_worktree_candidate(cls) -> str: + """Resolve the initial default directory without using the server cwd.""" + + configured = os.getenv("FLOCKS_DEFAULT_PROJECT_DIR") + if configured: + path = Path(configured).expanduser() + else: + configured_workspace = os.getenv("FLOCKS_WORKSPACE_DIR") + path = ( + Path(configured_workspace).expanduser() + if configured_workspace + else cls._flocks_root() / "workspace" + ) + path.mkdir(parents=True, exist_ok=True) + return str(path.resolve()) + + @classmethod + def allowed_roots(cls, default_worktree: Optional[str] = None) -> List[Path]: + """Return canonical roots available to the server-side folder picker.""" - for _, project in entries: - if project.id == exclude_project_id or not project.name: + configured = os.getenv("FLOCKS_PROJECT_ROOTS", "").strip() + candidates = [Path(item).expanduser() for item in configured.split(os.pathsep) if item] + if not candidates: + candidates = [Path.home()] + if default_worktree: + candidates.append(Path(default_worktree).expanduser()) + + roots: List[Path] = [] + for candidate in candidates: + try: + resolved = candidate.resolve(strict=True) + except (OSError, RuntimeError): continue - if ( - project.name.strip().casefold() == normalized_name - and cls._normalized_worktree(project.worktree) == normalized_worktree - ): - raise ProjectNameConflictError( - f"Project name '{name}' already exists in this workspace" - ) + if not resolved.is_dir(): + continue + if any(resolved == root or resolved.is_relative_to(root) for root in roots): + continue + roots = [root for root in roots if not root.is_relative_to(resolved)] + roots.append(resolved) + return sorted(roots, key=lambda item: str(item).casefold()) @classmethod - async def create( + def validate_worktree( cls, - *, - name: str, worktree: str, - icon: Optional[ProjectIcon] = None, - ) -> ProjectInfo: - """ - Create a user-managed project grouping. + *, + default_worktree: Optional[str] = None, + create_if_missing: bool = False, + ) -> str: + """Validate and canonicalize a project directory.""" - User-managed projects share the provided worktree but get their own - project ID so sessions can be organized independently from the default - auto-discovered project. - """ - normalized_name = name.strip() - if not normalized_name: - raise ValueError("Project name cannot be empty") + if not worktree or not worktree.strip(): + raise ValueError("Project directory cannot be empty") + requested_path = Path(worktree).expanduser() + if not requested_path.is_absolute(): + raise ValueError("Project directory must be an absolute path") + requested_path = Path(os.path.abspath(requested_path)) + roots = cls.allowed_roots(default_worktree) - await cls._ensure_unique_name(name=normalized_name, worktree=worktree) + if create_if_missing and not requested_path.exists(): + ancestor = requested_path + missing_parts: List[str] = [] + while not ancestor.exists() and ancestor != ancestor.parent: + missing_parts.append(ancestor.name) + ancestor = ancestor.parent + try: + resolved_ancestor = ancestor.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ValueError("Project directory cannot be resolved") from exc + target = resolved_ancestor.joinpath(*reversed(missing_parts)) + if roots and not any(target == root or target.is_relative_to(root) for root in roots): + raise ValueError("Project directory is outside the allowed roots") + try: + target.mkdir(parents=True, exist_ok=True) + except OSError as exc: + raise ValueError("Project directory could not be created") from exc + + try: + path = requested_path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise ValueError("Project directory does not exist") from exc + if not path.is_dir(): + raise ValueError("Project path must be a directory") + if not os.access(path, os.R_OK | os.X_OK): + raise ValueError("Project directory is not readable") + + if roots and not any(path == root or path.is_relative_to(root) for root in roots): + raise ValueError("Project directory is outside the allowed roots") + return os.path.normcase(str(path)) - now = int(datetime.now().timestamp() * 1000) - project_id = Identifier.ascending("slug").replace("slg_", "prj_", 1) - project = ProjectInfo( - id=project_id, - worktree=worktree, - vcs=None, - name=normalized_name, - icon=icon, - sandboxes=[], - time=ProjectTime( - created=now, - updated=now, - ), - ) - await Storage.write(["project", project_id], project) - log.info("project.created", {"id": project_id}) - return project - - @classmethod - async def from_directory(cls, directory: str) -> Dict[str, Any]: - """ - Create or load project from directory - - Args: - directory: Directory path - - Returns: - Dict with 'project' and 'sandbox' keys - """ - log.info("from_directory", {"directory": directory}) - - # Find git repository - git_info = await cls._find_git_repo(directory) - - project_id = git_info["id"] - worktree = git_info["worktree"] - sandbox = git_info["sandbox"] - vcs = git_info["vcs"] - - # Try to load existing project - existing = await Storage.read(["project", project_id], ProjectInfo) - - if not existing: - # Create new project - existing = ProjectInfo( - id=project_id, - worktree=worktree, - vcs=vcs, - sandboxes=[], - time=ProjectTime( - created=int(datetime.now().timestamp() * 1000), - updated=int(datetime.now().timestamp() * 1000), - ) - ) - - # Update project info - result = ProjectInfo( - id=existing.id, - worktree=worktree, - vcs=vcs, - name=existing.name, - icon=existing.icon, - time=ProjectTime( - created=existing.time.created, - updated=int(datetime.now().timestamp() * 1000), - initialized=existing.time.initialized, - ), - sandboxes=existing.sandboxes.copy() if existing.sandboxes else [], - ) - - # Add sandbox if not in list - if sandbox != result.worktree and sandbox not in result.sandboxes: - result.sandboxes.append(sandbox) - - # Filter out non-existent sandboxes - result.sandboxes = [s for s in result.sandboxes if os.path.exists(s)] - - # Save project - await Storage.write(["project", project_id], result) - - cls._current = result - - return { - "project": result, - "sandbox": sandbox, - } - @classmethod - async def _find_git_repo(cls, directory: str) -> Dict[str, Any]: - """ - Find git repository information - - Args: - directory: Starting directory - - Returns: - Dict with id, worktree, sandbox, vcs - """ - # Look for .git directory - current = os.path.abspath(directory) - git_dir = None - - while current != "/": - git_path = os.path.join(current, ".git") - if os.path.exists(git_path): - git_dir = git_path - break - parent = os.path.dirname(current) - if parent == current: - break - current = parent - - if not git_dir: - # No git repository found - return { - "id": "global", - "worktree": directory, - "sandbox": directory, - "vcs": None, - } - - sandbox = os.path.dirname(git_dir) - - # Try to get git root commit for ID + def _read_registry_file(cls, path: Path) -> Optional[ProjectRegistry]: + if not path.exists(): + return None try: - result = subprocess.run( - ["git", "rev-list", "--max-parents=0", "--all"], - cwd=sandbox, - capture_output=True, - text=True, - timeout=5, - ) - - if result.returncode == 0: - roots = [line.strip() for line in result.stdout.split("\n") if line.strip()] - roots.sort() - - if roots: - project_id = roots[0] - - # Cache the ID - id_file = os.path.join(git_dir, "flocks") - try: - with open(id_file, "w") as f: - f.write(project_id) - except Exception: - pass - - # Get worktree - try: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - cwd=sandbox, - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode == 0: - worktree = result.stdout.strip() - else: - worktree = sandbox - except Exception: - worktree = sandbox - - return { - "id": project_id, - "worktree": worktree, - "sandbox": sandbox, - "vcs": "git", - } - except Exception as e: - log.warn("git.error", {"error": str(e)}) - - # Check for cached ID - id_file = os.path.join(git_dir, "flocks") - if os.path.exists(id_file): - try: - with open(id_file, "r") as f: - project_id = f.read().strip() - if project_id: - return { - "id": project_id, - "worktree": sandbox, - "sandbox": sandbox, - "vcs": "git", - } - except Exception: - pass - - # Fallback to global - return { - "id": "global", - "worktree": sandbox, - "sandbox": sandbox, - "vcs": "git", - } - + return ProjectRegistry.model_validate_json(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + log.error("project.registry.read_failed", {"path": str(path), "error": str(exc)}) + return None + @classmethod - async def list(cls) -> List[ProjectInfo]: - """ - List all projects - - Returns: - List of project info - """ + def _read_registry( + cls, + owner_id: str, + *, + default_worktree: Optional[str] = None, + ) -> ProjectRegistry: + path = cls.registry_path(owner_id) + registry = cls._read_registry_file(path) + if registry is None: + registry = cls._read_registry_file(path.with_suffix(".json.bak")) + if registry is not None: + return registry + + candidate = default_worktree or cls.default_worktree_candidate() try: - entries = await Storage.list_entries(prefix="project/", model=ProjectInfo) - result = [project for _, project in entries] - - # Sort by updated time (newest first) - result.sort(key=lambda p: p.time.updated, reverse=True) - - return result - except Exception as e: - log.error("project.list.error", {"error": str(e)}) - return [] - + candidate = cls.validate_worktree(candidate, default_worktree=candidate) + except ValueError: + candidate = cls.default_worktree_candidate() + return ProjectRegistry(defaultWorktree=candidate, projects=[]) + @classmethod - async def get(cls, project_id: str) -> Optional[ProjectInfo]: - """ - Get project by ID - - Args: - project_id: Project ID - - Returns: - Project info or None - """ + def _write_registry(cls, owner_id: str, registry: ProjectRegistry) -> None: + path = cls.registry_path(owner_id) + path.parent.mkdir(parents=True, exist_ok=True) + backup_path = path.with_suffix(".json.bak") + + if path.exists(): + shutil.copy2(path, backup_path) + + fd, temporary_path = tempfile.mkstemp( + dir=str(path.parent), + prefix=f".{path.stem}.", + suffix=".tmp", + ) try: - return await Storage.read(["project", project_id], ProjectInfo) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump( + registry.model_dump(by_alias=True), + handle, + ensure_ascii=False, + indent=2, + ) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, path) + try: + directory_fd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + pass except Exception: - return None - + try: + os.unlink(temporary_path) + except OSError: + pass + raise + + @classmethod + def _entry_to_info(cls, entry: ProjectRegistryEntry) -> ProjectInfo: + return ProjectInfo( + id=entry.id, + worktree=entry.worktree, + name=entry.name, + vcs="git" if (Path(entry.worktree) / ".git").exists() else None, + time=ProjectTime(created=entry.created_at, updated=entry.updated_at), + pathStatus=cls._path_status(entry.worktree), + ) + + @classmethod + def _default_to_info(cls, registry: ProjectRegistry) -> ProjectInfo: + now = cls._now_ms() + return ProjectInfo( + id=DEFAULT_PROJECT_ID, + worktree=registry.default_worktree, + name=DEFAULT_PROJECT_NAME, + vcs="git" if (Path(registry.default_worktree) / ".git").exists() else None, + time=ProjectTime(created=now, updated=now), + isDefault=True, + pathStatus=cls._path_status(registry.default_worktree), + ) + + @classmethod + async def ensure_registry( + cls, + owner_id: str, + *, + default_worktree: Optional[str] = None, + ) -> ProjectRegistry: + """Load a registry and persist its initial default directory once.""" + + async with cls._lock: + path = cls.registry_path(owner_id) + registry = cls._read_registry(owner_id, default_worktree=default_worktree) + if not path.exists(): + cls._write_registry(owner_id, registry) + return registry + @classmethod - async def update(cls, project_id: str, **kwargs) -> ProjectInfo: - """ - Update project - - Args: - project_id: Project ID - **kwargs: Fields to update - - Returns: - Updated project info - """ - project = await cls.get(project_id) - if not project: - raise ValueError(f"Project {project_id} not found") - - if "name" in kwargs and kwargs["name"] is not None: - normalized_name = str(kwargs["name"]).strip() + async def create( + cls, + *, + owner_id: str, + name: Optional[str], + worktree: str, + ) -> ProjectInfo: + """Register an existing directory for a user.""" + + async with cls._lock: + registry = cls._read_registry(owner_id) + normalized_worktree = cls.validate_worktree( + worktree, + default_worktree=registry.default_worktree, + create_if_missing=True, + ) + + if normalized_worktree == cls._normalized_worktree(registry.default_worktree): + raise ProjectPathConflictError(cls._default_to_info(registry)) + + for entry in registry.projects: + if cls._normalized_worktree(entry.worktree) == normalized_worktree: + raise ProjectPathConflictError(cls._entry_to_info(entry)) + + normalized_name = (name or Path(normalized_worktree).name).strip() if not normalized_name: raise ValueError("Project name cannot be empty") - await cls._ensure_unique_name( + if normalized_name.casefold() in _RESERVED_PROJECT_NAMES: + raise ProjectNameConflictError("Project name is reserved") + if any(entry.name.strip().casefold() == normalized_name.casefold() for entry in registry.projects): + raise ProjectNameConflictError(f"Project name '{normalized_name}' already exists") + + now = cls._now_ms() + project_id = f"prj_{uuid.uuid4()}" + entry = ProjectRegistryEntry( + id=project_id, name=normalized_name, - worktree=project.worktree, - exclude_project_id=project_id, + worktree=normalized_worktree, + createdAt=now, + updatedAt=now, ) - kwargs["name"] = normalized_name - - # Update fields - update_data = project.model_dump() - - for key, value in kwargs.items(): - if key in update_data and value is not None: - if key == "icon" and isinstance(value, dict): - update_data["icon"] = ProjectIcon(**value) - else: - update_data[key] = value - - # Update timestamp - update_data["time"]["updated"] = int(datetime.now().timestamp() * 1000) - - updated_project = ProjectInfo(**update_data) - await Storage.write(["project", project_id], updated_project) - - if cls._current and cls._current.id == project_id: - cls._current = updated_project - - log.info("project.updated", {"id": project_id}) - - return updated_project + registry.projects.append(entry) + cls._write_registry(owner_id, registry) + cls.invalidate_session_stats(owner_id) + log.info("project.created", {"id": project_id, "owner": cls._owner_hash(owner_id)}) + return cls._entry_to_info(entry) + + @classmethod + async def list( + cls, + *, + owner_id: str, + default_worktree: Optional[str] = None, + ) -> List[ProjectInfo]: + registry = await cls.ensure_registry(owner_id, default_worktree=default_worktree) + projects = [cls._entry_to_info(entry) for entry in registry.projects] + projects.sort(key=lambda item: item.time.updated, reverse=True) + return [cls._default_to_info(registry), *projects] + + @classmethod + async def get( + cls, + project_id: str, + *, + owner_id: str, + default_worktree: Optional[str] = None, + ) -> Optional[ProjectInfo]: + registry = await cls.ensure_registry(owner_id, default_worktree=default_worktree) + if project_id == DEFAULT_PROJECT_ID: + return cls._default_to_info(registry) + entry = next((item for item in registry.projects if item.id == project_id), None) + return cls._entry_to_info(entry) if entry else None + + @classmethod + async def update(cls, project_id: str, *, owner_id: str, name: str) -> ProjectInfo: + """Rename a registered project.""" + + if project_id == DEFAULT_PROJECT_ID: + raise ProjectDeletionError("The default project cannot be renamed") + normalized_name = name.strip() + if not normalized_name: + raise ValueError("Project name cannot be empty") + if normalized_name.casefold() in _RESERVED_PROJECT_NAMES: + raise ProjectNameConflictError("Project name is reserved") + + async with cls._lock: + registry = cls._read_registry(owner_id) + entry = next((item for item in registry.projects if item.id == project_id), None) + if entry is None: + raise ValueError(f"Project {project_id} not found") + if any( + item.id != project_id and item.name.strip().casefold() == normalized_name.casefold() + for item in registry.projects + ): + raise ProjectNameConflictError(f"Project name '{normalized_name}' already exists") + + entry.name = normalized_name + entry.updated_at = cls._now_ms() + cls._write_registry(owner_id, registry) + cls.invalidate_session_stats(owner_id) + log.info("project.updated", {"id": project_id}) + return cls._entry_to_info(entry) @classmethod - async def delete(cls, project_id: str) -> bool: - """Delete an empty user-managed project.""" - project = await cls.get(project_id) - if not project: - raise ValueError(f"Project {project_id} not found") - if not project_id.startswith("prj_"): + async def delete(cls, project_id: str, *, owner_id: str) -> bool: + """Remove a project registration without changing sessions or files.""" + + if project_id == DEFAULT_PROJECT_ID: raise ProjectDeletionError("The default project cannot be deleted") + async with cls._lock: + registry = cls._read_registry(owner_id) + remaining = [entry for entry in registry.projects if entry.id != project_id] + if len(remaining) == len(registry.projects): + raise ValueError(f"Project {project_id} not found") + registry.projects = remaining + cls._write_registry(owner_id, registry) + cls.invalidate_session_stats(owner_id) + log.info("project.deleted", {"id": project_id}) + return True + + @classmethod + def registered_project_ids(cls, owner_id: str) -> set[str]: + registry = cls._read_registry(owner_id) + return {entry.id for entry in registry.projects} + + @classmethod + def effective_project_id(cls, owner_id: str, stored_project_id: Optional[str]) -> str: + if stored_project_id and stored_project_id in cls.registered_project_ids(owner_id): + return stored_project_id + return DEFAULT_PROJECT_ID + + @classmethod + def get_session_stats_cache( + cls, + owner_id: str, + search: str, + ) -> Optional[Dict[str, Tuple[int, int, Optional[int]]]]: + """Return a short-lived copy of cached project session statistics.""" + + key = (owner_id, search.casefold()) + cached = cls._session_stats_cache.get(key) + if cached is None: + return None + cached_at, stats = cached + if time.monotonic() - cached_at >= cls._session_stats_cache_ttl_seconds: + cls._session_stats_cache.pop(key, None) + return None + return dict(stats) - from flocks.session.session import SessionInfo + @classmethod + def set_session_stats_cache( + cls, + owner_id: str, + search: str, + stats: Dict[str, Tuple[int, int, Optional[int]]], + ) -> None: + """Cache project session statistics for an owner and search term.""" - entries = await Storage.list_entries( - prefix=f"session:{project_id}:", - model=SessionInfo, + cls._session_stats_cache[(owner_id, search.casefold())] = ( + time.monotonic(), + dict(stats), ) - if any(session.status != "deleted" for _, session in entries): - raise ProjectDeletionError( - "Delete all conversations in this project before deleting it" - ) - deleted = await Storage.delete(f"project/{project_id}") - if cls._current and cls._current.id == project_id: - cls._current = None - log.info("project.deleted", {"id": project_id}) - return deleted - @classmethod - def current(cls) -> Optional[ProjectInfo]: - """ - Get current project - - Returns: - Current project info or None - """ - return cls._current - + def invalidate_session_stats(cls, owner_id: Optional[str] = None) -> None: + """Invalidate cached project counts after session or registry changes.""" + + if owner_id is None: + cls._session_stats_cache.clear() + return + for key in [key for key in cls._session_stats_cache if key[0] == owner_id]: + cls._session_stats_cache.pop(key, None) + @classmethod - def set_current(cls, project: ProjectInfo) -> None: - """ - Set current project - - Args: - project: Project info - """ - cls._current = project + async def from_directory(cls, directory: str) -> Dict[str, Any]: + """Build an ephemeral runtime context without persisting a project.""" + + path = Path(directory).expanduser().resolve() + worktree = path + vcs: Optional[str] = None + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=path, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + worktree = Path(result.stdout.strip()).resolve() + vcs = "git" + except (OSError, subprocess.SubprocessError): + pass + + now = cls._now_ms() + project = ProjectInfo( + id=DEFAULT_PROJECT_ID, + worktree=str(worktree), + vcs=vcs, + name=DEFAULT_PROJECT_NAME, + time=ProjectTime(created=now, updated=now), + isDefault=True, + pathStatus=cls._path_status(str(path)), + ) + return {"project": project, "sandbox": str(worktree)} diff --git a/flocks/server/app.py b/flocks/server/app.py index 78271d130..ca3259ff9 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -877,7 +877,9 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if not directory: directory = request.headers.get("x-flocks-directory") if not directory: - directory = os.getcwd() + from flocks.project.project import Project + + directory = Project.default_worktree_candidate() try: directory = unquote(directory) diff --git a/flocks/server/routes/project.py b/flocks/server/routes/project.py index 7eb0f9dde..0756b8cdc 100644 --- a/flocks/server/routes/project.py +++ b/flocks/server/routes/project.py @@ -1,174 +1,273 @@ -""" -Project management routes +"""Project registry routes.""" -Routes for project listing, retrieval, and updates -""" +from __future__ import annotations +import os +from pathlib import Path from typing import List, Optional -from fastapi import APIRouter, HTTPException + +from fastapi import APIRouter, HTTPException, Query, Request from pydantic import BaseModel +from flocks.auth.context import AuthUser from flocks.project.project import ( + DEFAULT_PROJECT_ID, Project, ProjectDeletionError, - ProjectIcon, ProjectInfo, ProjectNameConflictError, + ProjectPathConflictError, ) +from flocks.server.auth import require_user +from flocks.session.policy import SessionPolicy +from flocks.session.session import Session from flocks.utils.log import Log router = APIRouter() log = Log.create(service="routes.project") +_MANAGER_CATEGORIES = {"user", "workflow", "entity-config"} + class ProjectUpdateRequest(BaseModel): - """Project update request""" - name: Optional[str] = None - icon: Optional[ProjectIcon] = None + """Editable project properties.""" + + name: str class ProjectCreateRequest(BaseModel): - """Project create request""" + """Register an existing project directory.""" + + worktree: str + name: Optional[str] = None + + +class FolderEntry(BaseModel): + """Directory row returned by the server-side folder picker.""" + name: str - worktree: Optional[str] = None - icon: Optional[ProjectIcon] = None + path: str + + +class FolderBrowserResponse(BaseModel): + """Directory listing constrained to configured project roots.""" + + path: str + parent: Optional[str] + roots: List[FolderEntry] + entries: List[FolderEntry] + + +async def _list_project_summaries(user: AuthUser, search: Optional[str]) -> List[ProjectInfo]: + owner_id = user.id + projects = await Project.list( + owner_id=owner_id, + default_worktree=Project.default_worktree_candidate(), + ) + term = search.strip().casefold() if search else None + stats = Project.get_session_stats_cache(owner_id, term or "") + if stats is None: + registered_ids = {project.id for project in projects if not project.is_default} + counts = {project.id: 0 for project in projects} + matched_counts = {project.id: 0 for project in projects} + last_activity: dict[str, int] = {} + + for session in await Session.list_all_unfiltered(): + if not SessionPolicy.can_read(session, user): + continue + metadata = session.metadata if isinstance(session.metadata, dict) else {} + if metadata.get("hideFromSessionManager"): + continue + if session.parent_id or session.category not in _MANAGER_CATEGORIES: + continue + project_id = ( + session.project_id + if session.project_id in registered_ids + else DEFAULT_PROJECT_ID + ) + counts[project_id] = counts.get(project_id, 0) + 1 + if term is None or term in session.title.casefold(): + matched_counts[project_id] = matched_counts.get(project_id, 0) + 1 + last_activity[project_id] = max( + last_activity.get(project_id, 0), + session.time.updated, + ) + stats = { + project.id: ( + counts.get(project.id, 0), + matched_counts.get(project.id, 0), + last_activity.get(project.id), + ) + for project in projects + } + Project.set_session_stats_cache(owner_id, term or "", stats) + + return [ + project.model_copy( + update={ + "session_count": stats.get(project.id, (0, 0, None))[0], + "matched_session_count": stats.get(project.id, (0, 0, None))[1], + "last_activity_at": stats.get(project.id, (0, 0, None))[2], + }, + ) + for project in projects + ] @router.get("", response_model=List[ProjectInfo], include_in_schema=False) -@router.get("/", response_model=List[ProjectInfo], summary="List all projects") -async def list_projects(): - """ - List all projects - - Get a list of projects that have been opened with Flocks. - """ +@router.get("/", response_model=List[ProjectInfo], summary="List projects") +async def list_projects(request: Request, search: Optional[str] = Query(None)): + """List the virtual default project and user-registered folders.""" + + user = require_user(request) try: - projects = await Project.list() - return projects - except Exception as e: - log.error("project.list.error", {"error": str(e)}) - raise HTTPException(status_code=500, detail=str(e)) + return await _list_project_summaries(user, search) + except Exception as exc: + log.error("project.list.error", {"error": str(exc)}) + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.post("", response_model=ProjectInfo, include_in_schema=False) @router.post("/", response_model=ProjectInfo, summary="Create project") -async def create_project(request: ProjectCreateRequest): - """ - Create a user-managed project. +async def create_project(request: Request, payload: ProjectCreateRequest): + """Register an existing directory without writing project database rows.""" - User-managed projects organize sessions without changing the current - workspace execution directory. - """ - name = request.name.strip() - if not name: - raise HTTPException(status_code=400, detail="Project name cannot be empty") + user = require_user(request) + try: + return await Project.create( + owner_id=user.id, + name=payload.name, + worktree=payload.worktree, + ) + except ProjectPathConflictError as exc: + return exc.project + except ProjectNameConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + log.error("project.create.error", {"error": str(exc)}) + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.get("/current", response_model=ProjectInfo, summary="Get default project") +async def get_current_project(request: Request, project_id: Optional[str] = Query(None, alias="projectID")): + """Return a requested registered project, falling back to default.""" + + user = require_user(request) + project = await Project.get( + project_id or DEFAULT_PROJECT_ID, + owner_id=user.id, + default_worktree=Project.default_worktree_candidate(), + ) + if project is None: + project = await Project.get( + DEFAULT_PROJECT_ID, + owner_id=user.id, + default_worktree=Project.default_worktree_candidate(), + ) + if project is None: + raise HTTPException(status_code=500, detail="Default project is unavailable") + return project + + +@router.get("/folders", response_model=FolderBrowserResponse, summary="Browse project folders") +async def browse_project_folders(request: Request, path: Optional[str] = Query(None)): + """List directories under configured roots without exposing file contents.""" + + user = require_user(request) + default_project = await Project.get( + DEFAULT_PROJECT_ID, + owner_id=user.id, + default_worktree=Project.default_worktree_candidate(), + ) + if default_project is None: + raise HTTPException(status_code=500, detail="Default project is unavailable") + + roots = Project.allowed_roots(default_project.worktree) + if not roots: + raise HTTPException(status_code=400, detail="No project roots are available") try: - import os - from flocks.project.instance import Instance + current = Path(path).expanduser().resolve(strict=True) if path else Path(default_project.worktree).resolve(strict=True) + except (OSError, RuntimeError) as exc: + raise HTTPException(status_code=400, detail="Directory does not exist") from exc + if not current.is_dir(): + raise HTTPException(status_code=400, detail="Path must be a directory") + containing_root = next( + (root for root in roots if current == root or current.is_relative_to(root)), + None, + ) + if containing_root is None: + raise HTTPException(status_code=403, detail="Directory is outside the allowed roots") - worktree = request.worktree - if not worktree: - try: - worktree = Instance.directory - except Exception: - worktree = os.getcwd() - - project = await Project.create( - name=name, - worktree=str(worktree), - icon=request.icon, + entries: List[FolderEntry] = [] + try: + children = sorted( + (child for child in current.iterdir() if child.is_dir()), + key=lambda child: (child.name.startswith("."), child.name.casefold()), ) - return project - except ProjectNameConflictError as e: - raise HTTPException(status_code=409, detail=str(e)) from e - except HTTPException: - raise - except Exception as e: - log.error("project.create.error", {"error": str(e)}) - raise HTTPException(status_code=400, detail=str(e)) - - -@router.get("/current", response_model=ProjectInfo, summary="Get current project") -async def get_current_project(): - """ - Get current project - - Retrieve the currently active project that Flocks is working with. - """ - current = Project.current() - if not current: - raise HTTPException(status_code=404, detail="No current project") - - return current - - -@router.patch("/{project_id}", response_model=ProjectInfo, summary="Update project") -async def update_project(project_id: str, update: ProjectUpdateRequest): - """ - Update project - - Update project properties such as name, icon and color. - """ + for child in children: + try: + resolved = child.resolve(strict=True) + except (OSError, RuntimeError): + continue + if not (resolved == containing_root or resolved.is_relative_to(containing_root)): + continue + if not os.access(resolved, os.R_OK | os.X_OK): + continue + entries.append(FolderEntry(name=child.name, path=str(resolved))) + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Directory is not readable") from exc + + parent = current.parent + parent_value = ( + str(parent) + if current != containing_root and (parent == containing_root or parent.is_relative_to(containing_root)) + else None + ) + return FolderBrowserResponse( + path=str(current), + parent=parent_value, + roots=[FolderEntry(name=root.name or str(root), path=str(root)) for root in roots], + entries=entries, + ) + + +@router.patch("/{project_id}", response_model=ProjectInfo, summary="Rename project") +async def update_project(project_id: str, payload: ProjectUpdateRequest, request: Request): + user = require_user(request) try: - # Get existing project - project = await Project.get(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - # Update fields - update_data = {} - if update.name is not None: - name = update.name.strip() - if not name: - raise HTTPException(status_code=400, detail="Project name cannot be empty") - update_data["name"] = name - if update.icon is not None: - update_data["icon"] = update.icon - - updated_project = await Project.update(project_id, **update_data) - - log.info("project.updated", {"id": project_id}) - - return updated_project - except ProjectNameConflictError as e: - raise HTTPException(status_code=409, detail=str(e)) from e - except HTTPException: - raise - except Exception as e: - log.error("project.update.error", {"error": str(e), "id": project_id}) - raise HTTPException(status_code=400, detail=str(e)) - - -@router.delete("/{project_id}", response_model=bool, summary="Delete project") -async def delete_project(project_id: str): - """Delete an empty user-managed project.""" + return await Project.update(project_id, owner_id=user.id, name=payload.name) + except ProjectNameConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ProjectDeletionError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.delete("/{project_id}", response_model=bool, summary="Remove project") +async def delete_project(project_id: str, request: Request): + """Remove a project registration while preserving sessions and files.""" + + user = require_user(request) try: - return await Project.delete(project_id) - except ProjectDeletionError as e: - raise HTTPException(status_code=409, detail=str(e)) from e - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) from e - except Exception as e: - log.error("project.delete.error", {"error": str(e), "id": project_id}) - raise HTTPException(status_code=400, detail=str(e)) from e + return await Project.delete(project_id, owner_id=user.id) + except ProjectDeletionError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/{project_id}", response_model=ProjectInfo, summary="Get project") -async def get_project(project_id: str): - """ - Get project - - Retrieve information about a specific project. - """ - try: - project = await Project.get(project_id) - if not project: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - - return project - except HTTPException: - raise - except Exception as e: - log.error("project.get.error", {"error": str(e), "id": project_id}) - raise HTTPException(status_code=500, detail=str(e)) +async def get_project(project_id: str, request: Request): + user = require_user(request) + project = await Project.get( + project_id, + owner_id=user.id, + default_worktree=Project.default_worktree_candidate(), + ) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + return project diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index d490b0c85..ff43f3f04 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -8,7 +8,9 @@ import asyncio import json +import os import time +from pathlib import Path from typing import List, Optional, Any, Dict, Literal, Union, Tuple from fastapi import APIRouter, HTTPException, status, Query, Request from fastapi.responses import StreamingResponse @@ -176,6 +178,7 @@ class SessionResponse(BaseModel): id: str = Field(..., description="Session ID") slug: str = Field("", description="Session slug") projectID: str = Field(..., description="Project ID") + effectiveProjectID: str = Field(..., description="Project used for session manager grouping") directory: str = Field(..., description="Working directory") parentID: Optional[str] = Field(None, description="Parent session ID") summary: Optional[Dict[str, Any]] = Field(None, description="Session summary with diffs") @@ -202,6 +205,7 @@ class SessionListItem(BaseModel): id: str projectID: str + effectiveProjectID: str directory: str title: str time: SessionTime @@ -215,7 +219,10 @@ class SessionListItem(BaseModel): isShared: bool = False -def _session_to_response(session: SessionModel) -> SessionResponse: +def _session_to_response( + session: SessionModel, + effective_project_id: Optional[str] = None, +) -> SessionResponse: """ Convert SessionModel to SessionResponse """ @@ -223,11 +230,19 @@ def _session_to_response(session: SessionModel) -> SessionResponse: can_write = SessionPolicy.can_write(session, current_user) can_delete = SessionPolicy.can_delete(session, current_user) is_shared = SessionPolicy.is_shared(session) + from flocks.project.project import Project + + if effective_project_id is None: + effective_project_id = Project.effective_project_id( + current_user.id if current_user else API_TOKEN_SERVICE_USER_ID, + session.project_id, + ) return SessionResponse( id=session.id, slug=session.slug, projectID=session.project_id, + effectiveProjectID=effective_project_id, directory=session.directory, title=session.title, version=session.version, @@ -253,12 +268,23 @@ def _session_to_response(session: SessionModel) -> SessionResponse: ) -def _session_to_list_item(session: SessionModel) -> SessionListItem: +def _session_to_list_item( + session: SessionModel, + effective_project_id: Optional[str] = None, +) -> SessionListItem: """Convert a session to the lightweight manager-list response shape.""" current_user = get_current_auth_user() + from flocks.project.project import Project + + if effective_project_id is None: + effective_project_id = Project.effective_project_id( + current_user.id if current_user else API_TOKEN_SERVICE_USER_ID, + session.project_id, + ) return SessionListItem( id=session.id, projectID=session.project_id, + effectiveProjectID=effective_project_id, directory=session.directory, title=session.title, time=SessionTime( @@ -278,9 +304,12 @@ def _session_to_list_item(session: SessionModel) -> SessionListItem: ) -async def _session_to_response_with_goal(session: SessionModel) -> SessionResponse: +async def _session_to_response_with_goal( + session: SessionModel, + effective_project_id: Optional[str] = None, +) -> SessionResponse: """Convert SessionModel to SessionResponse and attach persisted goal state.""" - response = _session_to_response(session) + response = _session_to_response(session, effective_project_id) try: from flocks.session.goal import GoalManager @@ -368,6 +397,39 @@ async def _get_session_by_id_unfiltered(session_id: str) -> Optional[SessionMode reset_current_auth_user(token) +async def _resolve_session_working_directory(session: SessionModel) -> str: + """Keep a valid historical cwd, otherwise use the user's default project.""" + + if session.directory: + directory = Path(session.directory).expanduser() + if directory.is_dir() and os.access(directory, os.R_OK | os.X_OK): + return str(directory.resolve()) + + from flocks.project.project import DEFAULT_PROJECT_ID, Project + + current_user = get_current_auth_user() + owner_id = current_user.id if current_user else API_TOKEN_SERVICE_USER_ID + default_project = await Project.get( + DEFAULT_PROJECT_ID, + owner_id=owner_id, + default_worktree=Project.default_worktree_candidate(), + ) + if default_project is None or default_project.path_status != "available": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The session directory and default project directory are unavailable", + ) + log.warn( + "session.directory.fallback", + { + "sessionID": session.id, + "stored_directory": session.directory, + "fallback_directory": default_project.worktree, + }, + ) + return default_project.worktree + + async def _publish_context_usage_update( event_publish_callback, session_id: str, @@ -445,6 +507,7 @@ async def list_sessions( view: Optional[Literal["list"]] = Query(None, description="Use lightweight list rows"), manager: Optional[bool] = Query(None, description="Apply session-manager visibility filters"), directory: Optional[str] = Query(None, description="Filter by project directory"), + projectID: Optional[str] = Query(None, description="Filter by effective project ID"), roots: Optional[bool] = Query(None, description="Only return root sessions (no parentID)"), start: Optional[int] = Query(None, description="Filter sessions updated on or after this timestamp"), search: Optional[str] = Query(None, description="Filter by title (case-insensitive)"), @@ -454,21 +517,33 @@ async def list_sessions( ) -> List[Union[SessionResponse, SessionListItem]]: """List all sessions with optional filters""" started_at = time.perf_counter() - _current_user = require_user(request) + current_user = require_user(request) + from flocks.project.project import DEFAULT_PROJECT_ID, Project list_started_at = time.perf_counter() - all_sessions = await Session.list_all() + all_sessions = await Session.list_all_unfiltered() list_elapsed_ms = (time.perf_counter() - list_started_at) * 1000 + registered_project_ids = Project.registered_project_ids(current_user.id) filtered = [] + effective_project_ids: Dict[str, str] = {} term = search.lower() if search else None manager_categories = {"user", "workflow", "entity-config"} skip_remaining = offset or 0 for session in all_sessions: + if not SessionPolicy.can_read(session, current_user): + continue if _is_hidden_from_session_manager(session): continue if directory is not None and session.directory != directory: continue + effective_project_id = ( + session.project_id + if session.project_id in registered_project_ids + else DEFAULT_PROJECT_ID + ) + if projectID is not None and effective_project_id != projectID: + continue if (roots or manager) and session.parent_id: continue if start is not None and session.time.updated < start: @@ -490,12 +565,16 @@ async def list_sessions( continue filtered.append(session) + effective_project_ids[session.id] = effective_project_id if limit is not None and len(filtered) >= limit: break if view == "list": - response = [_session_to_list_item(s) for s in filtered] + response = [ + _session_to_list_item(s, effective_project_ids[s.id]) + for s in filtered + ] log_route_timing(log, "session.list.light.complete", started_at=started_at, extra={ "total": len(all_sessions), "count": len(response), @@ -505,11 +584,15 @@ async def list_sessions( "offset": offset, "search": bool(search), "category": category, + "projectID": projectID, "list_ms": round(list_elapsed_ms, 2), }) return response - response = [await _session_to_response_with_goal(s) for s in filtered] + response = [ + await _session_to_response_with_goal(s, effective_project_ids[s.id]) + for s in filtered + ] log_route_timing(log, "session.list.complete", started_at=started_at, extra={ "count": len(response), "roots": roots, @@ -518,6 +601,7 @@ async def list_sessions( "offset": offset, "search": bool(search), "category": category, + "projectID": projectID, "list_ms": round(list_elapsed_ms, 2), }) return response @@ -534,31 +618,28 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR """Create a new session""" current_user = require_user(http_request) await assert_license_active(feature="session_create") - import os - if request is None: request = SessionCreateRequest() - - # Use Instance context if available, otherwise use cwd - from flocks.project.instance import Instance - try: - directory = Instance.directory - project_id = Instance.project.id if hasattr(Instance, 'project') else "default" - except Exception: - directory = os.getcwd() - project_id = "default" - if request.projectID: - from flocks.project.project import Project + from flocks.project.project import DEFAULT_PROJECT_ID, Project - project = await Project.get(request.projectID) - if not project: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Project {request.projectID} not found", - ) - project_id = project.id - directory = project.worktree + project_id = request.projectID or DEFAULT_PROJECT_ID + project = await Project.get( + project_id, + owner_id=current_user.id, + default_worktree=Project.default_worktree_candidate(), + ) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project {project_id} not found", + ) + if project.path_status != "available": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Project directory is {project.path_status}", + ) + directory = project.worktree # Trigger command:new hook if creating from parent (like /new command) if request.parentID: @@ -614,6 +695,7 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR owner_username=None if is_api_token_client else current_user.username, **({"category": request.category} if request.category else {}), ) + Project.invalidate_session_stats() log.info("session.created", {"session_id": session.id}) try: @@ -797,6 +879,9 @@ async def delete_session(sessionID: str, request: Request) -> bool: await _wait_for_sessions_idle([sessionID]) await _abort_and_wait_descendant_sessions(session.project_id, sessionID) await Session.delete(session.project_id, sessionID) + from flocks.project.project import Project + + Project.invalidate_session_stats() # Best-effort cleanup of any image/file uploads materialised for this # session via ``_materialize_data_url_to_disk`` (see prompt_async). @@ -961,11 +1046,7 @@ async def update_session( ) async def share_session_local(sessionID: str, http_request: Request) -> SessionResponse: current_user = require_user(http_request) - token = set_current_auth_user(current_user) - try: - existing = await Session.get_by_id(sessionID) - finally: - reset_current_auth_user(token) + existing = await _get_session_by_id_unfiltered(sessionID) if not existing: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -973,11 +1054,15 @@ async def share_session_local(sessionID: str, http_request: Request) -> SessionR ) _require_session_write_access(existing, current_user) metadata = _share_metadata(existing, shared=True, actor_user_id=current_user.id) - session = await Session.update( - project_id=existing.project_id, - session_id=sessionID, - metadata=metadata, - ) + token = set_current_auth_user(current_user) + try: + session = await Session.update( + project_id=existing.project_id, + session_id=sessionID, + metadata=metadata, + ) + finally: + reset_current_auth_user(token) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -994,11 +1079,7 @@ async def share_session_local(sessionID: str, http_request: Request) -> SessionR ) async def unshare_session_local(sessionID: str, http_request: Request) -> SessionResponse: current_user = require_user(http_request) - token = set_current_auth_user(current_user) - try: - existing = await Session.get_by_id(sessionID) - finally: - reset_current_auth_user(token) + existing = await _get_session_by_id_unfiltered(sessionID) if not existing: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1006,11 +1087,15 @@ async def unshare_session_local(sessionID: str, http_request: Request) -> Sessio ) _require_session_write_access(existing, current_user) metadata = _share_metadata(existing, shared=False, actor_user_id=current_user.id) - session = await Session.update( - project_id=existing.project_id, - session_id=sessionID, - metadata=metadata, - ) + token = set_current_auth_user(current_user) + try: + session = await Session.update( + project_id=existing.project_id, + session_id=sessionID, + metadata=metadata, + ) + finally: + reset_current_auth_user(token) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -2150,7 +2235,7 @@ async def resend_session_message( detail="Session is currently generating a response", ) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) async def _handle_resend() -> None: runtime = await _prepare_replay_runtime(sessionID, message) @@ -2245,7 +2330,7 @@ async def regenerate_session_message( detail="Session is currently generating a response", ) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) async def _handle_regenerate() -> None: runtime = await _prepare_replay_runtime(sessionID, parent_message) @@ -2310,7 +2395,7 @@ async def send_session_message(sessionID: str, request: PromptRequest, http_requ current_user = require_user(http_request) _require_session_write_access(session, current_user) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) log.info("session.message.send.processing", { "sessionID": sessionID, @@ -3792,7 +3877,7 @@ async def run_prompt_queue_item_now(sessionID: str, queueID: str) -> Dict[str, A status_code=status.HTTP_404_NOT_FOUND, detail=f"Session {sessionID} not found", ) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) try: await InteractionQueue.promote(sessionID, queueID) except QueueItemNotFoundError as exc: @@ -3834,7 +3919,7 @@ async def send_session_message_async( current_user = require_user(http_request) _require_session_write_access(session, current_user) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) await _require_agent_usable_for_chat(request.agent) log.info("session.prompt_async.accepted", { @@ -3931,7 +4016,7 @@ async def send_session_command(sessionID: str, request: CommandRequest, http_req current_user = require_user(http_request) _require_session_write_access(session, current_user) - working_directory = session.directory or os.getcwd() + working_directory = await _resolve_session_working_directory(session) await _require_agent_usable_for_chat(request.agent) raw_arguments = request.arguments if not raw_arguments and request.arguments_json is not None: diff --git a/flocks/session/session.py b/flocks/session/session.py index c9807b8af..6c546ecac 100644 --- a/flocks/session/session.py +++ b/flocks/session/session.py @@ -11,15 +11,19 @@ from datetime import datetime from pydantic import BaseModel, Field, ConfigDict -# Sentinel for explicitly setting a field to None via Session.update() -_UNSET = object() - -from flocks.auth.context import get_current_auth_user +from flocks.auth.context import ( + get_current_auth_user, + reset_current_auth_user, + set_current_auth_user, +) from flocks.storage.storage import Storage from flocks.utils.log import Log from flocks.utils.id import Identifier from flocks.session.message import Message, MessageInfo, AssistantMessageInfo +# Sentinel for explicitly setting a field to None via Session.update() +_UNSET = object() + log = Log.create(service="session") @@ -483,6 +487,16 @@ async def list_all(cls) -> List[SessionInfo]: except Exception as e: log.error("session.list_all.error", {"error": str(e)}) return [] + + @classmethod + async def list_all_unfiltered(cls) -> List[SessionInfo]: + """List all sessions for callers that apply an explicit access policy.""" + + token = set_current_auth_user(None) + try: + return await cls.list_all() + finally: + reset_current_auth_user(token) @classmethod async def update( diff --git a/tests/project/test_project.py b/tests/project/test_project.py index 4fc559429..31d0e029f 100644 --- a/tests/project/test_project.py +++ b/tests/project/test_project.py @@ -1,106 +1,207 @@ +import asyncio +import json +import uuid from unittest.mock import AsyncMock, patch import pytest from flocks.project.project import ( + DEFAULT_PROJECT_ID, Project, ProjectDeletionError, - ProjectInfo, ProjectNameConflictError, - ProjectTime, + ProjectPathConflictError, ) from flocks.storage.storage import Storage +@pytest.fixture +def project_root(tmp_path, monkeypatch): + root = tmp_path / ".flocks" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("FLOCKS_ROOT", str(root)) + monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(tmp_path)) + monkeypatch.setenv("FLOCKS_DEFAULT_PROJECT_DIR", str(workspace)) + return tmp_path + + @pytest.mark.asyncio -async def test_list_projects_reads_project_storage_entries(): - project = ProjectInfo( - id="prj_labs", - worktree="/tmp/flocks", - name="Labs", - time=ProjectTime(created=1, updated=2), - ) +async def test_list_projects_uses_json_registry_and_virtual_default(project_root): + labs = project_root / "labs" + labs.mkdir() + created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) - with patch.object( - Storage, - "list_entries", - new=AsyncMock(return_value=[("project/prj_labs", project)]), - ) as list_entries: - result = await Project.list() + with patch.object(Storage, "list_entries", new=AsyncMock()) as list_entries: + projects = await Project.list(owner_id="user-1") - assert result == [project] - list_entries.assert_awaited_once_with(prefix="project/", model=ProjectInfo) + assert [project.id for project in projects] == [DEFAULT_PROJECT_ID, created.id] + assert projects[0].name == "默认" + assert projects[0].is_default is True + assert projects[1].worktree == str(labs.resolve()) + list_entries.assert_not_awaited() + + registry = json.loads(Project.registry_path("user-1").read_text(encoding="utf-8")) + assert registry["defaultWorktree"] == str((project_root / "workspace").resolve()) + assert registry["projects"][0]["id"].startswith("prj_") + uuid.UUID(registry["projects"][0]["id"].removeprefix("prj_")) @pytest.mark.asyncio -async def test_create_rejects_duplicate_name_in_same_worktree(): - existing = ProjectInfo( - id="project-existing", - worktree="/tmp/workspace", - name="Test", - time=ProjectTime(created=1, updated=1), - ) +async def test_create_rejects_duplicate_canonical_directory(project_root): + labs = project_root / "labs" + labs.mkdir() + existing = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) - with patch.object( - Storage, - "list_entries", - new=AsyncMock(return_value=[("project/project-existing", existing)]), - ), patch.object(Storage, "write", new=AsyncMock()) as write: - with pytest.raises(ProjectNameConflictError): - await Project.create(name=" test ", worktree="/tmp/workspace") + with pytest.raises(ProjectPathConflictError) as exc_info: + await Project.create(owner_id="user-1", name="Other", worktree=str(labs / ".")) - write.assert_not_awaited() + assert exc_info.value.project.id == existing.id + assert len((await Project.list(owner_id="user-1"))) == 2 @pytest.mark.asyncio -async def test_update_allows_same_name_for_current_project(): - existing = ProjectInfo( - id="project-existing", - worktree="/tmp/workspace", - name="Test", - time=ProjectTime(created=1, updated=1), +async def test_create_rejects_duplicate_name_across_directories(project_root): + first = project_root / "first" + second = project_root / "second" + first.mkdir() + second.mkdir() + await Project.create(owner_id="user-1", name="Labs", worktree=str(first)) + + with pytest.raises(ProjectNameConflictError): + await Project.create(owner_id="user-1", name=" labs ", worktree=str(second)) + + +@pytest.mark.asyncio +async def test_create_project_creates_a_missing_worktree(project_root): + worktree = project_root / "new" / "nested-project" + + project = await Project.create( + owner_id="user-1", + name="Nested Project", + worktree=str(worktree), ) - with patch.object(Project, "get", new=AsyncMock(return_value=existing)), patch.object( - Storage, - "list_entries", - new=AsyncMock(return_value=[("project/project-existing", existing)]), - ), patch.object(Storage, "write", new=AsyncMock()) as write: - updated = await Project.update("project-existing", name=" Test ") + assert worktree.is_dir() + assert project.worktree == str(worktree.resolve()) + - assert updated.name == "Test" - write.assert_awaited_once() +@pytest.mark.asyncio +async def test_create_project_does_not_create_outside_allowed_roots(project_root, monkeypatch): + allowed_root = project_root / "allowed" + allowed_root.mkdir() + outside = project_root / "outside" / "project" + monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(allowed_root)) + + with pytest.raises(ValueError, match="outside the allowed roots"): + await Project.create( + owner_id="user-1", + name="Outside", + worktree=str(outside), + ) + + assert not outside.exists() @pytest.mark.asyncio -async def test_delete_empty_user_managed_project(): - project = ProjectInfo( - id="prj_labs", - worktree="/tmp/workspace", - name="Labs", - time=ProjectTime(created=1, updated=1), - ) +async def test_update_renames_registered_project(project_root): + labs = project_root / "labs" + labs.mkdir() + created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) + + updated = await Project.update(created.id, owner_id="user-1", name=" Security ") + + assert updated.name == "Security" + assert (await Project.get(created.id, owner_id="user-1")).name == "Security" - with patch.object(Project, "get", new=AsyncMock(return_value=project)), patch.object( - Storage, - "list_entries", - new=AsyncMock(return_value=[]), - ), patch.object(Storage, "delete", new=AsyncMock(return_value=True)) as delete: - result = await Project.delete(project.id) + +@pytest.mark.asyncio +async def test_delete_only_removes_registry_entry(project_root): + labs = project_root / "labs" + labs.mkdir() + created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) + + with patch.object(Storage, "delete", new=AsyncMock()) as storage_delete: + result = await Project.delete(created.id, owner_id="user-1") assert result is True - delete.assert_awaited_once_with("project/prj_labs") + assert labs.exists() + assert await Project.get(created.id, owner_id="user-1") is None + storage_delete.assert_not_awaited() @pytest.mark.asyncio -async def test_delete_rejects_default_project(): - project = ProjectInfo( - id="project-default", - worktree="/tmp/workspace", - name=None, - time=ProjectTime(created=1, updated=1), - ) +async def test_delete_rejects_default_project(project_root): + await Project.ensure_registry("user-1") + + with pytest.raises(ProjectDeletionError): + await Project.delete(DEFAULT_PROJECT_ID, owner_id="user-1") + + +@pytest.mark.asyncio +async def test_effective_project_maps_legacy_and_removed_projects_to_default(project_root): + labs = project_root / "labs" + labs.mkdir() + created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) + + assert Project.effective_project_id("user-1", created.id) == created.id + assert Project.effective_project_id("user-1", "old-git-project") == DEFAULT_PROJECT_ID + + await Project.delete(created.id, owner_id="user-1") + assert Project.effective_project_id("user-1", created.id) == DEFAULT_PROJECT_ID + + +@pytest.mark.asyncio +async def test_from_directory_never_writes_project_storage(project_root): + with patch.object(Storage, "write", new=AsyncMock()) as storage_write: + result = await Project.from_directory(str(project_root)) + + assert result["project"].id == DEFAULT_PROJECT_ID + storage_write.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_concurrent_project_creates_keep_every_registry_entry(project_root): + worktrees = [] + for index in range(8): + worktree = project_root / f"project-{index}" + worktree.mkdir() + worktrees.append(worktree) + + created = await asyncio.gather(*( + Project.create( + owner_id="user-1", + name=f"Project {index}", + worktree=str(worktree), + ) + for index, worktree in enumerate(worktrees) + )) + + projects = await Project.list(owner_id="user-1") + assert {project.id for project in projects[1:]} == {project.id for project in created} + + +@pytest.mark.asyncio +async def test_registry_recovers_from_last_backup(project_root): + labs = project_root / "labs" + labs.mkdir() + created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) + await Project.update(created.id, owner_id="user-1", name="Renamed Labs") + + Project.registry_path("user-1").write_text("{broken", encoding="utf-8") + + projects = await Project.list(owner_id="user-1") + assert [project.name for project in projects[1:]] == ["Labs"] + + +@pytest.mark.asyncio +async def test_corrupt_registry_without_backup_only_returns_default(project_root): + await Project.ensure_registry("user-1") + registry_path = Project.registry_path("user-1") + registry_path.write_text("{broken", encoding="utf-8") + registry_path.with_suffix(".json.bak").unlink(missing_ok=True) + + projects = await Project.list(owner_id="user-1") - with patch.object(Project, "get", new=AsyncMock(return_value=project)): - with pytest.raises(ProjectDeletionError): - await Project.delete(project.id) + assert [project.id for project in projects] == [DEFAULT_PROJECT_ID] + assert registry_path.read_text(encoding="utf-8") == "{broken" diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 4e1b3f819..2880ab582 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -20,11 +20,18 @@ async def _server_isolated_env(tmp_path: Path, monkeypatch): """ data_dir = tmp_path / "flocks_data" data_dir.mkdir(parents=True, exist_ok=True) + flocks_root = tmp_path / ".flocks" + workspace_dir = flocks_root / "workspace" + workspace_dir.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("FLOCKS_DATA_DIR", str(data_dir)) + monkeypatch.setenv("FLOCKS_ROOT", str(flocks_root)) + monkeypatch.setenv("FLOCKS_WORKSPACE_DIR", str(workspace_dir)) + monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(tmp_path)) from flocks.config.config import Config from flocks.storage.storage import Storage from flocks.project.instance import Instance, _state_manager + from flocks.project.project import Project Config._global_config = None Config._cached_config = None @@ -38,6 +45,7 @@ async def _server_isolated_env(tmp_path: Path, monkeypatch): original_cache = dict(Instance._cache) Instance._cache.clear() + Project.invalidate_session_stats() for key in list(_state_manager._states.keys()): _state_manager._states.pop(key, None) _state_manager._disposers.pop(key, None) @@ -69,6 +77,7 @@ async def _server_isolated_env(tmp_path: Path, monkeypatch): AuthService._has_users_cached = False Instance._cache.clear() Instance._cache.update(original_cache) + Project.invalidate_session_stats() for key in list(_state_manager._states.keys()): _state_manager._states.pop(key, None) _state_manager._disposers.pop(key, None) diff --git a/tests/server/routes/test_project_routes.py b/tests/server/routes/test_project_routes.py new file mode 100644 index 000000000..3ba10f60f --- /dev/null +++ b/tests/server/routes/test_project_routes.py @@ -0,0 +1,160 @@ +from pathlib import Path + +import pytest +from fastapi import status +from httpx import AsyncClient + +from flocks.auth.context import AuthUser +from flocks.storage.storage import Storage + + +@pytest.mark.asyncio +async def test_project_crud_uses_registry_without_project_database_rows( + client: AsyncClient, + tmp_path: Path, +): + worktree = tmp_path / "labs" + worktree.mkdir() + + create_response = await client.post( + "/api/project", + json={"name": "Labs", "worktree": str(worktree)}, + ) + assert create_response.status_code == status.HTTP_200_OK + project = create_response.json() + assert project["id"].startswith("prj_") + assert await Storage.list_keys(prefix="project/") == [] + + list_response = await client.get("/api/project") + assert list_response.status_code == status.HTTP_200_OK + projects = list_response.json() + assert projects[0]["id"] == "default" + assert projects[0]["name"] == "默认" + assert projects[0]["isDefault"] is True + assert [item["id"] for item in projects[1:]] == [project["id"]] + + rename_response = await client.patch( + f"/api/project/{project['id']}", + json={"name": "Security Labs"}, + ) + assert rename_response.status_code == status.HTTP_200_OK + assert rename_response.json()["name"] == "Security Labs" + + delete_response = await client.delete(f"/api/project/{project['id']}") + assert delete_response.status_code == status.HTTP_200_OK + assert worktree.exists() + assert [item["id"] for item in (await client.get("/api/project")).json()] == ["default"] + + +@pytest.mark.asyncio +async def test_create_project_creates_missing_directory( + client: AsyncClient, + tmp_path: Path, +): + worktree = tmp_path / "workspace" / "created-by-project" + + response = await client.post( + "/api/project", + json={"name": "Created Project", "worktree": str(worktree)}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["worktree"] == str(worktree.resolve()) + assert worktree.is_dir() + + +@pytest.mark.asyncio +async def test_duplicate_project_directory_returns_existing_project( + client: AsyncClient, + tmp_path: Path, +): + worktree = tmp_path / "same" + worktree.mkdir() + + first = await client.post( + "/api/project", + json={"name": "First", "worktree": str(worktree)}, + ) + second = await client.post( + "/api/project", + json={"name": "Second", "worktree": str(worktree / ".")}, + ) + + assert first.status_code == status.HTTP_200_OK + assert second.status_code == status.HTTP_200_OK + assert second.json()["id"] == first.json()["id"] + assert len((await client.get("/api/project")).json()) == 2 + + +@pytest.mark.asyncio +async def test_folder_browser_lists_directories_and_blocks_outside_root( + client: AsyncClient, + tmp_path: Path, +): + parent = tmp_path / "parent" + child = parent / "child" + child.mkdir(parents=True) + + response = await client.get("/api/project/folders", params={"path": str(parent)}) + + assert response.status_code == status.HTTP_200_OK + payload = response.json() + assert payload["path"] == str(parent.resolve()) + assert {entry["name"] for entry in payload["entries"]} == {"child"} + + outside = await client.get("/api/project/folders", params={"path": str(Path.home())}) + assert outside.status_code == status.HTTP_403_FORBIDDEN + + escape_link = parent / "escape" + escape_link.symlink_to(Path.home(), target_is_directory=True) + escaped_listing = await client.get( + "/api/project/folders", + params={"path": str(escape_link)}, + ) + assert escaped_listing.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.asyncio +async def test_project_counts_and_session_lists_are_user_scoped( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + from flocks.server.routes import project as project_routes + from flocks.server.routes import session as session_routes + + alice = AuthUser(id="usr_alice", username="alice", role="member", status="active") + bob = AuthUser(id="usr_bob", username="bob", role="member", status="active") + + monkeypatch.setattr(session_routes, "require_user", lambda _request: alice) + alice_session = await client.post("/api/session", json={"title": "Alice session"}) + assert alice_session.status_code == status.HTTP_200_OK + + monkeypatch.setattr(session_routes, "require_user", lambda _request: bob) + bob_session = await client.post("/api/session", json={"title": "Bob session"}) + assert bob_session.status_code == status.HTTP_200_OK + + monkeypatch.setattr(session_routes, "require_user", lambda _request: alice) + monkeypatch.setattr(project_routes, "require_user", lambda _request: alice) + + projects = (await client.get("/api/project")).json() + assert projects[0]["id"] == "default" + assert projects[0]["sessionCount"] == 1 + + second_alice_session = await client.post( + "/api/session", + json={"title": "Second Alice session"}, + ) + assert second_alice_session.status_code == status.HTTP_200_OK + refreshed_projects = (await client.get("/api/project")).json() + assert refreshed_projects[0]["sessionCount"] == 2 + + sessions = ( + await client.get( + "/api/session", + params={"view": "list", "manager": "true", "roots": "true"}, + ) + ).json() + assert {item["title"] for item in sessions} == { + "Alice session", + "Second Alice session", + } diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 7dbe4d85e..80d70ec58 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -236,6 +236,7 @@ async def test_list_sessions_light_manager_filters_and_omits_heavy_fields(self, assert set(row) == { "id", "projectID", + "effectiveProjectID", "directory", "title", "time", @@ -254,9 +255,20 @@ async def test_list_sessions_light_manager_filters_and_omits_heavy_fields(self, assert "summary" not in row @pytest.mark.asyncio - async def test_create_session_in_user_managed_project(self, client: AsyncClient): + async def test_create_session_in_user_managed_project( + self, + client: AsyncClient, + tmp_path, + monkeypatch: pytest.MonkeyPatch, + ): """Session creation can target a user-managed project.""" - project_resp = await client.post("/api/project", json={"name": "Labs"}) + worktree = tmp_path / "labs" + worktree.mkdir() + monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(tmp_path)) + project_resp = await client.post( + "/api/project", + json={"name": "Labs", "worktree": str(worktree)}, + ) assert project_resp.status_code == status.HTTP_200_OK project = project_resp.json() @@ -273,8 +285,74 @@ async def test_create_session_in_user_managed_project(self, client: AsyncClient) ) row = next(item for item in list_resp.json() if item["id"] == session_resp.json()["id"]) assert row["projectID"] == project["id"] + assert row["effectiveProjectID"] == project["id"] assert row["directory"] == project["worktree"] + @pytest.mark.asyncio + async def test_legacy_session_is_grouped_under_default_without_rewrite( + self, + client: AsyncClient, + tmp_path, + ): + """Legacy project IDs are projected to Default while storage stays unchanged.""" + legacy = await Session.create( + project_id="legacy-git-project", + directory=str(tmp_path), + title="Legacy Session", + ) + + response = await client.get( + "/api/session", + params={ + "view": "list", + "manager": "true", + "roots": "true", + "projectID": "default", + }, + ) + + assert response.status_code == status.HTTP_200_OK + row = next(item for item in response.json() if item["id"] == legacy.id) + assert row["projectID"] == "legacy-git-project" + assert row["effectiveProjectID"] == "default" + stored = await Session.get("legacy-git-project", legacy.id) + assert stored is not None + assert stored.project_id == "legacy-git-project" + + @pytest.mark.asyncio + async def test_removed_project_sessions_fall_back_to_default( + self, + client: AsyncClient, + tmp_path, + ): + """Removing a project keeps its sessions and maps them to Default.""" + worktree = tmp_path / "removable-project" + worktree.mkdir() + project_response = await client.post( + "/api/project", + json={"name": "Removable", "worktree": str(worktree)}, + ) + project = project_response.json() + session_response = await client.post( + "/api/session", + json={"title": "Keep Me", "projectID": project["id"]}, + ) + session_id = session_response.json()["id"] + + delete_response = await client.delete(f"/api/project/{project['id']}") + assert delete_response.status_code == status.HTTP_200_OK + assert worktree.exists() + + default_response = await client.get( + "/api/session", + params={"view": "list", "manager": "true", "projectID": "default"}, + ) + row = next(item for item in default_response.json() if item["id"] == session_id) + assert row["projectID"] == project["id"] + assert row["effectiveProjectID"] == "default" + stored = await Session.get(project["id"], session_id) + assert stored is not None + @pytest.mark.asyncio async def test_get_session(self, client: AsyncClient, session_id: str): """GET /api/session/{id} returns the specific session.""" @@ -829,6 +907,7 @@ async def test_owner_can_share_and_unshare_session( create_resp = await client.post("/api/session", json={"title": "share-session"}) assert create_resp.status_code == status.HTTP_200_OK session_id = create_resp.json()["id"] + assert await Session.get_by_id(session_id) is not None share_resp = await client.post(f"/api/session/{session_id}/share-local") assert share_resp.status_code == status.HTTP_200_OK diff --git a/webui/src/api/session.ts b/webui/src/api/session.ts index b07678af5..a421cb219 100644 --- a/webui/src/api/session.ts +++ b/webui/src/api/session.ts @@ -77,6 +77,7 @@ export interface SessionListParams { limit?: number; offset?: number; directory?: string; + projectID?: string; roots?: boolean; start?: number; search?: string; diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index 0551d8ab4..409bb8759 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Message } from '@/types'; import { - areChatMessagePartsRenderEqual, areChatTimelineItemsRenderEqual, buildInstructionDisplayText, buildChatTimelineItems, @@ -35,6 +34,7 @@ import { shouldRefetchFinishedMessage, truncateToolDisplayText, } from './SessionChat'; +import { areChatMessagePartsRenderEqual } from './sessionChatRenderEquality'; const clientGetMock = vi.fn(); const clientPostMock = vi.fn(); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index c52d08b8a..4e442e7fa 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -34,6 +34,7 @@ import type { Command } from '@/api/skill'; import type { Agent } from '@/api/agent'; import { useToast } from './Toast'; import { buildRunWorkflowHeaderSummary } from './toolStageSummary'; +import { areChatMessagePartsRenderEqual } from './sessionChatRenderEquality'; import { workspaceAPI } from '@/api/workspace'; import { formatSmartTime } from '@/utils/time'; import { getAgentDisplayDescription } from '@/utils/agentDisplay'; @@ -1079,83 +1080,6 @@ export function getUserAvatarSpacerClassName(_compact: boolean): string { return 'h-0'; } -function areToolStatesRenderEqual( - prevState?: ToolState, - nextState?: ToolState, -): boolean { - if (prevState === nextState) return true; - if ( - prevState?.status !== nextState?.status || - prevState?.title !== nextState?.title || - prevState?.error !== nextState?.error || - prevState?.time?.start !== nextState?.time?.start || - prevState?.time?.end !== nextState?.time?.end - ) { - return false; - } - - return ( - JSON.stringify(prevState?.input) === JSON.stringify(nextState?.input) - && JSON.stringify(prevState?.output) === JSON.stringify(nextState?.output) - && JSON.stringify(prevState?.metadata) === JSON.stringify(nextState?.metadata) - ); -} - -function areLegacyToolPayloadsRenderEqual( - prevPayload?: MessagePart['toolCall'] | MessagePart['toolResult'], - nextPayload?: MessagePart['toolCall'] | MessagePart['toolResult'], -): boolean { - if (prevPayload === nextPayload) return true; - return JSON.stringify(prevPayload) === JSON.stringify(nextPayload); -} - -export function areChatMessagePartsRenderEqual( - prevParts?: MessagePart[], - nextParts?: MessagePart[], -): boolean { - if (prevParts === nextParts) return true; - if ((prevParts?.length ?? 0) !== (nextParts?.length ?? 0)) return false; - - const total = prevParts?.length ?? 0; - for (let i = 0; i < total; i++) { - const prevPart = prevParts?.[i]; - const nextPart = nextParts?.[i]; - - if (prevPart === nextPart) continue; - if (!prevPart || !nextPart) return false; - - if ( - prevPart.id !== nextPart.id || - prevPart.type !== nextPart.type || - prevPart.text !== nextPart.text || - prevPart.thinking !== nextPart.thinking || - prevPart.synthetic !== nextPart.synthetic || - prevPart.ignored !== nextPart.ignored || - prevPart.tool !== nextPart.tool || - prevPart.callID !== nextPart.callID || - prevPart.mime !== nextPart.mime || - prevPart.filename !== nextPart.filename || - prevPart.url !== nextPart.url || - prevPart.image?.url !== nextPart.image?.url || - prevPart.image?.alt !== nextPart.image?.alt - ) { - return false; - } - - if (!areToolStatesRenderEqual(prevPart.state, nextPart.state)) { - return false; - } - if (!areLegacyToolPayloadsRenderEqual(prevPart.toolCall, nextPart.toolCall)) { - return false; - } - if (!areLegacyToolPayloadsRenderEqual(prevPart.toolResult, nextPart.toolResult)) { - return false; - } - } - - return true; -} - // ============================================================================ // Main component // ============================================================================ diff --git a/webui/src/components/common/sessionChatRenderEquality.ts b/webui/src/components/common/sessionChatRenderEquality.ts new file mode 100644 index 000000000..222477bed --- /dev/null +++ b/webui/src/components/common/sessionChatRenderEquality.ts @@ -0,0 +1,72 @@ +import type { MessagePart, ToolState } from '@/types'; + +function areToolStatesRenderEqual( + prevState?: ToolState, + nextState?: ToolState, +): boolean { + if (prevState === nextState) return true; + if ( + prevState?.status !== nextState?.status + || prevState?.title !== nextState?.title + || prevState?.error !== nextState?.error + || prevState?.time?.start !== nextState?.time?.start + || prevState?.time?.end !== nextState?.time?.end + ) { + return false; + } + + return ( + JSON.stringify(prevState?.input) === JSON.stringify(nextState?.input) + && JSON.stringify(prevState?.output) === JSON.stringify(nextState?.output) + && JSON.stringify(prevState?.metadata) === JSON.stringify(nextState?.metadata) + ); +} + +function areLegacyToolPayloadsRenderEqual( + prevPayload?: MessagePart['toolCall'] | MessagePart['toolResult'], + nextPayload?: MessagePart['toolCall'] | MessagePart['toolResult'], +): boolean { + if (prevPayload === nextPayload) return true; + return JSON.stringify(prevPayload) === JSON.stringify(nextPayload); +} + +export function areChatMessagePartsRenderEqual( + prevParts?: MessagePart[], + nextParts?: MessagePart[], +): boolean { + if (prevParts === nextParts) return true; + if ((prevParts?.length ?? 0) !== (nextParts?.length ?? 0)) return false; + + const total = prevParts?.length ?? 0; + for (let i = 0; i < total; i++) { + const prevPart = prevParts?.[i]; + const nextPart = nextParts?.[i]; + + if (prevPart === nextPart) continue; + if (!prevPart || !nextPart) return false; + + if ( + prevPart.id !== nextPart.id + || prevPart.type !== nextPart.type + || prevPart.text !== nextPart.text + || prevPart.thinking !== nextPart.thinking + || prevPart.synthetic !== nextPart.synthetic + || prevPart.ignored !== nextPart.ignored + || prevPart.tool !== nextPart.tool + || prevPart.callID !== nextPart.callID + || prevPart.mime !== nextPart.mime + || prevPart.filename !== nextPart.filename + || prevPart.url !== nextPart.url + || prevPart.image?.url !== nextPart.image?.url + || prevPart.image?.alt !== nextPart.image?.alt + ) { + return false; + } + + if (!areToolStatesRenderEqual(prevPart.state, nextPart.state)) return false; + if (!areLegacyToolPayloadsRenderEqual(prevPart.toolCall, nextPart.toolCall)) return false; + if (!areLegacyToolPayloadsRenderEqual(prevPart.toolResult, nextPart.toolResult)) return false; + } + + return true; +} diff --git a/webui/src/hooks/useSessions.test.ts b/webui/src/hooks/useSessions.test.ts index 2fe812538..828ca01e5 100644 --- a/webui/src/hooks/useSessions.test.ts +++ b/webui/src/hooks/useSessions.test.ts @@ -1015,4 +1015,133 @@ describe('useSessions list loading', () => { resolveSearch([]); }); }); + + it('loads and tracks pages independently for each project', async () => { + vi.mocked(sessionApi.list).mockImplementation(async (params: any) => { + if (params.projectID === 'default') { + return Array.from({ length: 100 }, (_, index) => ({ + id: `default-${index}`, + projectID: 'legacy-project', + effectiveProjectID: 'default', + title: `Default ${index}`, + time: { created: index, updated: index }, + category: 'user', + })) as any; + } + return [{ + id: 'labs-1', + projectID: 'prj_labs', + effectiveProjectID: 'prj_labs', + title: 'Labs', + time: { created: 1, updated: 1 }, + category: 'user', + }] as any; + }); + + const { result } = renderHook(() => useSessions('', { + projectIds: ['default', 'prj_labs'], + })); + await act(async () => {}); + + expect(sessionApi.list).toHaveBeenCalledWith(expect.objectContaining({ + projectID: 'default', + offset: 0, + })); + expect(sessionApi.list).toHaveBeenCalledWith(expect.objectContaining({ + projectID: 'prj_labs', + offset: 0, + })); + expect(result.current.sessions).toHaveLength(101); + expect(result.current.hasMoreByProject).toEqual({ + default: true, + prj_labs: false, + }); + }); + + it('uses a custom page size for project session pagination', async () => { + vi.mocked(sessionApi.list).mockImplementation(async (params: any) => ( + Array.from({ length: params.limit }, (_, index) => ({ + id: `${params.projectID}-${params.offset + index}`, + projectID: params.projectID, + effectiveProjectID: params.projectID, + title: `Session ${params.offset + index}`, + time: { created: index, updated: index }, + category: 'user', + })) as any + )); + + const { result } = renderHook(() => useSessions('', { + projectIds: ['default', 'prj_labs'], + pageSize: 6, + })); + await act(async () => {}); + + expect(sessionApi.list).toHaveBeenCalledWith(expect.objectContaining({ + projectID: 'default', + limit: 6, + offset: 0, + })); + expect(sessionApi.list).toHaveBeenCalledWith(expect.objectContaining({ + projectID: 'prj_labs', + limit: 6, + offset: 0, + })); + expect(result.current.sessions).toHaveLength(12); + expect(result.current.hasMoreByProject).toEqual({ + default: true, + prj_labs: true, + }); + + await act(async () => { + await result.current.loadMore('prj_labs'); + }); + + expect(sessionApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ + projectID: 'prj_labs', + limit: 6, + offset: 6, + })); + }); + + it('uses the selected project offset when loading more sessions', async () => { + vi.mocked(sessionApi.list).mockImplementation(async (params: any) => { + if (params.offset === 0) { + return [{ + id: `${params.projectID}-1`, + projectID: params.projectID, + effectiveProjectID: params.projectID, + title: 'First page', + time: { created: 1, updated: 1 }, + category: 'user', + }] as any; + } + return [{ + id: `${params.projectID}-2`, + projectID: params.projectID, + effectiveProjectID: params.projectID, + title: 'Second page', + time: { created: 2, updated: 2 }, + category: 'user', + }] as any; + }); + + const { result } = renderHook(() => useSessions('', { + projectIds: ['default', 'prj_labs'], + })); + await act(async () => {}); + + await act(async () => { + await result.current.loadMore('prj_labs'); + }); + + expect(sessionApi.list).toHaveBeenLastCalledWith(expect.objectContaining({ + projectID: 'prj_labs', + offset: 1, + })); + expect(result.current.sessions.map((item) => item.id)).toEqual([ + 'default-1', + 'prj_labs-1', + 'prj_labs-2', + ]); + }); }); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 03586f061..0ca145fdf 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -217,11 +217,22 @@ export function applyMessagePartUpdate( return updated; } -export function useSessions(search = '') { +type UseSessionsOptions = { + projectIds?: string[]; + pageSize?: number; +}; + +function sessionEffectiveProjectId(session: Session): string { + return session.effectiveProjectID || session.projectID; +} + +export function useSessions(search = '', options?: UseSessionsOptions) { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); + const [hasMoreByProject, setHasMoreByProject] = useState>({}); + const [loadingMoreProjectIds, setLoadingMoreProjectIds] = useState>(new Set()); const [error, setError] = useState(null); // Track whether the initial fetch has completed — refetches should be silent const initializedRef = useRef(false); @@ -229,12 +240,24 @@ export function useSessions(search = '') { const hasLoadedOnceRef = useRef(false); const requestSeqRef = useRef(0); const optimisticSessionsRef = useRef>(new Map()); + const projectIdsKey = options?.projectIds === undefined + ? null + : [...options.projectIds].sort().join('\u0000'); + const pageSize = options?.pageSize ?? SESSION_LIST_PAGE_SIZE; useEffect(() => { sessionsRef.current = sessions; }, [sessions]); - const fetchSessions = useCallback(async (options?: { append?: boolean }) => { - const append = Boolean(options?.append); + const fetchSessions = useCallback(async (fetchOptions?: { append?: boolean; projectId?: string }) => { + const append = Boolean(fetchOptions?.append); + const requestedProjectIds = projectIdsKey === null + ? null + : projectIdsKey.split('\u0000').filter(Boolean); + const targetProjectIds = requestedProjectIds === null + ? null + : fetchOptions?.projectId + ? [fetchOptions.projectId] + : requestedProjectIds; const requestSeq = ++requestSeqRef.current; try { // Only show the full-page loading state on the very first fetch. @@ -242,40 +265,78 @@ export function useSessions(search = '') { // to avoid unmounting SessionChat and disrupting the active conversation. if (append) { setLoadingMore(true); + if (fetchOptions?.projectId) { + setLoadingMoreProjectIds((current) => new Set(current).add(fetchOptions.projectId as string)); + } } else if (!initializedRef.current) { setLoading(true); } setError(null); + if (targetProjectIds?.length === 0) { + setSessions([]); + setHasMore(false); + setHasMoreByProject({}); + hasLoadedOnceRef.current = true; + return; + } // Fetch only root sessions: child sessions are internal and never shown // in the sidebar, so excluding them avoids extra payload and filtering. const startMark = append ? 'sessions:list:older-start' : 'sessions:list:first-start'; if (typeof performance !== 'undefined') performance.mark(startMark); - const response = await sessionApi.list({ - view: 'list', - manager: true, - roots: true, - limit: SESSION_LIST_PAGE_SIZE, - offset: append - ? sessionsRef.current.filter(session => !optimisticSessionsRef.current.has(session.id)).length - : 0, - search: search.trim() || undefined, - }); + const projectTargets = targetProjectIds ?? [undefined]; + const responses = await Promise.all(projectTargets.map(async (projectId) => { + const offset = append + ? sessionsRef.current.filter((session) => ( + !optimisticSessionsRef.current.has(session.id) + && (projectId === undefined || sessionEffectiveProjectId(session) === projectId) + )).length + : 0; + const response = await sessionApi.list({ + view: 'list', + manager: true, + roots: true, + limit: pageSize, + offset, + search: search.trim() || undefined, + projectID: projectId, + }); + return { projectId, response }; + })); if (requestSeq !== requestSeqRef.current) return; markMeasure(append ? 'sessions:list:older-page' : 'sessions:list:first-render', startMark); - if (Array.isArray(response)) { - const nextSessions = response.filter( + const nextSessions = responses.flatMap(({ response }) => ( + Array.isArray(response) ? response.filter( (s: any) => (!s.category || VISIBLE_CATEGORIES.has(s.category)) && !s.parentID, + ) : [] + )); + nextSessions.forEach((session: Session) => optimisticSessionsRef.current.delete(session.id)); + const nextHasMoreByProject = Object.fromEntries( + responses + .filter(({ projectId }) => projectId !== undefined) + .map(({ projectId, response }) => [ + projectId as string, + Array.isArray(response) && response.length >= pageSize, + ]), + ); + setHasMoreByProject((current) => append + ? { ...current, ...nextHasMoreByProject } + : nextHasMoreByProject); + setSessions((previous) => { + if (append) return appendSessionList(previous, nextSessions); + if (targetProjectIds === null) { + return mergeSessionListWithOptimistic(nextSessions, optimisticSessionsRef.current); + } + const optimistic = new Map( + Array.from(optimisticSessionsRef.current.entries()).filter(([, session]) => ( + targetProjectIds.includes(sessionEffectiveProjectId(session)) + )), ); - nextSessions.forEach((session: Session) => optimisticSessionsRef.current.delete(session.id)); - setSessions(prev => append - ? appendSessionList(prev, nextSessions) - : mergeSessionListWithOptimistic(nextSessions, optimisticSessionsRef.current)); - setHasMore(response.length >= SESSION_LIST_PAGE_SIZE); - hasLoadedOnceRef.current = true; - } else { - if (!append && !hasLoadedOnceRef.current) setSessions([]); - setHasMore(false); - } + return mergeSessionListWithOptimistic(nextSessions, optimistic); + }); + setHasMore(targetProjectIds === null + ? responses.some(({ response }) => Array.isArray(response) && response.length >= pageSize) + : Object.values(nextHasMoreByProject).some(Boolean)); + hasLoadedOnceRef.current = true; } catch (err: any) { if (requestSeq !== requestSeqRef.current) return; setError(err.message || 'Failed to fetch sessions'); @@ -284,10 +345,17 @@ export function useSessions(search = '') { if (requestSeq === requestSeqRef.current) { setLoading(false); setLoadingMore(false); + if (fetchOptions?.projectId) { + setLoadingMoreProjectIds((current) => { + const next = new Set(current); + next.delete(fetchOptions.projectId as string); + return next; + }); + } initializedRef.current = true; } } - }, [search]); + }, [pageSize, projectIdsKey, search]); const updateSessionTitle = useCallback((sessionId: string, title: string) => { const optimistic = optimisticSessionsRef.current.get(sessionId); @@ -338,8 +406,10 @@ export function useSessions(search = '') { removeSessions, addSession, hasMore, + hasMoreByProject, loadingMore, - loadMore: () => fetchSessions({ append: true }), + loadingMoreProjectIds, + loadMore: (projectId?: string) => fetchSessions({ append: true, projectId }), }; } diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index dcef00f54..1b4c8a410 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -268,22 +268,36 @@ "createSessionInProject": "New session in project {{project}}", "projectActions": "Project actions", "defaultProject": "Default project", + "projectPathUnavailable": "Project directory unavailable", "defaultProjectName": "Default", "noProjectSessions": "No conversations", "projectDialog": { "createTitle": "New Project", "renameTitle": "Rename Project", "renameAction": "Rename", + "newSessionAction": "New session", + "copyPathAction": "Copy path", "deleteAction": "Delete", "deleteTitle": "Delete Project", - "deleteDescription": "Delete project “{{project}}”? Only projects without conversations can be deleted.", - "confirmDelete": "Delete", + "deleteDescription": "Remove project “{{project}}”? Conversations and files will be kept, and its conversations will move to Default.", + "confirmDelete": "Remove", "defaultDeleteDisabled": "The default project cannot be deleted", "deleteSuccess": "Project deleted", "deleteFailed": "Failed to delete project", "nameLabel": "Project name", "namePlaceholder": "Enter project name", "nameEmpty": "Project name cannot be empty", + "folderLabel": "Project folder", + "folderPlaceholder": "Enter an absolute project folder path", + "folderEmpty": "Choose a project folder", + "defaultFolder": "Default: {{path}}", + "chooseFolder": "Choose", + "parentFolder": "Parent folder", + "selectCurrentFolder": "Select this folder", + "noSubfolders": "No accessible subfolders", + "folderBrowseFailed": "Failed to read folder", + "pathCopied": "Project path copied", + "copyPathFailed": "Failed to copy project path", "saveFailed": "Failed to save project" }, "filterConversations": "Filter conversations...", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 59826a3e0..4ae70d7a3 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -268,22 +268,36 @@ "createSessionInProject": "在项目 {{project}} 中新建会话", "projectActions": "项目操作", "defaultProject": "默认项目", + "projectPathUnavailable": "项目目录不可用", "defaultProjectName": "默认", "noProjectSessions": "暂无会话", "projectDialog": { "createTitle": "新建项目", "renameTitle": "重命名项目", "renameAction": "重命名", + "newSessionAction": "新建会话", + "copyPathAction": "复制路径", "deleteAction": "删除", "deleteTitle": "删除项目", - "deleteDescription": "确定删除项目“{{project}}”吗?仅可删除不包含会话的项目。", - "confirmDelete": "删除", + "deleteDescription": "确定移除项目“{{project}}”吗?不会删除会话和项目文件夹,原项目中的会话将归入默认项目。", + "confirmDelete": "移除", "defaultDeleteDisabled": "默认项目不能删除", "deleteSuccess": "项目已删除", "deleteFailed": "删除项目失败", "nameLabel": "项目名称", "namePlaceholder": "输入项目名称", "nameEmpty": "项目名称不能为空", + "folderLabel": "项目文件夹", + "folderPlaceholder": "输入项目文件夹的绝对路径", + "folderEmpty": "请选择项目文件夹", + "defaultFolder": "默认:{{path}}", + "chooseFolder": "选择", + "parentFolder": "上一级目录", + "selectCurrentFolder": "选择此文件夹", + "noSubfolders": "没有可访问的子文件夹", + "folderBrowseFailed": "读取文件夹失败", + "pathCopied": "项目路径已复制", + "copyPathFailed": "复制项目路径失败", "saveFailed": "保存项目失败" }, "filterConversations": "搜索会话...", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 9678acf1d..12b964679 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -4,7 +4,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; -import SessionPage, { getVisibleSessionGroupItems, groupSessionsByDate } from './index'; +import SessionPage from './index'; const { client, @@ -95,6 +95,10 @@ vi.mock('@/components/common/Toast', () => ({ useToast: () => toast, })); +vi.mock('@/contexts/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'user-1', username: 'admin', role: 'admin' } }), +})); + vi.mock('@/components/common/LoadingSpinner', () => ({ default: () =>
loading-spinner
, })); @@ -208,6 +212,7 @@ const session = { id: 'session-1', slug: 'session-1', projectID: 'project-1', + effectiveProjectID: 'default', directory: '/tmp/project', title: 'Original Session', version: '1.0.0', @@ -273,61 +278,6 @@ function deferred() { return { promise, resolve, reject }; } -describe('session sidebar grouping helpers', () => { - it('groups sessions by updated date and applies search filtering', () => { - const now = new Date(2026, 6, 9, 12, 0, 0); - const makeSession = (id: string, title: string, updated: number) => ({ - ...session, - id, - title, - time: { ...session.time, updated }, - }); - - const groups = groupSessionsByDate([ - makeSession('today', 'Today Investigation', new Date(2026, 6, 9, 9).getTime()), - makeSession('yesterday', 'Yesterday Work', new Date(2026, 6, 8, 9).getTime()), - makeSession('earlier', 'Old Investigation', new Date(2026, 5, 1, 9).getTime()), - ], 'investigation', now); - - expect(groups.map((group) => [group.key, group.items.map((item) => item.id)])).toEqual([ - ['today', ['today']], - ['earlier', ['earlier']], - ]); - }); - - it('limits collapsed older groups and reports hidden count', () => { - const group = { - key: 'thisWeek' as const, - labelKey: 'groupThisWeek', - items: Array.from({ length: 7 }, (_, index) => ({ - ...session, - id: `session-${index}`, - title: `Session ${index}`, - })), - }; - - expect(getVisibleSessionGroupItems({ - group, - expanded: false, - searching: false, - })).toMatchObject({ - visibleItems: group.items.slice(0, 5), - hiddenCount: 2, - limit: 5, - }); - - expect(getVisibleSessionGroupItems({ - group, - expanded: false, - searching: true, - })).toMatchObject({ - visibleItems: group.items, - hiddenCount: 0, - limit: Infinity, - }); - }); -}); - describe('SessionPage session actions menu', () => { beforeEach(() => { vi.clearAllMocks(); @@ -361,15 +311,15 @@ describe('SessionPage session actions menu', () => { }); defaultModelAPI.getResolved.mockResolvedValue({ data: { provider_id: '', model_id: '' } }); modelV2API.listDefinitions.mockResolvedValue({ data: { models: [] } }); - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') { - return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/project', name: 'project' } }); - } - return Promise.resolve({ - data: [ - { id: 'project-1', worktree: '/tmp/project', name: 'project' }, - ], - }); + client.get.mockResolvedValue({ + data: [{ + id: 'default', + worktree: '/tmp/project', + name: '默认', + isDefault: true, + pathStatus: 'available', + sessionCount: 1, + }], }); hubAPI.catalog.mockResolvedValue({ data: [{ id: 'soc-workspace', type: 'component', state: 'installed' }], @@ -378,7 +328,7 @@ describe('SessionPage session actions menu', () => { hubAPI.installStream.mockResolvedValue(undefined); sessionApi.update.mockResolvedValue({ ...session, title: 'Renamed Session' }); - client.patch.mockResolvedValue({ data: { id: 'project-1', worktree: '/tmp/project', name: 'Renamed Project' } }); + client.patch.mockResolvedValue({ data: { id: 'prj_project2', worktree: '/tmp/labs', name: 'Renamed Project' } }); client.post.mockResolvedValue({ data: secondSession }); sessionApi.get.mockResolvedValue(session); sessionApi.getMessages.mockResolvedValue([ @@ -403,6 +353,10 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); const projectLabel = await screen.findByText('defaultProjectName'); + expect(useSessions).toHaveBeenLastCalledWith('', { + projectIds: ['default'], + pageSize: 20, + }); expect(screen.getByText('Original Session')).toBeInTheDocument(); expect(screen.getByText('1')).toBeInTheDocument(); @@ -411,6 +365,37 @@ describe('SessionPage session actions menu', () => { expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); }); + it('uses six sessions per page when multiple projects exist', async () => { + client.get.mockResolvedValue({ + data: [ + { + id: 'default', + worktree: '/tmp/project', + name: '默认', + isDefault: true, + pathStatus: 'available', + sessionCount: 1, + }, + { + id: 'prj_labs', + worktree: '/tmp/labs', + name: 'Labs', + isDefault: false, + pathStatus: 'available', + sessionCount: 0, + }, + ], + }); + + renderSessionPage(); + + await screen.findByText('Labs'); + expect(useSessions).toHaveBeenLastCalledWith('', { + projectIds: ['default', 'prj_labs'], + pageSize: 6, + }); + }); + it('toggles project sessions when clicking the selected project row', async () => { const user = userEvent.setup(); @@ -430,15 +415,26 @@ describe('SessionPage session actions menu', () => { expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); }); - it('merges legacy default sessions into the current project group', async () => { + it('groups legacy sessions by the effective project returned by the backend', async () => { + client.get.mockResolvedValue({ + data: [{ + id: 'default', + worktree: '/tmp/project', + name: '默认', + isDefault: true, + pathStatus: 'available', + sessionCount: 2, + }], + }); useSessions.mockReturnValue({ sessions: [ session, { ...secondSession, - projectID: 'default', + projectID: 'old-project-id', + effectiveProjectID: 'default', directory: '/tmp/project', - title: 'Legacy Default Session', + title: 'Legacy Session', }, ], loading: false, @@ -454,212 +450,103 @@ describe('SessionPage session actions menu', () => { await screen.findByText('defaultProjectName'); expect(screen.getByText('Original Session')).toBeInTheDocument(); - expect(screen.getByText('Legacy Default Session')).toBeInTheDocument(); + expect(screen.getByText('Legacy Session')).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText('project')).not.toBeInTheDocument(); }); - it('labels the current project as default even when the stored name is the worktree name', async () => { - client.get.mockImplementation((url: string) => { - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; - if (url === '/api/project/current') { - return Promise.resolve({ data: currentProject }); - } - return Promise.resolve({ data: [currentProject] }); - }); - - renderSessionPage(); - - expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); - expect(screen.queryByText('project')).not.toBeInTheDocument(); - }); - - it('merges historical project rows for the current worktree into default', async () => { - client.get.mockImplementation((url: string) => { - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; - if (url === '/api/project/current') { - return Promise.resolve({ data: currentProject }); + it('creates a user-managed project from the sidebar', async () => { + const user = userEvent.setup(); + let projectRows = [ + { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }, + ]; + client.get.mockImplementation(() => Promise.resolve({ data: projectRows })); + client.post.mockImplementation((url: string, payload: Record) => { + if (url === '/api/project') { + const created = { id: 'prj_project2', worktree: payload.worktree as string, name: payload.name as string }; + projectRows = [projectRows[0], created]; + return Promise.resolve({ data: created }); } - return Promise.resolve({ - data: [ - currentProject, - { id: 'old-project-id', worktree: '/tmp/project', name: 'project' }, - ], - }); - }); - useSessions.mockReturnValue({ - sessions: [ - session, - { - ...secondSession, - projectID: 'old-project-id', - directory: '/tmp/project', - title: 'Historical Project Session', - }, - ], - loading: false, - error: null, - refetch: refetchSessions, - updateSessionTitle, - removeSession, - removeSessions, - addSession, + return Promise.resolve({ data: secondSession }); }); renderSessionPage(); - expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); - expect(screen.getByText('Historical Project Session')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText('project')).not.toBeInTheDocument(); - }); + await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); + const nameInput = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(nameInput); + await user.type(nameInput, 'Labs'); + const folderInput = screen.getByLabelText('projectDialog.folderLabel'); + await user.clear(folderInput); + await user.type(folderInput, '/tmp/labs'); + await user.click(screen.getByRole('button', { name: 'save' })); - it('falls back to the first automatic project as default when current project is missing', async () => { - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') { - return Promise.reject({ response: { status: 404 } }); - } - return Promise.resolve({ - data: [ - { id: 'project-1', worktree: '/tmp/project', name: 'project' }, - { id: 'old-project-id', worktree: '/tmp/project', name: 'project' }, - ], - }); - }); - useSessions.mockReturnValue({ - sessions: [ - session, - { - ...secondSession, - projectID: 'old-project-id', - directory: '/tmp/project', - title: 'Old Flocks Project Session', - }, - ], - loading: false, - error: null, - refetch: refetchSessions, - updateSessionTitle, - removeSession, - removeSessions, - addSession, + await waitFor(() => { + expect(client.post).toHaveBeenCalledWith('/api/project', { name: 'Labs', worktree: '/tmp/labs' }); + expect(screen.getByText('Labs')).toBeInTheDocument(); }); - - renderSessionPage(); - - expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); - expect(screen.getByText('Old Flocks Project Session')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText('flocks')).not.toBeInTheDocument(); }); - it('uses the current project as default when the project list is empty', async () => { - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') { - return Promise.resolve({ data: { id: 'project-1', worktree: '/tmp/flocks', name: null } }); - } - return Promise.resolve({ data: [] }); - }); - useSessions.mockReturnValue({ - sessions: [ - { ...session, projectID: 'project-1', directory: '/tmp/flocks' }, - { - ...secondSession, - projectID: 'old-project-id', - directory: '/tmp/flocks', - title: 'Old Same Worktree Session', - }, - ], - loading: false, - error: null, - refetch: refetchSessions, - updateSessionTitle, - removeSession, - removeSessions, - addSession, + it('shows the backend detail when project creation fails', async () => { + const user = userEvent.setup(); + client.post.mockRejectedValue({ + message: 'Request failed with status code 400', + response: { data: { detail: 'Project directory does not exist' } }, }); renderSessionPage(); - expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); - expect(screen.getByText('Old Same Worktree Session')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText('flocks')).not.toBeInTheDocument(); - }); + await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); + const nameInput = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(nameInput); + await user.type(nameInput, 'Missing project'); + const folderInput = screen.getByLabelText('projectDialog.folderLabel'); + await user.clear(folderInput); + await user.type(folderInput, '/tmp/missing-project'); + await user.click(screen.getByRole('button', { name: 'save' })); - it('infers the default project from sessions when project metadata is unavailable', async () => { - client.get.mockRejectedValue({ response: { status: 404 } }); - useSessions.mockReturnValue({ - sessions: [ - { ...session, projectID: 'project-1', directory: '/tmp/flocks' }, - { - ...secondSession, - projectID: 'old-project-id', - directory: '/tmp/flocks', - title: 'Fallback Same Worktree Session', - }, - ], - loading: false, - error: null, - refetch: refetchSessions, - updateSessionTitle, - removeSession, - removeSessions, - addSession, + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'projectDialog.saveFailed', + 'Project directory does not exist', + ); }); - - renderSessionPage(); - - expect(await screen.findByText('defaultProjectName')).toBeInTheDocument(); - expect(screen.getByText('Fallback Same Worktree Session')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); - expect(screen.queryByText('flocks')).not.toBeInTheDocument(); }); - it('creates a user-managed project from the sidebar', async () => { + it('submits project creation only once when save is clicked twice quickly', async () => { const user = userEvent.setup(); - let projectRows = [ - { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }, - ]; - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') { - return Promise.resolve({ data: projectRows[0] }); - } - return Promise.resolve({ data: projectRows }); - }); - client.post.mockImplementation((url: string, payload: Record) => { - if (url === '/api/project') { - const created = { id: 'prj_project2', worktree: '/tmp/project', name: payload.name as string }; - projectRows = [projectRows[0], created]; - return Promise.resolve({ data: created }); - } - return Promise.resolve({ data: secondSession }); - }); + let resolveCreate: ((value: { data: Record }) => void) | undefined; + client.post.mockImplementation(() => new Promise((resolve) => { + resolveCreate = resolve; + })); renderSessionPage(); await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); - await user.type(screen.getByLabelText('projectDialog.nameLabel'), 'Labs'); - await user.click(screen.getByRole('button', { name: 'save' })); - - await waitFor(() => { - expect(client.post).toHaveBeenCalledWith('/api/project', { name: 'Labs' }); - expect(screen.getByText('Labs')).toBeInTheDocument(); - }); + const nameInput = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(nameInput); + await user.type(nameInput, 'Labs'); + const folderInput = screen.getByLabelText('projectDialog.folderLabel'); + await user.clear(folderInput); + await user.type(folderInput, '/tmp/labs'); + const saveButton = screen.getByRole('button', { name: 'save' }); + + act(() => { + saveButton.click(); + saveButton.click(); + }); + + expect(client.post).toHaveBeenCalledTimes(1); + resolveCreate?.({ data: { id: 'prj_project2', name: 'Labs', worktree: '/tmp/labs' } }); + await waitFor(() => expect(screen.queryByRole('button', { name: 'save' })).not.toBeInTheDocument()); }); it('keeps a newly created empty project visible while search is active', async () => { const user = userEvent.setup(); - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'Security Project' }; - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') { - return Promise.resolve({ data: currentProject }); - } - return Promise.resolve({ data: [currentProject] }); - }); + const currentProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; + client.get.mockResolvedValue({ data: [currentProject] }); client.post.mockImplementation((url: string, payload: Record) => { if (url === '/api/project') { - const created = { id: 'prj_project2', worktree: '/tmp/project', name: payload.name as string }; + const created = { id: 'prj_project2', worktree: payload.worktree as string, name: payload.name as string }; return Promise.resolve({ data: created }); } return Promise.resolve({ data: secondSession }); @@ -669,7 +556,12 @@ describe('SessionPage session actions menu', () => { await user.type(screen.getByPlaceholderText('filterConversations'), 'nothing matches'); await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); - await user.type(screen.getByLabelText('projectDialog.nameLabel'), 'Labs'); + const nameInput = screen.getByLabelText('projectDialog.nameLabel'); + await user.clear(nameInput); + await user.type(nameInput, 'Labs'); + const folderInput = screen.getByLabelText('projectDialog.folderLabel'); + await user.clear(folderInput); + await user.type(folderInput, '/tmp/labs'); await user.click(screen.getByRole('button', { name: 'save' })); expect(await screen.findByText('Labs')).toBeInTheDocument(); @@ -678,17 +570,12 @@ describe('SessionPage session actions menu', () => { it('renames a project from the sidebar', async () => { const user = userEvent.setup(); - client.get.mockImplementation((url: string) => { - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; - if (url === '/api/project/current') { - return Promise.resolve({ data: currentProject }); - } - return Promise.resolve({ - data: [ - currentProject, - { id: 'historical-project-id', worktree: '/tmp/labs', name: 'Labs' }, - ], - }); + const defaultProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; + client.get.mockResolvedValue({ + data: [defaultProject, { id: 'prj_project2', worktree: '/tmp/labs', name: 'Labs' }], + }); + client.patch.mockResolvedValue({ + data: { id: 'prj_project2', worktree: '/tmp/labs', name: 'Renamed Project' }, }); renderSessionPage(); @@ -704,15 +591,12 @@ describe('SessionPage session actions menu', () => { await user.click(screen.getByRole('button', { name: 'save' })); await waitFor(() => { - expect(client.patch).toHaveBeenCalledWith('/api/project/historical-project-id', { name: 'Renamed Project' }); + expect(client.patch).toHaveBeenCalledWith('/api/project/prj_project2', { name: 'Renamed Project' }); }); }); - it('opens project actions and renames the default project', async () => { + it('does not expose rename or delete actions for the default project', async () => { const user = userEvent.setup(); - client.patch.mockResolvedValue({ - data: { id: 'project-1', worktree: '/tmp/project', name: 'Main Project' }, - }); renderSessionPage(); @@ -720,32 +604,25 @@ describe('SessionPage session actions menu', () => { const projectRow = projectLabel.closest('[class*="group/project"]'); expect(projectRow).not.toBeNull(); await user.click(within(projectRow as HTMLElement).getByRole('button', { name: 'projectActions' })); - await user.click(within(projectRow as HTMLElement).getByRole('menuitem', { name: 'projectDialog.renameAction' })); - const input = screen.getByLabelText('projectDialog.nameLabel'); - await user.clear(input); - await user.type(input, 'Main Project'); - await user.click(screen.getByRole('button', { name: 'save' })); - - await waitFor(() => { - expect(client.patch).toHaveBeenCalledWith('/api/project/project-1', { name: 'Main Project' }); - }); - expect(await screen.findByText('Main Project')).toBeInTheDocument(); + expect(within(projectRow as HTMLElement).queryByRole('menuitem', { name: 'projectDialog.newSessionAction' })).not.toBeInTheDocument(); + expect(within(projectRow as HTMLElement).queryByRole('menuitem', { name: 'projectDialog.renameAction' })).not.toBeInTheDocument(); + expect(within(projectRow as HTMLElement).queryByRole('menuitem', { name: 'projectDialog.deleteAction' })).not.toBeInTheDocument(); + expect(client.patch).not.toHaveBeenCalled(); }); it('deletes an empty user-managed project after confirmation', async () => { const user = userEvent.setup(); - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; - client.get.mockImplementation((url: string) => { - if (url === '/api/project/current') return Promise.resolve({ data: currentProject }); - return Promise.resolve({ - data: [ - currentProject, - { id: 'prj_project2', worktree: '/tmp/project', name: 'Labs' }, - ], - }); + const currentProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; + let projectRows = [ + currentProject, + { id: 'prj_project2', worktree: '/tmp/labs', name: 'Labs' }, + ]; + client.get.mockImplementation(() => Promise.resolve({ data: projectRows })); + client.delete.mockImplementation(() => { + projectRows = [currentProject]; + return Promise.resolve({ data: true }); }); - client.delete.mockResolvedValue({ data: true }); renderSessionPage(); @@ -764,17 +641,9 @@ describe('SessionPage session actions menu', () => { it('creates a session from a specific project row', async () => { const user = userEvent.setup(); - client.get.mockImplementation((url: string) => { - const currentProject = { id: 'project-1', worktree: '/tmp/project', name: 'project' }; - if (url === '/api/project/current') { - return Promise.resolve({ data: currentProject }); - } - return Promise.resolve({ - data: [ - currentProject, - { id: 'prj_project2', worktree: '/tmp/project', name: 'Labs' }, - ], - }); + const currentProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; + client.get.mockResolvedValue({ + data: [currentProject, { id: 'prj_project2', worktree: '/tmp/labs', name: 'Labs' }], }); client.post.mockResolvedValue({ data: { @@ -809,6 +678,7 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); + await screen.findByText('Original Session'); await user.click(screen.getByRole('button', { name: 'moreActions' })); expect(screen.getByRole('button', { name: 'rename' })).toBeInTheDocument(); @@ -821,6 +691,7 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); + await screen.findByText('Original Session'); await user.click(screen.getByRole('button', { name: 'moreActions' })); await user.click(screen.getByRole('button', { name: 'rename' })); @@ -897,6 +768,7 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); + await screen.findByText('Original Session'); await user.click(screen.getByRole('button', { name: 'moreActions' })); await user.click(screen.getByRole('button', { name: 'downloadJson' })); @@ -938,6 +810,7 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); + await screen.findByText('Original Session'); await user.click(screen.getByRole('button', { name: 'moreActions' })); await user.click(screen.getByRole('button', { name: 'deleteAction' })); diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index eb0563fb1..599de665f 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -5,7 +5,7 @@ import { PanelLeftClose, PanelLeft, Bot, Loader2, Workflow as WorkflowIcon, Settings2, CheckSquare, MoreHorizontal, PencilLine, Download, Share2, Cpu, Info, - FolderGit2, FolderPlus, + FolderGit2, FolderPlus, FolderOpen, Copy, ArrowUp, HardDrive, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import i18n from '@/i18n'; @@ -32,6 +32,7 @@ import { buildPromptParts, type ImagePartData } from '@/utils/imageUpload'; import { getAgentDisplayDescription, getAgentDisplayName, isAgentUsableInChat } from '@/utils/agentDisplay'; import { formatSessionDate } from '@/utils/time'; import type { ModelDefinitionV2, Session } from '@/types'; +import { useAuth } from '@/contexts/AuthContext'; function sanitizeSessionExportName(value: string) { const trimmed = value.trim(); @@ -53,6 +54,11 @@ type ProjectSummary = { worktree: string; vcs?: string | null; name?: string | null; + isDefault?: boolean; + pathStatus?: 'available' | 'missing' | 'unreadable'; + sessionCount?: number; + matchedSessionCount?: number; + lastActivityAt?: number | null; }; type ProjectDialogMode = 'create' | 'rename'; type ProjectSessionGroup = { @@ -60,7 +66,19 @@ type ProjectSessionGroup = { label: string; worktree: string; sessions: Session[]; + sessionCount: number; + isDefault: boolean; + pathStatus: 'available' | 'missing' | 'unreadable'; }; +type FolderEntry = { name: string; path: string }; +type FolderBrowserResponse = { + path: string; + parent?: string | null; + roots: FolderEntry[]; + entries: FolderEntry[]; +}; +const MULTI_PROJECT_SESSION_PAGE_SIZE = 6; +const SINGLE_PROJECT_SESSION_PAGE_SIZE = 20; type ChatModelOption = { key: string; providerID: string; @@ -150,88 +168,6 @@ function getProjectLabel(project?: ProjectSummary, fallbackDirectory?: string): return getPathBasename(directory); } -function normalizeProjectPath(path?: string): string { - return (path || '').replace(/[\\/]+$/g, ''); -} - -function isLegacyDefaultProjectId(projectId?: string): boolean { - return !projectId || projectId === 'default' || projectId === 'global'; -} - -export type SessionGroupKey = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'earlier'; - -export interface SessionGroup { - key: SessionGroupKey; - labelKey: string; - items: Session[]; -} - -const SESSION_GROUP_DEFAULT_LIMIT: Record = { - today: Infinity, - yesterday: Infinity, - thisWeek: 5, - lastWeek: 5, - earlier: 5, -}; - -const SESSION_GROUP_DEFS: Array> = [ - { key: 'today', labelKey: 'groupToday' }, - { key: 'yesterday', labelKey: 'groupYesterday' }, - { key: 'thisWeek', labelKey: 'groupThisWeek' }, - { key: 'lastWeek', labelKey: 'groupLastWeek' }, - { key: 'earlier', labelKey: 'groupEarlier' }, -]; - -export function groupSessionsByDate( - sessions: Session[], - searchQuery: string, - now = new Date(), -): SessionGroup[] { - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const yesterdayStart = todayStart - 86400000; - const dayOfWeek = now.getDay() === 0 ? 7 : now.getDay(); - const thisWeekStart = todayStart - (dayOfWeek - 1) * 86400000; - const lastWeekStart = thisWeekStart - 7 * 86400000; - - const q = searchQuery.toLowerCase().trim(); - const filtered = q ? sessions.filter(s => s.title.toLowerCase().includes(q)) : sessions; - const buckets: SessionGroup[] = SESSION_GROUP_DEFS.map((group) => ({ - ...group, - items: [], - })); - - for (const session of filtered) { - const ts = session.time?.updated ?? 0; - if (ts >= todayStart) buckets[0].items.push(session); - else if (ts >= yesterdayStart) buckets[1].items.push(session); - else if (ts >= thisWeekStart) buckets[2].items.push(session); - else if (ts >= lastWeekStart) buckets[3].items.push(session); - else buckets[4].items.push(session); - } - - return buckets.filter(group => group.items.length > 0); -} - -export function getVisibleSessionGroupItems({ - group, - expanded, - searching, -}: { - group: SessionGroup; - expanded: boolean; - searching: boolean; -}): { visibleItems: Session[]; hiddenCount: number; limit: number } { - const limit = searching ? Infinity : SESSION_GROUP_DEFAULT_LIMIT[group.key]; - const visibleItems = (searching || expanded || group.items.length <= limit) - ? group.items - : group.items.slice(0, limit); - return { - visibleItems, - hiddenCount: group.items.length - visibleItems.length, - limit, - }; -} - interface SessionSidebarItemProps { session: Session; selected: boolean; @@ -371,16 +307,10 @@ const SessionSidebarItem = memo(SessionSidebarItemInner, (prev, next) => ( prev.t === next.t )); -function isUserManagedProjectId(projectId?: string | null): boolean { - return Boolean(projectId?.startsWith('prj_')); -} - -function isUserManagedProject(project?: ProjectSummary): boolean { - return isUserManagedProjectId(project?.id); -} - export default function SessionPage() { const { t, i18n } = useTranslation('session'); + const { user } = useAuth(); + const activeProjectStorageKey = `flocks:sessions:active-project:${user?.id ?? 'anonymous'}`; const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -399,7 +329,6 @@ export default function SessionPage() { const [selectMode, setSelectMode] = useState(false); const [checkedIds, setCheckedIds] = useState>(new Set()); const [projects, setProjects] = useState([]); - const [currentProjectId, setCurrentProjectId] = useState(null); const [selectedProjectId, setSelectedProjectId] = useState(null); const [collapsedProjectIds, setCollapsedProjectIds] = useState>(new Set()); const [projectDialogMode, setProjectDialogMode] = useState(null); @@ -408,6 +337,10 @@ export default function SessionPage() { const [projectDeleting, setProjectDeleting] = useState(false); const [editingProjectId, setEditingProjectId] = useState(null); const [projectNameValue, setProjectNameValue] = useState(''); + const [projectWorktreeValue, setProjectWorktreeValue] = useState(''); + const [projectNameManuallyEdited, setProjectNameManuallyEdited] = useState(false); + const [folderBrowser, setFolderBrowser] = useState(null); + const [folderBrowserLoading, setFolderBrowserLoading] = useState(false); const [projectSubmitting, setProjectSubmitting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); const [openMenuSessionId, setOpenMenuSessionId] = useState(null); @@ -423,9 +356,14 @@ export default function SessionPage() { const [selectorTooltip, setSelectorTooltip] = useState(null); const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); + const projectSubmitInFlightRef = useRef(false); const sessionUpdateRefetchTimerRef = useRef(null); const toast = useToast(); + const sessionProjectIds = useMemo( + () => projects.map((project) => project.id), + [projects], + ); const { sessions, loading: loadingSessions, @@ -434,10 +372,15 @@ export default function SessionPage() { removeSession, removeSessions, addSession, - hasMore: hasMoreSessions, - loadingMore: loadingMoreSessions, + hasMoreByProject = {}, + loadingMoreProjectIds = new Set(), loadMore: loadMoreSessions, - } = useSessions(searchQuery); + } = useSessions(searchQuery, { + projectIds: sessionProjectIds, + pageSize: projects.length >= 2 + ? MULTI_PROJECT_SESSION_PAGE_SIZE + : SINGLE_PROJECT_SESSION_PAGE_SIZE, + }); const { agents, loading: loadingAgents } = useAgents(); const { providers, loading: loadingProviders } = useProviders(); const { @@ -560,82 +503,49 @@ export default function SessionPage() { }); }, []); - const defaultProjectId = useMemo(() => { - if (currentProjectId) return currentProjectId; - const firstAutoProject = projects.find((project) => !isUserManagedProject(project)); - const firstAutoSession = sessions.find((session) => ( - session.projectID - && !isLegacyDefaultProjectId(session.projectID) - && !isUserManagedProjectId(session.projectID) - )); - return firstAutoProject?.id ?? firstAutoSession?.projectID ?? projects[0]?.id ?? null; - }, [currentProjectId, projects, sessions]); + const defaultProjectId = useMemo( + () => projects.find((project) => project.isDefault)?.id ?? projects[0]?.id ?? null, + [projects], + ); const projectSessionGroups = useMemo(() => { - const projectMap = new Map(); - const defaultProject = defaultProjectId - ? projects.find((project) => project.id === defaultProjectId) - : undefined; - const defaultSession = defaultProjectId - ? sessions.find((session) => session.projectID === defaultProjectId) - : undefined; - const defaultProjectWorktree = normalizeProjectPath(defaultProject?.worktree ?? defaultSession?.directory); - const userManagedProjectIds = new Set( - [ - ...projects.filter(isUserManagedProject).map((project) => project.id), - ...sessions.map((session) => session.projectID).filter(isUserManagedProjectId), - ], - ); - - projects.forEach((project) => { - const isDefaultProject = defaultProjectId === project.id; - const isSameWorktreeAsDefault = Boolean( - defaultProjectWorktree - && normalizeProjectPath(project.worktree) === defaultProjectWorktree, - ); - if (!isDefaultProject && isSameWorktreeAsDefault && !isUserManagedProject(project)) { - return; - } - - projectMap.set(project.id, { - id: project.id, - label: isDefaultProject - && (!project.name?.trim() || project.name.trim() === getPathBasename(project.worktree)) - ? t('defaultProjectName') - : getProjectLabel(project), - worktree: project.worktree, - sessions: [], - }); - }); - + const registeredIds = new Set(projects.map((project) => project.id)); + const sessionsByProject = new Map(); sessions.forEach((session) => { - const sessionDirectory = normalizeProjectPath(session.directory); - const shouldUseDefaultProject = defaultProjectId - && defaultProjectWorktree - && sessionDirectory === defaultProjectWorktree - && !userManagedProjectIds.has(session.projectID); - const projectId = (isLegacyDefaultProjectId(session.projectID) || shouldUseDefaultProject) && defaultProjectId - ? defaultProjectId - : session.projectID || session.directory || 'global'; - const existing = projectMap.get(projectId); - if (existing) { - existing.sessions.push(session); - return; - } - - projectMap.set(projectId, { - id: projectId, - label: projectId === defaultProjectId ? t('defaultProjectName') : getProjectLabel(undefined, session.directory), - worktree: session.directory, - sessions: [session], - }); + const responseProjectId = session.effectiveProjectID || session.projectID; + const projectId = registeredIds.has(responseProjectId) + ? responseProjectId + : defaultProjectId; + if (!projectId) return; + const groupSessions = sessionsByProject.get(projectId) ?? []; + groupSessions.push(session); + sessionsByProject.set(projectId, groupSessions); }); - return Array.from(projectMap.values()) - .filter((group) => group.sessions.length > 0 || !searchQuery.trim() || group.id === selectedProjectId) + return projects.map((project) => { + const isDefault = project.isDefault ?? project.id === defaultProjectId; + const projectSessions = sessionsByProject.get(project.id) ?? []; + return { + id: project.id, + label: isDefault ? t('defaultProjectName') : getProjectLabel(project), + worktree: project.worktree, + sessions: projectSessions, + sessionCount: searchQuery.trim() + ? project.matchedSessionCount ?? projectSessions.length + : project.sessionCount ?? projectSessions.length, + isDefault, + pathStatus: project.pathStatus ?? 'available', + }; + }) + .filter((group) => { + if (!searchQuery.trim()) return true; + const project = projects.find((item) => item.id === group.id); + return (project?.matchedSessionCount ?? group.sessions.length) > 0 || group.id === selectedProjectId; + }) .sort((a, b) => { - const aLatest = a.sessions[0]?.time?.updated ?? 0; - const bLatest = b.sessions[0]?.time?.updated ?? 0; + if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; + const aLatest = projects.find((project) => project.id === a.id)?.lastActivityAt ?? a.sessions[0]?.time?.updated ?? 0; + const bLatest = projects.find((project) => project.id === b.id)?.lastActivityAt ?? b.sessions[0]?.time?.updated ?? 0; if (aLatest !== bLatest) return bLatest - aLatest; return a.label.localeCompare(b.label); }); @@ -653,11 +563,28 @@ export default function SessionPage() { return; } - const fallbackProjectId = defaultProjectId && projectSessionGroups.some((group) => group.id === defaultProjectId) - ? defaultProjectId - : projectSessionGroups[0].id; + let storedProjectId: string | null = null; + try { + storedProjectId = window.localStorage.getItem(activeProjectStorageKey); + } catch { + storedProjectId = null; + } + const fallbackProjectId = storedProjectId && projectSessionGroups.some((group) => group.id === storedProjectId) + ? storedProjectId + : defaultProjectId && projectSessionGroups.some((group) => group.id === defaultProjectId) + ? defaultProjectId + : projectSessionGroups[0].id; setSelectedProjectId(fallbackProjectId); - }, [defaultProjectId, projectSessionGroups, selectedProjectId]); + }, [activeProjectStorageKey, defaultProjectId, projectSessionGroups, selectedProjectId]); + + useEffect(() => { + if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) return; + try { + window.localStorage.setItem(activeProjectStorageKey, selectedProjectId); + } catch { + // Active project persistence must never block the session manager. + } + }, [activeProjectStorageKey, projects, selectedProjectId]); // Handle SSE events for session-level updates (title changes, etc.) const handleChatError = useCallback((msg: string) => { @@ -692,38 +619,22 @@ export default function SessionPage() { } }, [scheduleSessionListRefetch, updateSessionTitle]); - const fetchProjects = useCallback(async (ensureProject?: ProjectSummary) => { - const [listResult, currentResult] = await Promise.allSettled([ - client.get('/api/project'), - client.get('/api/project/current'), - ]); - - let nextProjects = listResult.status === 'fulfilled' && Array.isArray(listResult.value.data) - ? listResult.value.data - : []; - const currentProject = currentResult.status === 'fulfilled' - ? currentResult.value.data as ProjectSummary | undefined - : undefined; - if (currentProject?.id && !nextProjects.some((project) => project.id === currentProject.id)) { - nextProjects = [currentProject, ...nextProjects]; - } + const fetchProjects = useCallback(async (ensureProject?: ProjectSummary, query = '') => { + const listResult = await client.get('/api/project', { + params: { search: query.trim() || undefined }, + }); + let nextProjects = Array.isArray(listResult.data) ? listResult.data : []; if (ensureProject?.id) { nextProjects = nextProjects.some((project) => project.id === ensureProject.id) ? nextProjects.map((project) => (project.id === ensureProject.id ? ensureProject : project)) : [ensureProject, ...nextProjects]; } - const nextCurrentProjectId = currentResult.status === 'fulfilled' - ? currentResult.value.data?.id ?? null - : null; - setProjects(nextProjects); - setCurrentProjectId(nextCurrentProjectId); - setSelectedProjectId((current) => current ?? nextCurrentProjectId ?? nextProjects[0]?.id ?? null); }, []); useEffect(() => { - void fetchProjects(); - }, [fetchProjects]); + void fetchProjects(undefined, searchQuery); + }, [fetchProjects, searchQuery]); useEffect(() => { if (!openProjectMenuId) return; @@ -907,6 +818,7 @@ export default function SessionPage() { ...(projectID ? { projectID } : {}), }); addSession(response.data); + await fetchProjects(undefined, searchQuery); setSelectedSessionFallback(response.data); if (projectID) { setSelectedProjectId(projectID); @@ -924,7 +836,7 @@ export default function SessionPage() { } finally { setCreating(false); } - }, [creating, selectedProjectIDForCreate, addSession, toast, t]); + }, [creating, selectedProjectIDForCreate, addSession, fetchProjects, searchQuery, toast, t]); const handleCreateSessionInProject = useCallback((projectId: string) => { void handleCreateSession(projectId); @@ -962,6 +874,7 @@ export default function SessionPage() { const newSessionId = response.data.id; addSession(response.data); + await fetchProjects(undefined, searchQuery); setSelectedSessionFallback(response.data); setSelectedModelKey(null); setSelectedSessionId(newSessionId); @@ -979,7 +892,7 @@ export default function SessionPage() { } catch (err: any) { toast.error(t('createFailed'), err.message); } - }, [addSession, selectedAgent, selectedProjectIDForCreate, toast, t]); + }, [addSession, fetchProjects, searchQuery, selectedAgent, selectedProjectIDForCreate, toast, t]); const handleSuiteInstallProgress = useCallback((progress: HubInstallProgressEvent) => { setSuiteInstallProgress(current => applySuiteInstallProgressEvent(current, progress)); @@ -1055,10 +968,11 @@ export default function SessionPage() { // No need to refetchSessions — removeSession already keeps the list accurate. if (selectedSessionId === sessionId) setSelectedSessionId(null); removeSession(sessionId); + await fetchProjects(undefined, searchQuery); } catch (err: any) { toast.error(t('deleteFailed'), err.message); } - }, [selectedSessionId, removeSession, toast, t]); + }, [fetchProjects, removeSession, searchQuery, selectedSessionId, toast, t]); const handleStartRename = useCallback((sessionId: string, currentTitle: string) => { setOpenMenuSessionId(null); @@ -1103,10 +1017,15 @@ export default function SessionPage() { }, [renameValue, sessions, t, toast, updateSessionTitle]); const handleOpenCreateProject = useCallback(() => { + const defaultProject = projects.find((project) => project.isDefault) + ?? projects.find((project) => project.id === defaultProjectId); setProjectDialogMode('create'); setEditingProjectId(null); - setProjectNameValue(''); - }, []); + setProjectWorktreeValue(defaultProject?.worktree ?? ''); + setProjectNameValue(defaultProject?.worktree ? getPathBasename(defaultProject.worktree) : ''); + setProjectNameManuallyEdited(false); + setFolderBrowser(null); + }, [defaultProjectId, projects]); const handleOpenRenameProject = useCallback((project: ProjectSessionGroup) => { const persistedName = projects.find((item) => item.id === project.id)?.name?.trim(); @@ -1116,6 +1035,9 @@ export default function SessionPage() { setProjectNameValue(persistedName && persistedName !== getPathBasename(project.worktree) ? persistedName : project.label); + setProjectWorktreeValue(project.worktree); + setProjectNameManuallyEdited(true); + setFolderBrowser(null); }, [projects]); const handleCloseProjectDialog = useCallback(() => { @@ -1123,8 +1045,43 @@ export default function SessionPage() { setProjectDialogMode(null); setEditingProjectId(null); setProjectNameValue(''); + setProjectWorktreeValue(''); + setProjectNameManuallyEdited(false); + setFolderBrowser(null); }, [projectSubmitting]); + const loadFolderBrowser = useCallback(async (path?: string) => { + setFolderBrowserLoading(true); + try { + const response = await client.get('/api/project/folders', { + params: { path: path?.trim() || undefined }, + }); + setFolderBrowser(response.data as FolderBrowserResponse); + } catch (err: any) { + toast.error(t('projectDialog.folderBrowseFailed'), err.message); + } finally { + setFolderBrowserLoading(false); + } + }, [t, toast]); + + const handleSelectProjectFolder = useCallback((path: string) => { + setProjectWorktreeValue(path); + if (!projectNameManuallyEdited) { + setProjectNameValue(getPathBasename(path)); + } + setFolderBrowser(null); + }, [projectNameManuallyEdited]); + + const handleCopyProjectPath = useCallback(async (project: ProjectSessionGroup) => { + setOpenProjectMenuId(null); + try { + await navigator.clipboard.writeText(project.worktree); + toast.success(t('projectDialog.pathCopied')); + } catch (err: any) { + toast.error(t('projectDialog.copyPathFailed'), err.message); + } + }, [t, toast]); + const handleOpenDeleteProject = useCallback((project: ProjectSessionGroup) => { setOpenProjectMenuId(null); setProjectPendingDelete(project); @@ -1146,26 +1103,38 @@ export default function SessionPage() { current === projectPendingDelete.id ? defaultProjectId : current )); setProjectPendingDelete(null); + await Promise.all([ + fetchProjects(undefined, searchQuery), + refetchSessions(), + ]); toast.success(t('projectDialog.deleteSuccess')); } catch (err: any) { toast.error(t('projectDialog.deleteFailed'), err.message); } finally { setProjectDeleting(false); } - }, [defaultProjectId, projectDeleting, projectPendingDelete, t, toast]); + }, [defaultProjectId, fetchProjects, projectDeleting, projectPendingDelete, refetchSessions, searchQuery, t, toast]); const handleSubmitProject = useCallback(async () => { - if (!projectDialogMode || projectSubmitting) return; + if (!projectDialogMode || projectSubmitInFlightRef.current) return; const nextName = projectNameValue.trim(); if (!nextName) { toast.error(t('projectDialog.saveFailed'), t('projectDialog.nameEmpty')); return; } + if (projectDialogMode === 'create' && !projectWorktreeValue.trim()) { + toast.error(t('projectDialog.saveFailed'), t('projectDialog.folderEmpty')); + return; + } + projectSubmitInFlightRef.current = true; setProjectSubmitting(true); try { if (projectDialogMode === 'create') { - const response = await client.post('/api/project', { name: nextName }); + const response = await client.post('/api/project', { + name: nextName, + worktree: projectWorktreeValue.trim(), + }); const createdProject = response.data as ProjectSummary; setProjects(prev => ( prev.some((project) => project.id === createdProject.id) @@ -1186,12 +1155,20 @@ export default function SessionPage() { setProjectDialogMode(null); setEditingProjectId(null); setProjectNameValue(''); + setProjectWorktreeValue(''); + setProjectNameManuallyEdited(false); + setFolderBrowser(null); } catch (err: any) { - toast.error(t('projectDialog.saveFailed'), err.message); + const detail = err?.response?.data?.detail; + toast.error( + t('projectDialog.saveFailed'), + typeof detail === 'string' ? detail : err?.message, + ); } finally { + projectSubmitInFlightRef.current = false; setProjectSubmitting(false); } - }, [editingProjectId, fetchProjects, projectDialogMode, projectNameValue, projectSubmitting, t, toast]); + }, [editingProjectId, fetchProjects, projectDialogMode, projectNameValue, projectWorktreeValue, t, toast]); const handleDownloadSession = useCallback(async (sessionId: string, title: string) => { setOpenMenuSessionId(null); @@ -1296,6 +1273,7 @@ export default function SessionPage() { })); if (succeeded.length > 0) { removeSessions(succeeded); + await fetchProjects(undefined, searchQuery); if (selectedSessionId && succeeded.includes(selectedSessionId)) { setSelectedSessionId(null); } @@ -1308,7 +1286,7 @@ export default function SessionPage() { setSelectMode(false); } setBatchDeleting(false); - }, [checkedIds, batchDeleting, removeSessions, selectedSessionId, toast, t]); + }, [batchDeleting, checkedIds, fetchProjects, removeSessions, searchQuery, selectedSessionId, toast, t]); const renderSessionListItem = (session: Session) => (
{projectSessionGroups.map((group) => { const collapsed = collapsedProjectIds.has(group.id); - const isDefaultProject = defaultProjectId === group.id; + const isDefaultProject = group.isDefault; const isSelectedProject = selectedProjectId === group.id; + const hasMoreProjectSessions = projects.length >= 2 + ? group.sessions.length < group.sessionCount + : hasMoreByProject[group.id]; const persistedProject = projects.find((project) => project.id === group.id); return (
@@ -1501,25 +1482,17 @@ export default function SessionPage() { type="button" onClick={() => { setSelectedProjectId(group.id); - if (isSelectedProject) { - toggleProjectCollapsed(group.id); - } else { - setCollapsedProjectIds(prev => { - const next = new Set(prev); - next.delete(group.id); - return next; - }); - } + toggleProjectCollapsed(group.id); }} className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-0.5 text-left" aria-label={t('selectProject', { project: group.label })} > {group.label} - {isDefaultProject && ( - )} @@ -1529,7 +1502,7 @@ export default function SessionPage() { event.stopPropagation(); handleCreateSessionInProject(group.id); }} - disabled={creating} + disabled={creating || group.pathStatus !== 'available'} className="rounded p-1 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200" title={t('createSessionInProject', { project: group.label })} aria-label={t('createSessionInProject', { project: group.label })} @@ -1552,7 +1525,7 @@ export default function SessionPage() { )} - {group.sessions.length} + {group.sessionCount}
{persistedProject && openProjectMenuId === group.id && ( @@ -1561,6 +1534,16 @@ export default function SessionPage() { role="menu" onClick={(event) => event.stopPropagation()} > + + {!isDefaultProject && ( + )} + {!isDefaultProject && (
+ )} + {!isDefaultProject && ( + )}
)} {!collapsed && ( @@ -1613,6 +1599,21 @@ export default function SessionPage() { {t('noProjectSessions')}
)} + {hasMoreProjectSessions && ( +
+ +
+ )}
)}
@@ -1621,18 +1622,6 @@ export default function SessionPage() {
)} - {hasMoreSessions && ( -
- -
- )}
{/* Bottom:批量操作栏 / 批量选择入口 */} @@ -1954,7 +1943,7 @@ export default function SessionPage() { {projectDialogMode && (
-
+

{projectDialogMode === 'create' ? t('projectDialog.createTitle') : t('projectDialog.renameTitle')} @@ -1967,7 +1956,10 @@ export default function SessionPage() { setProjectNameValue(event.target.value)} + onChange={(event) => { + setProjectNameValue(event.target.value); + setProjectNameManuallyEdited(true); + }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); @@ -1983,12 +1975,107 @@ export default function SessionPage() { className="w-full rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-blue-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-blue-500" autoFocus /> + {projectDialogMode === 'create' && ( + <> +
+ + project.isDefault)?.worktree}> + {t('projectDialog.defaultFolder', { + path: projects.find((project) => project.isDefault)?.worktree ?? '', + })} + +
+
+ { + setProjectWorktreeValue(event.target.value); + if (!projectNameManuallyEdited) { + setProjectNameValue(getPathBasename(event.target.value)); + } + }} + disabled={projectSubmitting} + placeholder={t('projectDialog.folderPlaceholder')} + className="min-w-0 flex-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-blue-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-blue-500" + /> + +
+ {folderBrowser && ( +
+
+ + + {folderBrowser.path} + + +
+
+ {folderBrowser.roots.map((root) => ( + + ))} +
+
+ {folderBrowser.entries.length > 0 ? folderBrowser.entries.map((entry) => ( + + )) : ( +
+ {t('projectDialog.noSubfolders')} +
+ )} +
+
+ )} + + )}

- {projectSessionGroups.map((group) => { + {managedProjectSessionGroups.map((group) => { const collapsed = collapsedProjectIds.has(group.id); const isDefaultProject = group.isDefault; const isSelectedProject = selectedProjectId === group.id; @@ -1651,6 +1670,101 @@ export default function SessionPage() { ); })}
+ {taskSessionGroup && ( +
+
+ + + + + {taskSessionGroup.sessionCount} + +
+ {!taskGroupCollapsed && ( +
+ {taskSessionGroup.sessions.map((session) => ( + + ))} + {hasMoreTaskSessions && ( +
+ +
+ )} +
+ )} +
+ )}
)}
From da75f61e971d6ac098d2a25938acce725397a0d9 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 20 Jul 2026 11:25:01 +0800 Subject: [PATCH 35/86] feat: add collapsible sidebar sections --- webui/src/locales/en-US/session.json | 1 + webui/src/locales/zh-CN/session.json | 1 + webui/src/pages/Session/index.test.tsx | 30 +++++++- webui/src/pages/Session/index.tsx | 102 ++++++++++++++++--------- 4 files changed, 95 insertions(+), 39 deletions(-) diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index e369939e4..359c6007d 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -263,6 +263,7 @@ "groupLastWeek": "Last Week", "groupEarlier": "Earlier", "projectsSection": "Projects", + "toggleProjects": "Expand or collapse projects", "tasksSection": "Tasks", "toggleTasks": "Expand or collapse tasks", "selectTasks": "Select tasks", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 55c5a4ccc..658c02dee 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -263,6 +263,7 @@ "groupLastWeek": "上周", "groupEarlier": "更早", "projectsSection": "项目", + "toggleProjects": "展开或收起项目", "tasksSection": "任务", "toggleTasks": "展开或收起任务", "selectTasks": "选择任务", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 68973a75a..3beb6b61b 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -351,7 +351,14 @@ describe('SessionPage session actions menu', () => { const user = userEvent.setup(); renderSessionPage(); - await screen.findByText('tasksSection'); + const tasksHeading = await screen.findByText('tasksSection'); + const projectsHeading = screen.getByText('projectsSection'); + const tasksSection = tasksHeading.closest('section'); + const projectsSection = projectsHeading.closest('section'); + expect(tasksSection).not.toBeNull(); + expect(projectsSection).not.toBeNull(); + expect(tasksSection?.parentElement).toBe(projectsSection?.parentElement); + expect(projectsSection).not.toContainElement(tasksHeading); expect(useSessions).toHaveBeenLastCalledWith('', { projectIds: ['default'], pageSize: 20, @@ -381,6 +388,27 @@ describe('SessionPage session actions menu', () => { }); }); + it('collapses the projects section and restores it after remounting', async () => { + const user = userEvent.setup(); + client.get.mockResolvedValue({ + data: [ + { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }, + { id: 'prj_labs', worktree: '/tmp/labs', name: 'Labs', isDefault: false }, + ], + }); + const firstRender = renderSessionPage(); + + await screen.findByText('Labs'); + await user.click(screen.getByRole('button', { name: 'toggleProjects' })); + expect(screen.queryByText('Labs')).not.toBeInTheDocument(); + + firstRender.unmount(); + renderSessionPage(); + + await screen.findByText('projectsSection'); + expect(screen.queryByText('Labs')).not.toBeInTheDocument(); + }); + it('restores collapsed projects after the session page remounts', async () => { const user = userEvent.setup(); client.get.mockResolvedValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index b01fd53e4..a0a3fdaa4 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -312,6 +312,7 @@ export default function SessionPage() { const { user } = useAuth(); const activeProjectStorageKey = `flocks:sessions:active-project:${user?.id ?? 'anonymous'}`; const collapsedProjectsStorageKey = `flocks:sessions:collapsed-projects:${user?.id ?? 'anonymous'}`; + const projectsSectionCollapsedStorageKey = `flocks:sessions:projects-section-collapsed:${user?.id ?? 'anonymous'}`; const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -339,6 +340,13 @@ export default function SessionPage() { return new Set(); } }); + const [projectsSectionCollapsed, setProjectsSectionCollapsed] = useState(() => { + try { + return window.localStorage.getItem(projectsSectionCollapsedStorageKey) === 'true'; + } catch { + return false; + } + }); const [projectDialogMode, setProjectDialogMode] = useState(null); const [openProjectMenuId, setOpenProjectMenuId] = useState(null); const [projectPendingDelete, setProjectPendingDelete] = useState(null); @@ -522,6 +530,17 @@ export default function SessionPage() { } }, [collapsedProjectIds, collapsedProjectsStorageKey]); + useEffect(() => { + try { + window.localStorage.setItem( + projectsSectionCollapsedStorageKey, + String(projectsSectionCollapsed), + ); + } catch { + // Section collapse persistence must never block the session manager. + } + }, [projectsSectionCollapsed, projectsSectionCollapsedStorageKey]); + const defaultProjectId = useMemo( () => projects.find((project) => project.isDefault)?.id ?? projects[0]?.id ?? null, [projects], @@ -1486,19 +1505,31 @@ export default function SessionPage() {
) : (
-
- {t('projectsSection')} - -
-
+
+
+ + +
+ {!projectsSectionCollapsed && ( +
{managedProjectSessionGroups.map((group) => { const collapsed = collapsedProjectIds.has(group.id); const isDefaultProject = group.isDefault; @@ -1669,40 +1700,35 @@ export default function SessionPage() {
); })} -
+
+ )} + {taskSessionGroup && ( -
-
+
+
- + {taskSessionGroup.sessionCount}
@@ -1763,7 +1789,7 @@ export default function SessionPage() { )}
)} -
+ )}
)} From ee5735aae3afa61215792b16d7c4253a5a9eb583 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 20 Jul 2026 11:30:01 +0800 Subject: [PATCH 36/86] fix(updater): tolerate Windows handoff output encoding --- flocks/updater/restart_handoff.py | 11 ++++++----- tests/updater/test_restart_handoff.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index 32b6543e3..55f45ed98 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -270,16 +270,17 @@ def _start_service_after_upgrade(args: argparse.Namespace) -> tuple[bool, str, s start_argv, cwd=Path(args.install_root), capture_output=True, - text=True, + text=False, check=False, ) + stdout = updater_module._clean_process_output(completed.stdout) + stderr = updater_module._clean_process_output(completed.stderr) if completed.returncode == 0: - return True, completed.stdout, completed.stderr + return True, stdout, stderr _record_handoff_log( - "restart_failed " - f"returncode={completed.returncode} stdout={completed.stdout.strip()} stderr={completed.stderr.strip()}" + f"restart_failed returncode={completed.returncode} stdout={stdout} stderr={stderr}" ) - return False, completed.stdout, completed.stderr + return False, stdout, stderr def _write_upgrade_result( diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index 9681d4bb8..60a028148 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -434,6 +434,30 @@ def test_upgrade_that_was_stopped_does_not_start_service(monkeypatch, tmp_path: assert restart_handoff._start_service_after_upgrade(parsed) == (True, "", "") +def test_upgrade_start_decodes_invalid_windows_output_without_crashing(monkeypatch, tmp_path: Path) -> None: + parsed = restart_handoff._parse_args(_simple_upgrade_handoff_args(tmp_path)) + logs: list[str] = [] + + def fake_run(command, **kwargs): + assert kwargs["text"] is False + return subprocess.CompletedProcess( + command, + 1, + stdout=b"start stdout \x80", + stderr=b"start stderr \x81", + ) + + monkeypatch.setattr(restart_handoff.subprocess, "run", fake_run) + monkeypatch.setattr(restart_handoff, "_record_handoff_log", logs.append) + + assert restart_handoff._start_service_after_upgrade(parsed) == ( + False, + "start stdout �", + "start stderr �", + ) + assert logs == ["restart_failed returncode=1 stdout=start stdout � stderr=start stderr �"] + + def test_run_waits_for_parent_and_backend_port_before_spawning( monkeypatch, tmp_path: Path, From 517059fdae615e92194bf6c67cf56193ccbdba0c Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 20 Jul 2026 12:50:15 +0800 Subject: [PATCH 37/86] fix(updater): preserve legacy handoff endpoints --- flocks/updater/restart_handoff.py | 35 +++++++++++++++++++++++++++ tests/updater/test_restart_handoff.py | 18 ++++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/flocks/updater/restart_handoff.py b/flocks/updater/restart_handoff.py index 55f45ed98..f763d8970 100644 --- a/flocks/updater/restart_handoff.py +++ b/flocks/updater/restart_handoff.py @@ -516,6 +516,40 @@ def _cli_subcommand(argv: Sequence[str]) -> str | None: return None +def _restore_legacy_handoff_endpoints(args: argparse.Namespace) -> None: + """Restore endpoints lost by pre-v2026.7.8 split-service handoffs.""" + if not args.backend_pid_file or _cli_subcommand(args.restart_argv) != "serve": + return + + state_path = updater_module._flocks_root() / "run" / "upgrade-state.json" + try: + payload = json.loads(state_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except (OSError, json.JSONDecodeError) as exc: + _record_handoff_log(f"legacy_service_config_invalid error={exc}") + return + + if not isinstance(payload, dict): + _record_handoff_log("legacy_service_config_invalid error=state is not an object") + return + + hosts = (payload.get("backend_host"), payload.get("frontend_host")) + ports = (payload.get("backend_port"), payload.get("frontend_port")) + if not all(isinstance(host, str) and host.strip() for host in hosts) or not all( + isinstance(port, int) and not isinstance(port, bool) and 0 < port <= 65535 for port in ports + ): + _record_handoff_log("legacy_service_config_invalid error=invalid host or port") + return + + args.backend_host, args.frontend_host = hosts + args.backend_port, args.frontend_port = ports + _record_handoff_log( + "legacy_service_config_restored " + f"backend={args.backend_host}:{args.backend_port} frontend={args.frontend_host}:{args.frontend_port}" + ) + + def _restart_argv_for_current_runtime(args: argparse.Namespace, restart_argv: Sequence[str]) -> list[str]: if _cli_subcommand(restart_argv) != "serve": return list(restart_argv) @@ -545,6 +579,7 @@ def run(argv: Sequence[str] | None = None) -> int: if args.mode == "upgrade": return _run_simple_upgrade(args) + _restore_legacy_handoff_endpoints(args) restart_argv = _restart_argv_for_current_runtime(args, args.restart_argv) if not restart_argv: _record_handoff_log("missing_restart_argv") diff --git a/tests/updater/test_restart_handoff.py b/tests/updater/test_restart_handoff.py index 60a028148..b044d3baa 100644 --- a/tests/updater/test_restart_handoff.py +++ b/tests/updater/test_restart_handoff.py @@ -586,6 +586,20 @@ def test_run_accepts_legacy_backend_pid_file_argument(monkeypatch, tmp_path: Pat def test_v2026_7_1_upgrade_handoff_runs_tasks_and_restarts(monkeypatch, tmp_path: Path) -> None: events: list[str] = [] + flocks_root = tmp_path / "flocks-root" + upgrade_state = flocks_root / "run" / "upgrade-state.json" + upgrade_state.parent.mkdir(parents=True) + upgrade_state.write_text( + json.dumps( + { + "backend_host": "127.0.0.1", + "backend_port": 8000, + "frontend_host": "0.0.0.0", + "frontend_port": 8888, + } + ), + encoding="utf-8", + ) restart_argv = [ "python.exe", "-m", @@ -606,15 +620,15 @@ def test_v2026_7_1_upgrade_handoff_runs_tasks_and_restarts(monkeypatch, tmp_path "--host", "0.0.0.0", "--port", - "5173", + "8888", "--server-host", "127.0.0.1", "--server-port", "8000", ] args = _v2026_7_1_handoff_args(tmp_path, restart_argv) - args[args.index("--frontend-host") + 1] = "0.0.0.0" + monkeypatch.setattr(restart_handoff.updater_module, "_flocks_root", lambda: flocks_root) monkeypatch.setattr(restart_handoff, "_record_handoff_log", lambda _message: None) monkeypatch.setattr( restart_handoff, From 96c7d1af69b8f57213e4f730a16bdd6bd2968fe3 Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Mon, 20 Jul 2026 14:24:55 +0800 Subject: [PATCH 38/86] fix(channel): persist slack allowlist removal --- flocks/server/routes/config.py | 48 ++++++++++++++++++++ tests/server/routes/test_remaining_routes.py | 33 ++++++++++++++ webui/src/pages/Channel/index.tsx | 12 ++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 7f403f92c..64f920891 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -16,6 +16,7 @@ } """ +import json import re import xml.etree.ElementTree as ET from pathlib import Path @@ -35,6 +36,51 @@ log = Log.create(service="routes.config") +def _apply_channel_allow_from_deletions(config_data: Dict[str, Any]) -> None: + """Remove persisted channel allowFrom when PATCH explicitly sends null.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return + + channel_ids = [ + channel_id + for channel_id, channel_cfg in channels.items() + if isinstance(channel_cfg, dict) + and "allowFrom" in channel_cfg + and channel_cfg.get("allowFrom") is None + ] + if not channel_ids: + return + + config_file = Config.get_config_file() + if not config_file.exists(): + return + + try: + raw_config = json.loads(config_file.read_text(encoding="utf-8")) + except Exception as exc: + log.warning("config.channel_allow_from_delete_failed", {"error": str(exc)}) + return + + raw_channels = raw_config.get("channels") + if not isinstance(raw_channels, dict): + return + + changed = False + for channel_id in channel_ids: + raw_channel = raw_channels.get(channel_id) + if isinstance(raw_channel, dict) and "allowFrom" in raw_channel: + del raw_channel["allowFrom"] + changed = True + + if changed: + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text( + json.dumps(raw_config, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + + def _build_model_from_config( provider_id: str, model_id: str, @@ -554,6 +600,8 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: flocks.json, so that plaintext secrets never land in that file. """ try: + _apply_channel_allow_from_deletions(config_data) + # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): from flocks.security.channel_secrets import extract_channel_secrets diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index ce681b707..c312b2cc6 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio +import json from pathlib import Path import pytest @@ -508,6 +509,38 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) + def test_null_allow_from_deletes_existing_channel_field( + self, + tmp_path, + monkeypatch, + ): + """channels..allowFrom=null removes the persisted allowlist.""" + from flocks.server.routes import config as config_routes + + config_file = tmp_path / "config" / "flocks.json" + config_file.parent.mkdir(parents=True) + config_file.write_text( + json.dumps( + { + "channels": { + "slack": { + "enabled": False, + "allowFrom": ["U123"], + } + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(config_routes.Config, "get_config_file", lambda: config_file) + + config_routes._apply_channel_allow_from_deletions( + {"channels": {"slack": {"enabled": False, "allowFrom": None}}} + ) + + saved = json.loads(config_file.read_text(encoding="utf-8")) + assert "allowFrom" not in saved["channels"]["slack"] + @pytest.mark.asyncio async def test_ui_display_defaults_and_updates( self, diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index 56f0cf3fb..e83bf6ed4 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -3078,7 +3078,7 @@ export default function ChannelPage() { const updatedChannels = { ...(fullConfig.channels ?? {}), ...Object.fromEntries( - Object.entries(channelConfigs).map(([id, cfg]) => [id, stripEmpty(cfg)]) + Object.entries(channelConfigs).map(([id, cfg]) => [id, stripChannelConfigForSave(id, cfg)]) ), }; @@ -3441,3 +3441,13 @@ function stripEmpty(obj: Record): Record { } return result; } + +function stripChannelConfigForSave(channelId: string, cfg: Record): Record { + const result = stripEmpty(cfg); + + if (channelId === 'slack' && cfg.allowFrom === undefined) { + result.allowFrom = null; + } + + return result; +} From ccec29f4296a6e60403787a30e0002b5bf101d22 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 20 Jul 2026 15:02:56 +0800 Subject: [PATCH 39/86] fix: address session project review issues --- flocks/cli/commands/session.py | 11 ++- flocks/cli/commands/stats.py | 10 +- flocks/cli/session_runner.py | 14 ++- flocks/project/project.py | 46 +++++---- .../cli/test_session_runner_project_scope.py | 48 ++++++++++ tests/cli/test_stats_command.py | 27 ++++++ webui/src/hooks/useSessions.test.ts | 96 +++++++++++++++++++ webui/src/hooks/useSessions.ts | 53 +++++++--- webui/src/pages/Session/index.test.tsx | 68 ++++++++++++- webui/src/pages/Session/index.tsx | 18 ++-- 10 files changed, 343 insertions(+), 48 deletions(-) create mode 100644 tests/cli/test_session_runner_project_scope.py diff --git a/flocks/cli/commands/session.py b/flocks/cli/commands/session.py index 625cde4e9..fb13111f3 100644 --- a/flocks/cli/commands/session.py +++ b/flocks/cli/commands/session.py @@ -8,6 +8,7 @@ import asyncio import json import os +from functools import cache from typing import Optional import typer @@ -76,9 +77,13 @@ async def _list_sessions( all_sessions = await Session.list_all() if project_filter == "": - result = await Project.from_directory(os.getcwd()) - current_project_id = result["project"].id - all_sessions = [s for s in all_sessions if s.project_id == current_project_id] + resolve_worktree = cache(Project.worktree_for_directory) + current_worktree = resolve_worktree(os.getcwd()) + all_sessions = [ + session + for session in all_sessions + if session.directory and resolve_worktree(session.directory) == current_worktree + ] elif project_filter: all_sessions = [s for s in all_sessions if s.project_id == project_filter] diff --git a/flocks/cli/commands/stats.py b/flocks/cli/commands/stats.py index ec3093198..74eca6244 100644 --- a/flocks/cli/commands/stats.py +++ b/flocks/cli/commands/stats.py @@ -6,6 +6,7 @@ import os from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta +from functools import cache from typing import Dict, List, Optional, Sequence import typer @@ -118,8 +119,13 @@ async def _resolve_project_sessions(project_filter: Optional[str]) -> List[Sessi if project_filter is None: return sessions if project_filter == "": - result = await Project.from_directory(os.getcwd()) - project_filter = result["project"].id + resolve_worktree = cache(Project.worktree_for_directory) + current_worktree = resolve_worktree(os.getcwd()) + return [ + session + for session in sessions + if session.directory and resolve_worktree(session.directory) == current_worktree + ] return [session for session in sessions if session.project_id == project_filter] diff --git a/flocks/cli/session_runner.py b/flocks/cli/session_runner.py index ccbf254cf..ff57c4b1d 100644 --- a/flocks/cli/session_runner.py +++ b/flocks/cli/session_runner.py @@ -12,6 +12,7 @@ import asyncio import json import os +from functools import cache from pathlib import Path from typing import Optional, Dict, Any @@ -253,9 +254,18 @@ async def _get_or_create_session( continue_session: bool = False, ) -> SessionInfo: """Get or create session.""" + resolve_worktree = cache(Project.worktree_for_directory) + current_worktree = resolve_worktree(str(self.directory)) + + def belongs_to_current_worktree(session: SessionInfo) -> bool: + return bool( + session.directory + and resolve_worktree(session.directory) == current_worktree + ) + if session_id: session = await Session.get(project_id, session_id) - if session: + if session and belongs_to_current_worktree(session): return session self.console.print(f"[yellow]Session {session_id} not found, creating new[/yellow]") @@ -263,7 +273,7 @@ async def _get_or_create_session( sessions = await Session.list(project_id) if sessions: for s in sessions: - if not s.parent_id: + if not s.parent_id and belongs_to_current_worktree(s): return s return await Session.create( diff --git a/flocks/project/project.py b/flocks/project/project.py index ca7811d76..d7d4c7de3 100644 --- a/flocks/project/project.py +++ b/flocks/project/project.py @@ -180,6 +180,34 @@ def registry_path(cls, owner_id: str) -> Path: def _normalized_worktree(worktree: str) -> str: return os.path.normcase(str(Path(worktree).expanduser().resolve(strict=True))) + @staticmethod + def _directory_context(directory: str) -> Tuple[Path, Path, Optional[str]]: + path = Path(directory).expanduser().resolve() + worktree = path + vcs: Optional[str] = None + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=path, + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + worktree = Path(result.stdout.strip()).resolve() + vcs = "git" + except (OSError, subprocess.SubprocessError): + pass + return path, worktree, vcs + + @classmethod + def worktree_for_directory(cls, directory: str) -> str: + """Return a stable runtime scope for CLI sessions in a directory.""" + + _, worktree, _ = cls._directory_context(directory) + return os.path.normcase(str(worktree)) + @staticmethod def _path_status(worktree: str) -> Literal["available", "missing", "unreadable"]: path = Path(worktree) @@ -568,23 +596,7 @@ def invalidate_session_stats(cls, owner_id: Optional[str] = None) -> None: async def from_directory(cls, directory: str) -> Dict[str, Any]: """Build an ephemeral runtime context without persisting a project.""" - path = Path(directory).expanduser().resolve() - worktree = path - vcs: Optional[str] = None - try: - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - cwd=path, - capture_output=True, - text=True, - timeout=5, - check=False, - ) - if result.returncode == 0 and result.stdout.strip(): - worktree = Path(result.stdout.strip()).resolve() - vcs = "git" - except (OSError, subprocess.SubprocessError): - pass + path, worktree, vcs = cls._directory_context(directory) now = cls._now_ms() project = ProjectInfo( diff --git a/tests/cli/test_session_runner_project_scope.py b/tests/cli/test_session_runner_project_scope.py new file mode 100644 index 000000000..01b0a1a6b --- /dev/null +++ b/tests/cli/test_session_runner_project_scope.py @@ -0,0 +1,48 @@ +from io import StringIO +from pathlib import Path + +import pytest +from rich.console import Console + +from flocks.cli.session_runner import CLISessionRunner +from flocks.session.session import Session, SessionInfo, SessionTime + + +def _session(session_id: str, directory: Path, *, updated: int) -> SessionInfo: + return SessionInfo( + id=session_id, + projectID="default", + directory=str(directory), + title=session_id, + time=SessionTime(created=updated, updated=updated), + ) + + +@pytest.mark.asyncio +async def test_continue_uses_latest_session_from_current_worktree( + monkeypatch, + tmp_path: Path, +) -> None: + project_a = tmp_path / "project-a" + project_b = tmp_path / "project-b" + project_a.mkdir() + project_b.mkdir() + session_a = _session("ses-a", project_a, updated=2_000) + session_b = _session("ses-b", project_b, updated=1_000) + + async def fake_list(project_id: str): + assert project_id == "default" + return [session_a, session_b] + + monkeypatch.setattr(Session, "list", fake_list) + runner = CLISessionRunner( + console=Console(file=StringIO()), + directory=project_b, + ) + + resumed = await runner._get_or_create_session( + project_id="default", + continue_session=True, + ) + + assert resumed.id == session_b.id diff --git a/tests/cli/test_stats_command.py b/tests/cli/test_stats_command.py index 14b9ef561..3f88cf183 100644 --- a/tests/cli/test_stats_command.py +++ b/tests/cli/test_stats_command.py @@ -20,6 +20,33 @@ def _build_session(session_id: str = "ses_stats_test") -> SessionInfo: ) +@pytest.mark.asyncio +async def test_current_project_stats_only_include_sessions_from_current_worktree( + monkeypatch, + tmp_path, +) -> None: + current_project = tmp_path / "current" + other_project = tmp_path / "other" + current_project.mkdir() + other_project.mkdir() + current = _build_session("ses-current") + current.directory = str(current_project) + current.project_id = "default" + other = _build_session("ses-other") + other.directory = str(other_project) + other.project_id = "default" + + async def fake_get_all_sessions(): + return [other, current] + + monkeypatch.chdir(current_project) + monkeypatch.setattr(stats_cmd, "_get_all_sessions", fake_get_all_sessions) + + sessions = await stats_cmd._resolve_project_sessions("") + + assert [session.id for session in sessions] == [current.id] + + @pytest.mark.asyncio async def test_aggregate_stats_uses_usage_records(monkeypatch) -> None: session = _build_session() diff --git a/webui/src/hooks/useSessions.test.ts b/webui/src/hooks/useSessions.test.ts index 4b99f9e42..499489c6a 100644 --- a/webui/src/hooks/useSessions.test.ts +++ b/webui/src/hooks/useSessions.test.ts @@ -5,6 +5,27 @@ import { sessionApi } from '@/api/session'; import client from '@/api/client'; import type { Message } from '@/types'; +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function sessionListItem(id: string, projectID: string, updated = 1) { + return { + id, + projectID, + effectiveProjectID: projectID, + title: id, + time: { created: updated, updated }, + category: 'user', + }; +} + // --------------------------------------------------------------------------- // Mocks — keep API calls from running in unit tests // --------------------------------------------------------------------------- @@ -1184,4 +1205,79 @@ describe('useSessions list loading', () => { 'prj_labs-2', ]); }); + + it('loads more sessions for different projects concurrently', async () => { + const defaultPage = deferred(); + const labsPage = deferred(); + vi.mocked(sessionApi.list).mockImplementation(async (params: any) => { + if (params.offset === 0) { + return [sessionListItem(`${params.projectID}-1`, params.projectID)] as any; + } + return params.projectID === 'default' ? defaultPage.promise : labsPage.promise; + }); + + const { result } = renderHook(() => useSessions('', { + projectIds: ['default', 'prj_labs'], + pageSize: 1, + })); + await act(async () => {}); + + let loadDefault!: Promise; + let loadLabs!: Promise; + act(() => { + loadDefault = result.current.loadMore('default'); + loadLabs = result.current.loadMore('prj_labs'); + }); + expect(result.current.loadingMoreProjectIds).toEqual(new Set(['default', 'prj_labs'])); + + await act(async () => { + defaultPage.resolve([sessionListItem('default-2', 'default', 2)] as any); + await loadDefault; + }); + expect(result.current.loadingMoreProjectIds).toEqual(new Set(['prj_labs'])); + + await act(async () => { + labsPage.resolve([sessionListItem('prj_labs-2', 'prj_labs', 2)] as any); + await loadLabs; + }); + expect(result.current.loadingMoreProjectIds).toEqual(new Set()); + expect(result.current.sessions.map((item) => item.id)).toEqual([ + 'default-1', + 'prj_labs-1', + 'default-2', + 'prj_labs-2', + ]); + }); + + it('clears project pagination state when a background refresh supersedes it', async () => { + const stalePage = deferred(); + vi.mocked(sessionApi.list).mockImplementation(async (params: any) => { + if (params.offset > 0) return stalePage.promise; + return [sessionListItem(`${params.projectID}-1`, params.projectID)] as any; + }); + + const { result } = renderHook(() => useSessions('', { + projectIds: ['default', 'prj_labs'], + pageSize: 1, + })); + await act(async () => {}); + + let loadMore!: Promise; + act(() => { + loadMore = result.current.loadMore('default'); + }); + expect(result.current.loadingMoreProjectIds).toEqual(new Set(['default'])); + + await act(async () => { + await result.current.refetch(); + }); + expect(result.current.loadingMoreProjectIds).toEqual(new Set()); + + await act(async () => { + stalePage.resolve([sessionListItem('default-stale', 'default', 2)] as any); + await loadMore; + }); + expect(result.current.sessions.map((item) => item.id)).not.toContain('default-stale'); + expect(result.current.loadingMoreProjectIds).toEqual(new Set()); + }); }); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 5dc86b36d..519aa382a 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -238,7 +238,9 @@ export function useSessions(search = '', options?: UseSessionsOptions) { const initializedRef = useRef(false); const sessionsRef = useRef([]); const hasLoadedOnceRef = useRef(false); - const requestSeqRef = useRef(0); + const replaceRequestSeqRef = useRef(0); + const appendRequestSeqByProjectRef = useRef>(new Map()); + const activeAppendProjectsRef = useRef>(new Set()); const optimisticSessionsRef = useRef>(new Map()); const loadedQueryKeyRef = useRef(null); const projectIdsKey = options?.projectIds === undefined @@ -252,6 +254,22 @@ export function useSessions(search = '', options?: UseSessionsOptions) { const fetchSessions = useCallback(async (fetchOptions?: { append?: boolean; projectId?: string }) => { const append = Boolean(fetchOptions?.append); + const appendProjectKey = fetchOptions?.projectId ?? '*'; + const replaceRequestSeq = append + ? replaceRequestSeqRef.current + : ++replaceRequestSeqRef.current; + const appendRequestSeq = append + ? (appendRequestSeqByProjectRef.current.get(appendProjectKey) ?? 0) + 1 + : 0; + if (append) { + appendRequestSeqByProjectRef.current.set(appendProjectKey, appendRequestSeq); + } + const isCurrentRequest = () => ( + append + ? replaceRequestSeqRef.current === replaceRequestSeq + && appendRequestSeqByProjectRef.current.get(appendProjectKey) === appendRequestSeq + : replaceRequestSeqRef.current === replaceRequestSeq + ); const requestedProjectIds = projectIdsKey === null ? null : projectIdsKey.split('\u0000').filter(Boolean); @@ -260,12 +278,12 @@ export function useSessions(search = '', options?: UseSessionsOptions) { : fetchOptions?.projectId ? [fetchOptions.projectId] : requestedProjectIds; - const requestSeq = ++requestSeqRef.current; try { // Only show the full-page loading state on the very first fetch. // Subsequent refetches (triggered by SSE events) update data silently // to avoid unmounting SessionChat and disrupting the active conversation. if (append) { + activeAppendProjectsRef.current.add(appendProjectKey); setLoadingMore(true); if (fetchOptions?.projectId) { setLoadingMoreProjectIds((current) => new Set(current).add(fetchOptions.projectId as string)); @@ -273,6 +291,11 @@ export function useSessions(search = '', options?: UseSessionsOptions) { } else if (!initializedRef.current) { setLoading(true); } + if (!append) { + activeAppendProjectsRef.current.clear(); + setLoadingMore(false); + setLoadingMoreProjectIds(new Set()); + } setError(null); if (targetProjectIds?.length === 0) { setSessions([]); @@ -310,7 +333,7 @@ export function useSessions(search = '', options?: UseSessionsOptions) { }); return { projectId, requestLimit, response }; })); - if (requestSeq !== requestSeqRef.current) return; + if (!isCurrentRequest()) return; markMeasure(append ? 'sessions:list:older-page' : 'sessions:list:first-render', startMark); const nextSessions = responses.flatMap(({ response }) => ( Array.isArray(response) ? response.filter( @@ -349,20 +372,24 @@ export function useSessions(search = '', options?: UseSessionsOptions) { hasLoadedOnceRef.current = true; loadedQueryKeyRef.current = queryKey; } catch (err: any) { - if (requestSeq !== requestSeqRef.current) return; + if (!isCurrentRequest()) return; setError(err.message || 'Failed to fetch sessions'); if (!append && !hasLoadedOnceRef.current) setSessions([]); } finally { - if (requestSeq === requestSeqRef.current) { - setLoading(false); - setLoadingMore(false); - if (fetchOptions?.projectId) { - setLoadingMoreProjectIds((current) => { - const next = new Set(current); - next.delete(fetchOptions.projectId as string); - return next; - }); + if (append) { + if (appendRequestSeqByProjectRef.current.get(appendProjectKey) === appendRequestSeq) { + activeAppendProjectsRef.current.delete(appendProjectKey); + setLoadingMore(activeAppendProjectsRef.current.size > 0); + if (fetchOptions?.projectId) { + setLoadingMoreProjectIds((current) => { + const next = new Set(current); + next.delete(fetchOptions.projectId as string); + return next; + }); + } } + } else if (isCurrentRequest()) { + setLoading(false); initializedRef.current = true; } } diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 3beb6b61b..51aaf4dc5 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useNavigate } from 'react-router-dom'; import { __resetChatModelResourcesForTesting } from '@/hooks/useChatModelResources'; @@ -672,9 +672,20 @@ describe('SessionPage session actions menu', () => { it('renames a project from the sidebar', async () => { const user = userEvent.setup(); const defaultProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; - client.get.mockResolvedValue({ - data: [defaultProject, { id: 'prj_project2', worktree: '/tmp/labs', name: 'Labs' }], - }); + const projectList = [defaultProject, { + id: 'prj_project2', + worktree: '/tmp/labs', + name: 'Labs', + sessionCount: 8, + lastActivityAt: 10_000, + }]; + client.get + .mockResolvedValueOnce({ data: projectList }) + .mockResolvedValue({ + data: projectList.map((project) => ( + project.id === 'prj_project2' ? { ...project, name: 'Renamed Project' } : project + )), + }); client.patch.mockResolvedValue({ data: { id: 'prj_project2', worktree: '/tmp/labs', name: 'Renamed Project' }, }); @@ -694,6 +705,55 @@ describe('SessionPage session actions menu', () => { await waitFor(() => { expect(client.patch).toHaveBeenCalledWith('/api/project/prj_project2', { name: 'Renamed Project' }); }); + const renamedProject = await screen.findByText('Renamed Project'); + expect(renamedProject.closest('[class*="group/project"]')).toHaveTextContent('8'); + }); + + it('ignores stale project results after the search changes', async () => { + const initialProject = { id: 'default', worktree: '/tmp/project', name: '默认', isDefault: true }; + const olderSearch = deferred<{ data: Array> }>(); + const latestSearch = deferred<{ data: Array> }>(); + client.get.mockImplementation((_url: string, config?: { params?: { search?: string } }) => { + const query = config?.params?.search; + if (query === 'a') return olderSearch.promise; + if (query === 'ab') return latestSearch.promise; + return Promise.resolve({ data: [initialProject] }); + }); + + renderSessionPage(); + await screen.findByText('tasksSection'); + const searchInput = screen.getByPlaceholderText('filterConversations'); + fireEvent.change(searchInput, { target: { value: 'a' } }); + fireEvent.change(searchInput, { target: { value: 'ab' } }); + + await act(async () => { + latestSearch.resolve({ + data: [initialProject, { + id: 'prj_latest', + worktree: '/tmp/latest', + name: 'Latest result', + isDefault: false, + matchedSessionCount: 1, + }], + }); + await latestSearch.promise; + }); + expect(await screen.findByText('Latest result')).toBeInTheDocument(); + + await act(async () => { + olderSearch.resolve({ + data: [initialProject, { + id: 'prj_stale', + worktree: '/tmp/stale', + name: 'Stale result', + isDefault: false, + matchedSessionCount: 1, + }], + }); + await olderSearch.promise; + }); + expect(screen.getByText('Latest result')).toBeInTheDocument(); + expect(screen.queryByText('Stale result')).not.toBeInTheDocument(); }); it('does not render project actions for the default task group', async () => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index a0a3fdaa4..73274083a 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -374,6 +374,7 @@ export default function SessionPage() { const renameSubmitInFlightRef = useRef(false); const projectSubmitInFlightRef = useRef(false); const sessionUpdateRefetchTimerRef = useRef(null); + const projectListRequestSeqRef = useRef(0); const toast = useToast(); const sessionProjectIds = useMemo( @@ -649,16 +650,19 @@ export default function SessionPage() { }, [toast, t]); const fetchProjects = useCallback(async (ensureProject?: ProjectSummary, query = '') => { + const requestSeq = ++projectListRequestSeqRef.current; const listResult = await client.get('/api/project', { params: { search: query.trim() || undefined }, }); - let nextProjects = Array.isArray(listResult.data) ? listResult.data : []; - if (ensureProject?.id) { - nextProjects = nextProjects.some((project) => project.id === ensureProject.id) - ? nextProjects.map((project) => (project.id === ensureProject.id ? ensureProject : project)) - : [ensureProject, ...nextProjects]; - } - setProjects(nextProjects); + if (requestSeq !== projectListRequestSeqRef.current) return; + const nextProjects = Array.isArray(listResult.data) ? listResult.data : []; + setProjects((currentProjects) => { + if (!ensureProject?.id || nextProjects.some((project) => project.id === ensureProject.id)) { + return nextProjects; + } + const currentProject = currentProjects.find((project) => project.id === ensureProject.id); + return [{ ...currentProject, ...ensureProject }, ...nextProjects]; + }); }, []); const scheduleSessionListRefetch = useCallback(() => { From d22a236e9eec1969447a77d6a08fc85a2e9792ea Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 16:16:09 +0800 Subject: [PATCH 40/86] fix(runtime): reduce noisy warnings and harden startup Prefer user-defined plugin resources over built-ins and skip stale workflow trigger configs without alarming startup logs. Classify expected runtime conditions consistently, deduplicate MCP failures, and add regression coverage for startup isolation and notification loading. --- flocks/agent/agent_factory.py | 34 ++-- flocks/channel/builtin/weixin/channel.py | 2 +- flocks/ingest/kafka/manager.py | 38 +++- flocks/ingest/syslog/manager.py | 35 +++- flocks/mcp/client.py | 10 +- flocks/mcp/errors.py | 35 ++++ flocks/mcp/server.py | 12 +- flocks/notifications/service.py | 1 - flocks/server/app.py | 3 +- flocks/server/routes/notifications.py | 2 - flocks/server/routes/session.py | 163 +++++++----------- flocks/server/routes/workflow.py | 11 +- flocks/session/features/memory.py | 2 +- flocks/session/runner.py | 21 +-- flocks/skill/skill.py | 47 +++-- flocks/tool/device/startup.py | 3 + flocks/tool/file/glob.py | 6 +- flocks/tool/registry.py | 16 +- flocks/workflow/center.py | 111 ++++++++++-- flocks/workflow/fs_store.py | 9 +- flocks/workflow/poller_manager.py | 34 +++- tests/agent/test_agent_factory.py | 38 ++++ tests/channel/test_weixin_log_levels.py | 47 +++++ tests/ingest/test_kafka_manager.py | 94 ++++++++++ .../test_syslog_manager_bind_failure.py | 99 +++++++++-- tests/mcp/test_mcp_client_sse.py | 21 +++ tests/mcp/test_mcp_server.py | 67 +++++++ tests/provider/test_api_service_management.py | 52 ++++++ .../routes/test_notifications_routes.py | 6 +- tests/server/test_app_errors.py | 42 +++++ tests/server/test_input_dispatcher.py | 21 ++- tests/session/test_runner_step.py | 19 ++ .../session/test_session_memory_log_levels.py | 40 +++++ tests/skill/test_skill.py | 19 +- tests/tool/test_device_startup_sync.py | 28 +++ tests/tool/test_tools.py | 49 +++++- tests/workflow/test_fs_store.py | 39 +++++ tests/workflow/test_poller_manager.py | 70 +++++++- tests/workflow/test_workflow_paths.py | 98 ++++++++++- webui/src/api/notifications.ts | 6 +- webui/src/components/layout/Layout.test.tsx | 32 +++- webui/src/components/layout/Layout.tsx | 20 +-- 42 files changed, 1248 insertions(+), 254 deletions(-) create mode 100644 flocks/mcp/errors.py create mode 100644 tests/channel/test_weixin_log_levels.py create mode 100644 tests/session/test_session_memory_log_levels.py diff --git a/flocks/agent/agent_factory.py b/flocks/agent/agent_factory.py index d8c08f265..2c2ac9b37 100644 --- a/flocks/agent/agent_factory.py +++ b/flocks/agent/agent_factory.py @@ -223,8 +223,9 @@ def scan_and_load(dirs: Optional[List[Path]] = None) -> Dict[str, AgentInfo]: """ Scan agent directories and load all valid agents. - Scans built-in agents first, then user plugin agents, then project plugin - agents. Name conflicts are skipped with a warning (first wins). + Scans built-in agents first, then user plugin agents, then project-bundled + agents. The first match wins, so user customizations take precedence over + project bundles while core built-ins remain protected. ``native`` is determined by the source directory, not by agent.yaml: - ``_BUILTIN_AGENTS_DIR`` → native=True @@ -238,20 +239,21 @@ def scan_and_load(dirs: Optional[List[Path]] = None) -> Dict[str, AgentInfo]: Returns: Dict mapping agent name → AgentInfo. """ - # (directory, is_native) pairs — order determines first-wins conflict resolution - search_dirs: List[tuple[Path, bool]] = [(_BUILTIN_AGENTS_DIR, True)] + # (directory, is_native, source) tuples — order determines first-wins priority. + search_dirs: List[tuple[Path, bool, str]] = [(_BUILTIN_AGENTS_DIR, True, "builtin")] if _PLUGIN_AGENTS_DIR.exists(): - search_dirs.append((_PLUGIN_AGENTS_DIR, False)) + search_dirs.append((_PLUGIN_AGENTS_DIR, False, "user")) # Project-level plugin agents: resolved at call time because cwd is dynamic project_agents_dir = Path.cwd() / ".flocks" / "plugins" / "agents" if project_agents_dir.exists() and project_agents_dir != _PLUGIN_AGENTS_DIR: - search_dirs.append((project_agents_dir, True)) + search_dirs.append((project_agents_dir, True, "project")) if dirs: - search_dirs.extend((d, False) for d in dirs) + search_dirs.extend((d, False, "extra") for d in dirs) result: Dict[str, AgentInfo] = {} + selected_sources: Dict[str, tuple[str, Path]] = {} - for scan_dir, is_native in search_dirs: + for scan_dir, is_native, source in search_dirs: if not scan_dir.is_dir(): continue for agent_dir in _iter_agent_dirs(scan_dir): @@ -259,13 +261,21 @@ def scan_and_load(dirs: Optional[List[Path]] = None) -> Dict[str, AgentInfo]: if agent is None: continue if agent.name in result: - log.warn("agent.factory.name_conflict", { + selected_source, selected_dir = selected_sources[agent.name] + details = { "name": agent.name, - "existing_source": "previous scan", - "skipped_source": str(agent_dir), - }) + "selected_source": selected_source, + "selected_path": str(selected_dir), + "skipped_source": source, + "skipped_path": str(agent_dir), + } + if source != selected_source and source != "extra": + log.info("agent.factory.lower_priority_skipped", details) + else: + log.warn("agent.factory.name_conflict", details) continue result[agent.name] = agent + selected_sources[agent.name] = (source, agent_dir) log.debug("agent.factory.loaded", { "name": agent.name, "dir": str(agent_dir), diff --git a/flocks/channel/builtin/weixin/channel.py b/flocks/channel/builtin/weixin/channel.py index cb1e30d5e..fb1c51c7b 100644 --- a/flocks/channel/builtin/weixin/channel.py +++ b/flocks/channel/builtin/weixin/channel.py @@ -237,7 +237,7 @@ async def start( "base_url": self._base_url, }) if self._group_policy != "disabled": - log.warning("weixin.group_policy.note", { + log.info("weixin.group_policy.note", { "group_policy": self._group_policy, "note": ( "QR-login connects an iLink bot identity (e.g. ...@im.bot), not a " diff --git a/flocks/ingest/kafka/manager.py b/flocks/ingest/kafka/manager.py index 16acca4b6..d265b0320 100644 --- a/flocks/ingest/kafka/manager.py +++ b/flocks/ingest/kafka/manager.py @@ -297,7 +297,14 @@ async def start_all(self) -> None: if not workflow_id: continue if isinstance(data, dict) and data.get("enabled"): - await self.restart_workflow(workflow_id) + try: + await self.restart_workflow(workflow_id, startup=True) + except Exception as exc: + self._status[workflow_id] = {"state": "failed", "error": str(exc)} + log.warning( + "kafka.start_failed", + {"workflow_id": workflow_id, "error": str(exc)}, + ) async def stop_all(self) -> None: for workflow_id in list(self._tasks.keys()): @@ -360,7 +367,12 @@ async def stop_workflow(self, workflow_id: str) -> None: if workflow_id in self._status: self._status[workflow_id] = {"state": "stopped", "error": None} - async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: + async def restart_workflow( + self, + workflow_id: str, + *, + startup: bool = False, + ) -> Dict[str, Any]: """Restart the consumer and return its post-connect runtime status. Blocks until the consumer connects, the connection fails, or @@ -377,6 +389,21 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: self._status[workflow_id] = {"state": "stopped", "error": None} return {"state": "stopped", "error": None} + # Load and cache the workflow JSON once; avoids a disk read per message. + wf_data = read_workflow_from_fs(workflow_id) + if not wf_data: + err = "workflow_not_found" + if startup: + self._status[workflow_id] = {"state": "stopped", "error": err} + log.info("kafka.workflow_not_found_on_start", { + "workflow_id": workflow_id, + "action": "stale_config_skipped", + }) + return {"state": "stopped", "error": err} + self._status[workflow_id] = {"state": "failed", "error": err} + log.warning("kafka.workflow_not_found", {"workflow_id": workflow_id}) + return {"state": "failed", "error": err} + input_broker = str(data.get("inputBroker") or "").strip() input_topic = str(data.get("inputTopic") or "").strip() if not input_broker or not input_topic: @@ -385,13 +412,6 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: log.warning("kafka.config_incomplete", {"workflow_id": workflow_id}) return {"state": "failed", "error": err} - # Load and cache the workflow JSON once; avoids a disk read per message. - wf_data = read_workflow_from_fs(workflow_id) - if not wf_data: - err = "workflow_not_found" - self._status[workflow_id] = {"state": "failed", "error": err} - log.warning("kafka.workflow_not_found_on_start", {"workflow_id": workflow_id}) - return {"state": "failed", "error": err} workflow_json = wf_data.get("workflowJson") if not workflow_json: err = "workflow_json_missing" diff --git a/flocks/ingest/syslog/manager.py b/flocks/ingest/syslog/manager.py index 3fccaed88..8e6913b31 100644 --- a/flocks/ingest/syslog/manager.py +++ b/flocks/ingest/syslog/manager.py @@ -186,7 +186,17 @@ async def start_all(self) -> None: if not workflow_id: continue if isinstance(data, dict) and data.get("enabled"): - await self.restart_workflow(workflow_id) + try: + await self.restart_workflow(workflow_id, startup=True) + except Exception as exc: + self._listener_status[workflow_id] = { + "state": "failed", + "error": str(exc), + } + log.warning( + "syslog.start_failed", + {"workflow_id": workflow_id, "error": str(exc)}, + ) async def stop_all(self) -> None: for workflow_id in list(self._tasks.keys()): @@ -245,7 +255,12 @@ async def stop_workflow(self, workflow_id: str) -> None: if workflow_id in self._listener_status: self._listener_status[workflow_id] = {"state": "stopped", "error": None} - async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: + async def restart_workflow( + self, + workflow_id: str, + *, + startup: bool = False, + ) -> Dict[str, Any]: """Restart the listener and return its post-bind runtime status. This call blocks until the underlying socket either binds successfully, @@ -268,8 +283,15 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: wf_data = read_workflow_from_fs(workflow_id) if not wf_data: err = "workflow_not_found" + if startup: + self._listener_status[workflow_id] = {"state": "stopped", "error": err} + log.info("syslog.workflow_not_found_on_start", { + "workflow_id": workflow_id, + "action": "stale_config_skipped", + }) + return {"state": "stopped", "error": err} self._listener_status[workflow_id] = {"state": "failed", "error": err} - log.warning("syslog.workflow_not_found_on_start", {"workflow_id": workflow_id}) + log.warning("syslog.workflow_not_found", {"workflow_id": workflow_id}) return {"state": "failed", "error": err} workflow_json = wf_data.get("workflowJson") if not workflow_json: @@ -286,6 +308,10 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: self._listener_status[workflow_id] = {"state": "failed", "error": err} log.warning("syslog.workflow_plan_failed", {"workflow_id": workflow_id, "error": str(exc)}) return self.get_listener_status(workflow_id) + + host = str(data.get("host") or "0.0.0.0") + port = int(data.get("port") or 5140) + protocol = str(data.get("protocol") or "udp").lower() queue: asyncio.Queue = asyncio.Queue(maxsize=_MAX_QUEUE_SIZE) self._queues[workflow_id] = queue @@ -295,9 +321,6 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: ready = asyncio.Event() self._listener_ready[workflow_id] = ready - host = str(data.get("host") or "0.0.0.0") - port = int(data.get("port") or 5140) - protocol = str(data.get("protocol") or "udp").lower() self._listener_status[workflow_id] = { "state": "binding", "error": None, diff --git a/flocks/mcp/client.py b/flocks/mcp/client.py index c2bf675ae..8acdf8e33 100644 --- a/flocks/mcp/client.py +++ b/flocks/mcp/client.py @@ -703,15 +703,7 @@ async def list_resources(self) -> List[McpResource]: Raises: RuntimeError: If not connected """ - try: - result = await self._submit_command("list_resources") - return result - except Exception as exc: - log.error("mcp.client.list_resources_error", { - "server": self.name, - "error": str(exc), - }) - raise + return await self._submit_command("list_resources") async def read_resource(self, uri: str) -> Any: """ diff --git a/flocks/mcp/errors.py b/flocks/mcp/errors.py new file mode 100644 index 000000000..7849bf81a --- /dev/null +++ b/flocks/mcp/errors.py @@ -0,0 +1,35 @@ +"""Helpers for classifying MCP protocol errors.""" + +from __future__ import annotations + +from mcp.types import METHOD_NOT_FOUND + + +def is_method_not_found_error(exc: BaseException) -> bool: + """Return whether an exception chain contains JSON-RPC method-not-found.""" + return _contains_method_not_found(exc, seen=set()) + + +def _contains_method_not_found(exc: BaseException, *, seen: set[int]) -> bool: + exc_id = id(exc) + if exc_id in seen: + return False + seen.add(exc_id) + + error = getattr(exc, "error", None) + if getattr(error, "code", None) == METHOD_NOT_FOUND: + return True + + if isinstance(exc, BaseExceptionGroup): + if any(_contains_method_not_found(child, seen=seen) for child in exc.exceptions): + return True + + cause = exc.__cause__ + if cause is not None and _contains_method_not_found(cause, seen=seen): + return True + context = exc.__context__ + if context is not None and _contains_method_not_found(context, seen=seen): + return True + + message = str(exc).strip().lower() + return message == "method not found" or message.endswith(": method not found") diff --git a/flocks/mcp/server.py b/flocks/mcp/server.py index b7a20cd10..ae6dd0240 100644 --- a/flocks/mcp/server.py +++ b/flocks/mcp/server.py @@ -8,6 +8,7 @@ import time from typing import Dict, Optional, Any, List from flocks.mcp.client import McpClient +from flocks.mcp.errors import is_method_not_found_error from flocks.mcp.types import ( McpStatus, McpStatusInfo, @@ -226,10 +227,13 @@ async def _connect_and_register( resources = await client.list_resources() self._resources_cache[name] = resources except Exception as e: - log.warn("mcp.resources_unavailable", { - "server": name, - "error": str(e) - }) + if is_method_not_found_error(e): + log.info("mcp.resources_unsupported", {"server": name}) + else: + log.warn("mcp.resources_unavailable", { + "server": name, + "error": str(e) + }) self._resources_cache[name] = [] # 5. Register tools diff --git a/flocks/notifications/service.py b/flocks/notifications/service.py index 931fc2fc0..3cf3a02cd 100644 --- a/flocks/notifications/service.py +++ b/flocks/notifications/service.py @@ -206,7 +206,6 @@ async def list_active( *, user_id: str, locale: str | None = None, - current_version: str | None = None, ) -> list[NotificationResponse]: target_locale = _normalize_locale(locale) config_notifications = await cls._load_config_notifications() diff --git a/flocks/server/app.py b/flocks/server/app.py index 78271d130..c71a1bbb6 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -1111,7 +1111,8 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request: Request, exc: StarletteHTTPException): """Handle HTTP exceptions""" - log.error("http.error", { + http_log = log.warn if 400 <= exc.status_code < 500 else log.error + http_log("http.error", { "path": request.url.path, "status": exc.status_code, "detail": exc.detail, diff --git a/flocks/server/routes/notifications.py b/flocks/server/routes/notifications.py index 709a9240f..0792abd3b 100644 --- a/flocks/server/routes/notifications.py +++ b/flocks/server/routes/notifications.py @@ -26,13 +26,11 @@ async def list_active_notifications( request: Request, locale: str | None = None, - current_version: str | None = None, ) -> list[NotificationResponse]: user = require_user(request) return await NotificationService.list_active( user_id=user.id, locale=locale, - current_version=current_version, ) diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index 3cf75b890..3a6fc98a5 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -9,6 +9,7 @@ import asyncio import json import time +from pathlib import Path from typing import List, Optional, Any, Dict, Literal, Union, Tuple from fastapi import APIRouter, HTTPException, status, Query, Request from fastapi.responses import StreamingResponse @@ -48,6 +49,62 @@ # extension (e.g. a PNG named report.pdf.exe whose tail would otherwise be # ".exe"). _UPLOAD_SAFE_EXTS = frozenset({"png", "jpg", "jpeg", "gif", "webp", "bmp", "pdf"}) +_UPLOAD_EXT_BY_MIME = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/bmp": ".bmp", + "application/pdf": ".pdf", +} + + +def _session_uploads_dir(session_id: str) -> Path: + """Return the application-owned upload directory for one session.""" + from flocks.config.config import Config + + uploads_root = (Config.get_data_path() / "uploads").resolve() + target = (uploads_root / session_id).resolve() + if target == uploads_root or not target.is_relative_to(uploads_root): + raise ValueError(f"Invalid session ID for upload path: {session_id}") + return target + + +def _materialize_data_url_part( + session_id: str, + data_url: str, + mime_hint: str, + filename_hint: Optional[str], + *, + failure_event: str = "session.prompt_queue.materialize_failed", +) -> str: + """Persist a data URL under the application data directory.""" + try: + import base64 + from flocks.utils.id import Identifier + + _header, _sep, encoded = data_url.partition(",") + if not encoded: + return data_url + raw_bytes = base64.b64decode(encoded) + uploads_root = _session_uploads_dir(session_id) + uploads_root.mkdir(parents=True, exist_ok=True) + + ext = _UPLOAD_EXT_BY_MIME.get(mime_hint, "") + if not ext and filename_hint: + _, _, tail = filename_hint.rpartition(".") + if tail.lower() in _UPLOAD_SAFE_EXTS: + ext = "." + tail.lower() + target = uploads_root / f"{Identifier.create('part')}{ext}" + target.write_bytes(raw_bytes) + return target.resolve().as_uri() + except Exception as exc: + log.warn(failure_event, { + "sessionID": session_id, + "error": str(exc), + }) + return data_url def _context_usage_cache_key(session_id: str, session: SessionModel) -> Tuple[str, int]: @@ -782,17 +839,14 @@ async def delete_session(sessionID: str, request: Request) -> bool: await Session.delete(session.project_id, sessionID) # Best-effort cleanup of any image/file uploads materialised for this - # session via ``_materialize_data_url_to_disk`` (see prompt_async). + # session via ``_materialize_data_url_part`` (see prompt_async). # The session DB row is gone, so the on-disk bytes are now orphaned — - # remove them to keep the workspace tidy. We deliberately swallow any + # remove them to keep application data tidy. We deliberately swallow any # filesystem errors: deletion of the session record is the contract, # the upload cleanup is incidental. try: import shutil - from flocks.workspace.manager import WorkspaceManager - - ws = WorkspaceManager.get_instance() - uploads_root = ws.resolve_workspace_path(f"uploads/{sessionID}") + uploads_root = _session_uploads_dir(sessionID) if uploads_root.exists() and uploads_root.is_dir(): shutil.rmtree(uploads_root, ignore_errors=True) log.info("session.uploads.cleaned", { @@ -2764,51 +2818,6 @@ async def _process_session_message( # ------------------------------------------------------------------ from flocks.session.message import FilePart - def _materialize_data_url_to_disk( - data_url: str, mime_hint: str, filename_hint: Optional[str] - ) -> str: - """Decode a ``data:`` URL to ``~/.flocks/workspace/uploads//...``. - - Returns a ``file://`` URL pointing at the persisted file. On failure - the original ``data:`` URL is returned unchanged (older code paths - still cope with that, just with the now-known token-cost penalty). - """ - try: - import base64 - from flocks.workspace.manager import WorkspaceManager - - header, _, encoded = data_url.partition(",") - if not encoded: - return data_url - raw_bytes = base64.b64decode(encoded) - - ws = WorkspaceManager.get_instance() - # Use resolve_workspace_path to guard against path traversal if - # sessionID were ever user-controlled (e.g. ../../../tmp/x). - uploads_root = ws.resolve_workspace_path(f"uploads/{sessionID}") - uploads_root.mkdir(parents=True, exist_ok=True) - - ext_map = { - "image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg", - "image/gif": ".gif", "image/webp": ".webp", "image/bmp": ".bmp", - "application/pdf": ".pdf", - } - ext = ext_map.get(mime_hint, "") - if not ext and filename_hint: - _, _, tail = filename_hint.rpartition(".") - if tail.lower() in _UPLOAD_SAFE_EXTS: - ext = "." + tail.lower() - unique_name = f"{Identifier.create('part')}{ext}" - target = uploads_root / unique_name - target.write_bytes(raw_bytes) - return target.resolve().as_uri() - except Exception as exc: - log.warn("session.message.file_part.materialize_failed", { - "sessionID": sessionID, - "error": str(exc), - }) - return data_url - for raw_part in request.parts or []: part_type = raw_part.get("type") if part_type == "text": @@ -2824,7 +2833,13 @@ def _materialize_data_url_to_disk( continue # Materialize ``data:`` URLs to disk before persisting the part. if url.startswith("data:"): - url = _materialize_data_url_to_disk(url, mime, raw_part.get("filename")) + url = _materialize_data_url_part( + sessionID, + url, + mime, + raw_part.get("filename"), + failure_event="session.message.file_part.materialize_failed", + ) file_part_id = raw_part.get("id") or Identifier.create("part") file_part = FilePart( id=file_part_id, @@ -3277,50 +3292,6 @@ def _materialize_queued_parts(session_id: str, parts: List[Dict[str, Any]]) -> L return prepared -def _materialize_data_url_part( - session_id: str, - data_url: str, - mime_hint: str, - filename_hint: Optional[str], -) -> str: - try: - import base64 - from flocks.workspace.manager import WorkspaceManager - from flocks.utils.id import Identifier - - _header, _sep, encoded = data_url.partition(",") - if not encoded: - return data_url - raw_bytes = base64.b64decode(encoded) - ws = WorkspaceManager.get_instance() - uploads_root = ws.resolve_workspace_path(f"uploads/{session_id}") - uploads_root.mkdir(parents=True, exist_ok=True) - - ext_map = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/bmp": ".bmp", - "application/pdf": ".pdf", - } - ext = ext_map.get(mime_hint, "") - if not ext and filename_hint: - _, _, tail = filename_hint.rpartition(".") - if tail.lower() in _UPLOAD_SAFE_EXTS: - ext = "." + tail.lower() - target = uploads_root / f"{Identifier.create('part')}{ext}" - target.write_bytes(raw_bytes) - return target.resolve().as_uri() - except Exception as exc: - log.warn("session.prompt_queue.materialize_failed", { - "sessionID": session_id, - "error": str(exc), - }) - return data_url - - def _event_from_queued_prompt(item, working_directory: str): from flocks.input.events import UserInputEvent diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index 1bf338498..bc7c5cc23 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -329,9 +329,9 @@ def _workflow_integration_config_key(workflow_id: str) -> str: def _read_workflow_from_fs(workflow_id: str) -> Optional[Dict[str, Any]]: """Read workflow data from the filesystem. - Search order (lowest → highest priority), same roots as - resolve_global_workflow_roots / resolve_project_workflow_roots; per-id dir - is ``//`` with ``workflow.json`` inside. + Search order (lowest → highest priority) visits project-bundled roots before + global user roots; per-id dir is ``//`` with ``workflow.json`` + inside. """ return shared_read_workflow_from_fs(workflow_id) @@ -531,9 +531,8 @@ def _scan_workflow_base_dir(base_dir: Path, source: str) -> Dict[str, Dict[str, def _list_workflows_from_fs() -> List[Dict[str, Any]]: """Scan global and project workflow directories and return merged list. - Scan order matches *_all_scan_dirs()* (lowest -> highest priority): each - root from *resolve_global_workflow_roots* then each from - *resolve_project_workflow_roots(workspace)*; under each root, immediate + Scan order matches *_all_scan_dirs()* (lowest -> highest priority): project + bundle roots first, then global user roots; under each root, immediate subdirectories with *workflow.json* are workflows. Later entries override earlier ones when the workflow directory name (*id*) diff --git a/flocks/session/features/memory.py b/flocks/session/features/memory.py index 7c2370bfb..418531b5b 100644 --- a/flocks/session/features/memory.py +++ b/flocks/session/features/memory.py @@ -66,7 +66,7 @@ async def initialize(self) -> bool: memory_config_dict = config.memory if hasattr(config, 'memory') and config.memory else None if not memory_config_dict: - log.warn("session.memory.no_config", {"session_id": self.session_id}) + log.info("session.memory.no_config", {"session_id": self.session_id}) memory_config = MemoryConfig(enabled=True) else: if isinstance(memory_config_dict, dict): diff --git a/flocks/session/runner.py b/flocks/session/runner.py index e9c10b5ae..b605cca4d 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -1341,18 +1341,15 @@ async def device_asset_prompt_factory() -> Optional[str]: except Exception as e: error_attempt += 1 - log.error("runner.step.error", { - "error": str(e), - "attempt": error_attempt, - }) - + # Convert exception to error dict for retry check error_dict = self._exception_to_error_dict(e) - + # Check if retryable retry_message = SessionRetry.retryable(error_dict) + will_retry = retry_message is not None and error_attempt <= MAX_ERROR_RETRIES - if retry_message is not None and error_attempt <= MAX_ERROR_RETRIES: + if will_retry: # Error is retryable and we have budget left delay_ms = SessionRetry.delay(error_attempt, error_dict) # Always cap the sleep to RETRY_MAX_DELAY_NO_HEADERS so a @@ -1361,8 +1358,9 @@ async def device_asset_prompt_factory() -> Optional[str]: from flocks.session.lifecycle.retry import RETRY_MAX_DELAY_NO_HEADERS delay_ms = min(delay_ms, RETRY_MAX_DELAY_NO_HEADERS) next_retry_time = int(asyncio.get_event_loop().time() * 1000) + delay_ms - - log.info("runner.step.retry", { + + log.warn("runner.step.retry", { + "error": str(e), "attempt": error_attempt, "delay_ms": delay_ms, "reason": retry_message, @@ -1393,7 +1391,10 @@ async def device_asset_prompt_factory() -> Optional[str]: "max_retries": MAX_ERROR_RETRIES, }) else: - log.error("runner.step.not_retryable", {"error": str(e)}) + log.error("runner.step.not_retryable", { + "error": str(e), + "attempt": error_attempt, + }) final_error_message = str(e) if SessionRetry.is_connection_error(error_dict): diff --git a/flocks/skill/skill.py b/flocks/skill/skill.py index db5f2e1e5..3b6be7b08 100644 --- a/flocks/skill/skill.py +++ b/flocks/skill/skill.py @@ -198,10 +198,9 @@ class Skill: Skill discovery and management. Discovers SKILL.md files from (lowest → highest priority): - - .flocks dirs (global + project-level) - - .claude dirs (global ~/.claude + project-level) - - ~/.flocks (global user-level) - - /.flocks (project-level, wins on collision) + - .claude dirs + - global built-in and project-bundled .flocks dirs + - ~/.flocks/plugins (user-level customizations, wins on collision) """ _cache: Optional[Dict[str, SkillInfo]] = None @@ -392,11 +391,21 @@ def _scan_directory( skill_info = cls._parse_skill_md(match, source=source) if skill_info: if skill_info.name in skills: - log.warn("skill.duplicate", { - "name": skill_info.name, - "existing": skills[skill_info.name].location, - "duplicate": match, - }) + existing = skills[skill_info.name] + if existing.source != skill_info.source: + log.info("skill.override", { + "name": skill_info.name, + "selected": match, + "selected_source": skill_info.source, + "replaced": existing.location, + "replaced_source": existing.source, + }) + else: + log.warn("skill.duplicate", { + "name": skill_info.name, + "existing": existing.location, + "duplicate": match, + }) skills[skill_info.name] = skill_info log.debug("skill.found", { @@ -416,9 +425,10 @@ def _discover(cls) -> Dict[str, SkillInfo]: Discover all skills. Last wins on name collision. Scan order (lowest → highest priority): - 1. .claude dirs (global ~/.claude + project-level) - 2. ~/.flocks (global user-level, overrides .claude) - 3. /.flocks (project-level, highest priority) + 1. .claude dirs + 2. ~/.flocks/skill[s] built-ins + 3. /.flocks project-bundled skills + 4. ~/.flocks/plugins user customizations Source labels: "flocks" — built-in skills inside .flocks/skills/ directories @@ -450,16 +460,12 @@ def _discover(cls) -> Dict[str, SkillInfo]: for claude_dir in cls._find_dirs_up(".claude", current_dir, worktree): cls._scan_directory(claude_dir, "skills/**/SKILL.md", skills, source="claude") - # 2) Global ~/.flocks — overrides .claude - # Built-in skills: source="flocks"; user-installed plugins: source="user" + # 2) Global built-in skills — overrides .claude if os.path.isdir(global_flocks): for pattern in builtin_patterns: cls._scan_directory(global_flocks, pattern, skills, source="flocks") - for pattern in plugin_patterns: - cls._scan_directory(global_flocks, pattern, skills, source="user") - # 3) Project-level .flocks — highest priority - # Built-in skills: source="flocks"; project-installed plugins: source="project" + # 3) Project-bundled .flocks skills — built-in baseline for this project for flocks_dir in cls._find_dirs_up(".flocks", current_dir, worktree): if os.path.normpath(flocks_dir) == os.path.normpath(global_flocks): continue @@ -468,6 +474,11 @@ def _discover(cls) -> Dict[str, SkillInfo]: for pattern in plugin_patterns: cls._scan_directory(flocks_dir, pattern, skills, source="project") + # 4) User-installed plugins — explicit customizations win over project bundles + if os.path.isdir(global_flocks): + for pattern in plugin_patterns: + cls._scan_directory(global_flocks, pattern, skills, source="user") + log.info("skill.discovery.complete", {"count": len(skills), "names": list(skills.keys())}) return skills diff --git a/flocks/tool/device/startup.py b/flocks/tool/device/startup.py index 82a32920a..7b1b0454b 100644 --- a/flocks/tool/device/startup.py +++ b/flocks/tool/device/startup.py @@ -7,6 +7,8 @@ """ from __future__ import annotations +import aiosqlite + from flocks.storage.storage import Storage from flocks.utils.log import Log @@ -40,6 +42,7 @@ async def _heal_stale_service_ids() -> None: from flocks.tool.device.store import storage_key_to_service_id async with Storage.connect(Storage.get_db_path()) as db: + db.row_factory = aiosqlite.Row cur = await db.execute("SELECT id, storage_key, service_id FROM device_integrations") rows = await cur.fetchall() updates: list[tuple[str, str]] = [] diff --git a/flocks/tool/file/glob.py b/flocks/tool/file/glob.py index c9f5eb8a0..3a5e9b769 100644 --- a/flocks/tool/file/glob.py +++ b/flocks/tool/file/glob.py @@ -24,6 +24,7 @@ # Constants MAX_FILES = 100 +_ripgrep_fallback_logged = False # Description matching Flocks' glob.txt @@ -195,7 +196,10 @@ async def glob_tool( 'mtime': mtime }) else: - log.warn("glob.ripgrep_not_found", {"fallback": "python_glob"}) + global _ripgrep_fallback_logged + if not _ripgrep_fallback_logged: + _ripgrep_fallback_logged = True + log.info("glob.ripgrep_not_found", {"fallback": "python_glob"}) for filepath in fallback_glob(search_path, pattern): if len(files) >= MAX_FILES: diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index d2bff0d7a..cb97ca1fb 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -376,6 +376,13 @@ def _normalize_param_key(name: str) -> str: return "".join(ch for ch in str(name).lower() if ch.isalnum()) +def _api_service_storage_key(provider: str) -> str: + """Resolve the single persisted key used for an API-like provider.""" + from flocks.config.api_versioning import versioned_storage_key_for + + return versioned_storage_key_for(provider) or provider + + _SCOPED_SCHEMA_ALIASES: Dict[str, Dict[str, str]] = { # SkyEye historically surfaced "威胁级别", which some callers guessed as # threat_level. Keep the schema canonical on hazard_level, but accept the @@ -516,7 +523,7 @@ async def execute(self, ctx: ToolContext, **kwargs) -> ToolResult: # Validate required parameters for required_param in schema.required: if required_param not in effective_kwargs: - log.error("tool.execute.missing_param", { + log.warn("tool.execute.missing_param", { "tool": self.info.name, "missing": required_param, "provided": list(effective_kwargs.keys()), @@ -1188,7 +1195,7 @@ def _bootstrap_user_api_services(cls) -> None: or not info.enabled ): continue - provider = info.provider + provider = _api_service_storage_key(info.provider) if provider in seen_providers: continue seen_providers.add(provider) @@ -1240,9 +1247,10 @@ def _sync_api_service_states(cls) -> None: disabled_count = 0 restored_count = 0 for tool in cls._tools.values(): - provider = tool.info.provider - if not provider: + configured_provider = tool.info.provider + if not configured_provider: continue + provider = _api_service_storage_key(configured_provider) svc = api_services.get(provider, {}) svc_enabled = svc.get("enabled", False) if not svc_enabled: diff --git a/flocks/workflow/center.py b/flocks/workflow/center.py index ad05aff3b..b0fb594bc 100644 --- a/flocks/workflow/center.py +++ b/flocks/workflow/center.py @@ -100,6 +100,11 @@ def _normalize_workflow_id(path: Path) -> str: return digest[:24] +def _logical_workflow_id(path: Path) -> str: + """Return the filesystem workflow ID shared across discovery roots.""" + return path.parent.name + + def _fingerprint(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as f: @@ -156,6 +161,17 @@ def resolve_project_workflow_roots(base_dir: Optional[Path] = None) -> list[Path ] +def resolve_workflow_scan_roots( + base_dir: Optional[Path] = None, +) -> list[tuple[Path, str]]: + """Return workflow roots ordered from lowest to highest priority.""" + return [ + (root, "project") for root in resolve_project_workflow_roots(base_dir) + ] + [ + (root, "global") for root in resolve_global_workflow_roots() + ] + + def _resolve_project_workflow_root(base_dir: Optional[Path] = None) -> Path: """/.flocks/plugins/workflows/ — canonical project-level workflow storage.""" root = base_dir or Path.cwd() @@ -256,6 +272,17 @@ async def _read_registry(workflow_id: str) -> Dict[str, Any]: return data +def _load_discoverable_workflow(workflow_path: Path) -> Optional[Dict[str, Any]]: + """Load and validate a workflow, returning None when intentionally hidden.""" + raw = json.loads(workflow_path.read_text(encoding="utf-8")) + meta_path = workflow_path.parent / "meta.json" + meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else None + if is_hidden_workflow(raw, meta): + return None + Workflow.from_dict(raw) + return raw + + async def _scan_workflow_dir( workflow_root: Path, source_type: str, @@ -270,12 +297,9 @@ async def _scan_workflow_dir( return for workflow_path in sorted(workflow_root.glob("*/workflow.json")): try: - raw = json.loads(workflow_path.read_text(encoding="utf-8")) - meta_path = workflow_path.parent / "meta.json" - meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else None - if is_hidden_workflow(raw, meta): + raw = _load_discoverable_workflow(workflow_path) + if raw is None: continue - Workflow.from_dict(raw) except Exception as exc: log.warning( "workflow.center.scan.skip_invalid", @@ -284,6 +308,7 @@ async def _scan_workflow_dir( continue workflow_id = _normalize_workflow_id(workflow_path) + logical_workflow_id = _logical_workflow_id(workflow_path) fp = _fingerprint(workflow_path) now_ms = _now_ms() existing = await WorkflowStore.kv_get(_registry_key(workflow_id)) or {} @@ -291,6 +316,7 @@ async def _scan_workflow_dir( draft_changed = bool(existing) and existing.get("fingerprint") != fp entry = { "workflowId": workflow_id, + "logicalWorkflowId": logical_workflow_id, "name": raw.get("name") or workflow_path.parent.name, "description": raw.get("description") or "", "sourceType": source_type, @@ -308,7 +334,7 @@ async def _scan_workflow_dir( "serviceUrl": existing.get("serviceUrl"), } await WorkflowStore.kv_put(_registry_key(workflow_id), entry) - by_id[workflow_id] = entry + by_id[logical_workflow_id] = entry async def scan_skill_workflows(base_dir: Optional[Path] = None) -> List[Dict[str, Any]]: @@ -316,21 +342,15 @@ async def scan_skill_workflows(base_dir: Optional[Path] = None) -> List[Dict[str Scan order (lowest → highest priority), see resolve_global_workflow_roots / resolve_project_workflow_roots: - 1. ~/.flocks/plugins/workflow/ (global legacy, sourceType="global") - 2. ~/.flocks/workflow/ (global compat, sourceType="global") - 3. ~/.flocks/plugins/workflows/ (global canonical, sourceType="global") - 4. /.flocks/plugins/workflow/ (project legacy, sourceType="project") - 5. /.flocks/workflow/ (project compat, sourceType="project") - 6. /.flocks/plugins/workflows/ (project canonical, sourceType="project") + 1. /.flocks project-bundled workflows + 2. ~/.flocks user workflows (highest priority) When two directories contain a workflow with the same ID, the later (higher-priority) entry wins. """ by_id: Dict[str, Dict[str, Any]] = {} - for path in resolve_global_workflow_roots(): - await _scan_workflow_dir(path, "global", by_id) - for path in resolve_project_workflow_roots(base_dir): - await _scan_workflow_dir(path, "project", by_id) + for path, source_type in resolve_workflow_scan_roots(base_dir): + await _scan_workflow_dir(path, source_type, by_id) entries = list(by_id.values()) entries.sort(key=lambda item: item.get("updatedAt", 0), reverse=True) @@ -376,16 +396,71 @@ def format_workflow_entries( async def list_registry_entries() -> List[Dict[str, Any]]: """List registered skill workflows.""" keys = await WorkflowStore.kv_list(_REGISTRY_PREFIX) - items: List[Dict[str, Any]] = [] + selected_entries: Dict[str, tuple[tuple[int, int, int], Dict[str, Any]]] = {} for raw_key in keys: key = _key_to_string(raw_key) entry = await WorkflowStore.kv_get(key) if entry: - items.append(entry) + logical_id = entry.get("logicalWorkflowId") + if not logical_id: + workflow_path = entry.get("workflowPath") + logical_id = ( + _logical_workflow_id(Path(str(workflow_path))) + if workflow_path + else str(entry.get("workflowId") or key) + ) + priority = _registry_entry_priority(entry) + if priority[1] >= 0 and not _registry_entry_is_discoverable(entry): + continue + # Only collapse entries that belong to the currently active project + # or user roots. Registrations from another project remain manageable. + selection_key = ( + f"logical:{logical_id}" + if priority[1] >= 0 + else f"registry:{entry.get('workflowId') or key}" + ) + selected = selected_entries.get(selection_key) + if selected is None or priority >= selected[0]: + selected_entries[selection_key] = (priority, entry) + items = [entry for _priority, entry in selected_entries.values()] items.sort(key=lambda item: item.get("updatedAt", 0), reverse=True) return items +def _registry_entry_priority(entry: Dict[str, Any]) -> tuple[int, int, int]: + """Rank registry entries using the same project-then-user scan order.""" + workflow_path = Path(str(entry.get("workflowPath") or "")) + try: + resolved_path = workflow_path.resolve() + except OSError: + resolved_path = workflow_path + + roots = [root for root, _source in resolve_workflow_scan_roots(Path.cwd())] + root_priority = -1 + for index, root in enumerate(roots): + try: + resolved_path.relative_to(root.resolve()) + except (OSError, ValueError): + continue + root_priority = index + break + + return ( + int(resolved_path.is_file()), + root_priority, + int(entry.get("updatedAt") or 0), + ) + + +def _registry_entry_is_discoverable(entry: Dict[str, Any]) -> bool: + """Return whether an active-root registry entry still passes discovery.""" + workflow_path = Path(str(entry.get("workflowPath") or "")) + try: + return _load_discoverable_workflow(workflow_path) is not None + except Exception: + return False + + def _service_release_file(workflow_id: str, release_id: str) -> Path: base = Config.get_data_path() / _SERVICE_DATA_DIR / "releases" / workflow_id base.mkdir(parents=True, exist_ok=True) diff --git a/flocks/workflow/fs_store.py b/flocks/workflow/fs_store.py index 8f1900268..0f3471959 100644 --- a/flocks/workflow/fs_store.py +++ b/flocks/workflow/fs_store.py @@ -9,7 +9,7 @@ from flocks.utils.log import Log -from .center import resolve_global_workflow_roots, resolve_project_workflow_roots +from .center import resolve_workflow_scan_roots log = Log.create(service="workflow.fs-store") @@ -104,12 +104,7 @@ def find_workspace_root() -> Path: def workflow_scan_dirs() -> list[tuple[Path, str]]: """Return all workflow roots ordered from lowest to highest priority.""" - workspace = find_workspace_root() - return [ - (root, "global") for root in resolve_global_workflow_roots() - ] + [ - (root, "project") for root in resolve_project_workflow_roots(workspace) - ] + return resolve_workflow_scan_roots(find_workspace_root()) def read_workflow_dir( diff --git a/flocks/workflow/poller_manager.py b/flocks/workflow/poller_manager.py index c4596286c..179376c48 100644 --- a/flocks/workflow/poller_manager.py +++ b/flocks/workflow/poller_manager.py @@ -191,7 +191,18 @@ async def start_all(self) -> None: if not workflow_id: continue if isinstance(data, dict) and data.get("enabled"): - await self.restart_workflow(workflow_id) + try: + await self.restart_workflow(workflow_id, startup=True) + except Exception as exc: + self._status[workflow_id] = { + **self._base_status(workflow_id), + "state": "failed", + "error": str(exc), + } + log.warning( + "poller.start_failed", + {"workflow_id": workflow_id, "error": str(exc)}, + ) async def stop_all(self) -> None: for workflow_id in list(self._tasks.keys()): @@ -229,7 +240,12 @@ async def stop_workflow(self, workflow_id: str) -> None: self._run_cancel_events.pop(workflow_id, None) self._status[workflow_id] = current - async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: + async def restart_workflow( + self, + workflow_id: str, + *, + startup: bool = False, + ) -> Dict[str, Any]: await self.stop_workflow(workflow_id) try: stored = await WorkflowStore.get_config(workflow_id, kind="workflow_poller_config") @@ -237,8 +253,7 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: log.warning("poller.restart_read_failed", {"workflow_id": workflow_id, "error": str(exc)}) return {"workflowId": workflow_id, "state": "failed", "error": str(exc)} - config = self._normalize_config(workflow_id, stored) - if not config.get("enabled"): + if not isinstance(stored, dict) or not stored.get("enabled"): self._status[workflow_id] = { **self._base_status(workflow_id), "workflowId": workflow_id, @@ -253,11 +268,20 @@ async def restart_workflow(self, workflow_id: str) -> Dict[str, Any]: self._status[workflow_id] = { **self.get_status(workflow_id), "workflowId": workflow_id, - "state": "failed", + "state": "stopped" if startup else "failed", "error": err, } + poller_log = log.info if startup else log.warning + poller_log( + "poller.workflow_not_found_on_start" if startup else "poller.workflow_not_found", + { + "workflow_id": workflow_id, + **({"action": "stale_config_skipped"} if startup else {}), + }, + ) return self.get_status(workflow_id) + config = self._normalize_config(workflow_id, stored) workflow_json = wf_data.get("workflowJson") if not workflow_json: err = "workflow_json_missing" diff --git a/tests/agent/test_agent_factory.py b/tests/agent/test_agent_factory.py index 30ce129e6..78f86fdac 100644 --- a/tests/agent/test_agent_factory.py +++ b/tests/agent/test_agent_factory.py @@ -15,6 +15,7 @@ import textwrap from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -780,6 +781,43 @@ def test_user_plugin_agent_is_not_native( assert "custom-agent" in result assert result["custom-agent"].native is False + def test_user_plugin_agent_wins_over_project_bundle( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + user_agents_dir = tmp_path / "user_plugins" / "agents" + project_dir = tmp_path / "project" + project_agents_dir = project_dir / ".flocks" / "plugins" / "agents" + self._write_agent(user_agents_dir, "shared-agent") + self._write_agent(project_agents_dir, "shared-agent") + (user_agents_dir / "shared-agent" / "agent.yaml").write_text( + "name: shared-agent\ndescription: user customization\nmode: subagent\n", + encoding="utf-8", + ) + (project_agents_dir / "shared-agent" / "agent.yaml").write_text( + "name: shared-agent\ndescription: project bundle\nmode: subagent\n", + encoding="utf-8", + ) + + info_log = MagicMock() + warn_log = MagicMock() + monkeypatch.setattr(_factory_module, "_PLUGIN_AGENTS_DIR", user_agents_dir) + monkeypatch.setattr(_factory_module.log, "info", info_log) + monkeypatch.setattr(_factory_module.log, "warn", warn_log) + monkeypatch.chdir(project_dir) + + result = scan_and_load() + + assert result["shared-agent"].description == "user customization" + assert any( + call.args and call.args[0] == "agent.factory.lower_priority_skipped" + for call in info_log.call_args_list + ) + assert not any( + call.args and call.args[0] == "agent.factory.name_conflict" + and call.args[1].get("name") == "shared-agent" + for call in warn_log.call_args_list + ) + # =========================================================================== # Project-level plugin agent CRUD diff --git a/tests/channel/test_weixin_log_levels.py b/tests/channel/test_weixin_log_levels.py new file mode 100644 index 000000000..cc083af66 --- /dev/null +++ b/tests/channel/test_weixin_log_levels.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import flocks.channel.builtin.weixin.channel as weixin_module + + +@pytest.mark.asyncio +async def test_group_policy_note_logs_info(monkeypatch): + sessions = [MagicMock(), MagicMock()] + info_log = MagicMock() + warn_log = MagicMock() + warning_log = MagicMock() + + monkeypatch.setattr(weixin_module, "AIOHTTP_AVAILABLE", True) + monkeypatch.setattr(weixin_module, "CRYPTO_AVAILABLE", True) + monkeypatch.setattr( + weixin_module, + "aiohttp", + SimpleNamespace( + ClientTimeout=MagicMock(return_value=MagicMock()), + ClientSession=MagicMock(side_effect=sessions), + ), + ) + monkeypatch.setattr(weixin_module.ilink, "make_ssl_connector", MagicMock(return_value=None)) + monkeypatch.setattr(weixin_module, "ContextTokenStore", MagicMock(return_value=MagicMock())) + monkeypatch.setattr(weixin_module, "MessageDedup", MagicMock(return_value=MagicMock())) + monkeypatch.setattr(weixin_module, "MediaCache", MagicMock(return_value=MagicMock())) + monkeypatch.setattr(weixin_module.log, "info", info_log) + monkeypatch.setattr(weixin_module.log, "warn", warn_log) + monkeypatch.setattr(weixin_module.log, "warning", warning_log) + + channel = weixin_module.WeixinChannel() + monkeypatch.setattr(channel, "_poll_loop", AsyncMock(return_value=None)) + monkeypatch.setattr(channel, "_close_sessions", AsyncMock(return_value=None)) + + await channel.start( + {"token": "token", "accountId": "account", "groupPolicy": "all"}, + AsyncMock(), + ) + + assert any(call.args and call.args[0] == "weixin.group_policy.note" for call in info_log.call_args_list) + assert not any( + call.args and call.args[0] == "weixin.group_policy.note" + for call in warn_log.call_args_list + warning_log.call_args_list + ) diff --git a/tests/ingest/test_kafka_manager.py b/tests/ingest/test_kafka_manager.py index 31de29d02..e5803177a 100644 --- a/tests/ingest/test_kafka_manager.py +++ b/tests/ingest/test_kafka_manager.py @@ -25,6 +25,95 @@ from flocks.workflow.triggers.models import TriggerDefinition +@pytest.mark.asyncio +async def test_start_all_skips_stale_workflow_config_as_info( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "removed-workflow" + config = { + "enabled": True, + "inputBroker": "", + "inputTopic": "", + } + info_events: list[tuple[str, dict]] = [] + warning_events: list[str] = [] + + async def _list_configs(*, kind: str): # noqa: ANN202 + assert kind == "workflow_kafka_config" + return [(workflow_id, config)] + + async def _get_config(_workflow_id: str, *, kind: str) -> dict: + assert kind == "workflow_kafka_config" + return config + + monkeypatch.setattr(kafka_manager.WorkflowStore, "list_configs", _list_configs) + monkeypatch.setattr(kafka_manager.WorkflowStore, "get_config", _get_config) + monkeypatch.setattr(kafka_manager, "read_workflow_from_fs", lambda _workflow_id: None) + monkeypatch.setattr( + kafka_manager.log, + "info", + lambda event, data=None: info_events.append((event, data)), + ) + monkeypatch.setattr( + kafka_manager.log, + "warning", + lambda event, _data=None: warning_events.append(event), + ) + + manager = kafka_manager.KafkaManager() + await manager.start_all() + + assert manager.get_consumer_status(workflow_id) == { + "state": "stopped", + "error": "workflow_not_found", + } + assert info_events == [ + ( + "kafka.workflow_not_found_on_start", + {"workflow_id": workflow_id, "action": "stale_config_skipped"}, + ) + ] + assert warning_events == [] + + +@pytest.mark.asyncio +async def test_start_all_continues_after_one_workflow_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + restarted: list[str] = [] + warning_events: list[tuple[str, dict]] = [] + + async def _list_configs(*, kind: str): # noqa: ANN202 + assert kind == "workflow_kafka_config" + return [ + ("broken-workflow", {"enabled": True}), + ("healthy-workflow", {"enabled": True}), + ] + + async def _restart(workflow_id: str, *, startup: bool = False) -> dict: + assert startup is True + restarted.append(workflow_id) + if workflow_id == "broken-workflow": + raise ValueError("invalid config") + return {"state": "running", "error": None} + + monkeypatch.setattr(kafka_manager.WorkflowStore, "list_configs", _list_configs) + monkeypatch.setattr(kafka_manager.log, "warning", lambda event, data: warning_events.append((event, data))) + + manager = kafka_manager.KafkaManager() + monkeypatch.setattr(manager, "restart_workflow", _restart) + + await manager.start_all() + + assert restarted == ["broken-workflow", "healthy-workflow"] + assert warning_events == [ + ( + "kafka.start_failed", + {"workflow_id": "broken-workflow", "error": "invalid config"}, + ) + ] + + @pytest.mark.asyncio async def test_worker_pool_bounds_in_flight_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: """The fixed worker pool must cap concurrent ``_trigger_workflow`` calls.""" @@ -190,6 +279,11 @@ async def _fake_get_config(workflow_id: str, *, kind: str) -> dict: return {"enabled": True, "inputBroker": "", "inputTopic": ""} monkeypatch.setattr(kafka_manager.WorkflowStore, "get_config", _fake_get_config) + monkeypatch.setattr( + kafka_manager, + "read_workflow_from_fs", + lambda _workflow_id: {"workflowJson": {}}, + ) status = await manager.restart_workflow("wf-no-broker") assert status["state"] == "failed" diff --git a/tests/ingest/test_syslog_manager_bind_failure.py b/tests/ingest/test_syslog_manager_bind_failure.py index ecd621fcb..75809307d 100644 --- a/tests/ingest/test_syslog_manager_bind_failure.py +++ b/tests/ingest/test_syslog_manager_bind_failure.py @@ -14,6 +14,7 @@ import asyncio import socket +from unittest.mock import AsyncMock, MagicMock import pytest @@ -28,6 +29,84 @@ def _find_busy_udp_port() -> tuple[socket.socket, int]: return sock, port +@pytest.mark.asyncio +async def test_start_all_logs_stale_workflow_config_as_info( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "removed-workflow" + config = {"workflowId": workflow_id, "enabled": True} + info_log = MagicMock() + warning_log = MagicMock() + + monkeypatch.setattr( + syslog_manager.WorkflowStore, + "list_configs", + AsyncMock(return_value=[(workflow_id, config)]), + ) + monkeypatch.setattr( + syslog_manager.WorkflowStore, + "get_config", + AsyncMock(return_value=config), + ) + monkeypatch.setattr(syslog_manager, "read_workflow_from_fs", lambda _workflow_id: None) + monkeypatch.setattr(syslog_manager.log, "info", info_log) + monkeypatch.setattr(syslog_manager.log, "warning", warning_log) + + manager = syslog_manager.SyslogManager() + await manager.start_all() + + assert manager.get_listener_status(workflow_id) == { + "state": "stopped", + "error": "workflow_not_found", + } + info_log.assert_called_once_with( + "syslog.workflow_not_found_on_start", + { + "workflow_id": workflow_id, + "action": "stale_config_skipped", + }, + ) + warning_log.assert_not_called() + + +@pytest.mark.asyncio +async def test_start_all_continues_after_one_workflow_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + restarted: list[str] = [] + warning_log = MagicMock() + + monkeypatch.setattr( + syslog_manager.WorkflowStore, + "list_configs", + AsyncMock( + return_value=[ + ("broken-workflow", {"enabled": True}), + ("healthy-workflow", {"enabled": True}), + ] + ), + ) + + async def _restart(workflow_id: str, *, startup: bool = False) -> dict: + assert startup is True + restarted.append(workflow_id) + if workflow_id == "broken-workflow": + raise ValueError("invalid config") + return {"state": "listening", "error": None} + + manager = syslog_manager.SyslogManager() + monkeypatch.setattr(manager, "restart_workflow", _restart) + monkeypatch.setattr(syslog_manager.log, "warning", warning_log) + + await manager.start_all() + + assert restarted == ["broken-workflow", "healthy-workflow"] + warning_log.assert_called_once_with( + "syslog.start_failed", + {"workflow_id": "broken-workflow", "error": "invalid config"}, + ) + + @pytest.mark.asyncio async def test_restart_workflow_reports_failure_on_port_conflict( monkeypatch: pytest.MonkeyPatch, @@ -46,11 +125,6 @@ async def test_restart_workflow_reports_failure_on_port_conflict( "inputKey": "syslog_message", } - async def _fake_storage_read(key: str): # noqa: ANN001 - if key == syslog_manager.SyslogManager._config_key(workflow_id): - return config - return None - def _fake_read_workflow_from_fs(wid: str): # noqa: ANN001 return { "id": wid, @@ -62,7 +136,11 @@ def _fake_read_workflow_from_fs(wid: str): # noqa: ANN001 } # Patch the *module-level* names ``manager.py`` looks up at call time. - monkeypatch.setattr(syslog_manager.Storage, "read", _fake_storage_read) + monkeypatch.setattr( + syslog_manager.WorkflowStore, + "get_config", + AsyncMock(return_value=config), + ) monkeypatch.setattr(syslog_manager, "read_workflow_from_fs", _fake_read_workflow_from_fs) manager = syslog_manager.SyslogManager() @@ -95,10 +173,11 @@ async def test_restart_workflow_returns_stopped_when_disabled( "inputKey": "syslog_message", } - async def _fake_storage_read(key: str): # noqa: ANN001 - return config - - monkeypatch.setattr(syslog_manager.Storage, "read", _fake_storage_read) + monkeypatch.setattr( + syslog_manager.WorkflowStore, + "get_config", + AsyncMock(return_value=config), + ) manager = syslog_manager.SyslogManager() status = await manager.restart_workflow(workflow_id) diff --git a/tests/mcp/test_mcp_client_sse.py b/tests/mcp/test_mcp_client_sse.py index 479a32a44..ed4e9487d 100644 --- a/tests/mcp/test_mcp_client_sse.py +++ b/tests/mcp/test_mcp_client_sse.py @@ -5,9 +5,12 @@ from types import MethodType, SimpleNamespace import pytest +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, METHOD_NOT_FOUND import flocks.mcp.client as mcp_client_module from flocks.mcp.client import McpClient, _extract_root_cause +from flocks.mcp.errors import is_method_not_found_error def _make_session_class( @@ -374,3 +377,21 @@ class MockRequest: result = _extract_root_cause(exc) assert "401" in result assert "secret" not in result + + def test_method_not_found_detection_uses_structured_error_code(self): + exc = McpError(ErrorData(code=METHOD_NOT_FOUND, message="Unsupported operation")) + assert is_method_not_found_error(exc) is True + + def test_method_not_found_detection_checks_every_exception_group_child(self): + method_error = McpError( + ErrorData(code=METHOD_NOT_FOUND, message="Unsupported operation") + ) + exc = ExceptionGroup( + "request failed", + [RuntimeError("transport closed"), method_error], + ) + assert is_method_not_found_error(exc) is True + + def test_method_not_found_detection_does_not_match_unrelated_phrase(self): + exc = RuntimeError("cache method not found during local lookup") + assert is_method_not_found_error(exc) is False diff --git a/tests/mcp/test_mcp_server.py b/tests/mcp/test_mcp_server.py index b89a6b26d..7e3f6c098 100644 --- a/tests/mcp/test_mcp_server.py +++ b/tests/mcp/test_mcp_server.py @@ -4,7 +4,10 @@ from types import SimpleNamespace import pytest +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, METHOD_NOT_FOUND +from flocks.mcp import server as mcp_server_module from flocks.mcp.server import McpServerManager from flocks.mcp.types import McpStatus @@ -81,6 +84,70 @@ def __init__(self, **kwargs) -> None: assert manager._status["legacy-demo"].status == McpStatus.CONNECTED +@pytest.mark.asyncio +async def test_connect_treats_missing_resources_method_as_optional(monkeypatch: pytest.MonkeyPatch): + class ToolsOnlyClient(_FakeMcpClient): + async def list_resources(self) -> list: + raise McpError( + ErrorData(code=METHOD_NOT_FOUND, message="Unsupported operation") + ) + + info_events: list[str] = [] + warn_events: list[str] = [] + monkeypatch.setattr(mcp_server_module, "McpClient", ToolsOnlyClient) + monkeypatch.setattr( + mcp_server_module.log, + "info", + lambda event, _data=None: info_events.append(event), + ) + monkeypatch.setattr( + mcp_server_module.log, + "warn", + lambda event, _data=None: warn_events.append(event), + ) + + manager = McpServerManager() + await manager._connect_and_register( + "tools-only", + {"type": "local", "command": ["python", "-m", "demo"]}, + ) + + assert manager._resources_cache["tools-only"] == [] + assert manager._status["tools-only"].status == McpStatus.CONNECTED + assert "mcp.resources_unsupported" in info_events + assert "mcp.resources_unavailable" not in warn_events + + +@pytest.mark.asyncio +async def test_connect_logs_resource_discovery_failure_once(monkeypatch: pytest.MonkeyPatch): + class BrokenResourcesClient(_FakeMcpClient): + async def list_resources(self) -> list: + raise RuntimeError("resource endpoint unavailable") + + warn_events: list[str] = [] + error_events: list[str] = [] + monkeypatch.setattr(mcp_server_module, "McpClient", BrokenResourcesClient) + monkeypatch.setattr( + mcp_server_module.log, + "warn", + lambda event, _data=None: warn_events.append(event), + ) + monkeypatch.setattr( + mcp_server_module.log, + "error", + lambda event, _data=None: error_events.append(event), + ) + + manager = McpServerManager() + await manager._connect_and_register( + "broken-resources", + {"type": "local", "command": ["python", "-m", "demo"]}, + ) + + assert warn_events == ["mcp.resources_unavailable"] + assert error_events == [] + + @pytest.mark.asyncio async def test_init_is_serialized_for_concurrent_callers(monkeypatch: pytest.MonkeyPatch): manager = McpServerManager() diff --git a/tests/provider/test_api_service_management.py b/tests/provider/test_api_service_management.py index 4647fb8df..fbb1d9ebf 100644 --- a/tests/provider/test_api_service_management.py +++ b/tests/provider/test_api_service_management.py @@ -585,6 +585,58 @@ def test_bootstrap_adds_enabled_true_when_key_missing(self): assert written["apiKey"] == "{secret:key}" assert written["base_url"] == "https://api.example.com" + def test_bootstrap_writes_unique_versioned_storage_key(self): + tools = {"tdp_tool": _make_api_tool("tdp_tool", "tdp_api")} + with patch( + "flocks.config.api_versioning.versioned_storage_key_for", + return_value="tdp_api_v3_3_10", + ): + mock_set = self._run_bootstrap(tools, get_raw_side_effect=lambda _: None) + + mock_set.assert_called_once_with("tdp_api_v3_3_10", {"enabled": True}) + + def test_bootstrap_then_sync_reads_the_same_versioned_storage_key(self): + tool = _make_api_tool("tdp_tool", "tdp_api") + tools = {"tdp_tool": tool} + services = { + "tdp_api": {"apiKey": "legacy-copy"}, + "tdp_api_v3_3_10": {"apiKey": "versioned-copy"}, + } + + def get_raw(storage_key: str): + value = services.get(storage_key) + return value.copy() if value is not None else None + + def set_service(storage_key: str, value: dict): + services[storage_key] = value.copy() + + with ( + patch.object(ToolRegistry, "_tools", tools), + patch.object(ToolRegistry, "_enabled_defaults", {"tdp_tool": True}), + patch( + "flocks.config.api_versioning.versioned_storage_key_for", + return_value="tdp_api_v3_3_10", + ), + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + side_effect=get_raw, + ), + patch( + "flocks.config.config_writer.ConfigWriter.set_api_service", + side_effect=set_service, + ), + patch( + "flocks.config.config_writer.ConfigWriter.list_api_services_raw", + side_effect=lambda: services, + ), + ): + ToolRegistry._bootstrap_user_api_services() + ToolRegistry._sync_api_service_states() + + assert services["tdp_api_v3_3_10"]["enabled"] is True + assert "enabled" not in services["tdp_api"] + assert tool.info.enabled is True + def test_bootstrap_does_not_overwrite_explicit_disabled(self): """When api_services..enabled is False, bootstrap leaves it untouched.""" existing = {"enabled": False, "apiKey": "{secret:key}"} diff --git a/tests/server/routes/test_notifications_routes.py b/tests/server/routes/test_notifications_routes.py index 1245f8295..a0400e801 100644 --- a/tests/server/routes/test_notifications_routes.py +++ b/tests/server/routes/test_notifications_routes.py @@ -25,7 +25,7 @@ async def test_notifications_require_browser_login(client: AsyncClient): async def test_active_notifications_and_dismiss_forever(client: AsyncClient): response = await client.get( "/api/notifications/active", - params={"locale": "zh-CN", "current_version": "2026.04.27"}, + params={"locale": "zh-CN"}, ) assert response.status_code == 200, response.text items = response.json() @@ -37,14 +37,14 @@ async def test_active_notifications_and_dismiss_forever(client: AsyncClient): response = await client.get( "/api/notifications/active", - params={"locale": "zh-CN", "current_version": "2026.04.27"}, + params={"locale": "zh-CN"}, ) assert response.status_code == 200, response.text assert response.json() == [] response = await client.get( "/api/notifications/active", - params={"locale": "zh-CN", "current_version": "2026.05.01"}, + params={"locale": "zh-CN"}, ) assert response.status_code == 200, response.text assert response.json() == [] diff --git a/tests/server/test_app_errors.py b/tests/server/test_app_errors.py index 673cd89a1..71e97b879 100644 --- a/tests/server/test_app_errors.py +++ b/tests/server/test_app_errors.py @@ -1,6 +1,8 @@ import json +from unittest.mock import MagicMock import pytest +from starlette.exceptions import HTTPException from starlette.requests import Request from flocks.server import app as app_module @@ -31,3 +33,43 @@ async def test_general_exception_response_does_not_expose_traceback(): "error": "InternalServerError", "message": "Internal server error", } + + +@pytest.mark.asyncio +async def test_http_4xx_logs_warning(monkeypatch): + warning = MagicMock() + error = MagicMock() + monkeypatch.setattr(app_module.log, "warn", warning) + monkeypatch.setattr(app_module.log, "error", error) + + response = await app_module.http_exception_handler( + _request("/missing"), + HTTPException(status_code=404, detail="Not found"), + ) + + assert response.status_code == 404 + warning.assert_called_once_with( + "http.error", + {"path": "/missing", "status": 404, "detail": "Not found"}, + ) + error.assert_not_called() + + +@pytest.mark.asyncio +async def test_http_5xx_logs_error(monkeypatch): + warning = MagicMock() + error = MagicMock() + monkeypatch.setattr(app_module.log, "warn", warning) + monkeypatch.setattr(app_module.log, "error", error) + + response = await app_module.http_exception_handler( + _request("/unavailable"), + HTTPException(status_code=503, detail="Unavailable"), + ) + + assert response.status_code == 503 + error.assert_called_once_with( + "http.error", + {"path": "/unavailable", "status": 503, "detail": "Unavailable"}, + ) + warning.assert_not_called() diff --git a/tests/server/test_input_dispatcher.py b/tests/server/test_input_dispatcher.py index ff1c8bae6..17a11d8f0 100644 --- a/tests/server/test_input_dispatcher.py +++ b/tests/server/test_input_dispatcher.py @@ -357,13 +357,13 @@ def test_materialize_queued_data_url_returns_readable_file_uri(self, monkeypatch from flocks.server.routes import session as session_routes from flocks.session.utils.file_extractor import read_file_part_bytes - class FakeWorkspace: - def resolve_workspace_path(self, rel_path: str): - return tmp_path / rel_path - monkeypatch.setattr( "flocks.workspace.manager.WorkspaceManager.get_instance", - lambda: FakeWorkspace(), + lambda: (_ for _ in ()).throw(PermissionError("workspace access denied")), + ) + monkeypatch.setattr( + "flocks.config.config.Config.get_data_path", + staticmethod(lambda: tmp_path), ) data_url = "data:image/png;base64," + base64.b64encode(b"png-bytes").decode() @@ -377,6 +377,17 @@ def resolve_workspace_path(self, rel_path: str): assert url.startswith("file://") assert read_file_part_bytes(url) == b"png-bytes" + def test_session_upload_path_rejects_traversal(self, monkeypatch, tmp_path): + from flocks.server.routes import session as session_routes + + monkeypatch.setattr( + "flocks.config.config.Config.get_data_path", + staticmethod(lambda: tmp_path), + ) + + with pytest.raises(ValueError, match="Invalid session ID"): + session_routes._session_uploads_dir("../outside") + @pytest.mark.asyncio async def test_prompt_async_queues_when_session_running_without_creating_message(self, monkeypatch): from flocks.server.routes import session as session_routes diff --git a/tests/session/test_runner_step.py b/tests/session/test_runner_step.py index 9d67e6565..994ce4b42 100644 --- a/tests/session/test_runner_step.py +++ b/tests/session/test_runner_step.py @@ -2249,6 +2249,8 @@ async def test_process_step_limits_connection_error_retries(monkeypatch): provider.is_configured.return_value = True assistant_msg = SimpleNamespace(id="msg_assistant_connection_error") update_mock = AsyncMock(return_value=None) + warn_log = MagicMock() + error_log = MagicMock() call_count = 0 async def fake_call_llm(*_args, **_kwargs): @@ -2271,6 +2273,8 @@ async def fake_call_llm(*_args, **_kwargs): monkeypatch.setattr(runner_mod.Message, "update", update_mock) monkeypatch.setattr(runner_mod.SessionRetry, "sleep", AsyncMock(return_value=None)) monkeypatch.setattr(runner, "_call_llm", fake_call_llm) + monkeypatch.setattr(runner_mod.log, "warn", warn_log) + monkeypatch.setattr(runner_mod.log, "error", error_log) result = await runner._process_step([last_user], last_user) @@ -2284,6 +2288,21 @@ async def fake_call_llm(*_args, **_kwargs): assert final_update["error"]["data"]["message"] == "Connection error." assert final_update["error"]["data"]["displayMessage"] == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + retry_logs = [ + call for call in warn_log.call_args_list + if call.args and call.args[0] == "runner.step.retry" + ] + max_retry_logs = [ + call for call in error_log.call_args_list + if call.args and call.args[0] == "runner.step.max_retries_exceeded" + ] + assert len(retry_logs) == 3 + assert len(max_retry_logs) == 1 + assert not any( + call.args and call.args[0] == "runner.step.error" + for call in [*warn_log.call_args_list, *error_log.call_args_list] + ) + @pytest.mark.asyncio async def test_process_step_marks_aborted_llm_message_as_error(monkeypatch): diff --git a/tests/session/test_session_memory_log_levels.py b/tests/session/test_session_memory_log_levels.py new file mode 100644 index 000000000..5a0571cf1 --- /dev/null +++ b/tests/session/test_session_memory_log_levels.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import flocks.session.features.memory as memory_module + + +@pytest.mark.asyncio +async def test_missing_memory_config_logs_info(monkeypatch, tmp_path): + manager = SimpleNamespace(initialize=AsyncMock(return_value=None)) + info_log = MagicMock() + warn_log = MagicMock() + + monkeypatch.setattr( + memory_module.Config, + "get", + AsyncMock(return_value=SimpleNamespace(memory=None)), + ) + monkeypatch.setattr( + memory_module.MemoryManager, + "get_instance", + lambda **_kwargs: manager, + ) + monkeypatch.setattr(memory_module.log, "info", info_log) + monkeypatch.setattr(memory_module.log, "warn", warn_log) + + memory = memory_module.SessionMemory( + session_id="session-no-memory-config", + project_id="project", + workspace_dir=str(tmp_path), + enabled=True, + ) + + assert await memory.initialize() is True + info_log.assert_any_call( + "session.memory.no_config", + {"session_id": "session-no-memory-config"}, + ) + assert not any(call.args and call.args[0] == "session.memory.no_config" for call in warn_log.call_args_list) diff --git a/tests/skill/test_skill.py b/tests/skill/test_skill.py index e704e785e..c993c511f 100644 --- a/tests/skill/test_skill.py +++ b/tests/skill/test_skill.py @@ -10,6 +10,7 @@ from pathlib import Path from unittest.mock import patch +import flocks.skill.skill as skill_module from flocks.skill.skill import Skill, SkillInfo, SkillRequires, SkillInstallSpec @@ -392,8 +393,8 @@ async def test_discover_flocks_source_for_builtin_skills(tmp_path): @pytest.mark.asyncio -async def test_discover_project_overrides_global(tmp_path): - """Project-level plugin skill must override global skill of the same name.""" +async def test_discover_user_plugin_overrides_project_bundle(tmp_path): + """User plugin skills must override project-bundled skills of the same name.""" fake_home = tmp_path / "home" project_dir = tmp_path / "myproject" project_dir.mkdir() @@ -409,14 +410,24 @@ async def test_discover_project_overrides_global(tmp_path): patch("os.path.expanduser", return_value=str(fake_home)), patch("flocks.skill.skill.Instance.get_directory", return_value=str(project_dir)), patch("flocks.skill.skill.Instance.get_worktree", return_value=str(project_dir)), + patch.object(skill_module.log, "info") as info_log, + patch.object(skill_module.log, "warn") as warn_log, ): Skill.clear_cache() skills = await Skill.all() skill = next((s for s in skills if s.name == "shared-skill"), None) assert skill is not None - assert skill.source == "project", "project-level skill should override global" - assert "Project version" in skill.description + assert skill.source == "user", "user-level skill should override project bundle" + assert "Global version" in skill.description + assert any( + call.args and call.args[0] == "skill.override" + for call in info_log.call_args_list + ) + assert not any( + call.args and call.args[0] == "skill.duplicate" + for call in warn_log.call_args_list + ) @pytest.mark.asyncio diff --git a/tests/tool/test_device_startup_sync.py b/tests/tool/test_device_startup_sync.py index 37986aa48..0481e22e5 100644 --- a/tests/tool/test_device_startup_sync.py +++ b/tests/tool/test_device_startup_sync.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import sqlite3 from pathlib import Path from typing import List @@ -24,6 +25,33 @@ from flocks.tool.device import startup +@pytest.mark.asyncio +async def test_heal_stale_service_ids_reads_named_sqlite_rows(tmp_path, monkeypatch): + db_path = tmp_path / "devices.db" + with sqlite3.connect(db_path) as db: + db.execute( + "CREATE TABLE device_integrations (id TEXT, storage_key TEXT, service_id TEXT)" + ) + db.execute( + "INSERT INTO device_integrations VALUES (?, ?, ?)", + ("dev-1", "onesec_api_v2_8_2", "stale_service_id"), + ) + + monkeypatch.setattr(startup.Storage, "get_db_path", staticmethod(lambda: db_path)) + monkeypatch.setattr( + "flocks.tool.device.store.storage_key_to_service_id", + lambda storage_key: "onesec_api" if storage_key == "onesec_api_v2_8_2" else storage_key, + ) + + await startup._heal_stale_service_ids() + + with sqlite3.connect(db_path) as db: + row = db.execute( + "SELECT service_id FROM device_integrations WHERE id = ?", ("dev-1",) + ).fetchone() + assert row == ("onesec_api",) + + @pytest.fixture def isolated_plugins(monkeypatch, tmp_path): """Point plugin discovery at an empty tmp HOME so production plugins diff --git a/tests/tool/test_tools.py b/tests/tool/test_tools.py index e63e97cf7..895cf0f80 100644 --- a/tests/tool/test_tools.py +++ b/tests/tool/test_tools.py @@ -20,6 +20,7 @@ import uuid from pathlib import Path from typing import Dict, Any, List +from unittest.mock import MagicMock # Import the tool system from flocks.tool import ( @@ -35,6 +36,8 @@ ParameterType, ) from flocks.tool.code import bash as bash_module +import flocks.tool.file.glob as glob_module +import flocks.tool.registry as registry_module import flocks.tool.system.question as question_module @@ -579,6 +582,33 @@ async def test_glob_no_matches(self, tool_context, temp_dir): assert result.success assert "No files found" in result.output + @pytest.mark.asyncio + async def test_python_fallback_logs_info_once(self, monkeypatch, tool_context, temp_dir): + monkeypatch.setattr(glob_module, "_ripgrep_fallback_logged", False) + monkeypatch.setattr(glob_module, "find_ripgrep", lambda: None) + info_log = MagicMock() + warn_log = MagicMock() + monkeypatch.setattr(glob_module.log, "info", info_log) + monkeypatch.setattr(glob_module.log, "warn", warn_log) + + for _ in range(2): + result = await glob_module.glob_tool( + tool_context, + pattern="*.txt", + path=temp_dir, + ) + assert result.success + + fallback_calls = [ + call for call in info_log.call_args_list + if call.args and call.args[0] == "glob.ripgrep_not_found" + ] + assert len(fallback_calls) == 1 + assert not any( + call.args and call.args[0] == "glob.ripgrep_not_found" + for call in warn_log.call_args_list + ) + # ============================================================================= # P1 Tools Tests @@ -1030,8 +1060,13 @@ class TestErrorHandling: """Test error handling across tools""" @pytest.mark.asyncio - async def test_missing_required_parameter(self, tool_context): + async def test_missing_required_parameter(self, monkeypatch, tool_context): """Test error when required parameter is missing""" + warn_log = MagicMock() + error_log = MagicMock() + monkeypatch.setattr(registry_module.log, "warn", warn_log) + monkeypatch.setattr(registry_module.log, "error", error_log) + result = await ToolRegistry.execute( "read", ctx=tool_context @@ -1040,6 +1075,18 @@ async def test_missing_required_parameter(self, tool_context): assert not result.success assert "required" in result.error.lower() or "missing" in result.error.lower() + warn_log.assert_called_once_with( + "tool.execute.missing_param", + { + "tool": "read", + "missing": "filePath", + "provided": [], + }, + ) + assert not any( + call.args and call.args[0] == "tool.execute.missing_param" + for call in error_log.call_args_list + ) @pytest.mark.asyncio async def test_nonexistent_tool(self, tool_context): diff --git a/tests/workflow/test_fs_store.py b/tests/workflow/test_fs_store.py index 711f072ee..f5c7b4c31 100644 --- a/tests/workflow/test_fs_store.py +++ b/tests/workflow/test_fs_store.py @@ -53,6 +53,45 @@ def test_read_workflow_from_fs_refreshes_cached_workspace_root( assert fs_store.find_workspace_root() == second_workspace +def test_read_workflow_from_fs_prefers_user_workflow_over_project_bundle( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + workflow_id = "shared-workflow" + project_root = tmp_path / "project-workflows" + user_root = tmp_path / "user-workflows" + + for root, name in ( + (project_root, "project bundle"), + (user_root, "user customization"), + ): + workflow_dir = root / workflow_id + workflow_dir.mkdir(parents=True) + (workflow_dir / "workflow.json").write_text( + json.dumps( + { + "name": name, + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "pass"}], + "edges": [], + } + ), + encoding="utf-8", + ) + + monkeypatch.setattr( + fs_store, + "resolve_workflow_scan_roots", + lambda _workspace: [(project_root, "project"), (user_root, "global")], + ) + + workflow = fs_store.read_workflow_from_fs(workflow_id) + + assert workflow is not None + assert workflow["source"] == "global" + assert workflow["workflowJson"]["name"] == "user customization" + + def test_read_workflow_dir_uses_latest_file_mtime_when_meta_is_stale( tmp_path: Path, ): diff --git a/tests/workflow/test_poller_manager.py b/tests/workflow/test_poller_manager.py index 4b7f915df..b105cd121 100644 --- a/tests/workflow/test_poller_manager.py +++ b/tests/workflow/test_poller_manager.py @@ -12,6 +12,52 @@ from flocks.workflow.runner import RunWorkflowResult +@pytest.mark.asyncio +async def test_start_all_skips_stale_workflow_config_as_info( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "removed-workflow" + config = {"enabled": True, "intervalSeconds": "not-a-number"} + info_events: list[tuple[str, dict]] = [] + warning_events: list[str] = [] + + async def _list_configs(*, kind: str): # noqa: ANN202 + assert kind == "workflow_poller_config" + return [(workflow_id, config)] + + async def _get_config(_workflow_id: str, *, kind: str) -> dict[str, Any]: + assert kind == "workflow_poller_config" + return config + + monkeypatch.setattr(poller_manager.WorkflowStore, "list_configs", _list_configs) + monkeypatch.setattr(poller_manager.WorkflowStore, "get_config", _get_config) + monkeypatch.setattr(poller_manager, "read_workflow_from_fs", lambda _workflow_id: None) + monkeypatch.setattr( + poller_manager.log, + "info", + lambda event, data=None: info_events.append((event, data)), + ) + monkeypatch.setattr( + poller_manager.log, + "warning", + lambda event, _data=None: warning_events.append(event), + ) + + manager = poller_manager.WorkflowPollerManager() + await manager.start_all() + + status = manager.get_status(workflow_id) + assert status["state"] == "stopped" + assert status["error"] == "workflow_not_found" + assert info_events == [ + ( + "poller.workflow_not_found_on_start", + {"workflow_id": workflow_id, "action": "stale_config_skipped"}, + ) + ] + assert warning_events == [] + + @pytest.mark.asyncio async def test_restart_disabled_config_reports_stopped(monkeypatch: pytest.MonkeyPatch) -> None: manager = poller_manager.WorkflowPollerManager() @@ -411,22 +457,42 @@ def _fake_run_workflow( # noqa: ANN001 async def test_start_all_only_restarts_enabled_configs(monkeypatch: pytest.MonkeyPatch) -> None: manager = poller_manager.WorkflowPollerManager() restarted: list[str] = [] + warning_events: list[tuple[str, dict[str, str]]] = [] async def _fake_list_configs(*, kind: str) -> list[tuple[str, dict[str, Any]]]: return [ + ("wf-broken", {"enabled": True}), ("wf-enabled", {"enabled": True}), ("wf-disabled", {"enabled": False}), ] - async def _fake_restart(workflow_id: str) -> dict[str, Any]: + async def _fake_restart( + workflow_id: str, + *, + startup: bool = False, + ) -> dict[str, Any]: + assert startup is True restarted.append(workflow_id) + if workflow_id == "wf-broken": + raise ValueError("invalid config") return {"workflowId": workflow_id, "state": "running"} monkeypatch.setattr(poller_manager.WorkflowStore, "list_configs", _fake_list_configs) monkeypatch.setattr(manager, "restart_workflow", _fake_restart) + monkeypatch.setattr( + poller_manager.log, + "warning", + lambda event, data: warning_events.append((event, data)), + ) await manager.start_all() - assert restarted == ["wf-enabled"] + assert restarted == ["wf-broken", "wf-enabled"] + assert warning_events == [ + ( + "poller.start_failed", + {"workflow_id": "wf-broken", "error": "invalid config"}, + ) + ] @pytest.mark.asyncio diff --git a/tests/workflow/test_workflow_paths.py b/tests/workflow/test_workflow_paths.py index ff31e0237..2194b5bfb 100644 --- a/tests/workflow/test_workflow_paths.py +++ b/tests/workflow/test_workflow_paths.py @@ -162,5 +162,99 @@ async def test_new_canonical_path_wins_over_legacy( results = await center.scan_skill_workflows(tmp_path) # The new canonical path (plugins/workflows/) has higher priority and wins - names = [r["name"] for r in results] - assert "shared-wf-new" in names + assert [r["name"] for r in results] == ["shared-wf-new"] + + +@pytest.mark.asyncio +async def test_scan_and_registry_prefer_user_workflow_over_project_bundle( + tmp_path: Path, + isolated_storage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + project_root = tmp_path / "project" + user_root = tmp_path / "user" + for root, name in ( + (project_root, "project bundle"), + (user_root, "user customization"), + ): + workflow_dir = root / "shared-workflow" + workflow_dir.mkdir(parents=True) + (workflow_dir / "workflow.json").write_text( + json.dumps(_workflow_payload(name)), + encoding="utf-8", + ) + + monkeypatch.setattr( + center, + "resolve_project_workflow_roots", + lambda _base: [project_root], + ) + monkeypatch.setattr(center, "resolve_global_workflow_roots", lambda: [user_root]) + + scanned = await center.scan_skill_workflows(tmp_path) + registered = await center.list_registry_entries() + + assert len(scanned) == 1 + assert scanned[0]["name"] == "user customization" + assert scanned[0]["sourceType"] == "global" + matching_registry_entries = [ + entry + for entry in registered + if entry.get("logicalWorkflowId") == "shared-workflow" + and Path(str(entry.get("workflowPath"))).is_relative_to(tmp_path) + ] + assert len(matching_registry_entries) == 1 + assert matching_registry_entries[0]["name"] == "user customization" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("invalid_user_workflow", ["invalid_json", "hidden"]) +async def test_registry_falls_back_when_user_workflow_is_no_longer_discoverable( + tmp_path: Path, + isolated_storage, + monkeypatch: pytest.MonkeyPatch, + invalid_user_workflow: str, +) -> None: + project_root = tmp_path / "project" + user_root = tmp_path / "user" + project_dir = project_root / "shared-workflow" + user_dir = user_root / "shared-workflow" + project_dir.mkdir(parents=True) + user_dir.mkdir(parents=True) + (project_dir / "workflow.json").write_text( + json.dumps(_workflow_payload("project bundle")), + encoding="utf-8", + ) + user_workflow_path = user_dir / "workflow.json" + user_workflow_path.write_text( + json.dumps(_workflow_payload("user customization")), + encoding="utf-8", + ) + + monkeypatch.setattr( + center, + "resolve_project_workflow_roots", + lambda _base: [project_root], + ) + monkeypatch.setattr(center, "resolve_global_workflow_roots", lambda: [user_root]) + + await center.scan_skill_workflows(tmp_path) + if invalid_user_workflow == "invalid_json": + user_workflow_path.write_text("{", encoding="utf-8") + else: + (user_dir / "meta.json").write_text( + json.dumps({"hidden": True}), + encoding="utf-8", + ) + + scanned = await center.scan_skill_workflows(tmp_path) + registered = await center.list_registry_entries() + matching_registry_entries = [ + entry + for entry in registered + if entry.get("logicalWorkflowId") == "shared-workflow" + and Path(str(entry.get("workflowPath"))).is_relative_to(tmp_path) + ] + + assert [entry["name"] for entry in scanned] == ["project bundle"] + assert [entry["name"] for entry in matching_registry_entries] == ["project bundle"] diff --git a/webui/src/api/notifications.ts b/webui/src/api/notifications.ts index 6c8927989..98ac94aa0 100644 --- a/webui/src/api/notifications.ts +++ b/webui/src/api/notifications.ts @@ -32,14 +32,10 @@ export interface NotificationAckStatus { acknowledged: boolean; } -export const getActiveNotifications = async ( - locale?: string, - currentVersion?: string | null, -): Promise => { +export const getActiveNotifications = async (locale?: string): Promise => { const response = await client.get('/api/notifications/active', { params: { ...(locale ? { locale } : {}), - ...(currentVersion ? { current_version: currentVersion } : {}), }, }); return response.data; diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index d8c3ab000..f8911592d 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -482,7 +482,10 @@ describe('Layout onboarding entry', () => { expect(await screen.findByText('admin.roleMember')).toBeInTheDocument(); expect(await screen.findByText('v2026.6.21')).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Flocks' })).not.toBeInTheDocument(); - await waitFor(() => expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flockspro')); + expect(checkUpdate).not.toHaveBeenCalled(); + await waitFor(() => { + expect(getActiveNotifications).toHaveBeenCalledWith('zh-CN'); + }); const sidebarShell = container.querySelector('aside > div'); const logoRow = sidebarShell?.firstElementChild as HTMLElement | null; @@ -845,6 +848,33 @@ describe('Layout onboarding entry', () => { expect(await screen.findByText('Token 免费期已延长')).toBeInTheDocument(); expect(screen.getByText('Flocks v2026.04.28 更新内容')).toBeInTheDocument(); }); + + it('loads backend notifications without waiting for the update check', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + const updateCheck = deferred<{ + has_update: boolean; + latest_version: null; + current_version: string; + error: null; + }>(); + checkUpdate.mockReturnValue(updateCheck.promise); + + renderHomeWithLayout(); + + await waitFor(() => { + expect(getActiveNotifications).toHaveBeenCalledWith('zh-CN'); + }); + expect(getNotificationAckStatus).not.toHaveBeenCalled(); + + await act(async () => { + updateCheck.resolve({ + has_update: false, + latest_version: null, + current_version: '0.2.0', + error: null, + }); + }); + }); }); describe('Layout WebUI contract pages navigation', () => { diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index f4758e1ca..df4160bfe 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -217,6 +217,8 @@ export default function Layout() { const [flocksproStatusReady, setFlocksproStatusReady] = useState(false); const [flocksproVersion, setFlocksproVersion] = useState(null); const canManageUpdates = user?.role === 'admin'; + const notificationGateReady = flocksproStatusReady + && (!canManageUpdates || hasCompletedUpdateCheck); const canCreateWorkspaceCustomPage = user?.role === 'admin'; const { pages: webuiContractPages, workspaces: webuiContractWorkspaces = [] } = useWebUIContractPages(); const [openWorkspaceMenuId, setOpenWorkspaceMenuId] = useState(null); @@ -253,7 +255,7 @@ export default function Layout() { }, [handleOpenOnboarding]); const refreshUpdateStatus = useCallback(async (bypassMinGap = false) => { - if (!flocksproStatusReady) return; + if (!flocksproStatusReady || !canManageUpdates) return; const now = Date.now(); if (checkingUpdateRef.current) return; @@ -304,7 +306,7 @@ export default function Layout() { }, [canManageUpdates, flocksproStatusReady, i18n.language, isFlocksproActive]); useEffect(() => { - if (!flocksproStatusReady) return undefined; + if (!flocksproStatusReady || !canManageUpdates) return undefined; const initialCheckTimerId = window.setTimeout(() => { refreshUpdateStatus(true); @@ -335,7 +337,7 @@ export default function Layout() { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleWindowFocus); }; - }, [flocksproStatusReady, refreshUpdateStatus]); + }, [canManageUpdates, flocksproStatusReady, refreshUpdateStatus]); useEffect(() => { let cancelled = false; @@ -397,16 +399,14 @@ export default function Layout() { lastNotificationFetchKeyRef.current = null; return; } - if (!hasCompletedUpdateCheck) return; - - const fetchKey = `${user.id}:${i18n.language}:${currentVersion ?? 'pending-version'}`; + const fetchKey = `${user.id}:${i18n.language}`; if (lastNotificationFetchKeyRef.current === fetchKey) return; const previousFetchKey = lastNotificationFetchKeyRef.current; lastNotificationFetchKeyRef.current = fetchKey; setBackendNotificationsReady(false); let cancelled = false; - void getActiveNotifications(i18n.language, currentVersion) + void getActiveNotifications(i18n.language) .then((items) => { if (cancelled) return; setNotifications((prev) => { @@ -431,7 +431,7 @@ export default function Layout() { return () => { cancelled = true; }; - }, [currentVersion, hasCompletedUpdateCheck, i18n.language, user?.id]); + }, [i18n.language, user?.id]); useEffect(() => { if (!user?.id) { @@ -439,7 +439,7 @@ export default function Layout() { setUpdateNotificationReady(false); return; } - if (!hasCompletedUpdateCheck) return; + if (!notificationGateReady) return; setUpdateNotificationReady(false); const notification = buildUpdateNotification(updateInfo, i18n.language); @@ -465,7 +465,7 @@ export default function Layout() { return () => { cancelled = true; }; - }, [hasCompletedUpdateCheck, i18n.language, updateInfo, user?.id]); + }, [i18n.language, notificationGateReady, updateInfo, user?.id]); const allNotifications = updateNotification ? [...notifications, updateNotification].sort((a, b) => a.priority - b.priority) From 3efb48251585748baf2466dd3a97fce5ca3bc079 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Mon, 20 Jul 2026 16:53:34 +0800 Subject: [PATCH 41/86] fix(session): retry empty transport errors --- flocks/session/lifecycle/retry.py | 2 + flocks/session/runner.py | 49 +++++++++++++++- tests/session/test_runner_step.py | 96 +++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/flocks/session/lifecycle/retry.py b/flocks/session/lifecycle/retry.py index c220aff1a..6e754ee77 100644 --- a/flocks/session/lifecycle/retry.py +++ b/flocks/session/lifecycle/retry.py @@ -209,6 +209,8 @@ def retryable(error: Dict[str, Any]) -> Optional[str]: def is_connection_error(error: Dict[str, Any]) -> bool: """Return True for provider/model connection failures.""" data = error.get("data", {}) + if data.get("isConnectionError") is True: + return True message = data.get("message") or error.get("message", "") if not isinstance(message, str): return False diff --git a/flocks/session/runner.py b/flocks/session/runner.py index e9c10b5ae..e55611ed0 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -20,6 +20,9 @@ from typing import Optional, Dict, Any, List, Callable, Awaitable, Tuple from dataclasses import dataclass, field +import httpcore +import httpx + from flocks.utils.log import Log from flocks.utils.id import Identifier from flocks.session.session import Session, SessionInfo @@ -107,6 +110,13 @@ def _annotate_with_provider_version(tool_info: Any, description: Optional[str]) # spurious failures in those cases. LLM_STREAM_ONGOING_CHUNK_TIMEOUT_S = 300 +_RETRYABLE_TRANSPORT_EXCEPTIONS = ( + httpx.TransportError, + httpcore.NetworkError, + httpcore.ProtocolError, + httpcore.TimeoutException, +) + _WORKFLOW_NODE_REF_RE = re.compile(r"^@@node:([^|\n]+)\|([^\n]+)\n?([\s\S]*)$") @@ -175,6 +185,18 @@ async def _iter_with_chunk_timeout( pass +def _find_retryable_transport_exception(exception: Exception) -> Optional[Exception]: + """Return a retryable HTTP transport error from an exception chain.""" + current: Optional[BaseException] = exception + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, _RETRYABLE_TRANSPORT_EXCEPTIONS): + return current + current = current.__cause__ or current.__context__ + return None + + @dataclass class ToolCall: """Tool call from LLM response.""" @@ -1341,8 +1363,14 @@ async def device_asset_prompt_factory() -> Optional[str]: except Exception as e: error_attempt += 1 - log.error("runner.step.error", { + error_log_context = { "error": str(e), + "error_type": type(e).__name__, + "error_module": type(e).__module__, + "error_repr": repr(e)[:1000], + } + log.error("runner.step.error", { + **error_log_context, "attempt": error_attempt, }) @@ -1388,12 +1416,12 @@ async def device_asset_prompt_factory() -> Optional[str]: # Error is not retryable, or retry budget exhausted if retry_message is not None: log.error("runner.step.max_retries_exceeded", { - "error": str(e), + **error_log_context, "attempt": error_attempt, "max_retries": MAX_ERROR_RETRIES, }) else: - log.error("runner.step.not_retryable", {"error": str(e)}) + log.error("runner.step.not_retryable", error_log_context) final_error_message = str(e) if SessionRetry.is_connection_error(error_dict): @@ -1796,8 +1824,23 @@ def _exception_to_error_dict(self, exception: Exception) -> Dict[str, Any]: "message": str(exception), "data": { "message": str(exception), + "exceptionType": type(exception).__name__, + "exceptionModule": type(exception).__module__, } } + + transport_exception = _find_retryable_transport_exception(exception) + if transport_exception is not None: + transport_type = type(transport_exception).__name__ + error_dict["name"] = "APIError" + error_dict["data"].update({ + "message": str(exception) or f"Transport connection error ({transport_type})", + "isRetryable": True, + "isConnectionError": True, + "displayMessage": CONNECTION_ERROR_DISPLAY_MESSAGE, + "transportExceptionType": transport_type, + "transportExceptionModule": type(transport_exception).__module__, + }) # Check if it's an API error with specific attributes if hasattr(exception, 'status_code'): diff --git a/tests/session/test_runner_step.py b/tests/session/test_runner_step.py index 9d67e6565..466c5c0df 100644 --- a/tests/session/test_runner_step.py +++ b/tests/session/test_runner_step.py @@ -10,6 +10,8 @@ - SessionRunner construction and abort behavior (from existing tests) """ +import httpcore +import httpx import pytest from types import SimpleNamespace from unittest.mock import MagicMock, patch, AsyncMock @@ -270,6 +272,51 @@ def test_incomplete_chunked_read_exception_is_retryable_connection_error(self): assert result["data"]["isRetryable"] is True assert result["data"]["displayMessage"] == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + @pytest.mark.parametrize( + "exc", + [ + httpx.ReadError("", request=httpx.Request("GET", "https://example.test")), + httpx.RemoteProtocolError( + "", + request=httpx.Request("GET", "https://example.test"), + ), + httpcore.ReadError(), + httpcore.RemoteProtocolError(), + ], + ) + def test_empty_transport_exception_is_retryable_connection_error(self, exc): + runner = _make_runner() + + result = runner._exception_to_error_dict(exc) + + assert result["name"] == "APIError" + assert result["data"]["isRetryable"] is True + assert result["data"]["isConnectionError"] is True + assert result["data"]["displayMessage"] == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + assert result["data"]["exceptionType"] == type(exc).__name__ + + def test_wrapped_transport_exception_is_retryable_connection_error(self): + runner = _make_runner() + transport_error = httpcore.ReadError() + exc = RuntimeError("stream failed") + exc.__cause__ = transport_error + + result = runner._exception_to_error_dict(exc) + + assert result["name"] == "APIError" + assert result["data"]["isRetryable"] is True + assert result["data"]["isConnectionError"] is True + assert result["data"]["exceptionType"] == "RuntimeError" + assert result["data"]["transportExceptionType"] == "ReadError" + + def test_empty_non_transport_exception_is_not_retryable(self): + runner = _make_runner() + + result = runner._exception_to_error_dict(RuntimeError()) + + assert result["name"] == "RuntimeError" + assert result["data"].get("isRetryable") is not True + def test_exception_with_status_code_429(self): runner = _make_runner() exc = Exception("Rate limited") @@ -2634,6 +2681,55 @@ async def test_process_step_empty_retry_records_usage_per_attempt(monkeypatch): record_mock.assert_any_await(second_usage, message_id=assistant_msg.id) +@pytest.mark.asyncio +async def test_process_step_retries_empty_transport_exception(monkeypatch): + runner = _make_runner("ses_runner_transport_retry") + runner.callbacks = RunnerCallbacks(on_error=AsyncMock()) + + last_user = UserMessageInfo( + id="msg_user_transport_retry", + sessionID=runner.session.id, + role="user", + time={"created": 1_000}, + agent="rex", + model={"providerID": "openai", "modelID": "gpt-5"}, + ) + agent = SimpleNamespace(name="rex", steps=None, mode="primary", prompt="", tools=[]) + provider = MagicMock() + provider.is_configured.return_value = True + assistant_msg = SimpleNamespace(id="msg_assistant_transport_retry") + call_llm_mock = AsyncMock( + side_effect=[ + httpcore.ReadError(), + StepResult(action="stop", content="recovered"), + ] + ) + sleep_mock = AsyncMock(return_value=None) + + monkeypatch.setattr(runner_mod.Agent, "get", AsyncMock(return_value=agent)) + monkeypatch.setattr(runner_mod.Provider, "get", lambda provider_id: provider) + monkeypatch.setattr(runner_mod.Provider, "apply_config", AsyncMock(return_value=None)) + monkeypatch.setattr(runner_mod.SessionPrompt, "build_system_prompts", AsyncMock(return_value=[])) + monkeypatch.setattr(runner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr( + runner, + "_to_chat_messages", + AsyncMock(return_value=[SimpleNamespace(role="user", content="hi")]), + ) + monkeypatch.setattr(runner_mod.Message, "get_text_content", AsyncMock(return_value="hi")) + monkeypatch.setattr(runner_mod.Message, "create", AsyncMock(return_value=assistant_msg)) + monkeypatch.setattr(runner_mod.Message, "update", AsyncMock(return_value=None)) + monkeypatch.setattr(runner, "_call_llm", call_llm_mock) + monkeypatch.setattr(runner_mod.SessionRetry, "sleep", sleep_mock) + + result = await runner._process_step([last_user], last_user) + + assert result.content == "recovered" + assert call_llm_mock.await_count == 2 + sleep_mock.assert_awaited_once() + runner.callbacks.on_error.assert_not_awaited() + + @pytest.mark.asyncio async def test_process_step_uses_default_max_steps_when_agent_steps_missing(monkeypatch): runner = _make_runner("ses_runner_default_max_steps") From ed19736053575fe6ccbb42a23ee39f7f9bf5d826 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 17:25:50 +0800 Subject: [PATCH 42/86] feat: make tool failure auto-disable configurable --- .flocks/flocks.json.example | 3 + flocks/config/config.py | 19 +++ flocks/server/routes/config.py | 58 +++++++++ flocks/tool/registry.py | 19 ++- tests/server/routes/test_remaining_routes.py | 37 ++++++ .../tool/test_failure_auto_disable_config.py | 97 +++++++++++++++ webui/src/api/toolFailureConfig.ts | 19 +++ webui/src/locales/en-US/nav.json | 7 +- webui/src/locales/zh-CN/nav.json | 7 +- webui/src/pages/Settings/index.test.tsx | 37 +++++- webui/src/pages/Settings/index.tsx | 113 ++++++++++++++++-- 11 files changed, 402 insertions(+), 14 deletions(-) create mode 100644 tests/tool/test_failure_auto_disable_config.py create mode 100644 webui/src/api/toolFailureConfig.ts diff --git a/.flocks/flocks.json.example b/.flocks/flocks.json.example index c60d4d838..1f8b273c7 100644 --- a/.flocks/flocks.json.example +++ b/.flocks/flocks.json.example @@ -39,6 +39,9 @@ "readMaxBytes": 51200, "readMaxLineLength": 2000 }, + "toolFailure": { + "disableOnRepeatedFailure": true + }, "sandbox": { "mode": "off", "scope": "agent", diff --git a/flocks/config/config.py b/flocks/config/config.py index 9e5fc41fe..65d151dba 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -349,6 +349,20 @@ class ToolOutputConfig(BaseModel): ) +class ToolFailureConfig(BaseModel): + """Repeated tool-failure handling.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + True, + alias="disableOnRepeatedFailure", + description=( + "Disable a standalone custom tool after repeated identical failures." + ), + ) + + class EnterpriseConfig(BaseModel): """Enterprise configuration""" @@ -675,6 +689,11 @@ class ConfigInfo(BaseModel): alias="toolOutput", description="Tool output size limits (read, truncation caps).", ) + tool_failure: Optional[ToolFailureConfig] = Field( + None, + alias="toolFailure", + description="Repeated tool-failure handling.", + ) experimental: Optional[ExperimentalConfig] = None # Memory system configuration (added for memory system integration) diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 7f403f92c..6f706c281 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -141,6 +141,17 @@ class UIConfigUpdateRequest(BaseModel): display_name: Optional[str] = Field(None, alias="displayName") +class ToolFailurePreference(BaseModel): + """Repeated tool-failure preference exposed to the WebUI.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + ..., + alias="disableOnRepeatedFailure", + ) + + DEFAULT_UI_DISPLAY_NAME = "Flocks" DEFAULT_UI_PRO_DISPLAY_NAME = "Flocks Pro" FAVICON_MAX_BYTES = 512 * 1024 @@ -400,6 +411,12 @@ def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> Non ConfigWriter._write_raw(data) +def _effective_tool_failure_preference(config: ConfigInfoModel) -> bool: + if config.tool_failure is None: + return True + return config.tool_failure.disable_on_repeated_failure + + @router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name") async def get_ui_display() -> UIDisplayResponse: """Return only the effective WebUI display name for public screens.""" @@ -521,6 +538,47 @@ async def reset_ui_favicon() -> UIDisplayResponse: return await get_ui_display() +@router.get( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Get repeated tool-failure preference", +) +async def get_tool_failure_preference() -> ToolFailurePreference: + """Return whether repeated identical failures automatically disable tools.""" + try: + config = await Config.get() + return ToolFailurePreference( + disableOnRepeatedFailure=_effective_tool_failure_preference(config) + ) + except Exception as e: + log.error("config.tool_failure.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Update repeated tool-failure preference", +) +async def update_tool_failure_preference( + request: ToolFailurePreference, +) -> ToolFailurePreference: + """Update only the repeated-failure switch in flocks.json.""" + try: + data = ConfigWriter._read_raw() + existing = data.get("toolFailure", data.get("tool_failure", {})) + section = dict(existing) if isinstance(existing, dict) else {} + section.pop("disable_on_repeated_failure", None) + section["disableOnRepeatedFailure"] = request.disable_on_repeated_failure + data.pop("tool_failure", None) + data["toolFailure"] = section + ConfigWriter._write_raw(data) + return await get_tool_failure_preference() + except Exception as e: + log.error("config.tool_failure.update.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + @router.get("", summary="Get configuration") async def get_config() -> Dict[str, Any]: """ diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index d2bff0d7a..294450285 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -878,7 +878,11 @@ async def execute( if result.success: cls._reset_failure_state(tool_name) else: - disabled = cls._record_failure(tool, kwargs, result.error) + if await cls._failure_auto_disable_enabled(): + disabled = cls._record_failure(tool, kwargs, result.error) + else: + cls._reset_failure_state(tool_name) + disabled = False if disabled: result.metadata = {**(result.metadata or {}), "disabled": True, "disabled_reason": "repeated_error"} suffix = f"tool disabled after {cls._failure_disable_threshold} identical errors" @@ -1743,6 +1747,19 @@ def _reset_failure_state(cls, tool_name: str) -> None: if tool_name in cls._failure_state: cls._failure_state.pop(tool_name, None) + @classmethod + async def _failure_auto_disable_enabled(cls) -> bool: + """Return the configured repeated-failure behavior, defaulting on.""" + try: + from flocks.config.config import Config + + config = await Config.get() + if config.tool_failure is not None: + return config.tool_failure.disable_on_repeated_failure + except Exception: + pass + return True + @classmethod def _should_track_failure(cls, tool: Tool) -> bool: """Track failures only for standalone custom tools. diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index ce681b707..ebb703852 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -508,6 +508,43 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) + @pytest.mark.asyncio + async def test_tool_failure_preference_defaults_on_and_updates_only_its_section( + self, + client: AsyncClient, + tmp_path, + monkeypatch, + ): + from flocks.config.config import Config + from flocks.config.config_writer import ConfigWriter + + monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(tmp_path / "config")) + Config._global_config = None + Config._cached_config = None + ConfigWriter._write_raw({"theme": "dark"}) + + resp = await client.get("/api/config/tool-failure") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"disableOnRepeatedFailure": True} + + resp = await client.patch( + "/api/config/tool-failure", + json={"disableOnRepeatedFailure": False}, + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"disableOnRepeatedFailure": False} + assert ConfigWriter._read_raw() == { + "theme": "dark", + "toolFailure": {"disableOnRepeatedFailure": False}, + } + + resp = await client.get("/api/config/tool-failure") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"disableOnRepeatedFailure": False} + + resp = await client.patch("/api/config/tool-failure", json={}) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + @pytest.mark.asyncio async def test_ui_display_defaults_and_updates( self, diff --git a/tests/tool/test_failure_auto_disable_config.py b/tests/tool/test_failure_auto_disable_config.py new file mode 100644 index 000000000..2f3bf692b --- /dev/null +++ b/tests/tool/test_failure_auto_disable_config.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest + +from flocks.config.config import Config, ConfigInfo, ToolFailureConfig +from flocks.tool.registry import ( + Tool, + ToolCategory, + ToolContext, + ToolInfo, + ToolRegistry, + ToolResult, +) + + +def _failing_tool(name: str = "failing_custom_tool") -> Tool: + async def handler(_ctx: ToolContext, **_kwargs) -> ToolResult: + return ToolResult(success=False, error="synthetic repeated failure") + + return Tool( + info=ToolInfo( + name=name, + description="Always fails for repeated-failure tests", + category=ToolCategory.CUSTOM, + source="plugin_py", + ), + handler=handler, + ) + + +@pytest.fixture +def isolated_failure_tracking(monkeypatch: pytest.MonkeyPatch): + tool = _failing_tool() + monkeypatch.setattr(ToolRegistry, "_initialized", True) + monkeypatch.setattr(ToolRegistry, "_tools", {tool.info.name: tool}) + monkeypatch.setattr(ToolRegistry, "_failure_state", {}) + monkeypatch.setattr(Config, "_cached_config", ConfigInfo()) + return tool + + +def test_tool_failure_config_defaults_to_enabled() -> None: + section = ToolFailureConfig() + + assert section.disable_on_repeated_failure is True + + +def test_tool_failure_config_accepts_camel_and_snake_case() -> None: + camel = ConfigInfo.model_validate( + {"toolFailure": {"disableOnRepeatedFailure": False}} + ) + snake = ConfigInfo.model_validate( + {"tool_failure": {"disable_on_repeated_failure": False}} + ) + + assert camel.tool_failure is not None + assert camel.tool_failure.disable_on_repeated_failure is False + assert snake.tool_failure is not None + assert snake.tool_failure.disable_on_repeated_failure is False + + +@pytest.mark.asyncio +async def test_repeated_failures_disable_tool_by_default( + isolated_failure_tracking: Tool, +) -> None: + tool = isolated_failure_tracking + + for _ in range(ToolRegistry._failure_disable_threshold): + result = await ToolRegistry.execute(tool.info.name, query="same") + + assert tool.info.enabled is False + assert result.metadata == { + "disabled": True, + "disabled_reason": "repeated_error", + } + + +@pytest.mark.asyncio +async def test_config_can_turn_off_repeated_failure_auto_disable( + isolated_failure_tracking: Tool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + tool = isolated_failure_tracking + monkeypatch.setattr( + Config, + "_cached_config", + ConfigInfo.model_validate( + {"toolFailure": {"disableOnRepeatedFailure": False}} + ), + ) + + for _ in range(ToolRegistry._failure_disable_threshold + 1): + result = await ToolRegistry.execute(tool.info.name, query="same") + + assert result.success is False + assert "disabled" not in result.metadata + assert tool.info.enabled is True + assert ToolRegistry._failure_state == {} diff --git a/webui/src/api/toolFailureConfig.ts b/webui/src/api/toolFailureConfig.ts new file mode 100644 index 000000000..e45333cba --- /dev/null +++ b/webui/src/api/toolFailureConfig.ts @@ -0,0 +1,19 @@ +import client from './client'; + +export interface ToolFailureConfig { + disableOnRepeatedFailure: boolean; +} + +export const toolFailureConfigApi = { + async get(): Promise { + const response = await client.get('/api/config/tool-failure'); + return response.data; + }, + + async update(disableOnRepeatedFailure: boolean): Promise { + const response = await client.patch('/api/config/tool-failure', { + disableOnRepeatedFailure, + }); + return response.data; + }, +}; diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index e66d43e24..72e713ef5 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -27,7 +27,7 @@ "settingsTitle": "Settings", "settingsDescription": "Manage preferences, account, and system settings", "settingsPreferences": "Preferences", - "settingsPreferencesDescription": "Adjust the workspace name, console language, and interface theme.", + "settingsPreferencesDescription": "Adjust workspace display, language, theme, and tool failure handling.", "settingsGroupPreferences": "Preferences", "settingsGroupSystem": "Account & System", "settingsGroupIntegrations": "Models & Channels", @@ -53,6 +53,11 @@ "themeDescription": "Choose the default light or dark mode for the console.", "lightTheme": "Light", "darkTheme": "Dark", + "toolFailureAutoDisable": "Auto-disable repeatedly failing tools", + "toolFailureAutoDisableDescription": "Disable a standalone custom tool after the same parameters repeatedly produce the same error up to the configured threshold.", + "toolFailureSettingSaved": "Tool failure handling preference saved", + "toolFailureSettingSaveFailed": "Failed to save tool failure handling preference", + "toolFailureSettingLoadFailed": "Failed to load tool failure handling preference", "logout": "Log out", "expandNav": "Expand navigation", "collapseNav": "Collapse navigation", diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index abed9b104..752a117b6 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -27,7 +27,7 @@ "settingsTitle": "设置", "settingsDescription": "管理偏好、账号与系统配置", "settingsPreferences": "偏好设置", - "settingsPreferencesDescription": "调整当前工作台的名称、显示语言和界面主题。", + "settingsPreferencesDescription": "调整当前工作台的显示、语言、主题和工具失败处理。", "settingsGroupPreferences": "偏好", "settingsGroupSystem": "账号与系统", "settingsGroupIntegrations": "模型与通道", @@ -53,6 +53,11 @@ "themeDescription": "选择控制台默认使用的浅色或深色模式。", "lightTheme": "浅色", "darkTheme": "深色", + "toolFailureAutoDisable": "自动禁用重复失败工具", + "toolFailureAutoDisableDescription": "同一独立自定义工具以相同参数重复返回相同错误达到阈值后,自动禁用该工具。", + "toolFailureSettingSaved": "工具失败处理设置已保存", + "toolFailureSettingSaveFailed": "保存工具失败处理设置失败", + "toolFailureSettingLoadFailed": "加载工具失败处理设置失败", "logout": "退出登录", "expandNav": "展开导航", "collapseNav": "收起导航", diff --git a/webui/src/pages/Settings/index.test.tsx b/webui/src/pages/Settings/index.test.tsx index 35d5c605d..19a266cd1 100644 --- a/webui/src/pages/Settings/index.test.tsx +++ b/webui/src/pages/Settings/index.test.tsx @@ -1,17 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import SettingsPage from './index'; import { ThemeContext, type Theme } from '@/contexts/ThemeContext'; import { ToastProvider } from '@/components/common/Toast'; -const { changeLanguage, flocksproUsersApi, setTheme, useAuth } = vi.hoisted(() => ({ +const { changeLanguage, flocksproUsersApi, setTheme, toolFailureConfigApi, useAuth } = vi.hoisted(() => ({ changeLanguage: vi.fn(), flocksproUsersApi: { hasCapability: vi.fn(), }, setTheme: vi.fn(), + toolFailureConfigApi: { + get: vi.fn(), + update: vi.fn(), + }, useAuth: vi.fn(), })); @@ -33,6 +37,10 @@ vi.mock('@/api/flocksproUsers', () => ({ flocksproUsersApi, })); +vi.mock('@/api/toolFailureConfig', () => ({ + toolFailureConfigApi, +})); + vi.mock('@/pages/Config', () => ({ default: () =>
account page
, })); @@ -92,6 +100,10 @@ describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks(); flocksproUsersApi.hasCapability.mockResolvedValue(true); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: true }); + toolFailureConfigApi.update.mockImplementation(async (disableOnRepeatedFailure: boolean) => ({ + disableOnRepeatedFailure, + })); useAuth.mockReturnValue({ user: { id: 'user-1', @@ -117,6 +129,27 @@ describe('SettingsPage', () => { expect(setTheme).toHaveBeenCalledWith('dark'); }); + it('loads and updates repeated tool failure auto-disable', async () => { + const user = userEvent.setup(); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: false }); + + renderSettings('/settings/preferences'); + + const autoDisableSwitch = await screen.findByRole('switch', { + name: 'toolFailureAutoDisable', + }); + await waitFor(() => { + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'false'); + }); + + await user.click(autoDisableSwitch); + + await waitFor(() => { + expect(toolFailureConfigApi.update).toHaveBeenCalledWith(true); + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'true'); + }); + }); + it('redirects legacy model and channel settings URLs to workspace pages', async () => { const { unmount } = renderSettings('/settings/models'); diff --git a/webui/src/pages/Settings/index.tsx b/webui/src/pages/Settings/index.tsx index 337cd9e92..ba0c8238b 100644 --- a/webui/src/pages/Settings/index.tsx +++ b/webui/src/pages/Settings/index.tsx @@ -26,6 +26,7 @@ import { useAuth } from '@/contexts/AuthContext'; import { useProductName } from '@/contexts/ProductNameContext'; import { useToast } from '@/components/common/Toast'; import { flocksproUsersApi } from '@/api/flocksproUsers'; +import { toolFailureConfigApi } from '@/api/toolFailureConfig'; import { preloadI18nNamespaces } from '@/i18nResources'; type LazySettingsModule = { default: ComponentType }; @@ -156,6 +157,38 @@ function SegmentedOption({ ); } +function PreferenceSwitch({ + checked, + disabled, + label, + onChange, +}: { + checked: boolean; + disabled: boolean; + label: string; + onChange: () => void; +}) { + return ( + + ); +} + function PreferencesPanel() { const { t, i18n } = useTranslation('nav'); const { theme, setTheme } = useContext(ThemeContext); @@ -168,26 +201,58 @@ function PreferencesPanel() { uploadProductFavicon, resetProductFavicon, } = useProductName(); - const toast = useToast(); + const { error: showToastError, success: showToastSuccess } = useToast(); const language = i18n.language?.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US'; const faviconInputRef = useRef(null); const [displayNameDraft, setDisplayNameDraft] = useState(configuredDisplayName ?? ''); const [savingDisplayName, setSavingDisplayName] = useState(false); const [savingFavicon, setSavingFavicon] = useState(false); + const [toolFailureAutoDisable, setToolFailureAutoDisable] = useState(true); + const [loadingToolFailure, setLoadingToolFailure] = useState(true); + const [savingToolFailure, setSavingToolFailure] = useState(false); const normalizedDisplayName = displayNameDraft.trim(); const displayNameChanged = normalizedDisplayName !== (configuredDisplayName ?? ''); + const toolFailureSettingLoadFailedMessage = t('toolFailureSettingLoadFailed'); useEffect(() => { setDisplayNameDraft(configuredDisplayName ?? ''); }, [configuredDisplayName]); + useEffect(() => { + let cancelled = false; + setLoadingToolFailure(true); + toolFailureConfigApi.get() + .then((config) => { + if (!cancelled) { + setToolFailureAutoDisable(config.disableOnRepeatedFailure); + } + }) + .catch((err: any) => { + if (!cancelled) { + showToastError( + toolFailureSettingLoadFailedMessage, + err?.response?.data?.detail || err?.message, + ); + } + }) + .finally(() => { + if (!cancelled) { + setLoadingToolFailure(false); + } + }); + + return () => { + cancelled = true; + }; + }, [showToastError, toolFailureSettingLoadFailedMessage]); + const handleSaveDisplayName = async () => { setSavingDisplayName(true); try { await updateProductName(normalizedDisplayName || null); - toast.success(t('displayNameSaved')); + showToastSuccess(t('displayNameSaved')); } catch (err: any) { - toast.error(t('displayNameSaveFailed'), err?.response?.data?.detail || err?.message); + showToastError(t('displayNameSaveFailed'), err?.response?.data?.detail || err?.message); } finally { setSavingDisplayName(false); } @@ -197,9 +262,9 @@ function PreferencesPanel() { setSavingDisplayName(true); try { await updateProductName(null); - toast.success(t('displayNameSaved')); + showToastSuccess(t('displayNameSaved')); } catch (err: any) { - toast.error(t('displayNameSaveFailed'), err?.response?.data?.detail || err?.message); + showToastError(t('displayNameSaveFailed'), err?.response?.data?.detail || err?.message); } finally { setSavingDisplayName(false); } @@ -213,9 +278,9 @@ function PreferencesPanel() { setSavingFavicon(true); try { await uploadProductFavicon(file); - toast.success(t('faviconSaved')); + showToastSuccess(t('faviconSaved')); } catch (err: any) { - toast.error(t('faviconSaveFailed'), err?.response?.data?.detail || err?.message); + showToastError(t('faviconSaveFailed'), err?.response?.data?.detail || err?.message); } finally { setSavingFavicon(false); } @@ -225,14 +290,31 @@ function PreferencesPanel() { setSavingFavicon(true); try { await resetProductFavicon(); - toast.success(t('faviconSaved')); + showToastSuccess(t('faviconSaved')); } catch (err: any) { - toast.error(t('faviconSaveFailed'), err?.response?.data?.detail || err?.message); + showToastError(t('faviconSaveFailed'), err?.response?.data?.detail || err?.message); } finally { setSavingFavicon(false); } }; + const handleToolFailureAutoDisableChange = async () => { + const nextValue = !toolFailureAutoDisable; + setSavingToolFailure(true); + try { + const config = await toolFailureConfigApi.update(nextValue); + setToolFailureAutoDisable(config.disableOnRepeatedFailure); + showToastSuccess(t('toolFailureSettingSaved')); + } catch (err: any) { + showToastError( + t('toolFailureSettingSaveFailed'), + err?.response?.data?.detail || err?.message, + ); + } finally { + setSavingToolFailure(false); + } + }; + return (
@@ -371,6 +453,19 @@ function PreferencesPanel() {
+ + + void handleToolFailureAutoDisableChange()} + /> +
); From 471fa2928fb52fd958f973320ff6173643337457 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 18:13:26 +0800 Subject: [PATCH 43/86] fix(device-plugins): repair bundled WAF and EDR integrations Separate dispatcher actions from API payload fields, align script handler signatures with the loader contract, preserve response payloads in ToolResult.output, and add regression coverage. --- .../huaweicloud_waf_v39/hw_waf.handler.py | 32 ++-- .../huaweicloud_waf_v39/hw_waf_event.yaml | 9 +- .../huaweicloud_waf_v39/hw_waf_policy.yaml | 10 +- .../huorong_edr_v1_0/huorong.handler.py | 13 +- ..._huaweicloud_waf_huorong_device_plugins.py | 181 ++++++++++++++++++ 5 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 tests/tool/test_huaweicloud_waf_huorong_device_plugins.py diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py index fd0e4409b..5f6739eb0 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf.handler.py @@ -267,10 +267,10 @@ async def _request( if resp.status >= 400: return ToolResult( success=False, - data=resp_json, + output=resp_json, error=f"HTTP {resp.status}: {resp_text[:300]}", ) - return ToolResult(success=True, data=resp_json) + return ToolResult(success=True, output=resp_json) def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: @@ -291,10 +291,9 @@ def _page_query(params: dict[str, Any]) -> dict[str, Any]: # --------------------------------------------------------------------------- -async def host(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def host(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "host_list": q = _page_query(params) @@ -360,10 +359,9 @@ async def host(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def policy(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "policy_list": q = _page_query(params) @@ -400,7 +398,9 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "cc_rule_create": pol_id = params["policy_id"] - body = _pick(params, "url", "limit_num", "limit_period", "lock_time", "tag_type", "action") + body = _pick(params, "url", "limit_num", "limit_period", "lock_time", "tag_type") + if params.get("rule_action") is not None: + body["action"] = params["rule_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/policy/{pol_id}/cc", body=body) if action == "cc_rule_delete": @@ -415,7 +415,9 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "custom_rule_create": pol_id = params["policy_id"] - body = _pick(params, "name", "conditions", "action", "priority", "description") + body = _pick(params, "name", "conditions", "priority", "description") + if params.get("rule_action") is not None: + body["action"] = params["rule_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/policy/{pol_id}/custom", body=body) if action == "custom_rule_delete": @@ -446,16 +448,17 @@ async def policy(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def event(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") if action == "event_list": q = _page_query(params) - for k in ("from", "to", "hosts", "attacks", "action"): + for k in ("from", "to", "hosts", "attacks"): if params.get(k) is not None: q[k] = params[k] + if params.get("event_action") is not None: + q["action"] = params["event_action"] return await _request(cfg, "GET", f"/v1/{pid}/waf/event/attack/logs", query=q) if action == "event_show": @@ -471,9 +474,11 @@ async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: if action == "event_export_job": body = {} - for k in ("from", "to", "hosts", "attacks", "action"): + for k in ("from", "to", "hosts", "attacks"): if params.get(k) is not None: body[k] = params[k] + if params.get("event_action") is not None: + body["action"] = params["event_action"] return await _request(cfg, "POST", f"/v1/{pid}/waf/event/attack/log/job", body=body) if action == "threat_distribution": @@ -500,10 +505,9 @@ async def event(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def overview(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def overview(ctx: ToolContext, action: str, **params: Any) -> ToolResult: cfg = _load_config(params.get("enterprise_project_id")) pid = cfg.project_id - action = params.get("action", "") def _time_query() -> dict[str, Any]: q: dict[str, Any] = {} diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml index 0c6c99713..183d78778 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_event.yaml @@ -20,7 +20,7 @@ inputSchema: - event_list 用途: 查询攻击事件列表(分页) 必填: 无 - 常用: `from`、`to`、`hosts`、`attacks`、`action`、`page`、`pagesize` + 常用: `from`、`to`、`hosts`、`attacks`、`event_action`、`page`、`pagesize` 风险提示: 只读查询接口;建议传 from/to 缩小范围 是否任务型: 否 - event_show @@ -38,7 +38,7 @@ inputSchema: - event_export_job 用途: 下发自定义导出攻击事件的异步任务 必填: `from`、`to` - 常用: `from`、`to`、`hosts`、`attacks`、`action` + 常用: `from`、`to`、`hosts`、`attacks`、`event_action` 风险提示: 写操作(下发异步任务);任务完成后可通过 `event_log_download` 获取结果 是否任务型: 是 - threat_distribution @@ -89,9 +89,12 @@ inputSchema: items: type: string description: 攻击类型列表(如 `sqli`、`xss`、`cmdi`、`cc` 等) - action: + event_action: type: string description: 防护动作筛选,`block`(拦截)或 `log`(仅记录) + enum: + - block + - log top: type: integer description: 返回 Top N 条数,默认 5 diff --git a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml index fb04016f7..e37f02ed5 100644 --- a/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml +++ b/.flocks/flockshub/plugins/tools/device/huaweicloud_waf_v39/hw_waf_policy.yaml @@ -61,8 +61,8 @@ inputSchema: 是否任务型: 否 - cc_rule_create 用途: 创建 CC 防护规则 - 必填: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`action` - 常用: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`action` + 必填: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`rule_action` + 常用: `policy_id`、`url`、`limit_num`、`limit_period`、`lock_time`、`tag_type`、`rule_action` 风险提示: 写操作;会新增 CC 规则 是否任务型: 否 - cc_rule_delete @@ -79,8 +79,8 @@ inputSchema: 是否任务型: 否 - custom_rule_create 用途: 创建精准防护规则 - 必填: `policy_id`、`name`、`conditions`、`action` - 常用: `policy_id`、`name`、`conditions`、`action`、`priority` + 必填: `policy_id`、`name`、`conditions`、`rule_action` + 常用: `policy_id`、`name`、`conditions`、`rule_action`、`priority` 风险提示: 写操作;会新增精准防护规则 是否任务型: 否 - custom_rule_delete @@ -168,7 +168,7 @@ inputSchema: tag_type: type: string description: CC 规则标签类型,如 `ip`、`cookie`、`header` 等 - action: + rule_action: type: object description: 规则匹配后的动作,含 `category`(`block`/`pass`/`log`)等字段 conditions: diff --git a/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py b/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py index 3168dfdbc..cdb1fdcd7 100644 --- a/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py +++ b/.flocks/flockshub/plugins/tools/device/huorong_edr_v1_0/huorong.handler.py @@ -156,10 +156,10 @@ async def _post( if resp.status >= 400: return ToolResult( success=False, - data=resp_json, + output=resp_json, error=f"HTTP {resp.status}: {resp_text[:200]}", ) - return ToolResult(success=True, data=resp_json) + return ToolResult(success=True, output=resp_json) def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: @@ -171,9 +171,8 @@ def _pick(params: dict[str, Any], *keys: str) -> dict[str, Any]: # --------------------------------------------------------------------------- -async def group(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def group(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "group_list": return await _post(base_url, "/api/group/_list", {}, secret_id, secret_key, timeout, verify_ssl) @@ -194,9 +193,8 @@ async def group(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def clnts(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def clnts(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "clnts_online": body = _pick(params, "offset") @@ -231,9 +229,8 @@ async def clnts(params: dict[str, Any], ctx: ToolContext) -> ToolResult: return ToolResult(success=False, error=f"Unknown action: {action}") -async def task(params: dict[str, Any], ctx: ToolContext) -> ToolResult: +async def task(ctx: ToolContext, action: str, **params: Any) -> ToolResult: base_url, timeout, secret_id, secret_key, verify_ssl = _resolve_runtime_config() - action = params.get("action", "") if action == "task_create": body = _pick(params, "offset") diff --git a/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py b/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py new file mode 100644 index 000000000..e3a8cc8ee --- /dev/null +++ b/tests/tool/test_huaweicloud_waf_huorong_device_plugins.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import ast +import inspect +import shutil +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +import yaml + +from flocks.tool.registry import ToolContext, ToolResult +from flocks.tool.tool_loader import yaml_to_tool + + +_ROOT = Path(__file__).resolve().parents[2] +_DEVICE_ROOT = _ROOT / ".flocks" / "flockshub" / "plugins" / "tools" / "device" +_HUAWEI_DIR = _DEVICE_ROOT / "huaweicloud_waf_v39" +_HUORONG_DIR = _DEVICE_ROOT / "huorong_edr_v1_0" + + +def _installed_tool( + source_dir: Path, + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + project_root = tmp_path / "project" + install_dir = project_root / ".flocks" / "plugins" / "tools" / "device" / source_dir.name + shutil.copytree(source_dir, install_dir) + monkeypatch.chdir(project_root) + yaml_path = install_dir / yaml_name + raw = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + return raw, yaml_to_tool(raw, yaml_path) + + +def _script_globals(tool) -> dict[str, Any]: + return inspect.getclosurevars(tool.handler).nonlocals["fn"].__globals__ + + +def _ctx() -> ToolContext: + return ToolContext(session_id="device-plugin-test", message_id="device-plugin-test") + + +def test_huawei_manifests_separate_dispatch_action_from_api_payload(): + event = yaml.safe_load((_HUAWEI_DIR / "hw_waf_event.yaml").read_text(encoding="utf-8")) + event_properties = event["inputSchema"]["properties"] + + assert "event_list" in event_properties["action"]["enum"] + assert event_properties["event_action"]["enum"] == ["block", "log"] + + policy = yaml.safe_load((_HUAWEI_DIR / "hw_waf_policy.yaml").read_text(encoding="utf-8")) + policy_properties = policy["inputSchema"]["properties"] + + assert "policy_list" in policy_properties["action"]["enum"] + assert policy_properties["rule_action"]["type"] == "object" + + +@pytest.mark.parametrize( + "handler_path", + [_HUAWEI_DIR / "hw_waf.handler.py", _HUORONG_DIR / "huorong.handler.py"], +) +def test_tool_result_calls_use_supported_fields(handler_path: Path): + module = ast.parse(handler_path.read_text(encoding="utf-8"), filename=str(handler_path)) + unsupported: list[tuple[int, str]] = [] + for node in ast.walk(module): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name) or node.func.id != "ToolResult": + continue + unsupported.extend( + (node.lineno, keyword.arg) + for keyword in node.keywords + if keyword.arg is not None and keyword.arg not in ToolResult.model_fields + ) + + assert unsupported == [] + + +@pytest.mark.parametrize( + "yaml_name", + [ + "hw_waf_host.yaml", + "hw_waf_policy.yaml", + "hw_waf_event.yaml", + "hw_waf_overview.yaml", + ], +) +@pytest.mark.asyncio +async def test_huawei_handlers_execute_through_script_loader( + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, yaml_name, tmp_path, monkeypatch) + globals_ = _script_globals(tool) + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + + result = await tool.handler(_ctx(), action="unsupported") + + assert result.success is False + assert result.error == "Unknown action: unsupported" + + +@pytest.mark.asyncio +async def test_huawei_event_action_maps_to_api_filter( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, "hw_waf_event.yaml", tmp_path, monkeypatch) + globals_ = _script_globals(tool) + calls: list[dict[str, Any]] = [] + + async def fake_request(cfg, method, path, query=None, body=None): + calls.append({"method": method, "path": path, "query": query, "body": body}) + return ToolResult(success=True, output={}) + + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + monkeypatch.setitem(globals_, "_request", fake_request) + + result = await tool.handler( + _ctx(), + action="event_list", + event_action="block", + page=1, + ) + + assert result.success is True + assert calls[0]["query"] == {"page": 1, "action": "block"} + + +@pytest.mark.asyncio +async def test_huawei_rule_action_maps_to_api_body( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUAWEI_DIR, "hw_waf_policy.yaml", tmp_path, monkeypatch) + globals_ = _script_globals(tool) + calls: list[dict[str, Any]] = [] + + async def fake_request(cfg, method, path, query=None, body=None): + calls.append({"method": method, "path": path, "query": query, "body": body}) + return ToolResult(success=True, output={}) + + monkeypatch.setitem(globals_, "_load_config", lambda *_: SimpleNamespace(project_id="project-1")) + monkeypatch.setitem(globals_, "_request", fake_request) + rule_action = {"category": "block"} + + result = await tool.handler( + _ctx(), + action="cc_rule_create", + policy_id="policy-1", + url="/login", + rule_action=rule_action, + ) + + assert result.success is True + assert calls[0]["body"] == {"url": "/login", "action": rule_action} + + +@pytest.mark.parametrize( + "yaml_name", + ["huorong_group.yaml", "huorong_clnts.yaml", "huorong_task.yaml"], +) +@pytest.mark.asyncio +async def test_huorong_handlers_execute_through_script_loader( + yaml_name: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + _, tool = _installed_tool(_HUORONG_DIR, yaml_name, tmp_path, monkeypatch) + globals_ = _script_globals(tool) + monkeypatch.setitem( + globals_, + "_resolve_runtime_config", + lambda: ("https://huorong.example.com", 30, "secret-id", "secret-key", False), + ) + + result = await tool.handler(_ctx(), action="unsupported") + + assert result.success is False + assert result.error == "Unknown action: unsupported" From ebc101fef92518eb45394560f28dfc07a05380af Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 18:37:52 +0800 Subject: [PATCH 44/86] feat(provider): add Kimi K2.7 Code to ThreatBook CN Add the model catalog entry with pricing and token limits, make it the default ThreatBook CN model, and cover catalog, onboarding, bootstrap, and vision behavior. --- flocks/provider/catalog.json | 27 +++++++++++++++++++ flocks/server/routes/onboarding.py | 2 +- tests/provider/test_chinese_providers.py | 14 ++++++++++ tests/server/routes/test_onboarding_routes.py | 5 ++-- .../routes/test_provider_model_bootstrap.py | 2 ++ webui/src/hooks/useDefaultModelVision.test.ts | 13 +++++++++ webui/src/hooks/useDefaultModelVision.ts | 6 ++++- webui/src/pages/Model/index.tsx | 6 ++++- 8 files changed, 70 insertions(+), 5 deletions(-) diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index 363d1408d..b34a49806 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -52,6 +52,33 @@ "THREATBOOK_CN_LLM_API_KEY" ], "models": { + "kimi-k2.7-code": { + "name": "kimi-k2.7-code", + "family": "kimi-k2.7-code", + "capabilities": { + "supports_tools": true, + "supports_vision": true, + "supports_reasoning": true, + "interleaved": { + "field": "reasoning_content", + "echo": "tool_calls", + "placeholder": " ", + "cross_provider_policy": "placeholder" + }, + "supports_streaming": true + }, + "limits": { + "context_window": 256000, + "max_input_tokens": 224000, + "max_output_tokens": 16000 + }, + "pricing": { + "input": 6.5, + "output": 27.0, + "cache_read": 1.3, + "currency": "CNY" + } + }, "minimax-m3": { "name": "minimax-m3", "family": "minimax", diff --git a/flocks/server/routes/onboarding.py b/flocks/server/routes/onboarding.py index a94ba41c3..00abe7927 100644 --- a/flocks/server/routes/onboarding.py +++ b/flocks/server/routes/onboarding.py @@ -141,7 +141,7 @@ def _llm_provider_has_usable_credentials(provider_id: str) -> bool: "cn": { "activation_url": "https://x.threatbook.com/flocks/activate", "threatbook_llm_provider_id": "threatbook-cn-llm", - "threatbook_default_model_id": "minimax-m2.7", + "threatbook_default_model_id": "kimi-k2.7-code", "threatbook_api_service_id": "threatbook-cn", "threatbook_mcp_name": "threatbook_mcp", "threatbook_mcp_url": "https://mcp.threatbook.cn/mcp?apikey={api_key}", diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index 92a7b1b5e..e768d595c 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -253,6 +253,7 @@ def test_threatbook_cn_llm_catalog(self): assert get_provider_default_url("threatbook-cn-llm") == "https://llm.threatbook.cn/v1" models = get_provider_model_definitions("threatbook-cn-llm") assert {m.id for m in models} == { + "kimi-k2.7-code", "minimax-m3", "minimax-m2.7", "minimax-m2.5", @@ -263,6 +264,19 @@ def test_threatbook_cn_llm_catalog(self): "deepseek-v4-flash", } + kimi_code = next(m for m in models if m.id == "kimi-k2.7-code") + assert models[0].id == "kimi-k2.7-code" + assert kimi_code.capabilities.supports_vision is True + assert kimi_code.capabilities.supports_reasoning is True + assert kimi_code.capabilities.interleaved["field"] == "reasoning_content" + assert kimi_code.pricing.currency == "CNY" + assert kimi_code.pricing.cache_read == 1.3 + assert kimi_code.pricing.input == 6.5 + assert kimi_code.pricing.output == 27.0 + assert kimi_code.limits.context_window == 256000 + assert kimi_code.limits.max_input_tokens == 224000 + assert kimi_code.limits.max_output_tokens == 16000 + qwen = next(m for m in models if m.id == "qwen3.6-plus") assert qwen.capabilities.supports_vision is True diff --git a/tests/server/routes/test_onboarding_routes.py b/tests/server/routes/test_onboarding_routes.py index f2b812a48..7d317865d 100644 --- a/tests/server/routes/test_onboarding_routes.py +++ b/tests/server/routes/test_onboarding_routes.py @@ -277,7 +277,7 @@ async def fake_connect_mcp(name: str): return True async def fake_set_default_model(model_type, body): - calls.append(("default_model", body.provider_id)) + calls.append(("default_model", body.provider_id, body.model_id)) return {"provider_id": body.provider_id, "model_id": body.model_id} monkeypatch.setattr(onboarding_routes, "_validate_onboarding_request", fake_validate) @@ -302,13 +302,14 @@ async def fake_set_default_model(model_type, body): data = resp.json() assert data["success"] is True assert data["default_model"]["provider_id"] == "threatbook-cn-llm" + assert data["default_model"]["model_id"] == "kimi-k2.7-code" assert ("provider", "threatbook-cn-llm") in calls assert ("service", "threatbook-cn") in calls assert ("service_enabled", "threatbook-cn") in calls assert ("ensure_mcp", "cn") in calls assert ("mcp_credentials", "threatbook_mcp") in calls assert ("mcp_connect", "threatbook_mcp") in calls - assert ("default_model", "threatbook-cn-llm") in calls + assert ("default_model", "threatbook-cn-llm", "kimi-k2.7-code") in calls @pytest.mark.asyncio async def test_apply_returns_400_when_validation_fails( diff --git a/tests/server/routes/test_provider_model_bootstrap.py b/tests/server/routes/test_provider_model_bootstrap.py index aaee5869c..5ab24fae7 100644 --- a/tests/server/routes/test_provider_model_bootstrap.py +++ b/tests/server/routes/test_provider_model_bootstrap.py @@ -31,6 +31,8 @@ async def test_set_provider_credentials_bootstraps_kimi_k26_from_catalog( raw = ConfigWriter.get_provider_raw("threatbook-cn-llm") assert raw is not None + assert "kimi-k2.7-code" in raw["models"] + assert raw["models"]["kimi-k2.7-code"]["name"] == "kimi-k2.7-code" assert "kimi-k2.6" in raw["models"] assert raw["models"]["kimi-k2.6"]["name"] == "kimi-k2.6" fake_secrets.set.assert_called_once_with("threatbook-cn-llm_llm_key", "tb-key") diff --git a/webui/src/hooks/useDefaultModelVision.test.ts b/webui/src/hooks/useDefaultModelVision.test.ts index a0c37fb80..90fe0e341 100644 --- a/webui/src/hooks/useDefaultModelVision.test.ts +++ b/webui/src/hooks/useDefaultModelVision.test.ts @@ -93,6 +93,19 @@ describe('useDefaultModelVision', () => { await waitFor(() => expect(result.current).toBe(true)); }); + it('returns true for the predefined kimi-k2.7-code model', async () => { + mockResolved.mockResolvedValue(makeResolvedResp('threatbook-cn-llm', 'kimi-k2.7-code')); + mockDefinitions.mockResolvedValue(makeDefinitionsResp( + { supports_vision: true }, + 'predefined', + 'threatbook-cn-llm', + 'kimi-k2.7-code', + )); + + const { result } = renderHook(() => useDefaultModelVision()); + await waitFor(() => expect(result.current).toBe(true)); + }); + it('returns null when capabilities are absent', async () => { mockResolved.mockResolvedValue(makeResolvedResp()); mockDefinitions.mockResolvedValue({ diff --git a/webui/src/hooks/useDefaultModelVision.ts b/webui/src/hooks/useDefaultModelVision.ts index 90e980597..44f37edfa 100644 --- a/webui/src/hooks/useDefaultModelVision.ts +++ b/webui/src/hooks/useDefaultModelVision.ts @@ -46,7 +46,11 @@ const subscribers = new Set<(state: VisionState) => void>(); function allowsBuiltInVision(modelId: string): boolean { const lowered = modelId.toLowerCase(); - return lowered.includes('qwen3.6-plus') || lowered.includes('kimi-k2.6'); + return ( + lowered.includes('qwen3.6-plus') + || lowered.includes('kimi-k2.6') + || lowered.includes('kimi-k2.7-code') + ); } function visionSupportFromDefinition( diff --git a/webui/src/pages/Model/index.tsx b/webui/src/pages/Model/index.tsx index 298bad220..b34dc23f1 100644 --- a/webui/src/pages/Model/index.tsx +++ b/webui/src/pages/Model/index.tsx @@ -2190,7 +2190,11 @@ function getDefaultReasoningToggleValue(providerId: string, modelId: string): bo function allowsBuiltInVisionToggle(modelId: string): boolean { const lowered = modelId.toLowerCase(); - return lowered.includes('qwen3.6-plus') || lowered.includes('kimi-k2.6'); + return ( + lowered.includes('qwen3.6-plus') + || lowered.includes('kimi-k2.6') + || lowered.includes('kimi-k2.7-code') + ); } // ==================== Configure Dialog ==================== From 996595009625ff016bc3885bdd7e7ee731459826 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Mon, 20 Jul 2026 18:43:42 +0800 Subject: [PATCH 45/86] feat(provider): add Kimi K2.7 Code to ThreatBook IO Add the international catalog entry with matching pricing and token limits, make it the default global ThreatBook model, and cover catalog ordering and regional defaults. --- flocks/provider/catalog.json | 27 +++++++++++++++++++ flocks/server/routes/onboarding.py | 2 +- tests/provider/test_chinese_providers.py | 14 ++++++++++ tests/server/routes/test_onboarding_routes.py | 8 ++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index b34a49806..bef32dde4 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -289,6 +289,33 @@ "THREATBOOK_IO_LLM_API_KEY" ], "models": { + "kimi-k2.7-code": { + "name": "kimi-k2.7-code", + "family": "kimi-k2.7-code", + "capabilities": { + "supports_tools": true, + "supports_vision": true, + "supports_reasoning": true, + "interleaved": { + "field": "reasoning_content", + "echo": "tool_calls", + "placeholder": " ", + "cross_provider_policy": "placeholder" + }, + "supports_streaming": true + }, + "limits": { + "context_window": 256000, + "max_input_tokens": 224000, + "max_output_tokens": 16000 + }, + "pricing": { + "input": 6.5, + "output": 27.0, + "cache_read": 1.3, + "currency": "CNY" + } + }, "minimax-m3": { "name": "minimax-m3", "family": "minimax", diff --git a/flocks/server/routes/onboarding.py b/flocks/server/routes/onboarding.py index 00abe7927..7ccb2a380 100644 --- a/flocks/server/routes/onboarding.py +++ b/flocks/server/routes/onboarding.py @@ -151,7 +151,7 @@ def _llm_provider_has_usable_credentials(provider_id: str) -> bool: "global": { "activation_url": "https://threatbook.io/flocks/activate", "threatbook_llm_provider_id": "threatbook-io-llm", - "threatbook_default_model_id": "minimax-m2.7", + "threatbook_default_model_id": "kimi-k2.7-code", "threatbook_api_service_id": "threatbook-io", "threatbook_mcp_name": None, "threatbook_mcp_url": None, diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index e768d595c..f60060143 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -311,6 +311,7 @@ def test_threatbook_io_llm_catalog(self): assert get_provider_default_url("threatbook-io-llm") == "https://llm.threatbook.io/v1" models = get_provider_model_definitions("threatbook-io-llm") assert {m.id for m in models} == { + "kimi-k2.7-code", "minimax-m3", "minimax-m2.7", "minimax-m2.5", @@ -320,6 +321,19 @@ def test_threatbook_io_llm_catalog(self): "deepseek-v4-flash", } + kimi_code = next(m for m in models if m.id == "kimi-k2.7-code") + assert models[0].id == "kimi-k2.7-code" + assert kimi_code.capabilities.supports_vision is True + assert kimi_code.capabilities.supports_reasoning is True + assert kimi_code.capabilities.interleaved["field"] == "reasoning_content" + assert kimi_code.pricing.currency == "CNY" + assert kimi_code.pricing.cache_read == 1.3 + assert kimi_code.pricing.input == 6.5 + assert kimi_code.pricing.output == 27.0 + assert kimi_code.limits.context_window == 256000 + assert kimi_code.limits.max_input_tokens == 224000 + assert kimi_code.limits.max_output_tokens == 16000 + qwen = next(m for m in models if m.id == "qwen3.6-plus") assert qwen.capabilities.supports_vision is True diff --git a/tests/server/routes/test_onboarding_routes.py b/tests/server/routes/test_onboarding_routes.py index 7d317865d..53675b541 100644 --- a/tests/server/routes/test_onboarding_routes.py +++ b/tests/server/routes/test_onboarding_routes.py @@ -198,6 +198,14 @@ async def fake_test_mcp(region: str, api_key: str): class TestOnboardingApplyRoutes: + def test_threatbook_region_presets_use_kimi_k27_code(self): + assert onboarding_routes.ONBOARDING_REGION_PRESETS["cn"]["threatbook_default_model_id"] == ( + "kimi-k2.7-code" + ) + assert onboarding_routes.ONBOARDING_REGION_PRESETS["global"]["threatbook_default_model_id"] == ( + "kimi-k2.7-code" + ) + def test_ensure_threatbook_mcp_config_uses_explicit_secret_reference( self, monkeypatch: pytest.MonkeyPatch ): From 74b0555d59bebf9accf6ed19ddf2e2b336c4a995 Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Mon, 20 Jul 2026 23:29:15 +0800 Subject: [PATCH 46/86] Remove deprecated DeepSeek model names --- flocks/provider/catalog.json | 41 ------------------- flocks/provider/interleaved.py | 1 - tests/config/test_config_writer.py | 4 +- tests/provider/test_anthropic_interleaved.py | 2 +- tests/provider/test_chinese_providers.py | 9 ---- tests/provider/test_reasoning_replay.py | 6 +-- tests/provider/test_thinking_params.py | 23 +---------- tests/session/test_runner_step.py | 2 +- .../common/OnboardingModal.test.tsx | 2 +- webui/src/components/layout/Layout.test.tsx | 2 +- webui/src/locales/en-US/common.json | 2 +- webui/src/locales/zh-CN/common.json | 2 +- 12 files changed, 12 insertions(+), 84 deletions(-) diff --git a/flocks/provider/catalog.json b/flocks/provider/catalog.json index 363d1408d..7c4d1319b 100644 --- a/flocks/provider/catalog.json +++ b/flocks/provider/catalog.json @@ -1016,47 +1016,6 @@ "DEEPSEEK_API_KEY" ], "models": { - "deepseek-chat": { - "name": "DeepSeek Chat", - "family": "deepseek-v3", - "capabilities": { - "supports_tools": true, - "supports_streaming": true - }, - "limits": { - "context_window": 128000, - "max_output_tokens": 128000 - }, - "pricing": { - "input": 2.0, - "output": 3.0, - "currency": "CNY" - } - }, - "deepseek-reasoner": { - "name": "DeepSeek Reasoner", - "family": "deepseek-r1", - "capabilities": { - "supports_tools": true, - "supports_reasoning": true, - "interleaved": { - "field": "reasoning_content", - "echo": "tool_calls", - "placeholder": " ", - "cross_provider_policy": "placeholder" - }, - "supports_streaming": true - }, - "limits": { - "context_window": 128000, - "max_output_tokens": 128000 - }, - "pricing": { - "input": 4.0, - "output": 16.0, - "currency": "CNY" - } - }, "deepseek-v4-flash": { "name": "DeepSeek V4 Flash", "family": "deepseek-v4", diff --git a/flocks/provider/interleaved.py b/flocks/provider/interleaved.py index ad1b869b5..727628a4e 100644 --- a/flocks/provider/interleaved.py +++ b/flocks/provider/interleaved.py @@ -38,7 +38,6 @@ "deepseek-v4", "deepseek-v4-pro", "deepseek-v4-flash", - "deepseek-reasoner", "deepseek-r1", "reasoner", "r1-0528", diff --git a/tests/config/test_config_writer.py b/tests/config/test_config_writer.py index d66640115..1a136f4cf 100644 --- a/tests/config/test_config_writer.py +++ b/tests/config/test_config_writer.py @@ -187,13 +187,13 @@ def test_build_provider_config(self, temp_project): "deepseek", npm="@ai-sdk/openai-compatible", base_url="https://api.deepseek.com/v1", - models={"deepseek-chat": {"name": "DeepSeek Chat"}}, + models={"deepseek-v4-flash": {"name": "DeepSeek V4 Flash"}}, ) assert config["npm"] == "@ai-sdk/openai-compatible" assert config["options"]["apiKey"] == "{secret:deepseek_llm_key}" assert config["options"]["baseURL"] == "https://api.deepseek.com/v1" - assert config["models"]["deepseek-chat"]["name"] == "DeepSeek Chat" + assert config["models"]["deepseek-v4-flash"]["name"] == "DeepSeek V4 Flash" def test_build_provider_config_no_url(self, temp_project): from flocks.config.config_writer import ConfigWriter diff --git a/tests/provider/test_anthropic_interleaved.py b/tests/provider/test_anthropic_interleaved.py index 573d8b3e7..8241e235b 100644 --- a/tests/provider/test_anthropic_interleaved.py +++ b/tests/provider/test_anthropic_interleaved.py @@ -118,7 +118,7 @@ def test_anthropic_formatter_preserves_unsigned_thinking_for_deepseek_compatible formatted = AnthropicProvider._format_messages_anthropic( [message], base_url="https://api.deepseek.com/anthropic", - model_id="deepseek-chat", + model_id="deepseek-v4-flash", ) assert formatted[0]["content"][0] == { diff --git a/tests/provider/test_chinese_providers.py b/tests/provider/test_chinese_providers.py index 92a7b1b5e..956f1c89b 100644 --- a/tests/provider/test_chinese_providers.py +++ b/tests/provider/test_chinese_providers.py @@ -141,19 +141,10 @@ def test_deepseek_catalog(self): models = get_provider_model_definitions("deepseek") assert {m.id for m in models} == { - "deepseek-chat", - "deepseek-reasoner", "deepseek-v4-flash", "deepseek-v4-pro", } - r1 = next(m for m in models if m.id == "deepseek-reasoner") - assert r1.capabilities.supports_reasoning is True - assert r1.capabilities.interleaved["field"] == "reasoning_content" - assert r1.capabilities.interleaved["placeholder"] == " " - assert r1.pricing.currency == "CNY" - assert r1.pricing.output == 16.0 - v4_flash = next(m for m in models if m.id == "deepseek-v4-flash") assert v4_flash.capabilities.supports_reasoning is True assert v4_flash.capabilities.interleaved["field"] == "reasoning_content" diff --git a/tests/provider/test_reasoning_replay.py b/tests/provider/test_reasoning_replay.py index 15f901154..261446f6b 100644 --- a/tests/provider/test_reasoning_replay.py +++ b/tests/provider/test_reasoning_replay.py @@ -48,7 +48,7 @@ def test_prepare_reasoning_uses_placeholder_for_strict_echo_provider(): prepared = prepare_reasoning_for_replay( provider_id="deepseek", - model_id="deepseek-reasoner", + model_id="deepseek-v4-pro", message=message, interleaved={ "field": "reasoning_content", @@ -78,7 +78,7 @@ def test_prepare_reasoning_upgrades_whitespace_only_reasoning_content(): prepared = prepare_reasoning_for_replay( provider_id="deepseek", - model_id="deepseek-reasoner", + model_id="deepseek-v4-pro", message=message, interleaved={ "field": "reasoning_content", @@ -132,7 +132,7 @@ def test_prepare_reasoning_drops_details_when_target_uses_reasoning_content(): prepared = prepare_reasoning_for_replay( provider_id="deepseek", - model_id="deepseek-reasoner", + model_id="deepseek-v4-pro", message=message, interleaved={ "field": "reasoning_content", diff --git a/tests/provider/test_thinking_params.py b/tests/provider/test_thinking_params.py index 6d838d8fe..db82214f1 100644 --- a/tests/provider/test_thinking_params.py +++ b/tests/provider/test_thinking_params.py @@ -247,7 +247,7 @@ def test_glm5_emits_official_thinking_payload_on_every_provider( ("threatbook-io-llm", "minimax-m2.7", "reasoning_details", {"reasoning_split": True}), ("threatbook-io-llm", "minimax-m3", "reasoning_details", {"reasoning_split": True}), ("minimax", "minimax-m2.5", "reasoning_details", {"reasoning_split": True}), - ("deepseek", "deepseek-reasoner", "reasoning_content", DEEPSEEK_THINKING_EXTRA_BODY), + ("deepseek", "deepseek-v4-pro", "reasoning_content", DEEPSEEK_THINKING_EXTRA_BODY), ("stepfun", "step-3.5-flash", "reasoning_content", {"enable_thinking": True}), ], ) @@ -314,26 +314,6 @@ def test_no_shape_registry(self) -> None: "interleaved emits extra_body inline" ) - def test_deepseek_v3_is_not_auto_thinking_model(self) -> None: - """``deepseek-chat`` (V3) must not inherit thinking params from a - broad ``deepseek`` substring. - """ - catalog = model_catalog.get_raw_catalog() - v3_entry = catalog.get("deepseek", {}).get("models", {}).get("deepseek-chat") - assert v3_entry is not None, "deepseek-chat missing from catalog" - assert v3_entry.get("capabilities", {}).get("interleaved") is None, ( - "deepseek-chat now declares interleaved in catalog — remove the " - "series-token assertion and let the catalog coverage test pin it" - ) - - options = provider_options.build_provider_options( - "deepseek", "deepseek-chat", resolve_max_tokens=False, - ) - assert "extra_body" not in options, ( - "deepseek-chat does not declare interleaved in catalog and should " - f"not be auto-enabled by a broad deepseek token. options={options!r}" - ) - def test_explicit_reasoning_toggle_propagates(self) -> None: """``reasoning_enabled=False`` should produce ``enable_thinking: false`` on a generic_chat transport, mirroring the old token-matching branch's @@ -488,7 +468,6 @@ def test_series_token_fallback_emits_expected_extra_body( @pytest.mark.parametrize( "provider_id,model_id,expected_extra_body", [ - ("deepseek", "deepseek-reasoner", DEEPSEEK_THINKING_EXTRA_BODY), ("deepseek", "deepseek-v4-flash", DEEPSEEK_THINKING_EXTRA_BODY), ("minimax", "minimax-m3", {"reasoning_split": True}), ("stepfun", "step-3.5-flash", {"enable_thinking": True}), diff --git a/tests/session/test_runner_step.py b/tests/session/test_runner_step.py index 466c5c0df..3424d32eb 100644 --- a/tests/session/test_runner_step.py +++ b/tests/session/test_runner_step.py @@ -2148,7 +2148,7 @@ async def test_to_chat_messages_skips_reasoning_only_aborted_assistant(monkeypat def test_provider_capability_key_includes_interleaved_policy(monkeypatch): runner = _make_runner("ses_runner_interleaved_capability_key") runner.provider_id = "deepseek" - runner.model_id = "deepseek-reasoner" + runner.model_id = "deepseek-v4-pro" monkeypatch.setattr(SessionRunner, "_model_supports_vision", lambda self: False) monkeypatch.setattr( diff --git a/webui/src/components/common/OnboardingModal.test.tsx b/webui/src/components/common/OnboardingModal.test.tsx index cb04b6210..723d060fa 100644 --- a/webui/src/components/common/OnboardingModal.test.tsx +++ b/webui/src/components/common/OnboardingModal.test.tsx @@ -121,7 +121,7 @@ describe('OnboardingModal', () => { { id: 'qwen3-max', name: 'Qwen 3 Max' }, ]), makeProvider('openai-compatible', 'OpenAI Compatible', []), - makeProvider('deepseek', 'DeepSeek', [{ id: 'deepseek-chat', name: 'DeepSeek V3.2' }]), + makeProvider('deepseek', 'DeepSeek', [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }]), ], }, }); diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index d8c3ab000..c1a36e524 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -306,7 +306,7 @@ describe('Layout onboarding entry', () => { { id: 'qwen3-max', name: 'Qwen 3 Max' }, ]), makeProvider('openai-compatible', 'OpenAI Compatible', []), - makeProvider('deepseek', 'DeepSeek', [{ id: 'deepseek-chat', name: 'DeepSeek V3.2' }]), + makeProvider('deepseek', 'DeepSeek', [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }]), ], }, }); diff --git a/webui/src/locales/en-US/common.json b/webui/src/locales/en-US/common.json index 9a8e4b744..6fe7a67a5 100644 --- a/webui/src/locales/en-US/common.json +++ b/webui/src/locales/en-US/common.json @@ -248,7 +248,7 @@ "thirdPartySubtitle": "Choose another provider, enter its key, and select the default model.", "thirdPartyKeyPlaceholder": "Paste the third-party model API key", "thirdPartyBaseUrlPlaceholder": "Enter the model service base URL if needed", - "thirdPartyModelIdPlaceholder": "Enter the model ID (e.g. gpt-4o, deepseek-chat)", + "thirdPartyModelIdPlaceholder": "Enter the model ID (e.g. gpt-4o, deepseek-v4-flash)", "thirdPartyModelHint": "Will verify and set this as the default model: {{model}}", "thirdPartyAdvancedHint": "For advanced custom setups such as OpenAI Compatible, use the Models page instead.", "validateAndApply": "Validate & Apply", diff --git a/webui/src/locales/zh-CN/common.json b/webui/src/locales/zh-CN/common.json index 6a6f6abf4..a4b7c5c44 100644 --- a/webui/src/locales/zh-CN/common.json +++ b/webui/src/locales/zh-CN/common.json @@ -248,7 +248,7 @@ "thirdPartySubtitle": "选择一个其他模型厂商,填写其 key,并指定默认模型。", "thirdPartyKeyPlaceholder": "粘贴第三方模型 API Key", "thirdPartyBaseUrlPlaceholder": "填写模型服务 Base URL(如需要)", - "thirdPartyModelIdPlaceholder": "输入模型 ID(例如 gpt-4o、deepseek-chat)", + "thirdPartyModelIdPlaceholder": "输入模型 ID(例如 gpt-4o、deepseek-v4-flash)", "thirdPartyModelHint": "保存后会验证并设为默认模型:{{model}}", "thirdPartyAdvancedHint": "如需 OpenAI Compatible 等高级自定义接入,请前往 Models 页面配置。", "validateAndApply": "验证并应用", From 514f85b2a72770b2f2467c82df4a21172a0f6948 Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Mon, 20 Jul 2026 23:36:32 +0800 Subject: [PATCH 47/86] fix(webui): clarify stats access failures --- flocks/cli/service_manager.py | 2 +- tests/cli/test_service_manager.py | 2 +- webui/src/api/stats.test.ts | 16 +++++ webui/src/api/stats.ts | 91 +++++++++++++++++++++++++---- webui/src/locales/en-US/home.json | 1 + webui/src/locales/zh-CN/home.json | 1 + webui/src/pages/Home/index.test.tsx | 37 +++++++++--- webui/src/pages/Home/index.tsx | 2 +- 8 files changed, 131 insertions(+), 21 deletions(-) diff --git a/flocks/cli/service_manager.py b/flocks/cli/service_manager.py index 46134d52f..debe4cad9 100644 --- a/flocks/cli/service_manager.py +++ b/flocks/cli/service_manager.py @@ -50,7 +50,7 @@ LOG_TRIM_CHUNK_BYTES = 1024 * 1024 WEBUI_DIRECT_BACKEND_URLS_ENV = "FLOCKS_WEBUI_DIRECT_BACKEND_URLS" DEFAULT_FLOCKS_CONSOLE_BASE_URL = "https://portalflocks.threatbook.cn" -DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = "portalflocks.threatbook.cn" +DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS = "127.0.0.1,localhost,portalflocks.threatbook.cn" MISSING_PORT_OWNER_TOOLS_WARNING = ( "未检测到 lsof 或 fuser,无法解析端口占用 PID;将退回到 bind 检查。" "可尝试安装:apt/yum install lsof -y" diff --git a/tests/cli/test_service_manager.py b/tests/cli/test_service_manager.py index d75c1cac0..94d93d270 100644 --- a/tests/cli/test_service_manager.py +++ b/tests/cli/test_service_manager.py @@ -1485,7 +1485,7 @@ def test_build_frontend_env_sets_portal_defaults_when_env_missing(monkeypatch) - assert env["FLOCKS_CONSOLE_BASE_URL"] == service_manager.DEFAULT_FLOCKS_CONSOLE_BASE_URL assert ( env["__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS"] - == service_manager.DEFAULT_VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS + == "127.0.0.1,localhost,portalflocks.threatbook.cn" ) diff --git a/webui/src/api/stats.test.ts b/webui/src/api/stats.test.ts index 7407e93ba..df331176d 100644 --- a/webui/src/api/stats.test.ts +++ b/webui/src/api/stats.test.ts @@ -98,4 +98,20 @@ describe('statsApi.getSystemStats', () => { const result = await statsApi.getSystemStats(); expect(result.skills.total).toBe(0); }); + + it('reports an authentication-specific error when all stats resources fail with 401', async () => { + mockGet.mockImplementation((url: string) => { + if (url === '/api/stats/summary') return Promise.reject(new Error('legacy backend')); + if (url === '/api/health') return Promise.resolve({ data: { status: 'healthy' } }); + return Promise.reject({ + response: { status: 401, data: { message: 'unauthorized' } }, + }); + }); + + const { statsApi } = await import('./stats'); + const result = await statsApi.getSystemStats(); + + expect(result.system.status).toBe('error'); + expect(result.system.message).toContain('登录状态已失效'); + }); }); diff --git a/webui/src/api/stats.ts b/webui/src/api/stats.ts index 89ad2130f..c82aceca8 100644 --- a/webui/src/api/stats.ts +++ b/webui/src/api/stats.ts @@ -46,15 +46,73 @@ function isSystemStats(value: any): value is SystemStats { ); } +type StatsEndpointFailure = { + endpoint: string; + error: unknown; +}; + +function getErrorStatus(error: unknown): number | undefined { + return (error as { response?: { status?: number } } | undefined)?.response?.status; +} + +function getErrorDetail(error: unknown): string | undefined { + const responseData = (error as { response?: { data?: unknown } } | undefined)?.response?.data; + if (responseData && typeof responseData === 'object') { + const data = responseData as { + message?: unknown; + detail?: unknown; + error?: unknown; + }; + for (const value of [data.message, data.detail, data.error]) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + } + + const message = (error as { message?: unknown } | undefined)?.message; + return typeof message === 'string' && message.trim() ? message.trim() : undefined; +} + +function describeStatsFailure(failures: StatsEndpointFailure[]): string { + if (failures.some((failure) => getErrorStatus(failure.error) === 401)) { + return '登录状态已失效或 API Token 缺失,无法加载首页统计数据'; + } + + if (failures.some((failure) => getErrorStatus(failure.error) === 403)) { + return '当前账号无权加载首页统计数据'; + } + + if (failures.some((failure) => getErrorStatus(failure.error) === 404)) { + return '统计接口不可用,已尝试兼容旧版接口但仍未成功'; + } + + const firstDetail = failures.map((failure) => getErrorDetail(failure.error)).find(Boolean); + if (firstDetail) { + return `首页统计数据加载失败:${firstDetail}`; + } + + return '首页统计数据加载失败,请检查登录状态、API 路由或 Host/代理配置'; +} + async function getSystemStatsLegacy(): Promise { + const resourceFailures: StatsEndpointFailure[] = []; + const healthFailures: StatsEndpointFailure[] = []; + const getWithFallback = async (endpoint: string, fallbackData: unknown, failures: StatsEndpointFailure[] = resourceFailures) => { + try { + return await apiClient.get(endpoint); + } catch (error) { + failures.push({ endpoint, error }); + return { data: fallbackData }; + } + }; + const [taskDash, agents, workflows, skills, tools, providers, health] = await Promise.all([ - apiClient.get('/api/task-system/dashboard').catch(() => ({ data: {} })), - apiClient.get('/api/agent').catch(() => ({ data: [] })), - apiClient.get('/api/workflow').catch(() => ({ data: [] })), - apiClient.get('/api/skills').catch(() => ({ data: [] })), - apiClient.get('/api/tools').catch(() => ({ data: [] })), - apiClient.get('/api/provider').catch(() => ({ data: { all: [] } })), - apiClient.get('/api/health').catch(() => ({ data: { status: 'error' } })), + getWithFallback('/api/task-system/dashboard', {}), + getWithFallback('/api/agent', []), + getWithFallback('/api/workflow', []), + getWithFallback('/api/skills', []), + getWithFallback('/api/tools', []), + getWithFallback('/api/provider', { all: [] }), + getWithFallback('/api/health', { status: 'error' }, healthFailures), ]); const dash = taskDash.data || {}; @@ -72,6 +130,12 @@ async function getSystemStatsLegacy(): Promise { const totalModels = providerAll .filter((p: any) => connectedSet.has(p.id)) .reduce((sum: number, p: any) => sum + Object.keys(p.models ?? {}).length, 0); + const allResourceEndpointsFailed = resourceFailures.length === 6; + const healthIsHealthy = health.data.status === 'healthy'; + const healthMessage = typeof health.data.message === 'string' && health.data.message.trim() + ? health.data.message.trim() + : undefined; + const systemIsHealthy = healthIsHealthy && !allResourceEndpointsFailed; return { tasks: { @@ -84,8 +148,12 @@ async function getSystemStatsLegacy(): Promise { tools: { total: toolList.length }, models: { total: totalModels }, system: { - status: health.data.status === 'healthy' ? 'healthy' : 'error', - message: health.data.status === 'healthy' ? '所有服务运行正常' : '部分服务异常', + status: systemIsHealthy ? 'healthy' : 'error', + message: systemIsHealthy + ? '所有服务运行正常' + : allResourceEndpointsFailed + ? describeStatsFailure(resourceFailures) + : healthMessage ?? describeStatsFailure(healthFailures), }, }; } @@ -110,7 +178,10 @@ export const statsApi = { skills: { total: 0 }, tools: { total: 0 }, models: { total: 0 }, - system: { status: 'error', message: '无法连接到后端服务' }, + system: { + status: 'error', + message: describeStatsFailure([{ endpoint: '/api/stats/summary', error: fallbackError || error }]), + }, }; } } diff --git a/webui/src/locales/en-US/home.json b/webui/src/locales/en-US/home.json index 4c06881fa..293cc6fe2 100644 --- a/webui/src/locales/en-US/home.json +++ b/webui/src/locales/en-US/home.json @@ -20,6 +20,7 @@ "workflows": "Workflows", "normal": "Normal", "abnormal": "Abnormal", + "loadErrorHint": "Stats could not be loaded: {{message}}", "checking": "Checking...", "activeScheduled": "Active Scheduled", "agentCount": "Agents", diff --git a/webui/src/locales/zh-CN/home.json b/webui/src/locales/zh-CN/home.json index b469eefcb..bfa9c42a4 100644 --- a/webui/src/locales/zh-CN/home.json +++ b/webui/src/locales/zh-CN/home.json @@ -20,6 +20,7 @@ "workflows": "工作流", "normal": "正常", "abnormal": "异常", + "loadErrorHint": "首页统计数据加载失败:{{message}}", "checking": "检测中...", "activeScheduled": "启动计划任务", "agentCount": "Agent 数量", diff --git a/webui/src/pages/Home/index.test.tsx b/webui/src/pages/Home/index.test.tsx index f5141e841..cd7af58c3 100644 --- a/webui/src/pages/Home/index.test.tsx +++ b/webui/src/pages/Home/index.test.tsx @@ -4,11 +4,12 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import Home from './index'; -const { createMock, navigateMock, toastErrorMock, useAuthMock } = vi.hoisted(() => ({ +const { createMock, navigateMock, toastErrorMock, useAuthMock, useStatsMock } = vi.hoisted(() => ({ createMock: vi.fn(), navigateMock: vi.fn(), toastErrorMock: vi.fn(), useAuthMock: vi.fn(), + useStatsMock: vi.fn(), })); vi.mock('react-router-dom', async () => { @@ -26,11 +27,7 @@ vi.mock('@/api/session', () => ({ })); vi.mock('@/hooks/useStats', () => ({ - useStats: () => ({ - stats: null, - loading: false, - error: null, - }), + useStats: () => useStatsMock(), })); vi.mock('@/components/common/Toast', () => ({ @@ -45,7 +42,7 @@ vi.mock('@/contexts/AuthContext', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => key, + t: (key: string, options?: { message?: string }) => (options?.message ? `${key}: ${options.message}` : key), i18n: { language: 'zh-CN' }, }), })); @@ -54,6 +51,11 @@ describe('Home create WebUI contract page entry', () => { beforeEach(() => { vi.clearAllMocks(); createMock.mockResolvedValue({ id: 'session-webui-contract-1' }); + useStatsMock.mockReturnValue({ + stats: null, + loading: false, + error: null, + }); useAuthMock.mockReturnValue({ user: { id: 'user-1', @@ -76,7 +78,9 @@ describe('Home create WebUI contract page entry', () => { await user.click(screen.getByRole('button', { name: 'createWebUIContractPage' })); await waitFor(() => { - expect(createMock).toHaveBeenCalledWith({ title: 'createWebUIContractPageSessionTitle' }); + expect(createMock).toHaveBeenCalledWith({ + title: 'createWebUIContractPageSessionTitle', + }); }); expect(navigateMock).toHaveBeenCalledWith( @@ -104,4 +108,21 @@ describe('Home create WebUI contract page entry', () => { expect(screen.queryByRole('button', { name: 'createWebUIContractPage' })).not.toBeInTheDocument(); expect(createMock).not.toHaveBeenCalled(); }); + + it('shows the concrete stats load failure instead of a backend-not-running hint', () => { + useStatsMock.mockReturnValue({ + stats: null, + loading: false, + error: new Error('登录状态已失效或 API Token 缺失,无法加载首页统计数据'), + }); + + render( + + + , + ); + + expect(screen.getByText(/stats.loadErrorHint: 登录状态已失效/)).toBeInTheDocument(); + expect(screen.queryByText(/backend is running/i)).not.toBeInTheDocument(); + }); }); diff --git a/webui/src/pages/Home/index.tsx b/webui/src/pages/Home/index.tsx index 27d4198ae..d35eaa404 100644 --- a/webui/src/pages/Home/index.tsx +++ b/webui/src/pages/Home/index.tsx @@ -205,7 +205,7 @@ export default function Home() {
{t('stats.abnormal')} - Please ensure the {productName} backend is running + {t('stats.loadErrorHint', { message: error.message })}
)} From 7b6faf03add1e111a4c9681334679f1a17478e8c Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 00:31:34 +0800 Subject: [PATCH 48/86] fix: improve slack channel setup --- flocks/channel/base.py | 5 + flocks/channel/builtin/slack/channel.py | 134 ++++++++++++++-- flocks/channel/builtin/slack/inbound.py | 192 ++++++++++++++++++++-- flocks/channel/gateway/manager.py | 23 ++- tests/channel/test_channel.py | 65 ++++++++ tests/channel/test_slack.py | 201 +++++++++++++++++++++++- webui/public/channel-slack.png | Bin 0 -> 2415 bytes webui/src/locales/en-US/channel.json | 16 +- webui/src/locales/zh-CN/channel.json | 16 +- webui/src/pages/Channel/index.tsx | 63 ++++++-- 10 files changed, 662 insertions(+), 53 deletions(-) create mode 100644 webui/public/channel-slack.png diff --git a/flocks/channel/base.py b/flocks/channel/base.py index 67e96148c..ffd79dc4e 100644 --- a/flocks/channel/base.py +++ b/flocks/channel/base.py @@ -39,6 +39,7 @@ class ChannelCapabilities: reactions: bool = False edit: bool = False rich_text: bool = False + self_managed_connection: bool = False @dataclass @@ -86,6 +87,10 @@ class DeliveryResult: retryable: bool = False +class NonRetryableChannelError(RuntimeError): + """Raised when reconnecting cannot fix a channel startup failure.""" + + @dataclass class ChannelStatus: """Runtime health status of a channel connection.""" diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py index bc09b6ec6..200c169cc 100644 --- a/flocks/channel/builtin/slack/channel.py +++ b/flocks/channel/builtin/slack/channel.py @@ -13,6 +13,7 @@ ChatType, DeliveryResult, InboundMessage, + NonRetryableChannelError, OutboundContext, ) from flocks.utils.log import Log @@ -42,6 +43,83 @@ log = Log.create(service="channel.slack") +_PERMANENT_SLACK_ERROR_CODES = { + "invalid_auth", + "not_authed", + "no_auth", + "token_revoked", + "account_inactive", + "not_allowed_token_type", + "missing_scope", +} + + +def _is_token_reference(value: str) -> bool: + return value.startswith("{secret:") or value.startswith("{env:") + + +def _slack_response_get(response: Any, key: str, default: str = "") -> str: + getter = getattr(response, "get", None) + if callable(getter): + return str(getter(key, default) or default) + data = getattr(response, "data", None) + if isinstance(data, dict): + return str(data.get(key) or default) + return default + + +def _slack_error_code(exc: Exception) -> str: + response = getattr(exc, "response", None) + data = getattr(response, "data", None) + if isinstance(data, dict): + return str(data.get("error") or "").strip() + if isinstance(response, dict): + return str(response.get("error") or "").strip() + + message = str(exc).lower().replace("-", "_") + for code in _PERMANENT_SLACK_ERROR_CODES: + if code in message: + return code + if "invalid app token" in message: + return "invalid_auth" + return "" + + +def _is_permanent_slack_error(exc: Exception) -> bool: + return _slack_error_code(exc) in _PERMANENT_SLACK_ERROR_CODES + + +def _format_slack_start_error(exc: Exception, *, phase: str) -> str: + code = _slack_error_code(exc) + detail = f"Slack error={code}" if code else str(exc) + + if phase == "bot_auth": + if code == "missing_scope": + return ( + "Slack Bot Token 权限不足:请在 Slack 开发者管理后台的 OAuth & Permissions " + "确认 Bot Token 具备 Manifest 中的 bot scopes,重新安装 App 后复制 " + "Bot User OAuth Token(xoxb- 开头)。" + ) + return ( + "Slack Bot Token 验证失败:请在 Slack 开发者管理后台的 OAuth & Permissions " + "复制 Bot User OAuth Token(xoxb- 开头),确认 App 已安装到 workspace 后重新保存。" + f" 原始错误:{detail}" + ) + + if code == "missing_scope": + return ( + "Slack App Token 权限不足:请在 Basic Information > App-Level Tokens " + "创建或更新具备 connections:write scope 的 App-Level Token(xapp- 开头)。" + ) + if code in {"invalid_auth", "not_authed", "no_auth", "token_revoked", "account_inactive", "not_allowed_token_type"}: + return ( + "Slack App Token 验证失败:请在 Basic Information > App-Level Tokens " + "复制 App-Level Token(xapp- 开头),并确认它包含 connections:write scope。" + f" 原始错误:{detail}" + ) + return f"Slack Socket Mode 连接失败:{detail}" + + class SlackChannel(ChannelPlugin): """Slack bot channel — Socket Mode inbound and Web API outbound.""" @@ -62,20 +140,30 @@ def meta(self) -> ChannelMeta: def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=[ChatType.DIRECT, ChatType.GROUP, ChatType.CHANNEL], - media=True, + media=False, threads=True, reactions=False, edit=False, rich_text=True, + self_managed_connection=True, ) def validate_config(self, config: dict) -> Optional[str]: if not SLACK_AVAILABLE: return "Missing Python dependency: slack-bolt" - if not resolve_bot_token(config): + bot_token = resolve_bot_token(config) + app_token = resolve_app_token(config) + if not bot_token: return "Missing required config: botToken" - if not resolve_app_token(config): + if not app_token: return "Missing required config: appToken" + if not _is_token_reference(bot_token) and not bot_token.startswith("xoxb-"): + return ( + "Slack Bot Token 必须是 Bot User OAuth Token(xoxb- 开头)," + "不能使用 User Token(xoxp-)或其他 token。" + ) + if not _is_token_reference(app_token) and not app_token.startswith("xapp-"): + return "Slack App Token 必须是 App-Level Token(xapp- 开头)。" return None @property @@ -111,7 +199,7 @@ async def start( if error: self.mark_disconnected(error) log.error("slack.start.invalid_config", {"error": error}) - return + raise NonRetryableChannelError(error) bot_token = resolve_bot_token(config) app_token = resolve_app_token(config) @@ -121,9 +209,25 @@ async def start( self._app = AsyncApp(token=bot_token) abort = abort_event or asyncio.Event() try: - auth = await self._app.client.auth_test() - self._bot_user_id = str(auth.get("user_id") or "") - team_id = str(auth.get("team_id") or "default") + try: + auth = await self._app.client.auth_test() + except Exception as exc: + message = _format_slack_start_error(exc, phase="bot_auth") + self.mark_disconnected(message) + if _is_permanent_slack_error(exc): + raise NonRetryableChannelError(message) from exc + raise RuntimeError(message) from exc + + self._bot_user_id = _slack_response_get(auth, "user_id") + if not _slack_response_get(auth, "bot_id"): + message = ( + "Slack Bot Token 验证失败:当前 token 认证为用户而不是 Bot。" + "请在 Slack 开发者管理后台 OAuth & Permissions 中复制 " + "Bot User OAuth Token(xoxb- 开头),不要使用 xoxp- User Token。" + ) + self.mark_disconnected(message) + raise NonRetryableChannelError(message) + team_id = _slack_response_get(auth, "team_id", "default") self._register_handlers() await self._connect_socket_mode(app_token) @@ -131,9 +235,14 @@ async def start( await abort.wait() except asyncio.CancelledError: raise + except NonRetryableChannelError: + raise except Exception as exc: - self.mark_disconnected(str(exc)) - log.warning("slack.socket.stopped", {"error": str(exc)}) + message = str(exc) + if self.status.connected or self.status.last_error != message: + self.mark_disconnected(message) + log.warning("slack.socket.stopped", {"error": message}) + raise finally: await self.stop() @@ -152,8 +261,11 @@ async def _connect_socket_mode(self, app_token: str) -> None: self.mark_disconnected(message) raise TimeoutError(message) from exc except Exception as exc: - self.mark_disconnected(str(exc)) - raise + message = _format_slack_start_error(exc, phase="socket_mode") + self.mark_disconnected(message) + if _is_permanent_slack_error(exc): + raise NonRetryableChannelError(message) from exc + raise RuntimeError(message) from exc self.mark_connected() async def stop(self) -> None: diff --git a/flocks/channel/builtin/slack/inbound.py b/flocks/channel/builtin/slack/inbound.py index 674ac4697..51ac8f901 100644 --- a/flocks/channel/builtin/slack/inbound.py +++ b/flocks/channel/builtin/slack/inbound.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any, Optional from flocks.channel.base import ChatType, InboundMessage @@ -10,20 +11,189 @@ from .format import strip_bot_mention +def _join_parts(parts: list[str]) -> str: + return "\n\n".join(part for part in (p.strip() for p in parts) if part) + + +def _dedupe_part(part: str, existing_text: str) -> str: + candidate = part.strip() + existing = existing_text.strip() + if not candidate: + return "" + if not existing: + return candidate + if candidate == existing or candidate in existing: + return "" + if candidate.startswith(existing): + return candidate[len(existing):].strip() + return candidate + + +def _slack_text_object(value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + text = value.get("text") + if isinstance(text, str): + return text.strip() + return "" + + +def _rich_text_element_text(element: Any) -> str: + if not isinstance(element, dict): + return "" + + element_type = str(element.get("type") or "") + if element_type == "text": + return str(element.get("text") or "") + if element_type == "user": + return f"<@{element.get('user_id')}>" + if element_type == "channel": + return f"<#{element.get('channel_id')}>" + if element_type == "usergroup": + return f"" + if element_type == "broadcast": + return f"" + if element_type == "emoji": + return f":{element.get('name')}:" + if element_type == "link": + text = str(element.get("text") or "").strip() + url = str(element.get("url") or "").strip() + return f"{text} ({url})" if text and url else text or url + if element_type == "date": + return str(element.get("fallback") or element.get("timestamp") or "") + + children = element.get("elements") + if isinstance(children, list): + child_text = "".join(_rich_text_element_text(child) for child in children).strip() + if element_type == "rich_text_preformatted" and child_text: + return f"```\n{child_text}\n```" + if element_type == "rich_text_quote" and child_text: + return "\n".join(f"> {line}" for line in child_text.splitlines()) + return child_text + return "" + + +def _extract_blocks_text(blocks: Any) -> str: + if not isinstance(blocks, list): + return "" + + parts: list[str] = [] + for block in blocks: + if not isinstance(block, dict): + continue + + block_type = str(block.get("type") or "") + if block_type == "rich_text": + elements = block.get("elements") + if isinstance(elements, list): + parts.append(_join_parts([ + _rich_text_element_text(element) + for element in elements + ])) + continue + + for key in ("text", "title"): + value = _slack_text_object(block.get(key)) + if value: + parts.append(value) + + fields = block.get("fields") + if isinstance(fields, list): + parts.extend(_slack_text_object(field) for field in fields) + + elements = block.get("elements") + if isinstance(elements, list): + parts.extend(_slack_text_object(element) for element in elements) + + return _join_parts(parts) + + +def _extract_attachments_text(attachments: Any) -> str: + if not isinstance(attachments, list): + return "" + + parts: list[str] = [] + for attachment in attachments: + if not isinstance(attachment, dict): + continue + + for key in ("pretext", "title", "text", "fallback"): + value = _slack_text_object(attachment.get(key)) + if value: + parts.append(value) + + fields = attachment.get("fields") + if isinstance(fields, list): + for field in fields: + if not isinstance(field, dict): + continue + title = _slack_text_object(field.get("title")) + value = _slack_text_object(field.get("value")) + if title and value: + parts.append(f"{title}: {value}") + elif value: + parts.append(value) + + blocks = _extract_blocks_text(attachment.get("blocks")) + if blocks: + parts.append(blocks) + + return _join_parts(parts) + + +def _extract_files_text(files: Any) -> str: + if not isinstance(files, list) or not files: + return "" + + names = [ + str(item.get("name") or item.get("title") or item.get("id") or "file") + for item in files + if isinstance(item, dict) + ] + if names: + return "[Slack files: " + ", ".join(names) + "]" + return "" + + def extract_text(event: dict[str, Any]) -> str: + parts: list[str] = [] + combined = "" text = event.get("text") if isinstance(text, str) and text.strip(): - return text - - files = event.get("files") - if isinstance(files, list) and files: - names = [ - str(item.get("name") or item.get("title") or item.get("id") or "file") - for item in files - if isinstance(item, dict) - ] - if names: - return "[Slack files: " + ", ".join(names) + "]" + combined = text.strip() + parts.append(combined) + + blocks = _dedupe_part(_extract_blocks_text(event.get("blocks")), combined) + if blocks: + parts.append(blocks) + combined = _join_parts(parts) + + attachments = _dedupe_part(_extract_attachments_text(event.get("attachments")), combined) + if attachments: + parts.append(attachments) + combined = _join_parts(parts) + + files = _dedupe_part(_extract_files_text(event.get("files")), combined) + if files: + parts.append(files) + + rendered = _join_parts(parts) + if rendered: + return rendered + + if event.get("blocks") or event.get("attachments"): + try: + return "[Slack structured message]\n" + json.dumps( + { + "blocks": event.get("blocks"), + "attachments": event.get("attachments"), + }, + ensure_ascii=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + return "[Slack structured message]" return "" diff --git a/flocks/channel/gateway/manager.py b/flocks/channel/gateway/manager.py index 2d268560a..fa1eed803 100644 --- a/flocks/channel/gateway/manager.py +++ b/flocks/channel/gateway/manager.py @@ -12,7 +12,7 @@ import time from typing import Callable, Optional -from flocks.channel.base import ChannelPlugin, ChannelStatus +from flocks.channel.base import ChannelPlugin, ChannelStatus, NonRetryableChannelError from flocks.channel.inbound.dispatcher import InboundDispatcher from flocks.channel.registry import ChannelRegistry, default_registry from flocks.utils.log import Log @@ -281,8 +281,11 @@ async def _run_with_reconnect( await abort_event.wait() break - # Long-running start() (WebSocket / polling mode) - await self._mark_connected(plugin, channel_id) + # Long-running start() (WebSocket / polling mode). Some active + # channels only know they are connected after a platform + # handshake; let those plugins publish their own status. + if not plugin.capabilities().self_managed_connection: + await self._mark_connected(plugin, channel_id) await start_task if abort_event.is_set(): @@ -299,6 +302,14 @@ async def _run_with_reconnect( except asyncio.CancelledError: break + except NonRetryableChannelError as e: + self._record_error(plugin, channel_id, e) + log.error("gateway.non_retryable", { + "channel": channel_id, + "error": str(e), + "attempt": attempt + 1, + }) + break except Exception as e: self._record_error(plugin, channel_id, e) @@ -361,14 +372,16 @@ def _record_error( channel_id: str, error: Exception, ) -> None: - plugin.mark_disconnected(error=str(error)) + message = str(error) + if plugin.status.connected or plugin.status.last_error != message: + plugin.mark_disconnected(error=message) try: from flocks.channel.events import ChannelDisconnected from flocks.bus.bus import Bus asyncio.ensure_future( Bus.publish(ChannelDisconnected, { "channel_id": channel_id, - "reason": str(error), + "reason": message, }) ) except Exception: diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index ca43b75fb..8a75758aa 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -32,6 +32,7 @@ ChatType, DeliveryResult, InboundMessage, + NonRetryableChannelError, OutboundContext, ) from flocks.config.config import ChannelAccountConfig, ChannelConfig, ConfigInfo @@ -1407,6 +1408,70 @@ def test_record_error_updates_status(self): assert plugin.status.last_error == "fail" assert plugin.status.error_count == 1 + async def test_run_with_reconnect_stops_on_non_retryable_error(self): + from flocks.channel.gateway.manager import GatewayManager + + manager = GatewayManager() + plugin = _StubChannel() + attempts = 0 + abort_event = asyncio.Event() + + async def _fail_start(config, on_message, abort_event=None): + nonlocal attempts + attempts += 1 + raise NonRetryableChannelError("bad credentials") + + plugin.start = _fail_start # type: ignore[method-assign] + + await manager._run_with_reconnect( + channel_id="stub", + plugin=plugin, + config={}, + on_message=AsyncMock(), + abort_event=abort_event, + ) + + assert attempts == 1 + assert plugin.status.connected is False + assert plugin.status.last_error == "bad credentials" + assert plugin.status.error_count == 1 + + async def test_run_with_reconnect_does_not_pre_mark_self_managed_connection(self): + from flocks.channel.gateway.manager import GatewayManager + + class _SelfManagedChannel(_StubChannel): + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=[ChatType.DIRECT], + self_managed_connection=True, + ) + + manager = GatewayManager() + plugin = _SelfManagedChannel() + started = asyncio.Event() + abort_event = asyncio.Event() + + async def _start(config, on_message, abort_event=None): + started.set() + await asyncio.sleep(0.75) + abort_event.set() + raise RuntimeError("handshake failed") + + plugin.start = _start # type: ignore[method-assign] + + await manager._run_with_reconnect( + channel_id="stub", + plugin=plugin, + config={}, + on_message=AsyncMock(), + abort_event=abort_event, + ) + + assert started.is_set() + assert plugin.status.connected is False + assert plugin.status.last_error == "handshake failed" + assert plugin.status.error_count == 1 + async def test_stop_all_drains_cancelled_tasks(self, monkeypatch): from flocks.channel.gateway.manager import GatewayManager from flocks.channel.registry import ChannelRegistry diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py index 2b12fbff0..35f46798c 100644 --- a/tests/channel/test_slack.py +++ b/tests/channel/test_slack.py @@ -6,7 +6,7 @@ import pytest -from flocks.channel.base import ChatType, OutboundContext +from flocks.channel.base import ChatType, NonRetryableChannelError, OutboundContext import flocks.channel.builtin.slack.channel as slack_mod from flocks.channel.builtin.slack.channel import SlackChannel from flocks.channel.builtin.slack.format import markdown_to_slack_mrkdwn @@ -19,6 +19,9 @@ def test_plugin_exports_slack_channel(): assert plugin.meta().id == "slack" assert plugin.meta().label == "Slack" assert "sl" in plugin.meta().aliases + assert plugin.capabilities().media is False + assert plugin.capabilities().rich_text is True + assert plugin.capabilities().self_managed_connection is True def test_slack_manifest_matches_socket_mode_setup_needs(): @@ -59,6 +62,21 @@ def test_validate_config_requires_tokens(monkeypatch): assert plugin.validate_config({"botToken": "xoxb-1", "appToken": "xapp-1"}) is None +def test_validate_config_rejects_non_slack_token_prefixes(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + assert "xoxb-" in ( + plugin.validate_config({"botToken": "xoxp-user", "appToken": "xapp-1"}) or "" + ) + assert "xapp-" in ( + plugin.validate_config({"botToken": "xoxb-1", "appToken": "xoxb-wrong"}) or "" + ) + assert plugin.validate_config( + {"botToken": "{secret:slack_bot}", "appToken": "{secret:slack_app}"} + ) is None + + def test_validate_config_reports_missing_dependency(monkeypatch): monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", False) plugin = SlackChannel() @@ -123,6 +141,117 @@ def test_build_inbound_group_mention_strips_bot_mention(): assert msg.mention_text == "summarize this" +def test_build_inbound_extracts_rich_text_blocks(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.22", + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "UBOT"}, + {"type": "text", "text": " summarize "}, + {"type": "link", "text": "this", "url": "https://example.com"}, + ], + } + ], + } + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "<@UBOT> summarize this (https://example.com)" in msg.text + assert msg.mentioned is True + assert "summarize this" in msg.mention_text + + +def test_build_inbound_merges_blocks_when_plain_text_exists(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.24", + "text": "<@UBOT> please inspect", + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "UBOT"}, + {"type": "text", "text": " please inspect"}, + ], + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "text", "text": "quoted outage context"}, + ], + } + ], + }, + ], + } + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "please inspect" in msg.text + assert "quoted outage context" in msg.text + assert "quoted outage context" in msg.mention_text + + +def test_build_inbound_appends_attachments_and_file_names(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.23", + "text": "<@UBOT> review alert", + "attachments": [ + { + "title": "Alert context", + "fields": [ + {"title": "Severity", "value": "high"}, + ], + } + ], + "files": [ + {"name": "screenshot.png"}, + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert "review alert" in msg.text + assert "Alert context" in msg.text + assert "Severity: high" in msg.text + assert "[Slack files: screenshot.png]" in msg.text + + def test_build_inbound_group_dm_uses_group_chat_type(): msg = build_inbound_message( { @@ -329,11 +458,38 @@ async def connect_async(self): monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) - with pytest.raises(RuntimeError): + with pytest.raises(NonRetryableChannelError): await plugin._connect_socket_mode("xapp-bad") assert plugin.status.connected is False - assert plugin.status.last_error == "invalid app token" + assert "Slack App Token" in (plugin.status.last_error or "") + assert "xapp-" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_connect_socket_mode_slack_response_error_is_actionable(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeSlackError(Exception): + def __init__(self): + super().__init__("Slack API error") + self.response = SimpleNamespace(data={"error": "missing_scope"}) + + class FakeHandler: + def __init__(self, app, token): + pass + + async def connect_async(self): + raise FakeSlackError() + + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(NonRetryableChannelError): + await plugin._connect_socket_mode("xapp-missing-scope") + + assert "connections:write" in (plugin.status.last_error or "") @pytest.mark.asyncio @@ -358,6 +514,45 @@ async def connect_async(self): assert "timed out" in (plugin.status.last_error or "") +@pytest.mark.asyncio +async def test_start_invalid_config_raises_non_retryable(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + plugin = SlackChannel() + + with pytest.raises(NonRetryableChannelError): + await plugin.start({}, AsyncMock()) + + assert plugin.status.connected is False + assert "botToken" in (plugin.status.last_error or "") + + +@pytest.mark.asyncio +async def test_start_rejects_user_token_auth_identity(monkeypatch): + monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) + + class FakeClient: + async def auth_test(self): + return { + "user_id": "U_HUMAN", + "team_id": "T1", + } + + class FakeApp: + def __init__(self, token): + self.token = token + self.client = FakeClient() + + monkeypatch.setattr(slack_mod, "AsyncApp", FakeApp) + plugin = SlackChannel() + + with pytest.raises(NonRetryableChannelError): + await plugin.start({"botToken": "xoxb-looks-valid", "appToken": "xapp-1"}, AsyncMock()) + + assert plugin.status.connected is False + assert "Bot User OAuth Token" in (plugin.status.last_error or "") + assert "xoxp-" in (plugin.status.last_error or "") + + @pytest.mark.asyncio async def test_handle_slack_event_dispatches_inbound_message(): plugin = SlackChannel() diff --git a/webui/public/channel-slack.png b/webui/public/channel-slack.png new file mode 100644 index 0000000000000000000000000000000000000000..1cc40f368d2899adfaabb7c0eab3cb3c44333dd5 GIT binary patch literal 2415 zcmV-#36S=QP)pW{N$=ziyw|l$$oY~!(@7zD^Z=U^r_IcUg?|F#Jm2in-bS5Iw+lWX_ zn8>}D$lyrMM7`8YM5N7v5WUT%cC`Xb#zfwRL}?%k8VwUsL*8x$fJw>bi*hCF24cHN zl-+RYpyivq<+A=ccJHrlGEhI|)f)LGd(FvDzW3WwyL}?QR;9=bmY*Qg3HPZ1v z%DN%gzWUUa1&shZ%K<>@@{jrRW)*-l5dmh^`oML#NmVx= z_d!hAy_b5-@+;Wgk5`I$d=Rsc915#XAm-jwFY^tGyHs1B(^xDfQh z<0Vg~G(V<=rB6X~sLkF0I8lO}n$&N8qNX2jfsl|qghT`pmR01XWqwS*I<#6Du51fn zt3Ra4{FvTo4JO6f=PA-;eoVzJrS=8z!+O6i0C&_S4;||&pVtN8g`@fQ1#op$vn~Kv zx;@IODHaD22^JJTstW*}Gi(Wfj{jfKVSY?LG%|K~qlhl^V~PmaegLSxTIJCVpc#q7 zaXR^6MgW2WZ%+X8PP91ySOR#nHRu3f2_Pg$Fs-`90l-r3Ac85C+UEiXfPtzAAhEePQhWkfRpgaib88u!B5%JuN@fyb49|3v9CkYD{O{I}Ju zBm$WLNT-Zwq-GrVsaC_x0#Mso4~t5lO?mHH_j+jKI;;iIz=?3J32(e>99>oEfg@u; zFatpK#S=;~Kb`xZF8c-exh`t}&|znGU908hyM{zl7j&6wCV);p1V2BxNK=g8R{hQz z0PkLmFf-pZqz%4+sQ|X0DbN+Cvc1}T01uvSXAWRaU8|`8o-JRdE6#@6x6B7{J%h2| zl_0ul%d9Wq4nCke&eDoJ^8rkD>#t%NskXS1WH=)LVT|MU7U_*c=M7u*fGZMu09}jc z>MY7XRy64RqIP?*J}4VU8W_jUNQmjTFplD0c>m6x0%XF zz2fajY8U-l)nnRzPmy5&D%#xU1E5|1R*l;UjnoZCTa?;CV!Ofuf@jK>XfkX`#d3%U zf*}BC57d*E(Dfx;8^*dfRb`^dZV!|P)blp=U1woI@ndNjM)NuiJxxh5^Z=k6eZ677 zwYeagm!TVFt$g##Q_y%0DOPr6XGJ}L&J9g`>N27hHS&U{Q!#3y+s|okC*5u6bWWg$H`GLvZbqK4 znGgjT{5h5bfJ`I+5d^p^qAslAGz!XWY#l8Dg+SoCj1i7x5dc8~^nMla3;zV%_nrf8 z>XMYgwVsrQqk;Z+g*lg zu;41_`}iJ+Hf#f&@Eu_p0Oaig?!i~aH9Z{wsG$|OAH0zI{yU!sbkaL+yGTb3^#AMI zhMTUY{tt41bT?QGz}E%boOPPaR|Rmk4Y-_@>BYDu4{%+xYP+P4<1kq870b*Y?h(7L z0n&5MIsjbm>zd6U696K>-}9oT82r5TfFsH4cmZ=TP&mzU^JBWacNSnBCoZ)EC^@0a z{ILL_U7zTR!*3}`YFBUlo6O9QDSE!Zd;t84ZH58hAALg~fcdW`wd?Y}xy%7vI`Dw` z0LGmxH2~n!9s3hC{g=uLY$TQhuwxQ)00XA zo>|kE0XldhIgY^qF#Qs4KLUe0r&wUXuXeq2Y+m3ChH5 z!s=*n=Va*n@J{GGwHa`R|IvrB^#E#ky0u#q0pLU+M%4nS`0fc(5wj2g&SE(LhywsJ z1|Sk3J?B*=1{w`7B{~57BLD=hGrbkd`?tdXYVwsDF>B*(2>^yPu=@s({D+vEZ(9JM zEG4IarIhIan92qKRd2oagLMvosm1}gynmhpfMW=NZey`009ulq zwlphBl9}dnKA!++b#2yaUFP%OSsq_qBeL!^^x(T!+q4Hz01N-DuLtD5o)oL^qq}qg z=qtLas zKusOM&Hi!1GUSQVxM=I$a|Y zh@O8BE|tuOfq&entdQvMrGpQ_rx*UIY~*78yjcf;*i#Mw;s5|U%K<>x191MJGhrh{ zWZC(X=7c2xsgVNM%(@}irb43h7ndMJZ$qNg&$=<#W}==8LNv!kFHGdEtQ&-FCPd_S zT(JuPCSg%_vu+f&i$wV_^?Vq>Fa#oRafTz=G*NHqo1bL<=!=d^NR&3w8QNK*%0?(k hy`-KFkNKmn{{s=RSGaFb)iVG9002ovPDHLkV1jegb`Ssn literal 0 HcmV?d00001 diff --git a/webui/src/locales/en-US/channel.json b/webui/src/locales/en-US/channel.json index 0106e334a..8e17ae116 100644 --- a/webui/src/locales/en-US/channel.json +++ b/webui/src/locales/en-US/channel.json @@ -41,7 +41,9 @@ "lastMessage": "Last Message", "reconnects": "Reconnects", "totalErrors": "Total Errors", - "running": "Running" + "running": "Running", + "failureReason": "Failure Reason", + "retryAfterFix": "Fix the configuration based on this reason, save it, then click “Restart Connection” to reconnect." }, "card": { "running": "Running", @@ -241,17 +243,19 @@ "setup": "Slack App Setup", "setupDesc": "Create the Slack app from a manifest first, then paste the generated tokens below.", "downloadManifest": "Download Slack Manifest", + "downloadManifestDesc": "Get an import-ready Slack app configuration file", "downloadManifestFailed": "Failed to download Slack Manifest", - "openSlackApps": "Open Slack Apps", - "stepCreateApp": "Open Slack Apps, choose Create New App, then select From an app manifest and paste the downloaded manifest.", + "openSlackApps": "Open Slack Developer Console", + "openSlackAppsDesc": "Create the app, review permissions, and copy tokens", + "stepCreateApp": "Open the Slack Developer Console, choose Create New App, then select From an app manifest and paste the downloaded manifest.", "stepInstall": "Save the manifest, install the app to your workspace, and invite the bot to channels that should talk to Flocks.", "stepTokens": "Copy the Bot User OAuth Token from OAuth & Permissions and create an App-Level Token with connections:write under Basic Information.", "stepSaveEnable": "Paste both tokens here, save the config, then enable or restart the Slack channel.", "manifestHint": "The manifest enables Socket Mode and subscribes to app_mention plus message events for DMs, group DMs, public channels, and private channels.", "credentials": "Slack App Credentials", - "credentialsDesc": "Create a Slack app with Socket Mode enabled. Required events: message.im, message.mpim, message.channels, message.groups, app_mention. Required scopes include connections:write on the app token and chat:write, app_mentions:read, channels:history, groups:history, im:history, mpim:history on the bot token.", - "botTokenHint": "Bot User OAuth Token from OAuth & Permissions (starts with xoxb-)", - "appTokenHint": "App-level token with connections:write scope (starts with xapp-)", + "credentialsDesc": "Fill these in after creating and installing a Slack app with Socket Mode enabled. The Bot Token is under OAuth & Permissions; the App Token is under Basic Information > App-Level Tokens.", + "botTokenHint": "In the app’s OAuth & Permissions page, Install/Reinstall to Workspace, then copy the Bot User OAuth Token (starts with xoxb-).", + "appTokenHint": "In Basic Information > App-Level Tokens, click Generate Token and Scopes, add connections:write, then copy the App-Level Token (starts with xapp-).", "homeChannel": "Home Channel", "homeChannelHint": "Default Slack channel ID for proactive sends, for example C0123456789", "homeChannelName": "Home Channel Name", diff --git a/webui/src/locales/zh-CN/channel.json b/webui/src/locales/zh-CN/channel.json index 8e8fad50e..2de046cd3 100644 --- a/webui/src/locales/zh-CN/channel.json +++ b/webui/src/locales/zh-CN/channel.json @@ -41,7 +41,9 @@ "lastMessage": "最近消息", "reconnects": "重连次数", "totalErrors": "累计错误", - "running": "运行" + "running": "运行", + "failureReason": "失败原因", + "retryAfterFix": "请根据失败原因修正配置,保存后点击“重启连接”重新建立连接。" }, "card": { "running": "运行中", @@ -243,17 +245,19 @@ "setup": "Slack 应用配置", "setupDesc": "先用 Manifest 创建 Slack App,再把生成的 token 填到下方。", "downloadManifest": "下载 Slack Manifest", + "downloadManifestDesc": "获取可直接导入的应用配置文件", "downloadManifestFailed": "下载 Slack Manifest 失败", - "openSlackApps": "打开 Slack Apps", - "stepCreateApp": "打开 Slack Apps,选择 Create New App,再选择 From an app manifest,粘贴下载的 Manifest。", + "openSlackApps": "打开 slack 开发者管理后台", + "openSlackAppsDesc": "进入 Slack App 创建、权限和 Token 页面", + "stepCreateApp": "打开 slack 开发者管理后台,选择 Create New App,再选择 From an app manifest,粘贴下载的 Manifest。", "stepInstall": "保存 Manifest,将 App 安装到 workspace,并把 Bot 邀请进需要接入 Flocks 的频道。", "stepTokens": "从 OAuth & Permissions 复制 Bot User OAuth Token;在 Basic Information 创建带 connections:write 的 App-Level Token。", "stepSaveEnable": "把两个 token 填到这里,保存配置,然后启用或重启 Slack 通道。", "manifestHint": "Manifest 会启用 Socket Mode,并订阅 app_mention 以及私信、多人私信、公开频道、私有频道的 message 事件。", "credentials": "Slack 应用凭证", - "credentialsDesc": "创建启用 Socket Mode 的 Slack App。必需事件:message.im、message.mpim、message.channels、message.groups、app_mention。必需权限包括 App Token 的 connections:write,以及 Bot Token 的 chat:write、app_mentions:read、channels:history、groups:history、im:history、mpim:history。", - "botTokenHint": "OAuth & Permissions 页面中的 Bot User OAuth Token(xoxb- 开头)", - "appTokenHint": "具备 connections:write 权限的 App-Level Token(xapp- 开头)", + "credentialsDesc": "创建并安装启用 Socket Mode 的 Slack App 后填写。Bot Token 来自 OAuth & Permissions;App Token 来自 Basic Information 的 App-Level Tokens。", + "botTokenHint": "在 Slack 开发者管理后台进入应用的 OAuth & Permissions,先 Install/Reinstall to Workspace,然后复制 Bot User OAuth Token(xoxb- 开头)。", + "appTokenHint": "在应用的 Basic Information > App-Level Tokens 中点击 Generate Token and Scopes,添加 connections:write 后复制 App-Level Token(xapp- 开头)。", "homeChannel": "默认频道", "homeChannelHint": "主动发送消息时使用的默认 Slack 频道 ID,例如 C0123456789", "homeChannelName": "默认频道名称", diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index e83bf6ed4..14a9a1653 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -20,6 +20,7 @@ import { RefreshCw, Loader2, RotateCcw, + ExternalLink, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; @@ -772,6 +773,7 @@ const CHANNEL_ICON_SRC: Record = { email: '/channel-email.png', weixin: '/channel-weixin.png', whatsapp: '/channel-whatsapp.png', + slack: '/channel-slack.png', }; const FEISHU_GUIDE_PDF_URL = '/feishu-bot-guide.pdf'; @@ -950,8 +952,8 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa )} {status?.last_error && ( -

- {status.last_error} +

+ {t('connection.failureReason')}

)} @@ -966,6 +968,23 @@ function ConnectionStatusPanel({ status, config, channelId }: ConnectionStatusPa + {status?.last_error && ( +
+
+ + {t('connection.failureReason')} +
+

+ {status.last_error} +

+ {!isConnected && ( +

+ {t('connection.retryAfterFix')} +

+ )} +
+ )} + {/* Metrics row */} {isEnabled && (
@@ -998,9 +1017,12 @@ function ChannelCard({ meta, config, status, isSelected, onClick }: ChannelCardP // status key present = gateway is tracking this channel const isInGateway = status !== undefined; const isConnected = status?.connected === true; + const hasError = Boolean(status?.last_error); const dotColor = isConnected ? 'bg-green-500' + : hasError + ? 'bg-red-500' : isInGateway ? 'bg-amber-400' : isEnabled @@ -1009,6 +1031,8 @@ function ChannelCard({ meta, config, status, isSelected, onClick }: ChannelCardP const subText = isConnected ? t('card.running') + : hasError + ? t('connection.error') : isInGateway ? t('card.connecting') : isEnabled @@ -1766,19 +1790,35 @@ function SlackPanel({ - - {t('slack.openSlackApps')} + + + + + + {t('slack.openSlackApps')} + {t('slack.openSlackAppsDesc')} + + +
    @@ -1799,9 +1839,10 @@ function SlackPanel({ {t('slack.stepSaveEnable')}
-
- {t('slack.manifestHint')} -
+

+ * + {t('slack.manifestHint')} +

From 011df9f55344d463711ba1ce5a22a4e67ac089d7 Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 10:20:49 +0800 Subject: [PATCH 49/86] fix: address slack review findings --- flocks/channel/builtin/slack/channel.py | 44 ++++++++++ flocks/channel/builtin/slack/format.py | 2 - flocks/config/config.py | 18 ++++- flocks/server/routes/config.py | 48 +++-------- tests/channel/test_slack.py | 85 ++++++++++++++++++++ tests/server/routes/test_remaining_routes.py | 51 +++++++++++- 6 files changed, 204 insertions(+), 44 deletions(-) diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py index 200c169cc..ef0801366 100644 --- a/flocks/channel/builtin/slack/channel.py +++ b/flocks/channel/builtin/slack/channel.py @@ -16,6 +16,7 @@ NonRetryableChannelError, OutboundContext, ) +from flocks.storage.storage import Storage from flocks.utils.log import Log from .config import ( @@ -32,16 +33,20 @@ try: from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + from slack_sdk.web.async_client import AsyncWebClient SLACK_AVAILABLE = True except ImportError: # pragma: no cover - exercised by validate_config tests AsyncApp = None # type: ignore[assignment] AsyncSocketModeHandler = None # type: ignore[assignment] + AsyncWebClient = None # type: ignore[assignment] SLACK_AVAILABLE = False log = Log.create(service="channel.slack") +_KNOWN_THREADS_STORAGE_KEY = "channel:slack:known_threads" + _PERMANENT_SLACK_ERROR_CODES = { "invalid_auth", @@ -228,6 +233,7 @@ async def start( self.mark_disconnected(message) raise NonRetryableChannelError(message) team_id = _slack_response_get(auth, "team_id", "default") + await self._load_known_threads() self._register_handlers() await self._connect_socket_mode(app_token) @@ -249,6 +255,7 @@ async def start( async def _connect_socket_mode(self, app_token: str) -> None: """Connect Socket Mode once and mark connected only after success.""" assert AsyncSocketModeHandler is not None + await self._verify_app_token(app_token) self._handler = AsyncSocketModeHandler(self._app, app_token) timeout_seconds = float(self._config.get("socketConnectTimeoutSeconds") or 15.0) try: @@ -268,6 +275,19 @@ async def _connect_socket_mode(self, app_token: str) -> None: raise RuntimeError(message) from exc self.mark_connected() + async def _verify_app_token(self, app_token: str) -> None: + """Validate the app-level token once before Socket Mode's retry loop.""" + assert AsyncWebClient is not None + client = AsyncWebClient(token=app_token) + try: + await client.apps_connections_open() + except Exception as exc: + message = _format_slack_start_error(exc, phase="socket_mode") + self.mark_disconnected(message) + if _is_permanent_slack_error(exc): + raise NonRetryableChannelError(message) from exc + raise RuntimeError(message) from exc + async def stop(self) -> None: handler = self._handler self._handler = None @@ -327,6 +347,7 @@ async def send_text(self, ctx: OutboundContext) -> DeliveryResult: self._remember_thread(message_id) if thread_ts: self._remember_thread(thread_ts) + await self._persist_known_threads() self.record_message() return DeliveryResult( channel_id="slack", @@ -386,3 +407,26 @@ def _remember_thread(self, thread_ts: str) -> None: if len(self._known_thread_ids) > self._known_thread_ids_max: while len(self._known_thread_ids) > self._known_thread_ids_max // 2: self._known_thread_ids.popitem(last=False) + + async def _load_known_threads(self) -> None: + try: + stored = await Storage.get(_KNOWN_THREADS_STORAGE_KEY) + except Exception as exc: + log.warning("slack.known_threads.load_failed", {"error": str(exc)}) + return + if not isinstance(stored, list): + return + for thread_ts in stored[-self._known_thread_ids_max:]: + if isinstance(thread_ts, str) and thread_ts: + self._known_thread_ids[thread_ts] = None + while len(self._known_thread_ids) > self._known_thread_ids_max: + self._known_thread_ids.popitem(last=False) + + async def _persist_known_threads(self) -> None: + try: + await Storage.set( + _KNOWN_THREADS_STORAGE_KEY, + list(self._known_thread_ids.keys()), + ) + except Exception as exc: + log.warning("slack.known_threads.persist_failed", {"error": str(exc)}) diff --git a/flocks/channel/builtin/slack/format.py b/flocks/channel/builtin/slack/format.py index 7c6c77c59..c8834b620 100644 --- a/flocks/channel/builtin/slack/format.py +++ b/flocks/channel/builtin/slack/format.py @@ -2,7 +2,6 @@ from __future__ import annotations -import html import re @@ -23,7 +22,6 @@ def protect(match: re.Match[str]) -> str: result = re.sub(r"```.*?```", protect, text, flags=re.DOTALL) result = re.sub(r"`[^`\n]+`", protect, result) - result = html.unescape(result) result = _LINK_RE.sub(r"<\2|\1>", result) result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result, flags=re.DOTALL) result = re.sub(r"__([^_\n]+)__", r"*\1*", result) diff --git a/flocks/config/config.py b/flocks/config/config.py index 9e5fc41fe..d69d289bc 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -1372,7 +1372,13 @@ async def resolve_default_llm(cls) -> Optional[Dict[str, str]]: return None @classmethod - async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> None: + async def update( + cls, + config: ConfigInfo, + project_dir: Optional[Path] = None, + *, + channel_allow_from_deletions: Optional[set[str]] = None, + ) -> None: """ Update configuration @@ -1380,6 +1386,9 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> config: New configuration project_dir: Deprecated and ignored. Config is always written to the unified user config directory. + channel_allow_from_deletions: Channel IDs whose persisted + allowFrom field should be removed after a successful full + config validation and merge. """ _ = project_dir @@ -1396,6 +1405,13 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> # Write config_data = merged.model_dump(by_alias=True, exclude_none=True, mode="json") + if channel_allow_from_deletions: + channels = config_data.get("channels") + if isinstance(channels, dict): + for channel_id in channel_allow_from_deletions: + channel_cfg = channels.get(channel_id) + if isinstance(channel_cfg, dict): + channel_cfg.pop("allowFrom", None) config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8") # Clear cache diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 64f920891..a30a62ba4 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -16,7 +16,6 @@ } """ -import json import re import xml.etree.ElementTree as ET from pathlib import Path @@ -36,49 +35,19 @@ log = Log.create(service="routes.config") -def _apply_channel_allow_from_deletions(config_data: Dict[str, Any]) -> None: - """Remove persisted channel allowFrom when PATCH explicitly sends null.""" +def _channel_allow_from_deletion_ids(config_data: Dict[str, Any]) -> set[str]: + """Return channel IDs whose PATCH explicitly removes allowFrom.""" channels = config_data.get("channels") if not isinstance(channels, dict): - return + return set() - channel_ids = [ + return { channel_id for channel_id, channel_cfg in channels.items() if isinstance(channel_cfg, dict) and "allowFrom" in channel_cfg and channel_cfg.get("allowFrom") is None - ] - if not channel_ids: - return - - config_file = Config.get_config_file() - if not config_file.exists(): - return - - try: - raw_config = json.loads(config_file.read_text(encoding="utf-8")) - except Exception as exc: - log.warning("config.channel_allow_from_delete_failed", {"error": str(exc)}) - return - - raw_channels = raw_config.get("channels") - if not isinstance(raw_channels, dict): - return - - changed = False - for channel_id in channel_ids: - raw_channel = raw_channels.get(channel_id) - if isinstance(raw_channel, dict) and "allowFrom" in raw_channel: - del raw_channel["allowFrom"] - changed = True - - if changed: - config_file.parent.mkdir(parents=True, exist_ok=True) - config_file.write_text( - json.dumps(raw_config, indent=2, ensure_ascii=False), - encoding="utf-8", - ) + } def _build_model_from_config( @@ -600,7 +569,7 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: flocks.json, so that plaintext secrets never land in that file. """ try: - _apply_channel_allow_from_deletions(config_data) + channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data) # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): @@ -611,7 +580,10 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: config = ConfigInfoModel.model_validate(config_data) # Update project config - await Config.update(config) + await Config.update( + config, + channel_allow_from_deletions=channel_allow_from_deletions, + ) # Clear cache to reload Config.clear_cache() diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py index 35f46798c..c6261c843 100644 --- a/tests/channel/test_slack.py +++ b/tests/channel/test_slack.py @@ -95,6 +95,14 @@ def test_markdown_to_slack_mrkdwn_preserves_code_and_links(): assert "```py\n**not bold**\n```" in rendered +def test_markdown_to_slack_mrkdwn_keeps_escaped_special_mentions(): + rendered = markdown_to_slack_mrkdwn("safe <!channel> and &") + + assert "<!channel>" in rendered + assert "" not in rendered + assert "&" in rendered + + def test_build_inbound_direct_message(): msg = build_inbound_message( { @@ -417,11 +425,53 @@ async def test_send_text_posts_thread_reply_and_remembers_thread(): assert "172.1" in plugin._known_thread_ids +@pytest.mark.asyncio +async def test_known_thread_roots_are_persisted_and_restored(monkeypatch): + stored: dict[str, list[str]] = {} + + class FakeStorage: + @staticmethod + async def get(key): + return stored.get(key) + + @staticmethod + async def set(key, value, value_type="json"): + stored[key] = value + + monkeypatch.setattr(slack_mod, "Storage", FakeStorage) + + plugin = SlackChannel() + plugin._remember_thread("171.2") + await plugin._persist_known_threads() + + restarted = SlackChannel() + await restarted._load_known_threads() + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.3", + "thread_ts": "171.2", + "parent_user_id": "U123", + "text": "continue after process restart", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(restarted._known_thread_ids.keys()), + ) + + assert msg is not None + assert msg.mentioned is True + assert msg.mention_text == "continue after process restart" + + @pytest.mark.asyncio async def test_connect_socket_mode_marks_connected_after_connect(monkeypatch): plugin = SlackChannel() plugin._config = {"socketConnectTimeoutSeconds": 1} plugin._app = SimpleNamespace() + plugin._verify_app_token = AsyncMock() class FakeHandler: def __init__(self, app, token): @@ -448,6 +498,7 @@ async def test_connect_socket_mode_failure_marks_disconnected(monkeypatch): plugin = SlackChannel() plugin._config = {"socketConnectTimeoutSeconds": 1} plugin._app = SimpleNamespace() + plugin._verify_app_token = AsyncMock() class FakeHandler: def __init__(self, app, token): @@ -471,6 +522,7 @@ async def test_connect_socket_mode_slack_response_error_is_actionable(monkeypatc plugin = SlackChannel() plugin._config = {"socketConnectTimeoutSeconds": 1} plugin._app = SimpleNamespace() + plugin._verify_app_token = AsyncMock() class FakeSlackError(Exception): def __init__(self): @@ -497,6 +549,7 @@ async def test_connect_socket_mode_timeout_marks_disconnected(monkeypatch): plugin = SlackChannel() plugin._config = {"socketConnectTimeoutSeconds": 1} plugin._app = SimpleNamespace() + plugin._verify_app_token = AsyncMock() class FakeHandler: def __init__(self, app, token): @@ -514,6 +567,38 @@ async def connect_async(self): assert "timed out" in (plugin.status.last_error or "") +@pytest.mark.asyncio +async def test_connect_socket_mode_preflights_app_token_before_handler(monkeypatch): + plugin = SlackChannel() + plugin._config = {"socketConnectTimeoutSeconds": 1} + plugin._app = SimpleNamespace() + + class FakeSlackError(Exception): + def __init__(self): + super().__init__("Slack API error") + self.response = SimpleNamespace(data={"error": "invalid_auth"}) + + class FakeWebClient: + def __init__(self, token): + self.token = token + + async def apps_connections_open(self): + raise FakeSlackError() + + class FakeHandler: + def __init__(self, app, token): + raise AssertionError("handler should not start for invalid app token") + + monkeypatch.setattr(slack_mod, "AsyncWebClient", FakeWebClient) + monkeypatch.setattr(slack_mod, "AsyncSocketModeHandler", FakeHandler) + + with pytest.raises(NonRetryableChannelError): + await plugin._connect_socket_mode("xapp-bad") + + assert "Slack App Token" in (plugin.status.last_error or "") + assert "xapp-" in (plugin.status.last_error or "") + + @pytest.mark.asyncio async def test_start_invalid_config_raises_non_retryable(monkeypatch): monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index c312b2cc6..10ced80e0 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from fastapi import status +from fastapi import HTTPException, status from httpx import AsyncClient from unittest.mock import AsyncMock @@ -509,7 +509,8 @@ async def test_config_has_expected_top_level_keys(self, client: AsyncClient): f"No expected keys found. Got: {list(data.keys())}" ) - def test_null_allow_from_deletes_existing_channel_field( + @pytest.mark.asyncio + async def test_null_allow_from_deletes_existing_channel_field( self, tmp_path, monkeypatch, @@ -534,13 +535,57 @@ def test_null_allow_from_deletes_existing_channel_field( ) monkeypatch.setattr(config_routes.Config, "get_config_file", lambda: config_file) - config_routes._apply_channel_allow_from_deletions( + async def fake_get_config(): + return {} + + monkeypatch.setattr(config_routes, "get_config", fake_get_config) + + await config_routes.update_config( {"channels": {"slack": {"enabled": False, "allowFrom": None}}} ) saved = json.loads(config_file.read_text(encoding="utf-8")) assert "allowFrom" not in saved["channels"]["slack"] + @pytest.mark.asyncio + async def test_invalid_patch_does_not_delete_channel_allow_from( + self, + tmp_path, + monkeypatch, + ): + """A failing PATCH must not relax persisted channel allowlists.""" + from flocks.server.routes import config as config_routes + + config_file = tmp_path / "config" / "flocks.json" + config_file.parent.mkdir(parents=True) + config_file.write_text( + json.dumps( + { + "share": "manual", + "channels": { + "slack": { + "enabled": False, + "allowFrom": ["U123"], + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(config_routes.Config, "get_config_file", lambda: config_file) + + with pytest.raises(HTTPException) as exc: + await config_routes.update_config( + { + "share": "invalid-mode", + "channels": {"slack": {"allowFrom": None}}, + } + ) + + assert exc.value.status_code == status.HTTP_400_BAD_REQUEST + saved = json.loads(config_file.read_text(encoding="utf-8")) + assert saved["channels"]["slack"]["allowFrom"] == ["U123"] + @pytest.mark.asyncio async def test_ui_display_defaults_and_updates( self, From b128aadd57fe77848631b7de25db7d32f5d51046 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 10:26:53 +0800 Subject: [PATCH 50/86] fix(skyeye): encode alarm list IP filters --- .../skyeye_v4_0_14_0_SP2/skyeye.handler.py | 11 ++++- tests/tool/test_skyeye_api_tools.py | 41 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye.handler.py b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye.handler.py index e6f8f94d4..f00129ce6 100644 --- a/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye.handler.py +++ b/.flocks/plugins/tools/device/skyeye_v4_0_14_0_SP2/skyeye.handler.py @@ -1,4 +1,5 @@ import base64 +import gzip import hashlib import json import random @@ -189,6 +190,12 @@ def _clean_params(params: dict[str, Any]) -> dict[str, Any]: return cleaned +def _encode_ip_param(value: str | None) -> str | None: + if value is None or not value.strip() or value.startswith("H4sI"): + return value + return base64.b64encode(gzip.compress(value.encode("utf-8"))).decode("ascii") + + def _payload_error(payload: Any) -> str | None: if not isinstance(payload, dict): return None @@ -498,8 +505,8 @@ async def alarm_list( "status": status, "serial_num": serial_num, "data_source": data_source, - "alarm_sip": alarm_sip, - "attack_sip": attack_sip, + "alarm_sip": _encode_ip_param(alarm_sip), + "attack_sip": _encode_ip_param(attack_sip), "ioc": ioc, "threat_name": threat_name, "host": host, diff --git a/tests/tool/test_skyeye_api_tools.py b/tests/tool/test_skyeye_api_tools.py index 97c71bf07..9929919b8 100644 --- a/tests/tool/test_skyeye_api_tools.py +++ b/tests/tool/test_skyeye_api_tools.py @@ -1,3 +1,5 @@ +import base64 +import gzip from pathlib import Path from unittest.mock import MagicMock, patch @@ -207,6 +209,45 @@ async def test_skyeye_alarm_list_accepts_legacy_threat_level_alias(): assert "threat_level" not in request_kwargs["params"] +@pytest.mark.asyncio +async def test_skyeye_alarm_list_encodes_ip_filters_without_affecting_other_params(): + tool = _load_tool("skyeye_alarm_list.yaml") + mock_secret_manager = MagicMock() + mock_secret_manager.get.side_effect = lambda key: { + "skyeye_api_key": "login-key-123", + }.get(key) + fake_session = _FakeSession([ + _FakeResponse(json_payload={"access_token": "token-123", "status": 200}), + _FakeResponse(text_payload=''), + _FakeResponse(json_payload={"data": {"items": [], "total": 0}, "status": 1000, "message": "ok"}), + ]) + + with ( + patch("flocks.security.get_secret_manager", return_value=mock_secret_manager), + patch( + "flocks.config.config_writer.ConfigWriter.get_api_service_raw", + return_value={ + "apiKey": "{secret:skyeye_api_key}", + "base_url": "https://skyeye.local", + }, + ), + patch("aiohttp.ClientSession", return_value=fake_session), + ): + result = await tool.handler( + ToolContext(session_id="test", message_id="test"), + alarm_sip="10.105.249.255", + attack_sip="10.61.116.134", + limit=3, + ) + + assert result.success is True + _, _, request_kwargs = fake_session.calls[2] + params = request_kwargs["params"] + assert gzip.decompress(base64.b64decode(params["alarm_sip"])).decode() == "10.105.249.255" + assert gzip.decompress(base64.b64decode(params["attack_sip"])).decode() == "10.61.116.134" + assert params["limit"] == 3 + + @pytest.mark.asyncio async def test_skyeye_alarm_list_still_rejects_unknown_level_fields(): tool = _load_tool("skyeye_alarm_list.yaml") From d2c0617a2d6da06cb67ad5c6f9bff73f694586f6 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 11:31:23 +0800 Subject: [PATCH 51/86] fix(webui): clarify project session hierarchy --- webui/src/locales/en-US/session.json | 1 - webui/src/locales/zh-CN/session.json | 1 - webui/src/pages/Session/index.test.tsx | 38 ++++++++++++++++++-- webui/src/pages/Session/index.tsx | 48 ++++++++++++++------------ 4 files changed, 60 insertions(+), 28 deletions(-) diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 359c6007d..73bf4ba4e 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -268,7 +268,6 @@ "toggleTasks": "Expand or collapse tasks", "selectTasks": "Select tasks", "createTaskSession": "New task", - "toggleProject": "Expand or collapse project {{project}}", "selectProject": "Select project {{project}}", "createSessionInProject": "New session in project {{project}}", "projectActions": "Project actions", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 658c02dee..1066e38ea 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -268,7 +268,6 @@ "toggleTasks": "展开或收起任务", "selectTasks": "选择任务", "createTaskSession": "新建任务", - "toggleProject": "展开或收起项目 {{project}}", "selectProject": "选择项目 {{project}}", "createSessionInProject": "在项目 {{project}} 中新建会话", "projectActions": "项目操作", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 51aaf4dc5..10eb3f99a 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -435,7 +435,7 @@ describe('SessionPage session actions menu', () => { const firstRender = renderSessionPage(); await screen.findByText('Labs'); - await user.click(screen.getByRole('button', { name: 'toggleProject' })); + await user.click(screen.getByRole('button', { name: 'selectProject' })); expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); firstRender.unmount(); @@ -503,14 +503,17 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); await screen.findByText('Labs'); - const toggle = screen.getByRole('button', { name: 'toggleProject' }); const projectRow = screen.getByRole('button', { name: 'selectProject' }); + expect(screen.queryByRole('button', { name: 'toggleProject' })).not.toBeInTheDocument(); + expect(projectRow).toHaveAttribute('aria-expanded', 'true'); - await user.click(toggle); + await user.click(projectRow); expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); + expect(projectRow).toHaveAttribute('aria-expanded', 'false'); await user.click(projectRow); expect(screen.getByText('Original Session')).toBeInTheDocument(); + expect(projectRow).toHaveAttribute('aria-expanded', 'true'); await user.click(projectRow); expect(screen.queryByText('Original Session')).not.toBeInTheDocument(); @@ -587,6 +590,35 @@ describe('SessionPage session actions menu', () => { }); }); + it('shows only the browser path while choosing a project folder', async () => { + const user = userEvent.setup(); + const defaultProject = { + id: 'default', + worktree: '/tmp/project', + name: '默认', + isDefault: true, + }; + client.get.mockImplementation((url: string) => Promise.resolve({ + data: url === '/api/project/folders' + ? { + path: '/tmp/project/child', + parent: '/tmp/project', + roots: [], + entries: [], + } + : [defaultProject], + })); + + renderSessionPage(); + + await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); + expect(screen.getByLabelText('projectDialog.folderLabel')).toHaveValue('/tmp/project'); + + await user.click(screen.getByRole('button', { name: 'projectDialog.chooseFolder' })); + + expect(await screen.findByText('/tmp/project/child')).toBeInTheDocument(); + expect(screen.queryByLabelText('projectDialog.folderLabel')).not.toBeInTheDocument(); + }); it('shows the backend detail when project creation fails', async () => { const user = userEvent.setup(); client.post.mockRejectedValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 73274083a..3eb975ced 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -170,6 +170,7 @@ function getProjectLabel(project?: ProjectSummary, fallbackDirectory?: string): interface SessionSidebarItemProps { session: Session; + nested?: boolean; selected: boolean; selectMode: boolean; checked: boolean; @@ -189,6 +190,7 @@ interface SessionSidebarItemProps { function SessionSidebarItemInner({ session, + nested = false, selected, selectMode, checked, @@ -208,12 +210,16 @@ function SessionSidebarItemInner({ return (
onSelect(session.id)} - className={`group relative mx-2 mb-1 px-3 py-2.5 rounded-xl border cursor-pointer transition-all duration-150 ${ + className={`group relative mb-1 cursor-pointer border px-3 transition-all duration-150 ${ + nested ? 'ml-7 mr-2 rounded-lg py-2' : 'mx-2 rounded-xl py-2.5' + } ${ !selectMode && selected - ? 'bg-gray-100 border-gray-300 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' + ? 'border-gray-300 bg-gray-100 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-none' : selectMode && checked ? 'bg-blue-50 border-blue-200 dark:border-blue-500/40 dark:bg-blue-950/30' - : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' + : nested + ? 'border-transparent hover:bg-gray-50 dark:hover:bg-zinc-900' + : 'border-gray-100 hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm dark:border-transparent dark:hover:border-zinc-800 dark:hover:bg-zinc-900 dark:hover:shadow-none' }`} >
@@ -293,6 +299,7 @@ function SessionSidebarItemInner({ const SessionSidebarItem = memo(SessionSidebarItemInner, (prev, next) => ( prev.session.id === next.session.id && + prev.nested === next.nested && prev.session.title === next.session.title && prev.session.category === next.session.category && prev.session.isShared === next.session.isShared && @@ -1533,7 +1540,7 @@ export default function SessionPage() {
{!projectsSectionCollapsed && ( -
+
{managedProjectSessionGroups.map((group) => { const collapsed = collapsedProjectIds.has(group.id); const isDefaultProject = group.isDefault; @@ -1552,17 +1559,6 @@ export default function SessionPage() { }`} title={group.worktree} > - )} - + {group.sessionCount}
@@ -1656,12 +1653,13 @@ export default function SessionPage() {
)} {!collapsed && ( -
+
{group.sessions.length > 0 ? ( group.sessions.map((session) => ( )) ) : ( -
+
{t('noProjectSessions')}
)} {hasMoreProjectSessions && ( -
+
-
+ {t('projectDialog.chooseFolder')} + +
+ + )} {folderBrowser && (
From f2a36a0586ee6d642b8071918a46494d1c8b8b1e Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 11:37:53 +0800 Subject: [PATCH 52/86] fix: harden slack access controls --- flocks/channel/builtin/slack/channel.py | 15 +++--- flocks/channel/builtin/slack/format.py | 42 +++++++++++++-- flocks/channel/builtin/slack/inbound.py | 10 +++- flocks/channel/inbound/dispatcher.py | 5 +- flocks/server/routes/config.py | 16 ++++++ tests/channel/test_channel.py | 17 ++++++ tests/channel/test_slack.py | 54 ++++++++++++++++++-- tests/server/routes/test_remaining_routes.py | 28 ++++++++++ webui/src/pages/Channel/index.tsx | 8 ++- 9 files changed, 173 insertions(+), 22 deletions(-) diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py index ef0801366..cc1d63fc4 100644 --- a/flocks/channel/builtin/slack/channel.py +++ b/flocks/channel/builtin/slack/channel.py @@ -28,7 +28,7 @@ should_reply_in_thread, ) from .format import markdown_to_slack_mrkdwn -from .inbound import build_inbound_message +from .inbound import build_inbound_message, slack_thread_cache_key try: from slack_bolt.async_app import AsyncApp @@ -343,10 +343,10 @@ async def send_text(self, ctx: OutboundContext) -> DeliveryResult: result = await self._app.client.chat_postMessage(**payload) message_id = str(result.get("ts") or "") - if message_id: - self._remember_thread(message_id) if thread_ts: - self._remember_thread(thread_ts) + self._remember_thread(ctx.account_id or "default", target, thread_ts) + elif message_id: + self._remember_thread(ctx.account_id or "default", target, message_id) await self._persist_known_threads() self.record_message() return DeliveryResult( @@ -399,11 +399,12 @@ def _resolve_thread_ts(self, ctx: OutboundContext) -> Optional[str]: return ctx.reply_to_id return None - def _remember_thread(self, thread_ts: str) -> None: + def _remember_thread(self, account_id: str, channel_id: str, thread_ts: str) -> None: if not thread_ts: return - self._known_thread_ids[thread_ts] = None - self._known_thread_ids.move_to_end(thread_ts) + thread_key = slack_thread_cache_key(account_id, channel_id, thread_ts) + self._known_thread_ids[thread_key] = None + self._known_thread_ids.move_to_end(thread_key) if len(self._known_thread_ids) > self._known_thread_ids_max: while len(self._known_thread_ids) > self._known_thread_ids_max // 2: self._known_thread_ids.popitem(last=False) diff --git a/flocks/channel/builtin/slack/format.py b/flocks/channel/builtin/slack/format.py index c8834b620..f92d97615 100644 --- a/flocks/channel/builtin/slack/format.py +++ b/flocks/channel/builtin/slack/format.py @@ -3,9 +3,30 @@ from __future__ import annotations import re +from html import escape +from urllib.parse import quote _LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)") +_SAFE_ENTITY_RE = re.compile(r"&(amp|lt|gt);") + + +def _escape_slack_text(value: str) -> str: + placeholders: list[str] = [] + + def protect_entity(match: re.Match[str]) -> str: + placeholders.append(match.group(0)) + return f"\x02SLACK_ENTITY_{len(placeholders) - 1}\x02" + + escaped = _SAFE_ENTITY_RE.sub(protect_entity, value) + escaped = escape(escaped, quote=False) + for index, entity in enumerate(placeholders): + escaped = escaped.replace(f"\x02SLACK_ENTITY_{index}\x02", entity) + return escaped + + +def _escape_slack_link_url(value: str) -> str: + return quote(value, safe=":/?#[]@!$&'()*+,;=%") def markdown_to_slack_mrkdwn(text: str) -> str: @@ -13,22 +34,33 @@ def markdown_to_slack_mrkdwn(text: str) -> str: if not text: return "" - placeholders: list[str] = [] + code_placeholders: list[str] = [] def protect(match: re.Match[str]) -> str: - placeholders.append(match.group(0)) - return f"\x01SLACK_CODE_{len(placeholders) - 1}\x01" + code_placeholders.append(match.group(0)) + return f"\x01SLACK_CODE_{len(code_placeholders) - 1}\x01" + + link_placeholders: list[str] = [] + + def convert_link(match: re.Match[str]) -> str: + label = _escape_slack_text(match.group(1)) + url = _escape_slack_link_url(match.group(2)) + link_placeholders.append(f"<{url}|{label}>") + return f"\x01SLACK_LINK_{len(link_placeholders) - 1}\x01" result = re.sub(r"```.*?```", protect, text, flags=re.DOTALL) result = re.sub(r"`[^`\n]+`", protect, result) - result = _LINK_RE.sub(r"<\2|\1>", result) + result = _LINK_RE.sub(convert_link, result) result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result, flags=re.DOTALL) result = re.sub(r"__([^_\n]+)__", r"*\1*", result) result = re.sub(r"~~(.+?)~~", r"~\1~", result, flags=re.DOTALL) result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + result = _escape_slack_text(result) - for index, fragment in enumerate(placeholders): + for index, fragment in enumerate(link_placeholders): + result = result.replace(f"\x01SLACK_LINK_{index}\x01", fragment) + for index, fragment in enumerate(code_placeholders): result = result.replace(f"\x01SLACK_CODE_{index}\x01", fragment) return result diff --git a/flocks/channel/builtin/slack/inbound.py b/flocks/channel/builtin/slack/inbound.py index 51ac8f901..18db230e0 100644 --- a/flocks/channel/builtin/slack/inbound.py +++ b/flocks/channel/builtin/slack/inbound.py @@ -11,6 +11,10 @@ from .format import strip_bot_mention +def slack_thread_cache_key(account_id: str, channel_id: str, thread_ts: str) -> str: + return f"{account_id or 'default'}:{channel_id}:{thread_ts}" + + def _join_parts(parts: list[str]) -> str: return "\n\n".join(part for part in (p.strip() for p in parts) if part) @@ -257,13 +261,15 @@ def build_inbound_message( # as channel surfaces while preserving group-trigger behavior in the # dispatcher (all non-DIRECT chats use the same trigger policy). chat_type = ChatType.CHANNEL + account_id = str(event.get("team") or event.get("team_id") or "default") thread_ts = str(event.get("thread_ts") or "") is_thread_reply = bool(thread_ts and thread_ts != ts) mentioned = bool(bot_user_id and f"<@{bot_user_id}>" in text) + thread_key = slack_thread_cache_key(account_id, channel_id, thread_ts) if thread_ts else "" continues_bot_thread = bool( is_thread_reply and ( - thread_ts in known_thread_ids + thread_key in known_thread_ids or (bot_user_id and str(event.get("parent_user_id") or "") == bot_user_id) ) ) @@ -281,7 +287,7 @@ def build_inbound_message( return InboundMessage( channel_id="slack", - account_id=str(event.get("team") or event.get("team_id") or "default"), + account_id=account_id, message_id=ts, sender_id=user_id, sender_name=sender_name, diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 16ff95321..1b8105891 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -163,7 +163,10 @@ def _check_allowlist(msg: InboundMessage, config: ChannelConfig) -> bool: non-empty, but are otherwise unrestricted. """ allow_from = config.allow_from - dm_policy = config.dm_policy or "open" + dm_policy = config.dm_policy + if msg.channel_id == "slack" and dm_policy is None and allow_from: + dm_policy = "allowlist" + dm_policy = dm_policy or "open" if msg.chat_type == ChatType.DIRECT: if dm_policy == "open": diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index a30a62ba4..74a8e8895 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -50,6 +50,21 @@ def _channel_allow_from_deletion_ids(config_data: Dict[str, Any]) -> set[str]: } +def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None: + """Keep Slack allowFrom and dmPolicy aligned for DM access control.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return + slack = channels.get("slack") + if not isinstance(slack, dict) or "allowFrom" not in slack: + return + allow_from = slack.get("allowFrom") + if isinstance(allow_from, list) and len(allow_from) > 0: + slack["dmPolicy"] = "allowlist" + else: + slack["dmPolicy"] = "open" + + def _build_model_from_config( provider_id: str, model_id: str, @@ -570,6 +585,7 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: """ try: channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data) + _normalize_slack_dm_policy(config_data) # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index 8a75758aa..fcd98361f 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -1084,6 +1084,23 @@ def test_dm_allowlist_passes_listed(self): cfg = ChannelConfig(dm_policy="allowlist", allow_from=["u1"]) assert _check_allowlist(self._make_msg(sender_id="u1"), cfg) is True + def test_slack_dm_allow_from_without_policy_blocks_unlisted(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["U_ALLOWED"]) + msg = self._make_msg(channel_id="slack", sender_id="U_BLOCKED") + assert _check_allowlist(msg, cfg) is False + + def test_slack_dm_allow_from_without_policy_passes_listed(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["U_ALLOWED"]) + msg = self._make_msg(channel_id="slack", sender_id="U_ALLOWED") + assert _check_allowlist(msg, cfg) is True + + def test_non_slack_dm_allow_from_without_policy_stays_open(self): + from flocks.channel.inbound.dispatcher import _check_allowlist + cfg = ChannelConfig(allow_from=["u2"]) + assert _check_allowlist(self._make_msg(channel_id="test", sender_id="u1"), cfg) is True + def test_dm_allowlist_empty_blocks_all(self): from flocks.channel.inbound.dispatcher import _check_allowlist cfg = ChannelConfig(dm_policy="allowlist", allow_from=None) diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py index c6261c843..2e9b7a904 100644 --- a/tests/channel/test_slack.py +++ b/tests/channel/test_slack.py @@ -10,7 +10,7 @@ import flocks.channel.builtin.slack.channel as slack_mod from flocks.channel.builtin.slack.channel import SlackChannel from flocks.channel.builtin.slack.format import markdown_to_slack_mrkdwn -from flocks.channel.builtin.slack.inbound import build_inbound_message +from flocks.channel.builtin.slack.inbound import build_inbound_message, slack_thread_cache_key from flocks.channel.builtin.slack.manifest import build_slack_app_manifest @@ -103,6 +103,29 @@ def test_markdown_to_slack_mrkdwn_keeps_escaped_special_mentions(): assert "&" in rendered +def test_markdown_to_slack_mrkdwn_escapes_raw_slack_special_mentions(): + rendered = markdown_to_slack_mrkdwn( + "notify <@U123>; compare 1 < 2 & 3 > 2" + ) + + assert "" not in rendered + assert "" not in rendered + assert "" not in rendered + assert "<@U123>" not in rendered + assert "<!here>" in rendered + assert "<!channel>" in rendered + assert "<!everyone>" in rendered + assert "<@U123>" in rendered + assert "1 < 2 & 3 > 2" in rendered + + +def test_markdown_to_slack_mrkdwn_escapes_unsafe_link_url_delimiters(): + rendered = markdown_to_slack_mrkdwn("[x](https://example.com/a|)") + + assert rendered == "" + assert "|" not in rendered + + def test_build_inbound_direct_message(): msg = build_inbound_message( { @@ -291,7 +314,7 @@ def test_build_inbound_thread_reply_to_known_bot_thread_triggers_without_mention }, bot_user_id="UBOT", config={}, - known_thread_ids={"171.2"}, + known_thread_ids={slack_thread_cache_key("default", "C123", "171.2")}, ) assert msg is not None @@ -300,6 +323,26 @@ def test_build_inbound_thread_reply_to_known_bot_thread_triggers_without_mention assert msg.thread_id == "171.2" +def test_build_inbound_same_thread_ts_in_other_channel_does_not_trigger(): + msg = build_inbound_message( + { + "channel": "C999", + "channel_type": "channel", + "user": "U123", + "ts": "171.3", + "thread_ts": "171.2", + "text": "same timestamp, different channel", + "team": "T1", + }, + bot_user_id="UBOT", + config={}, + known_thread_ids={slack_thread_cache_key("T1", "C123", "171.2")}, + ) + + assert msg is not None + assert msg.mentioned is False + + def test_build_inbound_thread_reply_to_parent_bot_user_triggers_after_restart(): msg = build_inbound_message( { @@ -421,8 +464,8 @@ async def test_send_text_posts_thread_reply_and_remembers_thread(): thread_ts="171.1", reply_broadcast=True, ) - assert "171.1" in plugin._known_thread_ids - assert "172.1" in plugin._known_thread_ids + assert slack_thread_cache_key("default", "C123", "171.1") in plugin._known_thread_ids + assert slack_thread_cache_key("default", "C123", "172.1") not in plugin._known_thread_ids @pytest.mark.asyncio @@ -441,7 +484,7 @@ async def set(key, value, value_type="json"): monkeypatch.setattr(slack_mod, "Storage", FakeStorage) plugin = SlackChannel() - plugin._remember_thread("171.2") + plugin._remember_thread("T1", "C123", "171.2") await plugin._persist_known_threads() restarted = SlackChannel() @@ -455,6 +498,7 @@ async def set(key, value, value_type="json"): "thread_ts": "171.2", "parent_user_id": "U123", "text": "continue after process restart", + "team": "T1", }, bot_user_id="UBOT", config={}, diff --git a/tests/server/routes/test_remaining_routes.py b/tests/server/routes/test_remaining_routes.py index 10ced80e0..bf5e72705 100644 --- a/tests/server/routes/test_remaining_routes.py +++ b/tests/server/routes/test_remaining_routes.py @@ -546,6 +546,34 @@ async def fake_get_config(): saved = json.loads(config_file.read_text(encoding="utf-8")) assert "allowFrom" not in saved["channels"]["slack"] + assert saved["channels"]["slack"]["dmPolicy"] == "open" + + @pytest.mark.asyncio + async def test_slack_allow_from_save_sets_dm_policy_allowlist( + self, + tmp_path, + monkeypatch, + ): + """Slack allowFrom must also enable DM allowlist enforcement.""" + from flocks.server.routes import config as config_routes + + config_file = tmp_path / "config" / "flocks.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}", encoding="utf-8") + monkeypatch.setattr(config_routes.Config, "get_config_file", lambda: config_file) + + async def fake_get_config(): + return {} + + monkeypatch.setattr(config_routes, "get_config", fake_get_config) + + await config_routes.update_config( + {"channels": {"slack": {"enabled": False, "allowFrom": ["U123"]}}} + ) + + saved = json.loads(config_file.read_text(encoding="utf-8")) + assert saved["channels"]["slack"]["allowFrom"] == ["U123"] + assert saved["channels"]["slack"]["dmPolicy"] == "allowlist" @pytest.mark.asyncio async def test_invalid_patch_does_not_delete_channel_allow_from( diff --git a/webui/src/pages/Channel/index.tsx b/webui/src/pages/Channel/index.tsx index 14a9a1653..5bfb95c2d 100644 --- a/webui/src/pages/Channel/index.tsx +++ b/webui/src/pages/Channel/index.tsx @@ -3486,8 +3486,12 @@ function stripEmpty(obj: Record): Record { function stripChannelConfigForSave(channelId: string, cfg: Record): Record { const result = stripEmpty(cfg); - if (channelId === 'slack' && cfg.allowFrom === undefined) { - result.allowFrom = null; + if (channelId === 'slack') { + const allowFrom = Array.isArray(cfg.allowFrom) ? cfg.allowFrom : []; + result.dmPolicy = allowFrom.length > 0 ? 'allowlist' : 'open'; + if (cfg.allowFrom === undefined) { + result.allowFrom = null; + } } return result; From 5636c117a0e9a32899d9711efa86db2b031158ae Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 11:41:26 +0800 Subject: [PATCH 53/86] fix(project): start folder picker from home --- flocks/server/routes/project.py | 10 +++- tests/server/routes/test_project_routes.py | 14 +++++ webui/src/locales/en-US/session.json | 1 - webui/src/locales/zh-CN/session.json | 1 - webui/src/pages/Session/index.test.tsx | 14 +++-- webui/src/pages/Session/index.tsx | 65 ++++++++++------------ 6 files changed, 61 insertions(+), 44 deletions(-) diff --git a/flocks/server/routes/project.py b/flocks/server/routes/project.py index 0756b8cdc..aa4538bdb 100644 --- a/flocks/server/routes/project.py +++ b/flocks/server/routes/project.py @@ -189,7 +189,15 @@ async def browse_project_folders(request: Request, path: Optional[str] = Query(N raise HTTPException(status_code=400, detail="No project roots are available") try: - current = Path(path).expanduser().resolve(strict=True) if path else Path(default_project.worktree).resolve(strict=True) + if path: + current = Path(path).expanduser().resolve(strict=True) + else: + home = Path.home().resolve(strict=True) + current = ( + home + if any(home == root or home.is_relative_to(root) for root in roots) + else roots[0] + ) except (OSError, RuntimeError) as exc: raise HTTPException(status_code=400, detail="Directory does not exist") from exc if not current.is_dir(): diff --git a/tests/server/routes/test_project_routes.py b/tests/server/routes/test_project_routes.py index 5e407438a..c9db60a3b 100644 --- a/tests/server/routes/test_project_routes.py +++ b/tests/server/routes/test_project_routes.py @@ -86,6 +86,20 @@ async def test_duplicate_project_directory_returns_existing_project( assert len((await client.get("/api/project")).json()) == 2 +@pytest.mark.asyncio +async def test_folder_browser_starts_at_home_when_path_is_omitted( + client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +): + home = Path.home().resolve() + monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(home)) + + response = await client.get("/api/project/folders") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["path"] == str(home) + + @pytest.mark.asyncio async def test_folder_browser_lists_directories_and_blocks_outside_root( client: AsyncClient, diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 73bf4ba4e..4d8acb9ba 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -296,7 +296,6 @@ "folderLabel": "Project folder", "folderPlaceholder": "Enter an absolute project folder path", "folderEmpty": "Choose a project folder", - "defaultFolder": "Default: {{path}}", "chooseFolder": "Choose", "parentFolder": "Parent folder", "selectCurrentFolder": "Select this folder", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 1066e38ea..322af418f 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -296,7 +296,6 @@ "folderLabel": "项目文件夹", "folderPlaceholder": "输入项目文件夹的绝对路径", "folderEmpty": "请选择项目文件夹", - "defaultFolder": "默认:{{path}}", "chooseFolder": "选择", "parentFolder": "上一级目录", "selectCurrentFolder": "选择此文件夹", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 10eb3f99a..241653220 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -601,8 +601,8 @@ describe('SessionPage session actions menu', () => { client.get.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/project/folders' ? { - path: '/tmp/project/child', - parent: '/tmp/project', + path: '/home/test-user', + parent: null, roots: [], entries: [], } @@ -612,13 +612,19 @@ describe('SessionPage session actions menu', () => { renderSessionPage(); await user.click(await screen.findByRole('button', { name: 'projectDialog.createTitle' })); - expect(screen.getByLabelText('projectDialog.folderLabel')).toHaveValue('/tmp/project'); + expect(screen.getByLabelText('projectDialog.nameLabel')).toHaveValue(''); + expect(screen.getByLabelText('projectDialog.folderLabel')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'cancel' })).toBeEnabled(); await user.click(screen.getByRole('button', { name: 'projectDialog.chooseFolder' })); - expect(await screen.findByText('/tmp/project/child')).toBeInTheDocument(); + expect(await screen.findByText('/home/test-user')).toBeInTheDocument(); + expect(client.get).toHaveBeenCalledWith('/api/project/folders', { + params: { path: undefined }, + }); expect(screen.queryByLabelText('projectDialog.folderLabel')).not.toBeInTheDocument(); }); + it('shows the backend detail when project creation fails', async () => { const user = userEvent.setup(); client.post.mockRejectedValue({ diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 3eb975ced..6b3f77860 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1097,15 +1097,13 @@ export default function SessionPage() { }, [renameValue, sessions, t, toast, updateSessionTitle]); const handleOpenCreateProject = useCallback(() => { - const defaultProject = projects.find((project) => project.isDefault) - ?? projects.find((project) => project.id === defaultProjectId); setProjectDialogMode('create'); setEditingProjectId(null); - setProjectWorktreeValue(defaultProject?.worktree ?? ''); - setProjectNameValue(defaultProject?.worktree ? getPathBasename(defaultProject.worktree) : ''); + setProjectWorktreeValue(''); + setProjectNameValue(''); setProjectNameManuallyEdited(false); setFolderBrowser(null); - }, [defaultProjectId, projects]); + }, []); const handleOpenRenameProject = useCallback((project: ProjectSessionGroup) => { const persistedName = projects.find((item) => item.id === project.id)?.name?.trim(); @@ -2152,37 +2150,30 @@ export default function SessionPage() { <> {!folderBrowser && ( <> -
- - project.isDefault)?.worktree}> - {t('projectDialog.defaultFolder', { - path: projects.find((project) => project.isDefault)?.worktree ?? '', - })} - -
-
- { - setProjectWorktreeValue(event.target.value); - if (!projectNameManuallyEdited) { - setProjectNameValue(getPathBasename(event.target.value)); - } - }} - disabled={projectSubmitting} - placeholder={t('projectDialog.folderPlaceholder')} - className="min-w-0 flex-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-blue-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-blue-500" - /> -
@@ -2252,7 +2243,7 @@ export default function SessionPage() { - {!isDefaultProject && ( + {!isDefaultProject && group.canWrite && ( + + )} + {!isDefaultProject && group.canWrite && ( -
+
-
- - )} + +
+ { + setProjectWorktreeValue(event.target.value); + if (!projectNameManuallyEdited) { + setProjectNameValue(getPathBasename(event.target.value)); + } + }} + disabled={projectSubmitting} + placeholder={t('projectDialog.folderPlaceholder')} + className="min-w-0 flex-1 rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-900 outline-none transition-colors placeholder:text-zinc-400 focus:border-blue-400 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-600 dark:focus:border-blue-500" + /> + +
{folderBrowser && (
From 097cd06e07707a734dc0c5c3f65f7b1f99e6fc69 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 13:53:11 +0800 Subject: [PATCH 59/86] fix(webui): sync folder browser from path input --- webui/src/pages/Session/index.test.tsx | 12 ++++++++--- webui/src/pages/Session/index.tsx | 29 +++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 9255c25ca..c1aa5ac52 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -668,7 +668,7 @@ describe('SessionPage session actions menu', () => { }); }); - it('updates the folder input while navigating the folder browser', async () => { + it('keeps the folder input and folder browser in sync', async () => { const user = userEvent.setup(); client.get.mockImplementation((url: string, config?: { params?: { path?: string } }) => { if (url !== '/api/project/folders') { @@ -699,8 +699,14 @@ describe('SessionPage session actions menu', () => { await user.click(screen.getByRole('button', { name: 'projectDialog.chooseFolder' })); await waitFor(() => expect(folderInput).toHaveValue('/home/test-user')); - await user.click(screen.getByRole('button', { name: 'labs' })); - await waitFor(() => expect(folderInput).toHaveValue('/home/test-user/labs')); + await user.clear(folderInput); + await user.type(folderInput, '/home/test-user/labs'); + await waitFor(() => { + expect(client.get).toHaveBeenCalledWith('/api/project/folders', { + params: { path: '/home/test-user/labs' }, + }); + expect(screen.getByText('/home/test-user/labs')).toBeInTheDocument(); + }); }); it('does not submit when Enter is pressed before choosing a project folder', async () => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index ac248f743..36f79caa8 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -387,6 +387,7 @@ export default function SessionPage() { const renameInputRef = useRef(null); const renameSubmitInFlightRef = useRef(false); const projectSubmitInFlightRef = useRef(false); + const folderBrowserRequestIdRef = useRef(0); const sessionUpdateRefetchTimerRef = useRef(null); const projectListRequestSeqRef = useRef(0); const toast = useToast(); @@ -1138,22 +1139,42 @@ export default function SessionPage() { setFolderBrowser(null); }, [projectSubmitting]); - const loadFolderBrowser = useCallback(async (path?: string) => { + const loadFolderBrowser = useCallback(async ( + path?: string, + options?: { silent?: boolean }, + ) => { + const requestId = ++folderBrowserRequestIdRef.current; setFolderBrowserLoading(true); try { const response = await client.get('/api/project/folders', { params: { path: path?.trim() || undefined }, }); + if (requestId !== folderBrowserRequestIdRef.current) return; const browser = response.data as FolderBrowserResponse; setFolderBrowser(browser); setProjectWorktreeValue(browser.path); } catch (err: any) { - toast.error(t('projectDialog.folderBrowseFailed'), err.message); + if (requestId === folderBrowserRequestIdRef.current && !options?.silent) { + toast.error(t('projectDialog.folderBrowseFailed'), err.message); + } } finally { - setFolderBrowserLoading(false); + if (requestId === folderBrowserRequestIdRef.current) { + setFolderBrowserLoading(false); + } } }, [t, toast]); + useEffect(() => { + if (!folderBrowser) return undefined; + const path = projectWorktreeValue.trim(); + if (!path || path === folderBrowser.path) return undefined; + + const timer = window.setTimeout(() => { + void loadFolderBrowser(path, { silent: true }); + }, 300); + return () => window.clearTimeout(timer); + }, [folderBrowser, loadFolderBrowser, projectWorktreeValue]); + const handleSelectProjectFolder = useCallback((path: string) => { setProjectWorktreeValue(path); if (!projectNameManuallyEdited) { @@ -2215,6 +2236,8 @@ export default function SessionPage() { id="session-project-worktree" value={projectWorktreeValue} onChange={(event) => { + folderBrowserRequestIdRef.current += 1; + setFolderBrowserLoading(false); setProjectWorktreeValue(event.target.value); if (!projectNameManuallyEdited) { setProjectNameValue(getPathBasename(event.target.value)); From 1253efaf1d004d2e9f5427d2d2d5b9336d238922 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 13:57:01 +0800 Subject: [PATCH 60/86] fix(webui): preserve path text during folder sync --- webui/src/pages/Session/index.test.tsx | 8 ++++++++ webui/src/pages/Session/index.tsx | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index c1aa5ac52..d1a0a8045 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -699,6 +699,14 @@ describe('SessionPage session actions menu', () => { await user.click(screen.getByRole('button', { name: 'projectDialog.chooseFolder' })); await waitFor(() => expect(folderInput).toHaveValue('/home/test-user')); + await user.type(folderInput, '/'); + await waitFor(() => { + expect(client.get).toHaveBeenCalledWith('/api/project/folders', { + params: { path: '/home/test-user/' }, + }); + }); + expect(folderInput).toHaveValue('/home/test-user/'); + await user.clear(folderInput); await user.type(folderInput, '/home/test-user/labs'); await waitFor(() => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 36f79caa8..4c7454d9a 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -388,6 +388,7 @@ export default function SessionPage() { const renameSubmitInFlightRef = useRef(false); const projectSubmitInFlightRef = useRef(false); const folderBrowserRequestIdRef = useRef(0); + const folderBrowserInputPathRef = useRef(null); const sessionUpdateRefetchTimerRef = useRef(null); const projectListRequestSeqRef = useRef(0); const toast = useToast(); @@ -1141,7 +1142,7 @@ export default function SessionPage() { const loadFolderBrowser = useCallback(async ( path?: string, - options?: { silent?: boolean }, + options?: { preserveInput?: boolean; silent?: boolean }, ) => { const requestId = ++folderBrowserRequestIdRef.current; setFolderBrowserLoading(true); @@ -1152,7 +1153,12 @@ export default function SessionPage() { if (requestId !== folderBrowserRequestIdRef.current) return; const browser = response.data as FolderBrowserResponse; setFolderBrowser(browser); - setProjectWorktreeValue(browser.path); + if (options?.preserveInput) { + folderBrowserInputPathRef.current = path?.trim() || browser.path; + } else { + folderBrowserInputPathRef.current = browser.path; + setProjectWorktreeValue(browser.path); + } } catch (err: any) { if (requestId === folderBrowserRequestIdRef.current && !options?.silent) { toast.error(t('projectDialog.folderBrowseFailed'), err.message); @@ -1167,10 +1173,14 @@ export default function SessionPage() { useEffect(() => { if (!folderBrowser) return undefined; const path = projectWorktreeValue.trim(); - if (!path || path === folderBrowser.path) return undefined; + if ( + !path + || path === folderBrowser.path + || path === folderBrowserInputPathRef.current + ) return undefined; const timer = window.setTimeout(() => { - void loadFolderBrowser(path, { silent: true }); + void loadFolderBrowser(path, { preserveInput: true, silent: true }); }, 300); return () => window.clearTimeout(timer); }, [folderBrowser, loadFolderBrowser, projectWorktreeValue]); @@ -2237,6 +2247,7 @@ export default function SessionPage() { value={projectWorktreeValue} onChange={(event) => { folderBrowserRequestIdRef.current += 1; + folderBrowserInputPathRef.current = null; setFolderBrowserLoading(false); setProjectWorktreeValue(event.target.value); if (!projectNameManuallyEdited) { From f9b1fc51c4926d8ecaf9846c16d597ebbd9bdafe Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Tue, 21 Jul 2026 14:01:37 +0800 Subject: [PATCH 61/86] fix(webui): restore Pro product name fallback --- webui/src/components/layout/Layout.test.tsx | 6 +- webui/src/components/layout/Layout.tsx | 4 +- .../src/contexts/ProductNameContext.test.tsx | 74 +++++++++++++++++++ webui/src/contexts/ProductNameContext.tsx | 4 + .../src/pages/FlocksproUpgrade/index.test.tsx | 24 +++++- webui/src/pages/FlocksproUpgrade/index.tsx | 28 +++---- webui/src/pages/Settings/index.test.tsx | 2 +- webui/src/pages/Settings/index.tsx | 6 +- 8 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 webui/src/contexts/ProductNameContext.test.tsx diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index d8c3ab000..159d76c34 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -481,7 +481,7 @@ describe('Layout onboarding entry', () => { expect(await screen.findByText('admin.roleMember')).toBeInTheDocument(); expect(await screen.findByText('v2026.6.21')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Flocks' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Flocks Pro' })).not.toBeInTheDocument(); await waitFor(() => expect(checkUpdate).toHaveBeenCalledWith('zh-CN', 'flockspro')); const sidebarShell = container.querySelector('aside > div'); @@ -537,7 +537,7 @@ describe('Layout onboarding entry', () => { await user.click(screen.getByRole('button', { name: 'admin settings' })); - expect(screen.getByRole('link', { name: 'Flocks' })).toHaveAttribute('href', '/settings/flockspro'); + expect(screen.getByRole('link', { name: 'Flocks Pro' })).toHaveAttribute('href', '/settings/flockspro'); expect(screen.getByRole('button', { name: 'checkUpdate' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'settings' })).toHaveAttribute('href', '/settings/preferences'); @@ -608,7 +608,7 @@ describe('Layout onboarding entry', () => { await user.click(screen.getByRole('button', { name: 'admin settings' })); - expect(screen.getByRole('link', { name: 'Flocks' })).toHaveAttribute('href', '/settings/flockspro'); + expect(screen.getByRole('link', { name: 'Flocks Pro' })).toHaveAttribute('href', '/settings/flockspro'); expect(screen.getByRole('link', { name: 'settings' })).toHaveAttribute('href', '/settings/preferences'); await user.click(screen.getByRole('button', { name: 'logout' })); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index f4758e1ca..e6ae55109 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -198,7 +198,7 @@ export default function Layout() { const { t: tWebUIContractPage } = useTranslation('webuiContractPage'); const { t: tAuth } = useTranslation('auth'); const toast = useToast(); - const { productName } = useProductName(); + const { productName, proProductName } = useProductName(); const [hasUpdate, setHasUpdate] = useState(false); const [latestVersion, setLatestVersion] = useState(null); const [currentVersion, setCurrentVersion] = useState(null); @@ -914,7 +914,7 @@ export default function Layout() { className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 hover:text-zinc-950 dark:text-zinc-200 dark:hover:bg-zinc-800 dark:hover:text-zinc-50" > - {productName} + {proProductName} )} + {position && createPortal( + )} From 10638067745ef647f0a73af783d2c0673577f196 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 21 Jul 2026 14:51:52 +0800 Subject: [PATCH 64/86] refactor(workflow): tighten integration status contracts - model integration status responses with explicit Pydantic types - share frontend workflow stats and preserve typed translation keys - split summary and health monitor tests by responsibility --- flocks/server/routes/workflow.py | 90 +++-- .../routes/test_workflow_health_monitor.py | 209 +++++++++++ .../routes/test_workflow_routes_visibility.py | 344 ------------------ .../routes/test_workflow_summary_routes.py | 138 +++++++ webui/src/api/workflow.ts | 34 +- webui/src/pages/Workflow/index.test.tsx | 4 +- webui/src/pages/Workflow/index.tsx | 47 ++- 7 files changed, 452 insertions(+), 414 deletions(-) create mode 100644 tests/server/routes/test_workflow_health_monitor.py create mode 100644 tests/server/routes/test_workflow_summary_routes.py diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index bbab32fc1..b5fccad52 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -75,6 +75,7 @@ workflow_json_declares_triggers, workflow_trigger_definitions_from_json, ) +from flocks.workflow.triggers.models import TriggerType from flocks.workflow.triggers.dispatcher import evaluate_trigger_filter from flocks.workflow.triggers.runtime import default_runtime as default_trigger_runtime from flocks.workflow.triggers.compat import ( @@ -193,6 +194,32 @@ class WorkflowUpdateRequest(BaseModel): status: Optional[Literal["draft", "active", "archived"]] = Field(None, description="Status") +WorkflowCapabilityState = Literal["unconfigured", "starting", "running", "stopped", "error"] + + +class WorkflowCapabilityStatusResponse(BaseModel): + configured: bool + state: WorkflowCapabilityState + + +class WorkflowTriggerStatusItemResponse(BaseModel): + id: str + type: TriggerType + name: Optional[str] = None + state: WorkflowCapabilityState + rawState: Optional[str] = None + + +class WorkflowTriggerCapabilityStatusResponse(WorkflowCapabilityStatusResponse): + count: int = 0 + items: List[WorkflowTriggerStatusItemResponse] = Field(default_factory=list) + + +class WorkflowIntegrationStatusResponse(BaseModel): + api: WorkflowCapabilityStatusResponse + trigger: WorkflowTriggerCapabilityStatusResponse + + class WorkflowResponse(BaseModel): """Workflow response""" @@ -212,8 +239,8 @@ class WorkflowResponse(BaseModel): createdAt: int = Field(..., description="Created timestamp (ms)") updatedAt: int = Field(..., description="Updated timestamp (ms)") stats: Dict[str, Any] = Field(default_factory=dict, description="Statistics") - integrationStatus: Dict[str, Any] = Field( - default_factory=dict, + integrationStatus: Optional[WorkflowIntegrationStatusResponse] = Field( + None, description="API publishing and trigger runtime summary", ) @@ -232,7 +259,7 @@ class WorkflowSummaryResponse(BaseModel): updatedAt: int nodeCount: int = 0 stats: Dict[str, Any] = Field(default_factory=dict) - integrationStatus: Dict[str, Any] = Field(default_factory=dict) + integrationStatus: WorkflowIntegrationStatusResponse class WorkflowRunRequest(BaseModel): @@ -2378,7 +2405,7 @@ async def _normalize_listed_api_service(key: Any, entry: Any) -> Optional[Dict[s return service -def _summarize_capability_state(raw_state: Any) -> str: +def _summarize_capability_state(raw_state: Any) -> WorkflowCapabilityState: state = str(raw_state or "").strip().lower() if state in {"running", "listening", "ready", "active"}: return "running" @@ -2445,8 +2472,8 @@ def start_workflow_api_health_monitor() -> asyncio.Task[None]: async def _get_workflow_integration_status( workflow_id: str, workflow_data: Dict[str, Any], -) -> Dict[str, Any]: - api_summary: Dict[str, Any] = {"configured": False, "state": "unconfigured"} +) -> WorkflowIntegrationStatusResponse: + api_summary = WorkflowCapabilityStatusResponse(configured=False, state="unconfigured") try: service = await WorkflowStore.kv_get(_api_service_key(workflow_id)) if isinstance(service, dict) and service: @@ -2465,23 +2492,18 @@ async def _get_workflow_integration_status( state = "error" elif cached_health is None: state = "starting" - api_summary = { - "configured": True, - "state": state, - } + api_summary = WorkflowCapabilityStatusResponse(configured=True, state=state) except Exception as exc: log.warning("workflow.list.api_status_failed", {"id": workflow_id, "error": str(exc)}) - api_summary = {"configured": False, "state": "error"} + api_summary = WorkflowCapabilityStatusResponse(configured=False, state="error") try: triggers = await _get_workflow_trigger_defs(workflow_id, workflow_data) if not triggers: - trigger_summary: Dict[str, Any] = { - "configured": False, - "state": "unconfigured", - "count": 0, - "items": [], - } + trigger_summary = WorkflowTriggerCapabilityStatusResponse( + configured=False, + state="unconfigured", + ) else: statuses = await default_trigger_runtime.get_workflow_trigger_statuses( workflow_id, @@ -2492,20 +2514,20 @@ async def _get_workflow_integration_status( for item in statuses if isinstance(item, dict) and item.get("triggerId") } - trigger_items = [] + trigger_items: List[WorkflowTriggerStatusItemResponse] = [] for trigger in triggers: runtime_status = statuses_by_id.get(trigger.id) raw_state = runtime_status.get("state") if runtime_status else None trigger_items.append( - { - "id": trigger.id, - "type": trigger.type, - "name": trigger.name, - "state": _summarize_capability_state(raw_state) if raw_state else "error", - "rawState": raw_state, - } + WorkflowTriggerStatusItemResponse( + id=trigger.id, + type=trigger.type, + name=trigger.name, + state=_summarize_capability_state(raw_state) if raw_state else "error", + rawState=raw_state, + ) ) - summarized_states = [item["state"] for item in trigger_items] + summarized_states = [item.state for item in trigger_items] if "error" in summarized_states: state = "error" elif "stopped" in summarized_states: @@ -2514,17 +2536,17 @@ async def _get_workflow_integration_status( state = "running" else: state = "starting" - trigger_summary = { - "configured": True, - "state": state, - "count": len(triggers), - "items": trigger_items, - } + trigger_summary = WorkflowTriggerCapabilityStatusResponse( + configured=True, + state=state, + count=len(triggers), + items=trigger_items, + ) except Exception as exc: log.warning("workflow.list.trigger_status_failed", {"id": workflow_id, "error": str(exc)}) - trigger_summary = {"configured": True, "state": "error", "count": 0, "items": []} + trigger_summary = WorkflowTriggerCapabilityStatusResponse(configured=True, state="error") - return {"api": api_summary, "trigger": trigger_summary} + return WorkflowIntegrationStatusResponse(api=api_summary, trigger=trigger_summary) async def reconcile_published_workflow_api_services() -> Dict[str, int]: diff --git a/tests/server/routes/test_workflow_health_monitor.py b/tests/server/routes/test_workflow_health_monitor.py new file mode 100644 index 000000000..40f6a7c59 --- /dev/null +++ b/tests/server/routes/test_workflow_health_monitor.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +import pytest + +from flocks.server.routes import workflow as workflow_routes + + +@pytest.fixture(autouse=True) +def clear_workflow_health_cache() -> None: + workflow_routes._workflow_api_health_cache.clear() + + +@pytest.mark.asyncio +async def test_workflow_api_health_cache_has_process_wide_probe_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_ids = [f"wf-{index}" for index in range(7)] + active = 0 + peak = 0 + + async def fake_list_keys(_prefix: str) -> list[str]: + return [workflow_routes._api_service_key(workflow_id) for workflow_id in workflow_ids] + + async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + workflow_id = str(key).removeprefix(workflow_routes._API_SERVICE_PREFIX) + return {"workflowId": workflow_id, "status": "running"} + + async def fake_health(_workflow_id: str) -> dict[str, Any]: + nonlocal active, peak + active += 1 + peak = max(peak, active) + await asyncio.sleep(0.01) + active -= 1 + return {"ok": True} + + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_list_keys", fake_list_keys) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) + monkeypatch.setattr(workflow_routes, "get_workflow_health", fake_health) + + result = await workflow_routes.refresh_workflow_api_health_cache() + + assert result == {"checked": 7, "healthy": 7} + assert peak == workflow_routes._WORKFLOW_API_HEALTH_PROBE_CONCURRENCY + assert workflow_routes._workflow_api_health_cache == dict.fromkeys(workflow_ids, True) + + +@pytest.mark.asyncio +async def test_workflow_integration_status_summarizes_running_capabilities( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "wf-running" + workflow_data = { + "id": workflow_id, + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python"}], + "edges": [], + "triggers": [ + {"id": "schedule-default", "type": "schedule", "enabled": True}, + {"id": "webhook-default", "type": "webhook", "enabled": True}, + ], + }, + } + + async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: + if str(key) == workflow_routes._api_service_key(workflow_id): + return {"workflowId": workflow_id, "status": "running"} + if str(key) == workflow_routes._runtime_key_main(workflow_id): + return {"status": "running"} + return None + + async def fake_trigger_statuses(_workflow_id: str, _workflow_json: dict[str, Any]) -> list[dict[str, Any]]: + return [ + {"triggerId": "webhook-default", "state": "ready"}, + {"triggerId": "schedule-default", "state": "running"}, + ] + + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) + workflow_routes._workflow_api_health_cache[workflow_id] = True + monkeypatch.setattr( + workflow_routes, + "default_trigger_runtime", + SimpleNamespace(get_workflow_trigger_statuses=fake_trigger_statuses), + ) + + status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) + + assert status.model_dump() == { + "api": {"configured": True, "state": "running"}, + "trigger": { + "configured": True, + "state": "running", + "count": 2, + "items": [ + { + "id": "schedule-default", + "type": "schedule", + "name": None, + "state": "running", + "rawState": "running", + }, + { + "id": "webhook-default", + "type": "webhook", + "name": None, + "state": "running", + "rawState": "ready", + }, + ], + }, + } + + +@pytest.mark.asyncio +async def test_workflow_integration_status_marks_unhealthy_api_as_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "wf-unhealthy" + workflow_data = { + "id": workflow_id, + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python"}], + "edges": [], + "triggers": [], + }, + } + + async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: + if str(key) == workflow_routes._api_service_key(workflow_id): + return {"workflowId": workflow_id, "status": "running"} + if str(key) == workflow_routes._runtime_key_main(workflow_id): + return {"status": "running"} + return None + + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) + workflow_routes._workflow_api_health_cache[workflow_id] = False + + status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) + + assert status.api.model_dump() == {"configured": True, "state": "error"} + + +@pytest.mark.asyncio +async def test_workflow_integration_status_marks_stopped_and_failed_red_states( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "wf-stopped" + workflow_data = { + "id": workflow_id, + "workflowJson": { + "start": "n1", + "nodes": [{"id": "n1", "type": "python"}], + "edges": [], + "triggers": [ + {"id": "schedule-default", "type": "schedule", "enabled": False}, + {"id": "kafka-default", "type": "kafka", "enabled": True}, + ], + }, + } + + async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: + if str(key) == workflow_routes._api_service_key(workflow_id): + return {"workflowId": workflow_id, "status": "stopped", "stoppedAt": 1} + return None + + async def fake_trigger_statuses(_workflow_id: str, _workflow_json: dict[str, Any]) -> list[dict[str, Any]]: + return [ + {"triggerId": "schedule-default", "state": "stopped"}, + {"triggerId": "kafka-default", "state": "failed"}, + ] + + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) + monkeypatch.setattr( + workflow_routes, + "default_trigger_runtime", + SimpleNamespace(get_workflow_trigger_statuses=fake_trigger_statuses), + ) + + status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) + + assert status.model_dump() == { + "api": {"configured": True, "state": "stopped"}, + "trigger": { + "configured": True, + "state": "error", + "count": 2, + "items": [ + { + "id": "schedule-default", + "type": "schedule", + "name": None, + "state": "stopped", + "rawState": "stopped", + }, + { + "id": "kafka-default", + "type": "kafka", + "name": None, + "state": "error", + "rawState": "failed", + }, + ], + }, + } diff --git a/tests/server/routes/test_workflow_routes_visibility.py b/tests/server/routes/test_workflow_routes_visibility.py index 46fea454c..0773c329b 100644 --- a/tests/server/routes/test_workflow_routes_visibility.py +++ b/tests/server/routes/test_workflow_routes_visibility.py @@ -1,21 +1,13 @@ from __future__ import annotations -import asyncio import json from pathlib import Path -from types import SimpleNamespace -from typing import Any import pytest from flocks.server.routes import workflow as workflow_routes -@pytest.fixture(autouse=True) -def clear_workflow_health_cache() -> None: - workflow_routes._workflow_api_health_cache.clear() - - def _write_workflow( root: Path, workflow_id: str, @@ -61,339 +53,3 @@ def test_list_workflows_from_fs_skips_hidden_templates( items = workflow_routes._list_workflows_from_fs() assert [item["id"] for item in items] == ["visible"] - - -@pytest.mark.asyncio -async def test_list_workflow_summaries_includes_integration_status_without_full_definition( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_migrate() -> None: - return None - - async def fake_stats(_workflow_id: str) -> dict[str, Any]: - return {"callCount": 0} - - async def fake_integration_status(_workflow_id: str, _workflow_data: dict[str, Any]) -> dict[str, Any]: - return { - "api": {"configured": True, "state": "running"}, - "trigger": {"configured": False, "state": "unconfigured", "count": 0, "items": []}, - } - - monkeypatch.setattr(workflow_routes, "_migrate_storage_to_filesystem", fake_migrate) - monkeypatch.setattr( - workflow_routes, - "_list_workflows_from_fs", - lambda: [ - { - "id": "wf-list", - "name": "Listed Workflow", - "category": "default", - "workflowJson": {"start": "n1", "nodes": [], "edges": []}, - "status": "active", - "source": "global", - "createdAt": 1, - "updatedAt": 2, - } - ], - ) - monkeypatch.setattr(workflow_routes, "_get_workflow_stats", fake_stats) - monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", fake_integration_status) - - items = await workflow_routes.list_workflow_summaries(category=None, status=None, exclude_id=None) - - assert items[0].integrationStatus == { - "api": {"configured": True, "state": "running"}, - "trigger": {"configured": False, "state": "unconfigured", "count": 0, "items": []}, - } - assert items[0].nodeCount == 0 - assert not hasattr(items[0], "workflowJson") - - -@pytest.mark.asyncio -async def test_list_workflow_summaries_enriches_multiple_items_concurrently( - monkeypatch: pytest.MonkeyPatch, -) -> None: - both_started = asyncio.Event() - release = asyncio.Event() - started = 0 - - async def fake_migrate() -> None: - return None - - async def fake_stats(_workflow_id: str) -> dict[str, Any]: - nonlocal started - started += 1 - if started == 2: - both_started.set() - await release.wait() - return {"callCount": 0} - - async def fake_integration_status(_workflow_id: str, _workflow_data: dict[str, Any]) -> dict[str, Any]: - return { - "api": {"configured": False, "state": "unconfigured"}, - "trigger": {"configured": False, "state": "unconfigured", "count": 0, "items": []}, - } - - def workflow_data(workflow_id: str) -> dict[str, Any]: - return { - "id": workflow_id, - "name": workflow_id, - "category": "default", - "workflowJson": {"start": "n1", "nodes": [], "edges": []}, - "status": "active", - "source": "global", - "createdAt": 1, - "updatedAt": 2, - } - - monkeypatch.setattr(workflow_routes, "_migrate_storage_to_filesystem", fake_migrate) - monkeypatch.setattr( - workflow_routes, - "_list_workflows_from_fs", - lambda: [workflow_data("wf-one"), workflow_data("wf-two")], - ) - monkeypatch.setattr(workflow_routes, "_get_workflow_stats", fake_stats) - monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", fake_integration_status) - - list_task = asyncio.create_task( - workflow_routes.list_workflow_summaries(category=None, status=None, exclude_id=None) - ) - await asyncio.wait_for(both_started.wait(), timeout=1) - release.set() - items = await list_task - - assert {item.id for item in items} == {"wf-one", "wf-two"} - - -@pytest.mark.asyncio -async def test_full_workflow_list_does_not_load_integration_status( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_migrate() -> None: - return None - - async def fake_stats(_workflow_id: str) -> dict[str, Any]: - return {"callCount": 0} - - async def unexpected_integration_status(*_args: Any, **_kwargs: Any) -> dict[str, Any]: - raise AssertionError("full workflow list must not load card integration status") - - monkeypatch.setattr(workflow_routes, "_migrate_storage_to_filesystem", fake_migrate) - monkeypatch.setattr( - workflow_routes, - "_list_workflows_from_fs", - lambda: [ - { - "id": "wf-full", - "name": "Full Workflow", - "category": "default", - "workflowJson": {"start": "n1", "nodes": [], "edges": []}, - "status": "active", - "source": "global", - "createdAt": 1, - "updatedAt": 2, - } - ], - ) - monkeypatch.setattr(workflow_routes, "_get_workflow_stats", fake_stats) - monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", unexpected_integration_status) - - items = await workflow_routes.list_workflows(category=None, status=None, exclude_id=None) - - assert items[0].id == "wf-full" - assert items[0].integrationStatus == {} - - -@pytest.mark.asyncio -async def test_workflow_api_health_cache_has_process_wide_probe_limit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_ids = [f"wf-{index}" for index in range(7)] - active = 0 - peak = 0 - - async def fake_list_keys(_prefix: str) -> list[str]: - return [workflow_routes._api_service_key(workflow_id) for workflow_id in workflow_ids] - - async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> dict[str, Any]: - workflow_id = str(key).removeprefix(workflow_routes._API_SERVICE_PREFIX) - return {"workflowId": workflow_id, "status": "running"} - - async def fake_health(_workflow_id: str) -> dict[str, Any]: - nonlocal active, peak - active += 1 - peak = max(peak, active) - await asyncio.sleep(0.01) - active -= 1 - return {"ok": True} - - monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_list_keys", fake_list_keys) - monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) - monkeypatch.setattr(workflow_routes, "get_workflow_health", fake_health) - - result = await workflow_routes.refresh_workflow_api_health_cache() - - assert result == {"checked": 7, "healthy": 7} - assert peak == workflow_routes._WORKFLOW_API_HEALTH_PROBE_CONCURRENCY - assert workflow_routes._workflow_api_health_cache == dict.fromkeys(workflow_ids, True) - - -@pytest.mark.asyncio -async def test_workflow_integration_status_summarizes_running_capabilities( - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_id = "wf-running" - workflow_data = { - "id": workflow_id, - "workflowJson": { - "start": "n1", - "nodes": [{"id": "n1", "type": "python"}], - "edges": [], - "triggers": [ - {"id": "schedule-default", "type": "schedule", "enabled": True}, - {"id": "webhook-default", "type": "webhook", "enabled": True}, - ], - }, - } - - async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: - if str(key) == workflow_routes._api_service_key(workflow_id): - return {"workflowId": workflow_id, "status": "running"} - if str(key) == workflow_routes._runtime_key_main(workflow_id): - return {"status": "running"} - return None - - async def fake_trigger_statuses(_workflow_id: str, _workflow_json: dict[str, Any]) -> list[dict[str, Any]]: - return [ - {"triggerId": "webhook-default", "state": "ready"}, - {"triggerId": "schedule-default", "state": "running"}, - ] - - monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) - workflow_routes._workflow_api_health_cache[workflow_id] = True - monkeypatch.setattr( - workflow_routes, - "default_trigger_runtime", - SimpleNamespace(get_workflow_trigger_statuses=fake_trigger_statuses), - ) - - status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) - - assert status == { - "api": {"configured": True, "state": "running"}, - "trigger": { - "configured": True, - "state": "running", - "count": 2, - "items": [ - { - "id": "schedule-default", - "type": "schedule", - "name": None, - "state": "running", - "rawState": "running", - }, - { - "id": "webhook-default", - "type": "webhook", - "name": None, - "state": "running", - "rawState": "ready", - }, - ], - }, - } - - -@pytest.mark.asyncio -async def test_workflow_integration_status_marks_unhealthy_api_as_error( - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_id = "wf-unhealthy" - workflow_data = { - "id": workflow_id, - "workflowJson": { - "start": "n1", - "nodes": [{"id": "n1", "type": "python"}], - "edges": [], - "triggers": [], - }, - } - - async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: - if str(key) == workflow_routes._api_service_key(workflow_id): - return {"workflowId": workflow_id, "status": "running"} - if str(key) == workflow_routes._runtime_key_main(workflow_id): - return {"status": "running"} - return None - - monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) - workflow_routes._workflow_api_health_cache[workflow_id] = False - - status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) - - assert status["api"] == {"configured": True, "state": "error"} - - -@pytest.mark.asyncio -async def test_workflow_integration_status_marks_stopped_and_failed_red_states( - monkeypatch: pytest.MonkeyPatch, -) -> None: - workflow_id = "wf-stopped" - workflow_data = { - "id": workflow_id, - "workflowJson": { - "start": "n1", - "nodes": [{"id": "n1", "type": "python"}], - "edges": [], - "triggers": [ - {"id": "schedule-default", "type": "schedule", "enabled": False}, - {"id": "kafka-default", "type": "kafka", "enabled": True}, - ], - }, - } - - async def fake_kv_get(key: Any, *_args: Any, **_kwargs: Any) -> Any: - if str(key) == workflow_routes._api_service_key(workflow_id): - return {"workflowId": workflow_id, "status": "stopped", "stoppedAt": 1} - return None - - async def fake_trigger_statuses(_workflow_id: str, _workflow_json: dict[str, Any]) -> list[dict[str, Any]]: - return [ - {"triggerId": "schedule-default", "state": "stopped"}, - {"triggerId": "kafka-default", "state": "failed"}, - ] - - monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_kv_get) - monkeypatch.setattr( - workflow_routes, - "default_trigger_runtime", - SimpleNamespace(get_workflow_trigger_statuses=fake_trigger_statuses), - ) - - status = await workflow_routes._get_workflow_integration_status(workflow_id, workflow_data) - - assert status == { - "api": {"configured": True, "state": "stopped"}, - "trigger": { - "configured": True, - "state": "error", - "count": 2, - "items": [ - { - "id": "schedule-default", - "type": "schedule", - "name": None, - "state": "stopped", - "rawState": "stopped", - }, - { - "id": "kafka-default", - "type": "kafka", - "name": None, - "state": "error", - "rawState": "failed", - }, - ], - }, - } diff --git a/tests/server/routes/test_workflow_summary_routes.py b/tests/server/routes/test_workflow_summary_routes.py new file mode 100644 index 000000000..d8d3bb373 --- /dev/null +++ b/tests/server/routes/test_workflow_summary_routes.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable + +import pytest + +from flocks.server.routes import workflow as workflow_routes + + +def _workflow_data(workflow_id: str) -> dict[str, Any]: + return { + "id": workflow_id, + "name": workflow_id, + "category": "default", + "workflowJson": {"start": "n1", "nodes": [], "edges": []}, + "status": "active", + "source": "global", + "createdAt": 1, + "updatedAt": 2, + } + + +async def _noop_migrate() -> None: + return None + + +async def _zero_stats(_workflow_id: str) -> dict[str, Any]: + return {"callCount": 0} + + +async def _unconfigured_status( + _workflow_id: str, + _workflow_data: dict[str, Any], +) -> workflow_routes.WorkflowIntegrationStatusResponse: + return workflow_routes.WorkflowIntegrationStatusResponse( + api=workflow_routes.WorkflowCapabilityStatusResponse(configured=False, state="unconfigured"), + trigger=workflow_routes.WorkflowTriggerCapabilityStatusResponse( + configured=False, + state="unconfigured", + ), + ) + + +def _patch_list_dependencies( + monkeypatch: pytest.MonkeyPatch, + workflows: list[dict[str, Any]], + *, + stats: Callable[[str], Awaitable[dict[str, Any]]] = _zero_stats, +) -> None: + monkeypatch.setattr(workflow_routes, "_migrate_storage_to_filesystem", _noop_migrate) + monkeypatch.setattr(workflow_routes, "_list_workflows_from_fs", lambda: workflows) + monkeypatch.setattr(workflow_routes, "_get_workflow_stats", stats) + monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", _unconfigured_status) + + +@pytest.mark.asyncio +async def test_list_workflow_summaries_includes_integration_status_without_full_definition( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow = _workflow_data("wf-list") + workflow["name"] = "Listed Workflow" + _patch_list_dependencies(monkeypatch, [workflow]) + + async def running_status( + _workflow_id: str, + _workflow_data: dict[str, Any], + ) -> workflow_routes.WorkflowIntegrationStatusResponse: + return workflow_routes.WorkflowIntegrationStatusResponse( + api=workflow_routes.WorkflowCapabilityStatusResponse(configured=True, state="running"), + trigger=workflow_routes.WorkflowTriggerCapabilityStatusResponse( + configured=False, + state="unconfigured", + ), + ) + + monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", running_status) + + items = await workflow_routes.list_workflow_summaries(category=None, status=None, exclude_id=None) + + assert items[0].integrationStatus.model_dump() == { + "api": {"configured": True, "state": "running"}, + "trigger": {"configured": False, "state": "unconfigured", "count": 0, "items": []}, + } + assert items[0].nodeCount == 0 + assert not hasattr(items[0], "workflowJson") + + +@pytest.mark.asyncio +async def test_list_workflow_summaries_enriches_multiple_items_concurrently( + monkeypatch: pytest.MonkeyPatch, +) -> None: + both_started = asyncio.Event() + release = asyncio.Event() + started = 0 + + async def blocking_stats(_workflow_id: str) -> dict[str, Any]: + nonlocal started + started += 1 + if started == 2: + both_started.set() + await release.wait() + return {"callCount": 0} + + _patch_list_dependencies( + monkeypatch, + [_workflow_data("wf-one"), _workflow_data("wf-two")], + stats=blocking_stats, + ) + + list_task = asyncio.create_task( + workflow_routes.list_workflow_summaries(category=None, status=None, exclude_id=None) + ) + await asyncio.wait_for(both_started.wait(), timeout=1) + release.set() + + items = await list_task + + assert {item.id for item in items} == {"wf-one", "wf-two"} + + +@pytest.mark.asyncio +async def test_full_workflow_list_does_not_load_integration_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow = _workflow_data("wf-full") + workflow["name"] = "Full Workflow" + _patch_list_dependencies(monkeypatch, [workflow]) + + async def unexpected_integration_status(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("full workflow list must not load card integration status") + + monkeypatch.setattr(workflow_routes, "_get_workflow_integration_status", unexpected_integration_status) + + items = await workflow_routes.list_workflows(category=None, status=None, exclude_id=None) + + assert items[0].id == "wf-full" + assert items[0].integrationStatus is None diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index 5efaf507b..03e1ed227 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -201,6 +201,16 @@ export interface WorkflowIntegrationStatus { trigger: WorkflowTriggerCapabilityStatus; } +export interface WorkflowStats { + callCount: number; + successCount: number; + errorCount: number; + totalRuntime: number; + avgRuntime: number; + thumbsUp: number; + thumbsDown: number; +} + export interface WorkflowSummary { id: string; name: string; @@ -212,16 +222,8 @@ export interface WorkflowSummary { createdAt: number; updatedAt: number; nodeCount: number; - integrationStatus?: WorkflowIntegrationStatus; - stats: { - callCount: number; - successCount: number; - errorCount: number; - totalRuntime: number; - avgRuntime: number; - thumbsUp: number; - thumbsDown: number; - }; + integrationStatus?: WorkflowIntegrationStatus | null; + stats: WorkflowStats; } export interface Workflow { @@ -238,16 +240,8 @@ export interface Workflow { createdBy?: string; createdAt: number; updatedAt: number; - integrationStatus?: WorkflowIntegrationStatus; - stats: { - callCount: number; - successCount: number; - errorCount: number; - totalRuntime: number; - avgRuntime: number; - thumbsUp: number; - thumbsDown: number; - }; + integrationStatus?: WorkflowIntegrationStatus | null; + stats: WorkflowStats; } export interface WorkflowExecutionStep { diff --git a/webui/src/pages/Workflow/index.test.tsx b/webui/src/pages/Workflow/index.test.tsx index 5f128ef4d..7da7fcc4a 100644 --- a/webui/src/pages/Workflow/index.test.tsx +++ b/webui/src/pages/Workflow/index.test.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { WorkflowSummary } from '@/api/workflow'; import WorkflowPage from './index'; const { mockNavigate, mockUseWorkflows, mockLanguage } = vi.hoisted(() => ({ @@ -72,12 +73,11 @@ vi.mock('@/components/common/EmptyState', () => ({ ), })); -function makeWorkflow(overrides: Partial = {}) { +function makeWorkflow(overrides: Partial = {}): WorkflowSummary { return { id: 'wf-1', name: '默认工作流', category: 'default', - workflowJson: { start: 'node-1', nodes: [{ id: 'node-1' }], edges: [] }, status: 'draft' as const, source: 'project' as const, createdAt: Date.now(), diff --git a/webui/src/pages/Workflow/index.tsx b/webui/src/pages/Workflow/index.tsx index 331ce5ae4..ae175ad8a 100644 --- a/webui/src/pages/Workflow/index.tsx +++ b/webui/src/pages/Workflow/index.tsx @@ -46,13 +46,32 @@ const CAPABILITY_STATUS_STYLES: Record; + +const CAPABILITY_STATUS_LABEL_KEYS = { + unconfigured: 'integration.state.disabled', + starting: 'integration.state.disabled', + running: 'integration.state.enabled', + stopped: 'integration.state.disabled', + error: 'integration.state.disabled', +} as const satisfies Record; + +const CAPABILITY_DETAIL_LABEL_KEYS = { + unconfigured: 'integration.detailState.unconfigured', + starting: 'integration.detailState.starting', + stopped: 'integration.detailState.stopped', + error: 'integration.detailState.error', +} as const; // --------------------------------------------------------------------------- // WorkflowPage @@ -402,20 +421,20 @@ function WorkflowCard({ workflow }: { workflow: WorkflowSummary }) { {triggerItems.length > 0 ? visibleTriggerItems.map(trigger => ( )) : ( )}
@@ -464,7 +483,7 @@ function CapabilityStatus({ const { t } = useTranslation('workflow'); const detailLabel = state === 'running' ? null - : t(`integration.detailState.${state}` as any); + : t(CAPABILITY_DETAIL_LABEL_KEYS[state]); const accessibleLabel = detailLabel ? `${label}:${statusLabel}(${detailLabel})` : `${label}:${statusLabel}`; @@ -591,9 +610,9 @@ function OverflowStatusList({ items }: { items: WorkflowTriggerStatusSummary[] } {items.map(item => (
))} From 0b61d3e72ce6a99392d213b383f965516cd4d4fe Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 15:07:47 +0800 Subject: [PATCH 65/86] Fix Windows Chrome debug setup guidance --- flocks/browser/admin.py | 100 +++++++++++++++++++---------------- flocks/browser/daemon.py | 19 ++++--- tests/browser/test_admin.py | 45 +++++++++++++--- tests/browser/test_daemon.py | 39 ++++++++++++-- 4 files changed, 140 insertions(+), 63 deletions(-) diff --git a/flocks/browser/admin.py b/flocks/browser/admin.py index 0ed418e08..0298876fc 100644 --- a/flocks/browser/admin.py +++ b/flocks/browser/admin.py @@ -49,10 +49,11 @@ def _log_tail(name: str | None): def _needs_chrome_remote_debugging_prompt(msg: str | None) -> bool: - """Return True when a local browser needs the inspect-page permission flow.""" + """Return True when a local browser needs remote-debugging setup guidance.""" lower = (msg or "").lower() return ( "devtoolsactiveport not found" in lower + or "chrome remote debugging is not reachable" in lower or "remote-debugging page" in lower or "inspect/#remote-debugging" in lower or "not live yet" in lower @@ -60,6 +61,52 @@ def _needs_chrome_remote_debugging_prompt(msg: str | None) -> bool: ) +def _local_debugging_setup_lines(system: str | None = None) -> list[str]: + """Return concise manual setup guidance for local Chrome debugging.""" + import platform + + system = system or platform.system() + lines = [ + "Do not look for webSocketDebuggerUrl in chrome://inspect; that page does not reliably show it.", + ] + if system == "Windows": + lines.extend( + [ + "On Windows, close Chrome and run this in PowerShell:", + '& "$env:ProgramFiles\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\\.flocks\\chrome-debug-profile"', + "If Chrome is installed elsewhere, replace the chrome.exe path.", + ] + ) + elif system == "Darwin": + lines.extend( + [ + "On macOS, close Chrome and run:", + "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chrome-debug-profile\"", + ] + ) + else: + lines.extend( + [ + "On Linux, close Chrome and run:", + "google-chrome --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chrome-debug-profile\"", + ] + ) + lines.extend( + [ + "Then verify http://127.0.0.1:9222/json/version; Flocks reads the WebSocket URL from that endpoint.", + ] + ) + return lines + + +def _print_local_debugging_setup(out=None) -> None: + import sys + + out = out or sys.stderr + for line in _local_debugging_setup_lines(): + print(line, file=out) + + def _configured_cdp_endpoint(env: dict | None = None) -> tuple[str | None, str | None]: """Return the explicit CDP endpoint env var name and value, if configured.""" merged_env = {**os.environ, **(env or {})} @@ -273,12 +320,8 @@ def spawn_daemon(): raise RuntimeError( msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}" ) - _open_browser_inspect() - print( - f"{BROWSER_LABEL}: click Allow on your browser's inspect page " - "(for example chrome://inspect or edge://inspect), and tick the checkbox if shown", - file=sys.stderr, - ) + print(f"{BROWSER_LABEL}: local Chrome remote debugging is not reachable.", file=sys.stderr) + _print_local_debugging_setup(sys.stderr) continue raise RuntimeError(msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}") @@ -385,40 +428,6 @@ def _chrome_running() -> bool: return False -def _open_browser_inspect() -> None: - import platform - import subprocess - import webbrowser - - inspect_targets = [ - ("Google Chrome", "chrome://inspect/#remote-debugging"), - ("Microsoft Edge", "edge://inspect/#remote-debugging"), - ] - if platform.system() == "Darwin": - for app_name, url in inspect_targets: - try: - subprocess.run( - [ - "osascript", - "-e", - f'tell application "{app_name}" to activate', - "-e", - f'tell application "{app_name}" to open location "{url}"', - ], - timeout=5, - check=False, - ) - return - except Exception: - continue - for _app_name, url in inspect_targets: - try: - if webbrowser.open(url, new=2): - return - except Exception: - continue - - def run_setup() -> int: """Interactively attach to the running browser.""" import sys @@ -450,11 +459,8 @@ def run_setup() -> int: needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err) if needs_inspect: - print("browser remote debugging is not enabled on the current profile.") - print("opening your browser's inspect page -- in the tab that opens:") - print(" 1. if the browser shows the profile picker, pick your normal profile;") - print(" 2. tick 'Discover network targets' and click Allow if prompted.") - _open_browser_inspect() + print("browser remote debugging is not reachable for the current profile.") + _print_local_debugging_setup(sys.stdout) else: print(f"attach failed: {first_err}") print("retrying once (the browser may still be starting up)...") @@ -529,7 +535,7 @@ def row(label: str, ok: bool, detail: str = "") -> None: daemon, "" if daemon - else "not running; run `flocks browser --setup` to attach; if setup reports remote debugging is disabled, follow its inspect-page prompt", + else "not running; run `flocks browser --setup` to attach; if setup reports remote debugging is not reachable, follow its debug-browser instructions", ) row("active browser connections", bool(connections), str(len(connections))) for conn in connections: diff --git a/flocks/browser/daemon.py b/flocks/browser/daemon.py index c735a6d50..aa617b6a8 100644 --- a/flocks/browser/daemon.py +++ b/flocks/browser/daemon.py @@ -53,9 +53,11 @@ def profile_dirs( system = system or platform.system() home = home or Path.home() environ = os.environ if environ is None else environ + flocks_debug_profile = home / ".flocks/chrome-debug-profile" if system == "Darwin": support = home / "Library/Application Support" return [ + flocks_debug_profile, support / "Google/Chrome", support / "Google/Chrome Canary", support / "Comet", @@ -70,6 +72,7 @@ def profile_dirs( if system == "Windows": local = Path(environ.get("LOCALAPPDATA", str(home / "AppData/Local"))).expanduser() return [ + flocks_debug_profile, local / "Google/Chrome/User Data", local / "Google/Chrome Beta/User Data", local / "Google/Chrome Dev/User Data", @@ -84,6 +87,7 @@ def profile_dirs( local / "BraveSoftware/Brave-Browser-Nightly/User Data", ] return [ + flocks_debug_profile, home / ".config/google-chrome", home / ".config/google-chrome-beta", home / ".config/google-chrome-unstable", @@ -125,7 +129,9 @@ def _local_cdp_ws_url(port: int, devtools_path: str | None = None) -> str: if not isinstance(payload, dict): raise ValueError("invalid CDP version response") websocket_url = payload.get("webSocketDebuggerUrl") - if not isinstance(websocket_url, str): + if not isinstance(websocket_url, str) and devtools_path and devtools_path.startswith("/"): + websocket_url = f"ws://127.0.0.1:{port}{devtools_path}" + elif not isinstance(websocket_url, str): raise ValueError("invalid webSocketDebuggerUrl") except urllib.error.HTTPError: if not devtools_path or not devtools_path.startswith("/"): @@ -198,15 +204,14 @@ def get_ws_url() -> str: if profile_errors: details = "; ".join(dict.fromkeys(profile_errors)) raise RuntimeError( - "The browser's remote-debugging page is open, but DevTools is not live yet for any detected profile: " - f"{details} — if the browser opened a profile picker, choose your normal profile first, then tick the " - "checkbox and click Allow if shown" + "Chrome remote debugging is not reachable for any detected profile: " + f"{details} — for manual setup, start Chrome with --remote-debugging-port and a non-default " + "--user-data-dir, then verify http://127.0.0.1:9222/json/version" ) raise RuntimeError( "DevToolsActivePort not found in " - f"{[str(path) for path in profiles]} — enable your browser's remote-debugging page " - "(for example chrome://inspect/#remote-debugging or edge://inspect/#remote-debugging), " - "or set BU_CDP_WS for a remote browser" + f"{[str(path) for path in profiles]} — start Chrome with --remote-debugging-port and a non-default " + "--user-data-dir, or set BU_CDP_WS for a remote browser" ) diff --git a/tests/browser/test_admin.py b/tests/browser/test_admin.py index 52c786431..39d3b42e6 100644 --- a/tests/browser/test_admin.py +++ b/tests/browser/test_admin.py @@ -57,6 +57,25 @@ def test_generic_remote_debugging_message_triggers_prompt() -> None: assert admin._needs_chrome_remote_debugging_prompt(msg) +def test_current_remote_debugging_unreachable_message_triggers_prompt() -> None: + msg = ( + "Chrome remote debugging is not reachable for any detected profile: " + "/tmp/chrome: 127.0.0.1:9222 (connection refused) — for manual setup, " + "start Chrome with --remote-debugging-port and a non-default --user-data-dir" + ) + assert admin._needs_chrome_remote_debugging_prompt(msg) + + +def test_local_debugging_setup_lines_use_windows_user_data_dir() -> None: + lines = "\n".join(admin._local_debugging_setup_lines("Windows")) + + assert "chrome://inspect" in lines + assert "does not reliably show it" in lines + assert "--remote-debugging-port=9222" in lines + assert '--user-data-dir="$env:USERPROFILE\\.flocks\\chrome-debug-profile"' in lines + assert "http://127.0.0.1:9222/json/version" in lines + + def test_daemon_endpoint_names_discovers_default_and_named_sessions(tmp_path, monkeypatch) -> None: monkeypatch.setattr(admin.ipc, "IS_WINDOWS", False) monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) @@ -286,8 +305,7 @@ def test_run_setup_uses_generic_remote_debugging_wording(monkeypatch, capsys) -> monkeypatch.setattr(admin, "daemon_alive", lambda: False) monkeypatch.setattr(admin, "_chrome_running", lambda: True) monkeypatch.setattr(admin, "_is_local_chrome_mode", lambda env=None: True) - open_calls = [] - monkeypatch.setattr(admin, "_open_browser_inspect", lambda: open_calls.append(True)) + monkeypatch.setattr(admin, "_local_debugging_setup_lines", lambda: ["debug browser instructions"]) calls = {"count": 0} @@ -304,10 +322,9 @@ def fake_ensure_daemon(*args, **kwargs): assert admin.run_setup() == 0 out = capsys.readouterr().out - assert "browser remote debugging is not enabled on the current profile." in out - assert "opening your browser's inspect page" in out - assert "if the browser shows the profile picker" in out - assert open_calls == [True] + assert "browser remote debugging is not reachable for the current profile." in out + assert "debug browser instructions" in out + assert "opening your browser's inspect page" not in out def test_run_setup_restarts_stale_existing_local_daemon(monkeypatch, capsys) -> None: @@ -402,6 +419,22 @@ def test_run_doctor_uses_generic_browser_wording_when_missing(monkeypatch, capsy assert "next action start Chrome/Chromium/Edge or provide BU_CDP_URL/BU_CDP_WS" in out +def test_run_doctor_points_to_debug_browser_instructions_when_daemon_missing(monkeypatch, capsys) -> None: + monkeypatch.setattr(admin, "_version", lambda: "0.1.0") + monkeypatch.setattr(admin, "_install_mode", lambda: "git") + monkeypatch.setattr(admin, "_chrome_running", lambda: True) + monkeypatch.setattr(admin, "daemon_alive", lambda: False) + monkeypatch.setattr(admin, "browser_connections", lambda: []) + monkeypatch.setattr(admin, "_latest_release_tag", lambda: "0.1.0") + + assert admin.run_doctor() == 1 + + out = capsys.readouterr().out + assert "follow its debug-browser instructions" in out + assert "remote debugging is not reachable" in out + assert "inspect-page prompt" not in out + + def test_run_doctor_accepts_explicit_remote_cdp_without_local_browser(monkeypatch, capsys) -> None: monkeypatch.setenv("BU_CDP_URL", "http://127.0.0.1:9222") monkeypatch.setattr(admin, "_version", lambda: "0.1.0") diff --git a/tests/browser/test_daemon.py b/tests/browser/test_daemon.py index b791cb869..d837773f7 100644 --- a/tests/browser/test_daemon.py +++ b/tests/browser/test_daemon.py @@ -163,6 +163,7 @@ def test_is_real_page_accepts_normal_https_pages() -> None: def test_profile_dirs_only_returns_paths_for_requested_os() -> None: home = Path.home() / "profile-test-home" local_app_data = home / "AppData/Local" + flocks_debug_profile = home / ".flocks/chrome-debug-profile" mac_profiles = daemon.profile_dirs(system="Darwin", home=home, environ={}) linux_profiles = daemon.profile_dirs(system="Linux", home=home, environ={}) @@ -172,9 +173,12 @@ def test_profile_dirs_only_returns_paths_for_requested_os() -> None: environ={"LOCALAPPDATA": str(local_app_data)}, ) - assert all("Library" in path.parts and "Application Support" in path.parts for path in mac_profiles) - assert all(".config" in path.parts or ".var" in path.parts for path in linux_profiles) - assert all(path.is_relative_to(local_app_data) for path in windows_profiles) + assert mac_profiles[0] == flocks_debug_profile + assert linux_profiles[0] == flocks_debug_profile + assert windows_profiles[0] == flocks_debug_profile + assert all("Library" in path.parts and "Application Support" in path.parts for path in mac_profiles[1:]) + assert all(".config" in path.parts or ".var" in path.parts for path in linux_profiles[1:]) + assert all(path.is_relative_to(local_app_data) for path in windows_profiles[1:]) def test_get_ws_url_skips_unreachable_profile_and_uses_next_candidate(tmp_path, monkeypatch) -> None: @@ -302,6 +306,35 @@ def fake_urlopen(url: str, timeout: float): assert daemon.get_ws_url() == "ws://127.0.0.1:9222/devtools/browser/current" +def test_get_ws_url_uses_devtools_active_port_path_when_version_omits_websocket_url( + tmp_path, monkeypatch +) -> None: + profile = tmp_path / "chrome" + profile.mkdir() + (profile / "DevToolsActivePort").write_text( + "9222\n/devtools/browser/current\n", + encoding="utf-8", + ) + + class FakeHttpResponse: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + pass + + def read(self) -> bytes: + return b'{"Browser":"Chrome/126.0","Protocol-Version":"1.3"}' + + monkeypatch.delenv("BU_CDP_WS", raising=False) + monkeypatch.delenv("BU_CDP_URL", raising=False) + monkeypatch.setattr(daemon, "profile_dirs", lambda: [profile]) + monkeypatch.setattr(daemon.urllib.request, "urlopen", lambda _url, timeout: FakeHttpResponse()) + monkeypatch.setattr(daemon.time, "sleep", lambda _seconds: pytest.fail("missing field must not be retried")) + + assert daemon.get_ws_url() == "ws://127.0.0.1:9222/devtools/browser/current" + + def test_load_env_uses_shared_loader_for_existing_files(tmp_path, monkeypatch) -> None: workspace = tmp_path / "workspace" workspace.mkdir() From 2a8ae72066c075b547c4ccf90d8a6be8659dd398 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 21 Jul 2026 15:20:32 +0800 Subject: [PATCH 66/86] fix(workflow): provide tool context for trigger executions --- flocks/ingest/kafka/manager.py | 6 ++ flocks/ingest/syslog/manager.py | 6 ++ flocks/workflow/poller_manager.py | 6 ++ flocks/workflow/triggers/runtime.py | 6 ++ tests/ingest/test_kafka_manager.py | 20 ++++++- .../test_syslog_manager_backpressure.py | 18 +++++- tests/workflow/test_poller_manager.py | 28 ++++++++- tests/workflow/test_trigger_runtime.py | 57 +++++++++++++++++++ 8 files changed, 141 insertions(+), 6 deletions(-) diff --git a/flocks/ingest/kafka/manager.py b/flocks/ingest/kafka/manager.py index 16acca4b6..0cf60a49d 100644 --- a/flocks/ingest/kafka/manager.py +++ b/flocks/ingest/kafka/manager.py @@ -42,6 +42,7 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore +from flocks.workflow.tool_context import build_workflow_tool_context from flocks.ingest.kafka.constants import WORKFLOW_KAFKA_CONFIG_PREFIX from flocks.workflow.triggers.compat import legacy_kafka_trigger_from_config @@ -699,6 +700,10 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: ), ) try: + tool_context = await build_workflow_tool_context( + workflow_id=workflow_id, + action_name=f"trigger:{trigger.type}", + ) result = await asyncio.to_thread( run_workflow, workflow=workflow_plan, @@ -707,6 +712,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: trace=False, execution_profile="high_frequency", on_step_complete=step_recorder.on_step_complete, + tool_context=tool_context, ) status, error_msg = resolve_execution_outcome(result) duration = time.time() - start_time diff --git a/flocks/ingest/syslog/manager.py b/flocks/ingest/syslog/manager.py index 3fccaed88..737be4903 100644 --- a/flocks/ingest/syslog/manager.py +++ b/flocks/ingest/syslog/manager.py @@ -20,6 +20,7 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore +from flocks.workflow.tool_context import build_workflow_tool_context from flocks.ingest.syslog.constants import WORKFLOW_SYSLOG_CONFIG_PREFIX from flocks.ingest.syslog.listener import run_tcp_syslog_server, run_udp_syslog_server @@ -539,6 +540,10 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: start_time = time.time() trigger_meta = mapped_inputs.get("_flocks", {}).get("trigger", {}) try: + tool_context = await build_workflow_tool_context( + workflow_id=workflow_id, + action_name=f"trigger:{trigger.type}", + ) result = await asyncio.to_thread( run_workflow, workflow=workflow_plan, @@ -547,6 +552,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: trace=False, execution_profile="high_frequency", on_step_complete=step_recorder.on_step_complete, + tool_context=tool_context, ) status, error_msg = resolve_execution_outcome(result) duration = time.time() - start_time diff --git a/flocks/workflow/poller_manager.py b/flocks/workflow/poller_manager.py index c4596286c..4bcea2422 100644 --- a/flocks/workflow/poller_manager.py +++ b/flocks/workflow/poller_manager.py @@ -28,6 +28,7 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import RunWorkflowResult, run_workflow from flocks.workflow.store import WorkflowStore +from flocks.workflow.tool_context import build_workflow_tool_context WORKFLOW_POLLER_CONFIG_PREFIX = "workflow_poller_config/" DEFAULT_INTERVAL_SECONDS = 30 @@ -435,6 +436,10 @@ async def _execute_run( self._status[workflow_id] = current try: + tool_context = await build_workflow_tool_context( + workflow_id=workflow_id, + action_name="trigger:schedule", + ) result = await asyncio.to_thread( run_workflow, workflow=workflow_json, @@ -445,6 +450,7 @@ async def _execute_run( execution_profile="high_frequency", cancel=cancel_event.is_set, on_step_complete=step_recorder.on_step_complete, + tool_context=tool_context, ) if not isinstance(result, RunWorkflowResult): result = RunWorkflowResult(status="failed", error="invalid_run_result") diff --git a/flocks/workflow/triggers/runtime.py b/flocks/workflow/triggers/runtime.py index 74cb329e2..e41eba087 100644 --- a/flocks/workflow/triggers/runtime.py +++ b/flocks/workflow/triggers/runtime.py @@ -18,6 +18,7 @@ from flocks.workflow.fs_store import read_workflow_dir, workflow_scan_dirs from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore +from flocks.workflow.tool_context import build_workflow_tool_context from .compat import ( LEGACY_KAFKA_CONFIG_PREFIX, @@ -219,11 +220,16 @@ async def _execute_workflow( exec_id = exec_data["id"] started_at = time.time() try: + tool_context = await build_workflow_tool_context( + workflow_id=workflow_id, + action_name=f"trigger:{trigger.type}", + ) result = await asyncio.to_thread( run_workflow, workflow=workflow_json, inputs=mapped_inputs, trace=False, + tool_context=tool_context, ) status_value, error_message = resolve_execution_outcome(result) exec_data.update( diff --git a/tests/ingest/test_kafka_manager.py b/tests/ingest/test_kafka_manager.py index 31de29d02..9245aebc3 100644 --- a/tests/ingest/test_kafka_manager.py +++ b/tests/ingest/test_kafka_manager.py @@ -17,6 +17,7 @@ import json import sys from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -25,6 +26,14 @@ from flocks.workflow.triggers.models import TriggerDefinition +@pytest.fixture(autouse=True) +def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + context = object() + builder = AsyncMock(return_value=context) + monkeypatch.setattr(kafka_manager, "build_workflow_tool_context", builder) + return SimpleNamespace(context=context, builder=builder) + + @pytest.mark.asyncio async def test_worker_pool_bounds_in_flight_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: """The fixed worker pool must cap concurrent ``_trigger_workflow`` calls.""" @@ -61,7 +70,7 @@ async def _fake_trigger(workflow_id, workflow_json, msg, input_key, producer=Non manager._abort_events[workflow_id] = abort workers = [ asyncio.create_task( - manager._worker_loop(workflow_id, {}, trigger, {}, queue, abort, "topic-a"), + manager._worker_loop(workflow_id, {}, trigger, {}, queue, abort, "topic-a"), name=f"test-worker-{i}", ) for i in range(pool_size) @@ -83,8 +92,7 @@ async def _fake_trigger(workflow_id, workflow_json, msg, input_key, producer=Non assert completed == burst_size, f"expected {burst_size} dispatches, got {completed}" assert max_in_flight <= pool_size, ( - f"in-flight dispatches exceeded worker pool size: " - f"max_in_flight={max_in_flight}, pool_size={pool_size}" + f"in-flight dispatches exceeded worker pool size: max_in_flight={max_in_flight}, pool_size={pool_size}" ) @@ -405,6 +413,7 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 @pytest.mark.asyncio async def test_trigger_workflow_applies_mapping_and_filter( monkeypatch: pytest.MonkeyPatch, + trigger_tool_context: SimpleNamespace, ) -> None: manager = kafka_manager.KafkaManager() captured_run_kwargs: dict = {} @@ -456,6 +465,11 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 assert captured_run_kwargs["inputs"]["order_id"] == 7 assert captured_run_kwargs["inputs"]["region"] == "cn" assert captured_run_kwargs["inputs"]["pipeline"] == "orders" + assert captured_run_kwargs["tool_context"] is trigger_tool_context.context + trigger_tool_context.builder.assert_awaited_once_with( + workflow_id="wf-orders", + action_name="trigger:kafka", + ) assert recorded_exec_data["triggerId"] == "kafka-orders" assert recorded_exec_data["triggerSource"] == "orders-topic" diff --git a/tests/ingest/test_syslog_manager_backpressure.py b/tests/ingest/test_syslog_manager_backpressure.py index a042e2fc5..d35d88155 100644 --- a/tests/ingest/test_syslog_manager_backpressure.py +++ b/tests/ingest/test_syslog_manager_backpressure.py @@ -18,6 +18,7 @@ import asyncio from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -26,6 +27,14 @@ from flocks.workflow.triggers.models import TriggerDefinition +@pytest.fixture(autouse=True) +def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + context = object() + builder = AsyncMock(return_value=context) + monkeypatch.setattr(syslog_manager, "build_workflow_tool_context", builder) + return SimpleNamespace(context=context, builder=builder) + + @pytest.mark.asyncio async def test_worker_pool_bounds_in_flight_dispatches(monkeypatch: pytest.MonkeyPatch) -> None: """The fixed worker pool must cap concurrent ``_trigger_workflow`` calls. @@ -96,8 +105,7 @@ async def _fake_trigger(workflow_id, workflow_json, msg, input_key, **kwargs): assert completed == burst_size, f"expected {burst_size} dispatches, got {completed}" assert max_in_flight <= pool_size, ( - f"in-flight dispatches exceeded worker pool size: " - f"max_in_flight={max_in_flight}, pool_size={pool_size}" + f"in-flight dispatches exceeded worker pool size: max_in_flight={max_in_flight}, pool_size={pool_size}" ) @@ -167,6 +175,7 @@ async def _noop_trigger(*args, **kwargs): # noqa: ANN001, D401 @pytest.mark.asyncio async def test_trigger_workflow_applies_mapping_and_filter( monkeypatch: pytest.MonkeyPatch, + trigger_tool_context: SimpleNamespace, ) -> None: manager = syslog_manager.SyslogManager() captured_run_kwargs: dict = {} @@ -240,7 +249,12 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 assert captured_run_kwargs["inputs"]["pipeline"] == "syslog" assert captured_run_kwargs["run_id"] == "exec-syslog" assert captured_run_kwargs["execution_profile"] == "high_frequency" + assert captured_run_kwargs["tool_context"] is trigger_tool_context.context assert callable(captured_run_kwargs["on_step_complete"]) + trigger_tool_context.builder.assert_awaited_once_with( + workflow_id="wf-syslog", + action_name="trigger:syslog", + ) assert recorded_steps[0][0] == "exec-syslog" assert recorded_steps[0][1] == 1 assert recorded_steps[0][2]["node_id"] == "receive_alert" diff --git a/tests/workflow/test_poller_manager.py b/tests/workflow/test_poller_manager.py index 4b7f915df..b8c5ea7f1 100644 --- a/tests/workflow/test_poller_manager.py +++ b/tests/workflow/test_poller_manager.py @@ -4,14 +4,28 @@ import threading from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock import pytest +from flocks.tool import ToolContext from flocks.workflow import poller_manager from flocks.workflow import execution_store from flocks.workflow.runner import RunWorkflowResult +@pytest.fixture(autouse=True) +def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + context = ToolContext( + session_id="schedule-parent", + message_id="schedule-message", + agent="rex", + ) + builder = AsyncMock(return_value=context) + monkeypatch.setattr(poller_manager, "build_workflow_tool_context", builder) + return SimpleNamespace(context=context, builder=builder) + + @pytest.mark.asyncio async def test_restart_disabled_config_reports_stopped(monkeypatch: pytest.MonkeyPatch) -> None: manager = poller_manager.WorkflowPollerManager() @@ -42,7 +56,10 @@ async def _fake_get_config(_workflow_id: str, *, kind: str) -> dict[str, Any]: @pytest.mark.asyncio -async def test_run_once_injects_dynamic_inputs_and_summary(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_run_once_injects_dynamic_inputs_and_summary( + monkeypatch: pytest.MonkeyPatch, + trigger_tool_context: SimpleNamespace, +) -> None: manager = poller_manager.WorkflowPollerManager() captured_inputs: dict[str, Any] = {} @@ -63,6 +80,7 @@ def _fake_run_workflow( # noqa: ANN001 on_step_complete, run_id: str, execution_profile: str, + tool_context: ToolContext, ): captured_inputs.update(inputs) assert workflow["start"] == "n1" @@ -71,6 +89,7 @@ def _fake_run_workflow( # noqa: ANN001 assert trace is False assert run_id == "exec-wf-run-once" assert execution_profile == "high_frequency" + assert tool_context is trigger_tool_context.context assert cancel() is False return RunWorkflowResult( status="success", @@ -130,6 +149,10 @@ def _fake_run_workflow( # noqa: ANN001 assert captured_inputs["input_date"] assert captured_inputs["_trigger"] == "poller" assert captured_inputs["_poller_run_id"].startswith("poller-") + trigger_tool_context.builder.assert_awaited_once_with( + workflow_id="wf-run-once", + action_name="trigger:schedule", + ) @pytest.mark.asyncio @@ -193,6 +216,7 @@ def _fake_run_workflow( # noqa: ANN001 on_step_complete, run_id: str, execution_profile: str, + tool_context: ToolContext, ): assert workflow["start"] == "n1" assert workflow["nodes"][0]["id"] == "n1" @@ -284,6 +308,7 @@ def _fake_run_workflow( # noqa: ANN001 on_step_complete, run_id: str, execution_profile: str, + tool_context: ToolContext, ): _ = workflow, inputs, timeout_s, trace, cancel, run_id _ = on_step_complete @@ -380,6 +405,7 @@ def _fake_run_workflow( # noqa: ANN001 on_step_complete, run_id: str, execution_profile: str, + tool_context: ToolContext, ): _ = workflow, inputs, timeout_s, trace, cancel, run_id _ = on_step_complete diff --git a/tests/workflow/test_trigger_runtime.py b/tests/workflow/test_trigger_runtime.py index 023ce4f32..a7401e790 100644 --- a/tests/workflow/test_trigger_runtime.py +++ b/tests/workflow/test_trigger_runtime.py @@ -2,10 +2,67 @@ import asyncio from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import pytest +from flocks.tool import ToolContext from flocks.workflow.triggers import runtime as runtime_module +from flocks.workflow.triggers.models import TriggerDefinition + + +@pytest.mark.asyncio +async def test_trigger_execution_builds_tool_context_for_workflow_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + tool_context = ToolContext( + session_id="trigger-parent", + message_id="trigger-message", + agent="rex", + ) + build_context = AsyncMock(return_value=tool_context) + + def _fake_run_workflow(**kwargs): # noqa: ANN003 + missing_context = kwargs.get("tool_context") is None + return SimpleNamespace( + status="FAILED" if missing_context else "SUCCEEDED", + outputs={}, + error="Parent session not found" if missing_context else None, + history=[], + last_node_id="notify", + steps=1, + ) + + monkeypatch.setattr( + runtime_module, + "build_workflow_tool_context", + build_context, + raising=False, + ) + monkeypatch.setattr(runtime_module, "run_workflow", Mock(side_effect=_fake_run_workflow)) + monkeypatch.setattr( + runtime_module, + "create_execution_record", + AsyncMock(return_value={"id": "exec-1"}), + ) + monkeypatch.setattr(runtime_module, "record_execution_result", AsyncMock()) + + trigger = TriggerDefinition.model_validate({"id": "webhook-trigger", "type": "custom_webhook"}) + runtime = runtime_module.TriggerRuntime() + + result = await runtime._execute_workflow( # noqa: SLF001 + workflow_id="wf-trigger", + workflow_json={"start": "notify", "nodes": [], "edges": []}, + trigger=trigger, + mapped_inputs={"message": "hello"}, + ) + + assert result["status"] == "success" + build_context.assert_awaited_once_with( + workflow_id="wf-trigger", + action_name="trigger:custom_webhook", + ) + assert runtime_module.run_workflow.call_args.kwargs["tool_context"] is tool_context @pytest.mark.asyncio From ebbc006f447cd7e3f13b945550205652719bf291 Mon Sep 17 00:00:00 2001 From: luguili Date: Tue, 21 Jul 2026 15:53:44 +0800 Subject: [PATCH 67/86] fix(edr): recover browser daemon for auth flows --- .../plugins/skills/sangfor-edr-use/SKILL.md | 7 +- .../references/cdp-workflow.md | 27 +++-- .../sangfor_edr_webcli/sangfor_edr.handler.py | 53 +++++++++- .../sangfor_edr_webcli/sangfor_edr_auth.yaml | 4 +- tests/tool/test_sangfor_edr_handler.py | 99 +++++++++++++++++++ 5 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 tests/tool/test_sangfor_edr_handler.py diff --git a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md index a51058991..9ba6e0b06 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md @@ -11,7 +11,7 @@ description: 用于处理深信服 EDR(终端检测与响应)相关任务, 当用户需要打开深信服 EDR 页面,或需要通过 Web2CLI 抓取 EDR 页面请求时,必须按下面顺序处理登录态,不要一开始就要求用户提供账密: -1. 先调用 `sangfor_edr_auth` 的 `action=status_auth_state` 或 `action=validate_auth_state`,检查 `~/.flocks/browser/sangfor-edr/auth-state.json` 是否存在且可用。 +1. 先调用 `sangfor_edr_auth` 的 `action=status_auth_state` 或 `action=validate_auth_state`,检查 `~/.flocks/browser/sangfor-edr/auth-state.json` 是否存在且可用。凡是需要访问浏览器的 action,工具会自动启动 daemon,并替换陈旧或协议不兼容的 daemon 实例。 2. 如果登录态可用,直接复用该 state,继续打开 EDR 页面或执行 Web2CLI 抓取流程。 3. 如果 state 不存在或已失效,但本地已经保存了 EDR 地址、用户名和密码,调用 `sangfor_edr_auth` 的 `action=ensure_auth_state` 自动刷新登录态。 4. 如果没有可用 state,也没有保存账密配置,再引导用户选择: @@ -21,6 +21,8 @@ description: 用于处理深信服 EDR(终端检测与响应)相关任务, 无论采用哪种方式,只要获得可用登录态,就继续原有浏览器 / Web2CLI 流程:加载登录态、打开目标 EDR 页面、按需注入 Web2CLI hook、执行页面操作并导出捕获到的请求。后续再次打开页面时,仍必须先校验 `auth-state.json`;若登录态失效且已保存账密,则自动重新走 CDP 登录并刷新 state。 若自动登录过程中出现验证码识别失败、MFA 校验、页面选择器变化、未检测到登录成功或有效 `sessionid` 等情况,立即回退到原有浏览器手动登录流程。 + +若返回 `browser_daemon_not_ready` 或 `auth_state_load_failed_browser_daemon_not_ready`,依次执行 `flocks browser --setup` 和 `flocks browser --doctor`,然后重试原 action。不要手工创建或修改 `bu.port`:该文件保存的是 Flocks daemon IPC 端口,不是 Chrome remote-debugging 端口。 > ⚠️ **EDR 没有开放 API**,所有操作必须通过浏览器(CDP 直连)完成。 @@ -73,7 +75,8 @@ powershell -Command "& '\Scripts\python.exe' '\skil | 坑 | 原因 | 解法 | |---|---|---| | `flocks browser -c js(...)` 返回空文本 | daemon session 指向错误的 tab | 用 Python socket 直连 daemon,通过 `Runtime.evaluate` 在正确 context 执行 | -| `flocks browser -c new_tab()` 后后续命令无响应 | tab 切换导致 session 错位 | 用 `switch_tab(targetId)` 明确切到 EDR tab | +| `flocks browser -c new_tab()` 后后续命令无响应 | tab 切换导致 session 错位 | 用 `switch_tab(targetId)` 明确切到 EDR tab | +| `bu.port` 不存在或端口失效 | daemon 未启动或状态陈旧 | 先重试 `sangfor_edr_auth`;仍失败则运行 `flocks browser --setup` 和 `flocks browser --doctor`,禁止手工改 `bu.port` | | 多行代码转义失败 | PowerShell 引号嵌套 | 使用 `fetch_edr_system_state.py` 脚本,无需手动转义 | | EDR 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | | 失陷设备数量不匹配 | 读取的是"全部"筛选而非"已失陷"筛选 | 需点击"已失陷终端"标签页获取准确数量 | diff --git a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md index d8b224596..ddbcd6d05 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md +++ b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md @@ -10,12 +10,17 @@ ## 零、前置条件 -### 1. 确保浏览器 daemon 可用 -```bash -flocks browser --doctor -``` +### 1. 确保浏览器 daemon 可用 + +`sangfor_edr_auth` 在需要浏览器时会自动确保 daemon 运行,并尝试替换陈旧实例。仅在工具返回 `browser_daemon_not_ready` 时执行下列诊断: + +```bash +flocks browser --doctor +``` -如果 `active browser connections` 为 0,需用户开启浏览器 remote debugging。 +如果 `active browser connections` 为 0,需用户开启浏览器 remote debugging。 + +> 禁止手工创建或改写 `~/.flocks/browser/bu.port`。它指向 Flocks browser daemon 的自定义 IPC 端口,不是 Chrome 的 `--remote-debugging-port`。 ### 2. 开启 Chrome Remote Debugging @@ -60,10 +65,13 @@ chromium --remote-debugging-port=9222 - `https://edr.example.com/` - `https://edr.company.com/` -### Step 2:检查 daemon -```bash -flocks browser --doctor -``` +### Step 2:检查 daemon + +先直接调用 `sangfor_edr_auth`。该工具会自动启动或恢复 daemon。仅当返回 `browser_daemon_not_ready` 或 `auth_state_load_failed_browser_daemon_not_ready` 时,执行: + +```bash +flocks browser --doctor +``` 如果 daemon 未运行,执行: ```bash @@ -197,6 +205,7 @@ print(text_result["result"]["result"]["value"]) | 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | | 页面显示登录框 | 会话已失效 | 若已保存账密,调用 `sangfor_edr_auth` 自动刷新 state;否则提示用户手动登录 EDR | | 自动登录失败 | 验证码识别失败、MFA、页面选择器变化或登录成功检测失败 | 回退到浏览器手动登录,登录后保存 state | +| `browser_daemon_not_ready` | daemon 无法启动或连接 Chrome | 执行 `flocks browser --setup` 和 `flocks browser --doctor`,然后重试;不要修改 `bu.port` | --- diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py index d456738fa..2968452c3 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py @@ -24,6 +24,7 @@ from urllib.parse import urljoin, urlparse from flocks.browser import helpers +from flocks.browser.admin import ensure_daemon from flocks.config.config_writer import ConfigWriter from flocks.tool.registry import ToolContext, ToolResult @@ -326,6 +327,36 @@ def _open_page(url: str) -> None: helpers.wait_for_load(timeout=15) +def _ensure_browser_daemon() -> None: + """Start the browser daemon or replace a stale/incompatible instance.""" + ensure_daemon(wait=15.0, _open_inspect=False) + + +def _browser_daemon_result(exc: Exception, auth_state_path: Path) -> dict[str, Any]: + return { + "success": False, + "valid": False, + "status": "browser_daemon_not_ready", + "reason": "browser_daemon_not_ready", + "error": str(exc), + "next_action": "run `flocks browser --setup`, then `flocks browser --doctor`, then retry", + "auth_state_path": str(auth_state_path), + } + + +def _browser_page_open_result(exc: Exception, cfg: RuntimeConfig) -> dict[str, Any]: + return { + "success": False, + "valid": False, + "status": "browser_login_page_open_failed", + "reason": "browser_login_page_open_failed", + "error": str(exc), + "next_action": "verify base_url and EDR connectivity, then retry", + "login_url": _login_url(cfg), + "auth_state_path": str(cfg.auth_state_path), + } + + def _page_text() -> str: try: return str(helpers.js("document.body ? document.body.innerText : ''") or "") @@ -393,6 +424,13 @@ def _validate_auth_state(cfg: RuntimeConfig) -> dict[str, Any]: "reason": "auth_state_not_found", "auth_state_path": str(cfg.auth_state_path), } + try: + _ensure_browser_daemon() + except Exception as exc: + result = _browser_daemon_result(exc, cfg.auth_state_path) + result["reason"] = "auth_state_load_failed_browser_daemon_not_ready" + return result + try: loaded = helpers.load_state(cfg.auth_state_path, url=_index_url(cfg)) helpers.wait_for_load(timeout=15) @@ -790,7 +828,14 @@ def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = " "auth_state_path": str(cfg.auth_state_path), } - _open_page(_login_url(cfg)) + try: + _ensure_browser_daemon() + except Exception as exc: + return _browser_daemon_result(exc, cfg.auth_state_path) + try: + _open_page(_login_url(cfg)) + except Exception as exc: + return _browser_page_open_result(exc, cfg) form_state = _wait_for_login_form_ready(cfg) last_error = "" for attempt in range(1, cfg.max_captcha_retry + 1): @@ -881,7 +926,11 @@ async def handle(ctx: ToolContext) -> ToolResult: if action == "validate_auth_state": validation = _validate_auth_state(cfg) - return ToolResult(success=bool(validation.get("valid")), output=validation) + return ToolResult( + success=bool(validation.get("valid")), + output=validation, + error=None if validation.get("valid") else str(validation.get("reason") or "auth_state_invalid"), + ) if action not in {"ensure_auth_state", "refresh_auth_state"}: return ToolResult( diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml index 730338b81..cf7d94caa 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml @@ -3,11 +3,13 @@ description: > Manage Sangfor EDR browser authentication state. Use status_auth_state or validate_auth_state before opening pages; use ensure_auth_state to reuse a valid saved auth-state or automatically refresh full browser state through - browser daemon / CDP when credentials are configured. + browser daemon / CDP when credentials are configured. Browser actions start + or recover the daemon automatically and return structured recovery guidance. description_cn: > 管理深信服 EDR 浏览器登录态。先用 status_auth_state 或 validate_auth_state 检查已保存 state;当 state 缺失或失效且已配置账密时,ensure_auth_state 通过 browser daemon / CDP 驱动真实登录页自动登录并重新保存完整浏览器 state。 + 需要浏览器的 action 会自动启动或恢复 daemon,失败时返回结构化恢复指引。 category: custom enabled: true requires_confirmation: false diff --git a/tests/tool/test_sangfor_edr_handler.py b/tests/tool/test_sangfor_edr_handler.py new file mode 100644 index 000000000..83cb56b80 --- /dev/null +++ b/tests/tool/test_sangfor_edr_handler.py @@ -0,0 +1,99 @@ +import importlib.util +from pathlib import Path + + +_HANDLER_PATH = ( + Path(__file__).resolve().parents[2] + / ".flocks" + / "plugins" + / "tools" + / "device" + / "sangfor_edr_webcli" + / "sangfor_edr.handler.py" +) + + +def _load_handler(): + spec = importlib.util.spec_from_file_location("_test_sangfor_edr_handler", _HANDLER_PATH) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _cfg(handler, state_path: Path): + return handler.RuntimeConfig( + base_url="https://edr.example.com", + auth_state_path=state_path, + username="admin", + password="secret", + login_path="/ui/login.php", + index_path="/ui/#/index", + timeout=5, + auto_ocr_code=True, + max_captcha_retry=1, + username_selector="", + password_selector="", + captcha_selector="", + agreement_selector="", + submit_selector="", + ) + + +def test_validate_missing_state_does_not_start_daemon(tmp_path, monkeypatch): + handler = _load_handler() + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: (_ for _ in ()).throw(AssertionError())) + + result = handler._validate_auth_state(_cfg(handler, tmp_path / "missing.json")) + + assert result["reason"] == "auth_state_not_found" + + +def test_validate_existing_state_ensures_daemon(tmp_path, monkeypatch): + handler = _load_handler() + state_path = tmp_path / "auth-state.json" + state_path.write_text('{"cookies": [], "origins": []}', encoding="utf-8") + calls = [] + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: calls.append("ensure")) + monkeypatch.setattr(handler.helpers, "load_state", lambda *args, **kwargs: {}) + monkeypatch.setattr(handler.helpers, "wait_for_load", lambda *args, **kwargs: True) + monkeypatch.setattr(handler, "_is_logged_in", lambda cfg: True) + + result = handler._validate_auth_state(_cfg(handler, state_path)) + + assert result["valid"] is True + assert calls == ["ensure"] + + +def test_validate_reports_daemon_failure_separately(tmp_path, monkeypatch): + handler = _load_handler() + state_path = tmp_path / "auth-state.json" + state_path.write_text('{"cookies": [], "origins": []}', encoding="utf-8") + monkeypatch.setattr( + handler, + "_ensure_browser_daemon", + lambda: (_ for _ in ()).throw(RuntimeError("browser daemon did not start")), + ) + + result = handler._validate_auth_state(_cfg(handler, state_path)) + + assert result["status"] == "browser_daemon_not_ready" + assert result["reason"] == "auth_state_load_failed_browser_daemon_not_ready" + assert "flocks browser --setup" in result["next_action"] + + +def test_refresh_distinguishes_page_open_failure(tmp_path, monkeypatch): + handler = _load_handler() + cfg = _cfg(handler, tmp_path / "auth-state.json") + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: None) + monkeypatch.setattr( + handler, + "_open_page", + lambda url: (_ for _ in ()).throw(RuntimeError("EDR connection refused")), + ) + + result = handler._refresh_auth_state_with_cdp_login(cfg) + + assert result["status"] == "browser_login_page_open_failed" + assert result["reason"] == "browser_login_page_open_failed" + assert result["login_url"] == "https://edr.example.com/ui/login.php" From 167274339e99cfd3437b5d64e88f08716f26d6ad Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 21 Jul 2026 16:06:18 +0800 Subject: [PATCH 68/86] fix(workflow): clean up trigger tool contexts --- flocks/ingest/kafka/manager.py | 7 ++- flocks/ingest/syslog/manager.py | 7 ++- flocks/tool/agent/delegate_task.py | 2 + flocks/workflow/poller_manager.py | 7 ++- flocks/workflow/tool_context.py | 55 +++++++++++++++++ flocks/workflow/triggers/runtime.py | 8 ++- tests/ingest/test_kafka_manager.py | 5 +- .../test_syslog_manager_backpressure.py | 5 +- tests/tool/test_delegate_task_compat.py | 5 +- tests/workflow/test_poller_manager.py | 5 +- tests/workflow/test_trigger_runtime.py | 4 +- tests/workflow/test_workflow_tool_context.py | 60 ++++++++++++++++++- 12 files changed, 160 insertions(+), 10 deletions(-) diff --git a/flocks/ingest/kafka/manager.py b/flocks/ingest/kafka/manager.py index 0cf60a49d..c4c9cdaeb 100644 --- a/flocks/ingest/kafka/manager.py +++ b/flocks/ingest/kafka/manager.py @@ -42,7 +42,10 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore -from flocks.workflow.tool_context import build_workflow_tool_context +from flocks.workflow.tool_context import ( + build_workflow_tool_context, + cleanup_workflow_tool_context, +) from flocks.ingest.kafka.constants import WORKFLOW_KAFKA_CONFIG_PREFIX from flocks.workflow.triggers.compat import legacy_kafka_trigger_from_config @@ -699,6 +702,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: input_keys=trigger_input_keys, ), ) + tool_context = None try: tool_context = await build_workflow_tool_context( workflow_id=workflow_id, @@ -760,6 +764,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: } ) finally: + await cleanup_workflow_tool_context(tool_context) try: await record_execution_result(workflow_id, exec_id, exec_data) except Exception as exc: diff --git a/flocks/ingest/syslog/manager.py b/flocks/ingest/syslog/manager.py index 737be4903..43bf3c09b 100644 --- a/flocks/ingest/syslog/manager.py +++ b/flocks/ingest/syslog/manager.py @@ -20,7 +20,10 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore -from flocks.workflow.tool_context import build_workflow_tool_context +from flocks.workflow.tool_context import ( + build_workflow_tool_context, + cleanup_workflow_tool_context, +) from flocks.ingest.syslog.constants import WORKFLOW_SYSLOG_CONFIG_PREFIX from flocks.ingest.syslog.listener import run_tcp_syslog_server, run_udp_syslog_server @@ -539,6 +542,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: ) start_time = time.time() trigger_meta = mapped_inputs.get("_flocks", {}).get("trigger", {}) + tool_context = None try: tool_context = await build_workflow_tool_context( workflow_id=workflow_id, @@ -600,6 +604,7 @@ async def _executor(mapped_inputs: Dict[str, Any]) -> Dict[str, Any]: } ) finally: + await cleanup_workflow_tool_context(tool_context) try: await record_execution_result(workflow_id, exec_id, exec_data) except Exception as exc: diff --git a/flocks/tool/agent/delegate_task.py b/flocks/tool/agent/delegate_task.py index 176a8f8ec..cb4fe3b9d 100644 --- a/flocks/tool/agent/delegate_task.py +++ b/flocks/tool/agent/delegate_task.py @@ -474,6 +474,8 @@ async def delegate_task_tool( model_pinned=bool(explicit_model), ) created = await Session.create(**create_kwargs) + if ctx.extra.get("workflow_temp_parent") is True: + ctx.extra["workflow_child_session_created"] = True await Message.create( session_id=created.id, role=MessageRole.USER, diff --git a/flocks/workflow/poller_manager.py b/flocks/workflow/poller_manager.py index 4bcea2422..69b6ea93d 100644 --- a/flocks/workflow/poller_manager.py +++ b/flocks/workflow/poller_manager.py @@ -28,7 +28,10 @@ from flocks.workflow.models import Workflow from flocks.workflow.runner import RunWorkflowResult, run_workflow from flocks.workflow.store import WorkflowStore -from flocks.workflow.tool_context import build_workflow_tool_context +from flocks.workflow.tool_context import ( + build_workflow_tool_context, + cleanup_workflow_tool_context, +) WORKFLOW_POLLER_CONFIG_PREFIX = "workflow_poller_config/" DEFAULT_INTERVAL_SECONDS = 30 @@ -435,6 +438,7 @@ async def _execute_run( current["activeRuns"] = self._cleanup_done_runs(workflow_id) self._status[workflow_id] = current + tool_context = None try: tool_context = await build_workflow_tool_context( workflow_id=workflow_id, @@ -527,6 +531,7 @@ async def _execute_run( self._status[workflow_id] = current log.warning("poller.run_failed", {"workflow_id": workflow_id, "error": str(exc)}) finally: + await cleanup_workflow_tool_context(tool_context) try: await record_execution_result(workflow_id, exec_id, exec_data) except Exception as exc: diff --git a/flocks/workflow/tool_context.py b/flocks/workflow/tool_context.py index 859ddcc20..e06ad3b42 100644 --- a/flocks/workflow/tool_context.py +++ b/flocks/workflow/tool_context.py @@ -11,8 +11,11 @@ from flocks.session.message import Message, MessageRole from flocks.session.session import Session from flocks.tool import ToolContext +from flocks.utils.log import Log from flocks.workflow.fs_store import find_workspace_root +log = Log.create(service="workflow.tool_context") + async def build_workflow_tool_context( *, @@ -33,6 +36,7 @@ async def build_workflow_tool_context( effective_session_id = str(session_id or "").strip() effective_message_id = str(message_id or "").strip() effective_agent = str(agent or "").strip() + created_temp_parent = not effective_session_id workspace_dir = os.getcwd() project_id = "default" @@ -93,5 +97,56 @@ async def build_workflow_tool_context( extra={ "workspace_dir": workspace_dir, "main_session_key": effective_session_id, + "workflow_temp_parent": created_temp_parent, }, ) + + +async def cleanup_workflow_tool_context(tool_context: Optional[ToolContext]) -> bool: + """Purge an unused temporary workflow parent session. + + Parents with delegated child sessions are retained so returned task session + IDs remain valid. Caller-provided sessions are never modified. + """ + if tool_context is None: + return False + extra = getattr(tool_context, "extra", None) + if not isinstance(extra, dict) or extra.get("workflow_temp_parent") is not True: + return False + + session_id = str(getattr(tool_context, "session_id", "") or "").strip() + if not session_id: + return False + + try: + session = await Session.get_by_id(session_id) + metadata = getattr(session, "metadata", None) if session is not None else None + if session is None or not isinstance(metadata, dict) or metadata.get("workflowTempParent") is not True: + return False + + if extra.get("workflow_child_session_created") is True: + return False + + await Session.delete(session.project_id, session.id) + + # Session.delete is intentionally a soft delete. Trigger parents without + # child tasks are implementation details, so remove their residual rows + # to keep high-frequency trigger traffic storage-bounded. + from flocks.storage.storage import Storage + + await Storage.delete(f"session:{session.project_id}:{session.id}") + await Storage.delete(f"message:{session.id}") + await Storage.delete(f"todo:{session.id}") + await Storage.delete(f"goal:{session.id}") + await Storage.delete(f"session_diff:{session.id}") + await Storage.clear(prefix=f"message_diff:{session.id}:") + await Storage.clear(prefix=f"system_prompts:{session.id}:") + + Message.invalidate_cache(session.id) + return True + except Exception as exc: + log.warning( + "workflow.tool_context.cleanup_failed", + {"session_id": session_id, "error": str(exc)}, + ) + return False diff --git a/flocks/workflow/triggers/runtime.py b/flocks/workflow/triggers/runtime.py index e41eba087..7230bf0e2 100644 --- a/flocks/workflow/triggers/runtime.py +++ b/flocks/workflow/triggers/runtime.py @@ -18,7 +18,10 @@ from flocks.workflow.fs_store import read_workflow_dir, workflow_scan_dirs from flocks.workflow.runner import run_workflow from flocks.workflow.store import WorkflowStore -from flocks.workflow.tool_context import build_workflow_tool_context +from flocks.workflow.tool_context import ( + build_workflow_tool_context, + cleanup_workflow_tool_context, +) from .compat import ( LEGACY_KAFKA_CONFIG_PREFIX, @@ -219,6 +222,7 @@ async def _execute_workflow( ) exec_id = exec_data["id"] started_at = time.time() + tool_context = None try: tool_context = await build_workflow_tool_context( workflow_id=workflow_id, @@ -264,6 +268,8 @@ async def _execute_workflow( "triggerSource": mapped_inputs.get("_flocks", {}).get("trigger", {}).get("source"), } ) + finally: + await cleanup_workflow_tool_context(tool_context) await record_execution_result(workflow_id, exec_id, exec_data) return exec_data diff --git a/tests/ingest/test_kafka_manager.py b/tests/ingest/test_kafka_manager.py index 9245aebc3..f14251d41 100644 --- a/tests/ingest/test_kafka_manager.py +++ b/tests/ingest/test_kafka_manager.py @@ -30,8 +30,10 @@ def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: context = object() builder = AsyncMock(return_value=context) + cleanup = AsyncMock() monkeypatch.setattr(kafka_manager, "build_workflow_tool_context", builder) - return SimpleNamespace(context=context, builder=builder) + monkeypatch.setattr(kafka_manager, "cleanup_workflow_tool_context", cleanup) + return SimpleNamespace(context=context, builder=builder, cleanup=cleanup) @pytest.mark.asyncio @@ -470,6 +472,7 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 workflow_id="wf-orders", action_name="trigger:kafka", ) + trigger_tool_context.cleanup.assert_awaited_once_with(trigger_tool_context.context) assert recorded_exec_data["triggerId"] == "kafka-orders" assert recorded_exec_data["triggerSource"] == "orders-topic" diff --git a/tests/ingest/test_syslog_manager_backpressure.py b/tests/ingest/test_syslog_manager_backpressure.py index d35d88155..5309e4be8 100644 --- a/tests/ingest/test_syslog_manager_backpressure.py +++ b/tests/ingest/test_syslog_manager_backpressure.py @@ -31,8 +31,10 @@ def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: context = object() builder = AsyncMock(return_value=context) + cleanup = AsyncMock() monkeypatch.setattr(syslog_manager, "build_workflow_tool_context", builder) - return SimpleNamespace(context=context, builder=builder) + monkeypatch.setattr(syslog_manager, "cleanup_workflow_tool_context", cleanup) + return SimpleNamespace(context=context, builder=builder, cleanup=cleanup) @pytest.mark.asyncio @@ -255,6 +257,7 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 workflow_id="wf-syslog", action_name="trigger:syslog", ) + trigger_tool_context.cleanup.assert_awaited_once_with(trigger_tool_context.context) assert recorded_steps[0][0] == "exec-syslog" assert recorded_steps[0][1] == 1 assert recorded_steps[0][2]["node_id"] == "receive_alert" diff --git a/tests/tool/test_delegate_task_compat.py b/tests/tool/test_delegate_task_compat.py index 047ba6d07..1229aa1f9 100644 --- a/tests/tool/test_delegate_task_compat.py +++ b/tests/tool/test_delegate_task_compat.py @@ -23,6 +23,8 @@ def test_delegate_task_schema_allows_omitting_optional_fields(self): @pytest.mark.asyncio async def test_delegate_task_derives_description_and_ignores_blank_skills(self): + ctx = _make_ctx() + ctx.extra["workflow_temp_parent"] = True parent_session = SimpleNamespace( id="test-session", project_id="proj", @@ -47,7 +49,7 @@ async def test_delegate_task_derives_description_and_ignores_blank_skills(self): ): result = await ToolRegistry.execute( "delegate_task", - ctx=_make_ctx(), + ctx=ctx, subagent_type="asset-survey", prompt="Investigate threatbook.cn assets", load_skills=["", " "], @@ -56,6 +58,7 @@ async def test_delegate_task_derives_description_and_ignores_blank_skills(self): assert result.success is True assert result.title == "Investigate threatbook.cn assets" assert result.metadata["sessionId"] == "ses-child" + assert ctx.extra["workflow_child_session_created"] is True skill_get.assert_not_awaited() permissions = create_session.await_args.kwargs["permission"] denied_permissions = {rule.permission for rule in permissions if rule.action == "deny"} diff --git a/tests/workflow/test_poller_manager.py b/tests/workflow/test_poller_manager.py index b8c5ea7f1..0b337fa55 100644 --- a/tests/workflow/test_poller_manager.py +++ b/tests/workflow/test_poller_manager.py @@ -22,8 +22,10 @@ def trigger_tool_context(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: agent="rex", ) builder = AsyncMock(return_value=context) + cleanup = AsyncMock() monkeypatch.setattr(poller_manager, "build_workflow_tool_context", builder) - return SimpleNamespace(context=context, builder=builder) + monkeypatch.setattr(poller_manager, "cleanup_workflow_tool_context", cleanup) + return SimpleNamespace(context=context, builder=builder, cleanup=cleanup) @pytest.mark.asyncio @@ -153,6 +155,7 @@ def _fake_run_workflow( # noqa: ANN001 workflow_id="wf-run-once", action_name="trigger:schedule", ) + trigger_tool_context.cleanup.assert_awaited_once_with(trigger_tool_context.context) @pytest.mark.asyncio diff --git a/tests/workflow/test_trigger_runtime.py b/tests/workflow/test_trigger_runtime.py index a7401e790..dabf9bb7e 100644 --- a/tests/workflow/test_trigger_runtime.py +++ b/tests/workflow/test_trigger_runtime.py @@ -21,6 +21,7 @@ async def test_trigger_execution_builds_tool_context_for_workflow_tools( agent="rex", ) build_context = AsyncMock(return_value=tool_context) + cleanup_context = AsyncMock() def _fake_run_workflow(**kwargs): # noqa: ANN003 missing_context = kwargs.get("tool_context") is None @@ -37,8 +38,8 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 runtime_module, "build_workflow_tool_context", build_context, - raising=False, ) + monkeypatch.setattr(runtime_module, "cleanup_workflow_tool_context", cleanup_context) monkeypatch.setattr(runtime_module, "run_workflow", Mock(side_effect=_fake_run_workflow)) monkeypatch.setattr( runtime_module, @@ -63,6 +64,7 @@ def _fake_run_workflow(**kwargs): # noqa: ANN003 action_name="trigger:custom_webhook", ) assert runtime_module.run_workflow.call_args.kwargs["tool_context"] is tool_context + cleanup_context.assert_awaited_once_with(tool_context) @pytest.mark.asyncio diff --git a/tests/workflow/test_workflow_tool_context.py b/tests/workflow/test_workflow_tool_context.py index 9e0ee8fa4..cd5a00c01 100644 --- a/tests/workflow/test_workflow_tool_context.py +++ b/tests/workflow/test_workflow_tool_context.py @@ -7,7 +7,10 @@ from flocks.session.message import Message, MessageRole from flocks.session.session import Session from flocks.storage.storage import Storage -from flocks.workflow.tool_context import build_workflow_tool_context +from flocks.workflow.tool_context import ( + build_workflow_tool_context, + cleanup_workflow_tool_context, +) @pytest.fixture @@ -86,3 +89,58 @@ async def test_build_workflow_tool_context_reuses_existing_parent_session( assert message is not None assert message.role == MessageRole.USER assert message.agent == "rex-junior" + + assert await cleanup_workflow_tool_context(tool_context) is False + assert await Session.get_by_id(parent.id) is not None + + +@pytest.mark.asyncio +async def test_cleanup_workflow_tool_context_purges_unused_temp_parent( + tmp_path: Path, + isolated_storage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + tool_context = await build_workflow_tool_context( + workflow_id="wf-cleanup", + action_name="trigger:kafka", + ) + session_id = tool_context.session_id + message_id = tool_context.message_id + + cleaned = await cleanup_workflow_tool_context(tool_context) + + assert cleaned is True + assert await Session.get_by_id(session_id) is None + assert await Message.get(session_id, message_id) is None + assert await Storage.get(f"session_callable_tools:{session_id}") is None + + +@pytest.mark.asyncio +async def test_cleanup_workflow_tool_context_preserves_parent_with_child_session( + tmp_path: Path, + isolated_storage, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + tool_context = await build_workflow_tool_context( + workflow_id="wf-delegated", + action_name="trigger:schedule", + ) + parent = await Session.get_by_id(tool_context.session_id) + assert parent is not None + child = await Session.create( + project_id=parent.project_id, + directory=parent.directory, + title="Delegated child", + parent_id=parent.id, + agent="rex-junior", + category="task", + ) + tool_context.extra["workflow_child_session_created"] = True + + cleaned = await cleanup_workflow_tool_context(tool_context) + + assert cleaned is False + assert await Session.get_by_id(parent.id) is not None + assert await Session.get_by_id(child.id) is not None From c8172753d3ddacf69a4b21d4f16d82070bc192cb Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 16:46:29 +0800 Subject: [PATCH 69/86] Address Chrome debug setup review feedback --- .flocks/plugins/skills/browser-use/SKILL.md | 6 +- .../browser-use/references/cdp-direct.md | 8 +- .../browser-use/references/cdp-setup.md | 43 +++++-- flocks/browser/admin.py | 63 ++++++---- flocks/browser/daemon.py | 24 ++-- tests/browser/test_admin.py | 113 ++++++++++++++++-- tests/browser/test_daemon.py | 19 +-- 7 files changed, 210 insertions(+), 66 deletions(-) diff --git a/.flocks/plugins/skills/browser-use/SKILL.md b/.flocks/plugins/skills/browser-use/SKILL.md index b993f4e6e..d326e126e 100644 --- a/.flocks/plugins/skills/browser-use/SKILL.md +++ b/.flocks/plugins/skills/browser-use/SKILL.md @@ -61,9 +61,9 @@ flocks browser --doctor | 结果 | 触发条件 | 一线修复 | 仍失败兜底 | |---|---|---|---| | **A** | `next action` 以 `ready` 开头 | 立即确定 `CDP 直连`,阅读 `references/cdp-direct.md`,之后不再切到 `agent-browser` | — | -| **B** | `next action` 以 `attach` 开头 | 不要先反复 `--setup`;按输出执行 `flocks browser -c 'print(page_info())'` 或 `flocks browser -c 'print(list_tabs(include_chrome=False))'` 触发一次实际连接/观察 | 如果 `-c` 失败或仍无连接,执行 `flocks browser --reload` 清理旧 daemon,再执行 `flocks browser --setup`;setup 可能需要多次,直到用户完成浏览器 Allow/inspect 授权或错误信息稳定 | -| **C** | `next action` 以 `setup` 开头 | 先执行 `flocks browser --setup`(不包短超时),再运行 `--doctor` 确认 | 如提示 remote debugging 未启用、`DevToolsActivePort` 缺失、403 handshake 或 not live yet,再提示用户打开对应 inspect 页面并 Allow;用户确认后可多次 `--setup` | -| **D** | `next action` 提示启动浏览器或提供 endpoint | 明确告知需要先启动 Chrome/Chromium/Edge 或提供 CDP endpoint | **不**擅自降级到 curl/webfetch;坚持告知 skill 边界 | +| **B** | `next action` 以 `attach` 开头 | 不要先反复 `--setup`;按输出执行 `flocks browser -c 'print(page_info())'` 或 `flocks browser -c 'print(list_tabs(include_chrome=False))'` 触发一次实际连接/观察 | 如果 `-c` 失败或仍无连接,执行 `flocks browser --reload` 清理旧 daemon,再执行 `flocks browser --setup`;若 setup 输出本地 remote debugging 不可达,按 `references/cdp-setup.md` 的固定端口流程处理 | +| **C** | `next action` 以 `setup` 开头 | 先执行 `flocks browser --setup`(不包短超时),再运行 `--doctor` 确认 | 如提示 remote debugging 不可达、`DevToolsActivePort` 缺失、403 handshake 或 not live yet,按 `references/cdp-setup.md` 指引使用非默认 `--user-data-dir` 启动 Chromium 系浏览器,再访问 `/json/version` 验证 | +| **D** | `next action` 提示启动浏览器或提供 endpoint | 明确告知需要先用 remote-debugging 参数启动 Chrome/Chromium/Edge/Brave,或提供 CDP endpoint | **不**擅自降级到 curl/webfetch;坚持告知 skill 边界 | ### Step 4: 跨模式通用失败 diff --git a/.flocks/plugins/skills/browser-use/references/cdp-direct.md b/.flocks/plugins/skills/browser-use/references/cdp-direct.md index 0ab0de7ec..98b6bf685 100644 --- a/.flocks/plugins/skills/browser-use/references/cdp-direct.md +++ b/.flocks/plugins/skills/browser-use/references/cdp-direct.md @@ -350,11 +350,11 @@ print({"cookies": [c["name"] for c in cookies], "localStorage": js("Object.keys( 1. 先运行 `flocks browser --doctor` 看版本、安装模式、daemon 和浏览器状态;不要只看退出码,优先读 `next action`,再看 `browser running`、`daemon alive`、`active browser connections`。 2. `next action` 为 `attach`,或 `daemon alive` ok 但 `active browser connections` 为 0 时,不要先反复 `--setup`。先用一次实际命令触发连接/观察:`flocks browser -c 'print(page_info())'` 或 `flocks browser -c 'print(list_tabs(include_chrome=False))'`。 -3. 如果上一步失败或仍无连接,再执行 `flocks browser --reload` 清旧 daemon,然后执行 `flocks browser --setup`;setup 可能需要多次,因为用户可能需要完成浏览器 inspect/Allow 授权,或浏览器需要时间写入 remote debugging 状态。 +3. 如果上一步失败或仍无连接,再执行 `flocks browser --reload` 清旧 daemon,然后执行 `flocks browser --setup`。若 setup 输出本地 remote debugging 不可达,按 `references/cdp-setup.md` 的固定端口流程处理。 4. 首次安装、冷启动、daemon 不存在/不通,且浏览器已经运行或配置了 `BU_CDP_URL` / `BU_CDP_WS` 时,优先运行 `flocks browser --setup`。 -5. Chrome / Chromium / Edge 未运行且没有显式 CDP endpoint 时,只提示用户启动浏览器或提供 endpoint;不要直接让用户改设置。 -6. 只有在明确提示 remote debugging 未启用、`DevToolsActivePort` 缺失、403 handshake、remote-debugging page 或 not live yet 时,才让用户打开对应浏览器的 inspect 页面(例如 `chrome://inspect/#remote-debugging` 或 `edge://inspect/#remote-debugging`)并勾选 Allow remote debugging。 -7. 用户刚开启 remote debugging 时,不要立刻再次运行 `flocks browser --doctor`;先执行一次 `flocks browser --setup`,或直接执行 `flocks browser -c 'print(page_info())'` 触发 daemon attach,再用 `--doctor` 做只读确认。 +5. Chrome / Chromium / Edge / Brave 未运行且没有显式 CDP endpoint 时,按 `references/cdp-setup.md` 提供对应平台的 remote-debugging 启动命令。 +6. 只有在明确提示 remote debugging 不可达、`DevToolsActivePort` 缺失、403 handshake、remote-debugging page 或 not live yet 时,才让用户使用非默认 `--user-data-dir` 和 `--remote-debugging-port=9222` 启动 Chromium 系浏览器,并访问 `http://127.0.0.1:9222/json/version` 验证。 +7. 用户刚按固定端口流程启动浏览器时,不要立刻再次运行 `flocks browser --doctor`;先执行一次 `flocks browser --setup`,或直接执行 `flocks browser -c 'print(page_info())'` 触发 daemon attach,再用 `--doctor` 做只读确认。 8. `connection refused`、`DevTools not live yet`、`/json/version` 404 通常是浏览器正在启动,轮询等待,不要重启。 9. stale websocket / stale socket 时执行一次: diff --git a/.flocks/plugins/skills/browser-use/references/cdp-setup.md b/.flocks/plugins/skills/browser-use/references/cdp-setup.md index 3f72f5825..0626cdb0d 100644 --- a/.flocks/plugins/skills/browser-use/references/cdp-setup.md +++ b/.flocks/plugins/skills/browser-use/references/cdp-setup.md @@ -1,6 +1,6 @@ # Flocks browser setup -浏览器已运行,但 daemon 不存在/不通,或 active browser connection 不可用 +本地固定端口 CDP 设置:daemon 不存在/不通,active browser connection 不可用,或浏览器尚未以 remote debugging 参数启动。 先区分两种情况: @@ -11,15 +11,44 @@ 2. daemon 不存在/不通,且浏览器已运行或配置了 `BU_CDP_URL` / `BU_CDP_WS`: - 执行 `flocks browser --setup` 触发 attach,不要用短超时包装该命令。 -只有在错误明确指向 remote debugging 未启用、`DevToolsActivePort` 缺失、403 handshake 或 not live yet 时,才提示用户: +只有在错误明确指向 remote debugging 不可达、`DevToolsActivePort` 缺失、403 handshake 或 not live yet 时,才提示用户走本地固定端口流程: ```text -browser: not connected — 请确保 Chrome / Chromium / Edge 已打开,然后访问对应浏览器的 inspect 页面(例如 chrome://inspect/#remote-debugging 或 edge://inspect/#remote-debugging)并勾选 Allow remote debugging +不要从 chrome://inspect 查找 webSocketDebuggerUrl。关闭对应 Chromium 系浏览器后,使用非默认 --user-data-dir 和 --remote-debugging-port=9222 启动浏览器,再访问 http://127.0.0.1:9222/json/version 验证。 ``` -然后等待用户进一步指示,不要直接操作。 +候选命令按平台选择一个即可;如果浏览器安装路径不同,替换可执行文件路径: -当用户确认已开启remote-debugging后: -1. 执行 `flocks browser --setup` 触发交互式 attach,不要用短超时包装该命令 +Windows PowerShell: + +```powershell +& "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\.flocks\chrome-debug-profile" +& "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\.flocks\edge-debug-profile" +chromium.exe --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\.flocks\chromium-debug-profile" +& "$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\.flocks\brave-debug-profile" +``` + +macOS: + +```bash +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/chrome-debug-profile" +/Applications/Microsoft\ Edge.app/Contents/MacOS/Microsoft\ Edge --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/edge-debug-profile" +/Applications/Chromium.app/Contents/MacOS/Chromium --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/chromium-debug-profile" +/Applications/Brave\ Browser.app/Contents/MacOS/Brave\ Browser --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/brave-debug-profile" +``` + +Linux: + +```bash +google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/chrome-debug-profile" +microsoft-edge --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/edge-debug-profile" +chromium --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/chromium-debug-profile" +brave-browser --remote-debugging-port=9222 --user-data-dir="$HOME/.flocks/brave-debug-profile" +``` + +输出命令后等待用户进一步指示,不要占用当前终端盲目重试。 + +当用户确认 `http://127.0.0.1:9222/json/version` 已可访问后: +1. 执行 `flocks browser --setup` 触发 attach,不要用短超时包装该命令 2. 再运行 `flocks browser --doctor` 做只读确认。 -3. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。setup 可能需要多次,直到用户完成浏览器 Allow/inspect 授权或错误信息稳定。 +3. 如果还失败,先执行 `flocks browser --reload` 清理旧 daemon,再重新执行 `flocks browser --setup`,避免因为残留 daemon 造成干扰。 diff --git a/flocks/browser/admin.py b/flocks/browser/admin.py index 0298876fc..c8a4c0065 100644 --- a/flocks/browser/admin.py +++ b/flocks/browser/admin.py @@ -20,7 +20,7 @@ VERSION_CACHE = Path(tempfile.gettempdir()) / "flocks-browser-version-cache.json" VERSION_CACHE_TTL = 24 * 3600 DOCTOR_TEXT_LIMIT = 140 -# run_setup: at most two daemon/CDP attach attempts to avoid repeated Allow prompts. +# run_setup retries transient attach failures once, but exits after manual setup guidance. _SETUP_ATTACH_WAIT = 20.0 _SETUP_RETRY_WAIT = 30.0 @@ -54,6 +54,7 @@ def _needs_chrome_remote_debugging_prompt(msg: str | None) -> bool: return ( "devtoolsactiveport not found" in lower or "chrome remote debugging is not reachable" in lower + or "chromium-based browser remote debugging is not reachable" in lower or "remote-debugging page" in lower or "inspect/#remote-debugging" in lower or "not live yet" in lower @@ -62,38 +63,49 @@ def _needs_chrome_remote_debugging_prompt(msg: str | None) -> bool: def _local_debugging_setup_lines(system: str | None = None) -> list[str]: - """Return concise manual setup guidance for local Chrome debugging.""" + """Return concise manual setup guidance for local Chromium-based debugging.""" import platform system = system or platform.system() lines = [ "Do not look for webSocketDebuggerUrl in chrome://inspect; that page does not reliably show it.", + "Close the matching Chromium-based browser if it is already open, then run one command below.", ] if system == "Windows": lines.extend( [ - "On Windows, close Chrome and run this in PowerShell:", + "On Windows PowerShell:", '& "$env:ProgramFiles\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\\.flocks\\chrome-debug-profile"', - "If Chrome is installed elsewhere, replace the chrome.exe path.", + '& "${env:ProgramFiles(x86)}\\Microsoft\\Edge\\Application\\msedge.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\\.flocks\\edge-debug-profile"', + 'chromium.exe --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\\.flocks\\chromium-debug-profile"', + '& "$env:ProgramFiles\\BraveSoftware\\Brave-Browser\\Application\\brave.exe" --remote-debugging-port=9222 --user-data-dir="$env:USERPROFILE\\.flocks\\brave-debug-profile"', + "If your browser is installed elsewhere, replace the executable path.", ] ) elif system == "Darwin": lines.extend( [ - "On macOS, close Chrome and run:", + "On macOS:", "/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chrome-debug-profile\"", + "/Applications/Microsoft\\ Edge.app/Contents/MacOS/Microsoft\\ Edge --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/edge-debug-profile\"", + "/Applications/Chromium.app/Contents/MacOS/Chromium --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chromium-debug-profile\"", + "/Applications/Brave\\ Browser.app/Contents/MacOS/Brave\\ Browser --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/brave-debug-profile\"", ] ) else: lines.extend( [ - "On Linux, close Chrome and run:", + "On Linux:", "google-chrome --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chrome-debug-profile\"", + "microsoft-edge --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/edge-debug-profile\"", + "chromium --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/chromium-debug-profile\"", + "brave-browser --remote-debugging-port=9222 --user-data-dir=\"$HOME/.flocks/brave-debug-profile\"", ] ) lines.extend( [ "Then verify http://127.0.0.1:9222/json/version; Flocks reads the WebSocket URL from that endpoint.", + "After the endpoint responds, rerun `flocks browser --setup`.", ] ) return lines @@ -263,7 +275,7 @@ def _doctor_short_text(value, limit: int | None = None) -> str: def ensure_daemon( - wait: float = 60.0, name: str | None = None, env: dict | None = None, _open_inspect: bool = True + wait: float = 60.0, name: str | None = None, env: dict | None = None, _show_debugging_guidance: bool = True ) -> None: """Ensure a healthy daemon is running, restarting stale sessions when needed.""" effective_name = ipc.runtime_paths(name or NAME).name @@ -316,13 +328,10 @@ def spawn_daemon(): msg = _log_tail(effective_name) or "" if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg): restart_daemon(effective_name) - if not _open_inspect: - raise RuntimeError( - msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}" - ) - print(f"{BROWSER_LABEL}: local Chrome remote debugging is not reachable.", file=sys.stderr) - _print_local_debugging_setup(sys.stderr) - continue + if _show_debugging_guidance: + print(f"{BROWSER_LABEL}: local Chromium-based browser remote debugging is not reachable.", file=sys.stderr) + _print_local_debugging_setup(sys.stderr) + raise RuntimeError(msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}") raise RuntimeError(msg or f"daemon {effective_name} didn't come up -- check {ipc.log_path(effective_name)}") @@ -419,10 +428,10 @@ def _chrome_running() -> bool: try: if system == "Windows": output = subprocess.check_output(["tasklist"], timeout=5) - names = ("chrome.exe", "chromium.exe", "msedge.exe") + names = ("chrome.exe", "chromium.exe", "msedge.exe", "brave.exe") else: output = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5) - names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge") + names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge", "Brave Browser", "brave") return _output_contains_process_names(output, names) except Exception: return False @@ -448,25 +457,28 @@ def run_setup() -> int: print("daemon already running but browser connection is stale; restarting.") restart_daemon() if not endpoint_name and not _chrome_running(): - print("no Chrome/Chromium/Edge process detected. please start your browser and rerun `flocks browser --setup`.") + print("no Chrome/Chromium/Edge/Brave process detected.") + print("start a Chromium-based browser with remote debugging, then rerun `flocks browser --setup`.") + _print_local_debugging_setup(sys.stdout) return 1 try: - ensure_daemon(wait=_SETUP_ATTACH_WAIT, _open_inspect=False) + ensure_daemon(wait=_SETUP_ATTACH_WAIT, _show_debugging_guidance=False) print("daemon is up.") return 0 except RuntimeError as error: first_err = str(error) - needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err) - if needs_inspect: - print("browser remote debugging is not reachable for the current profile.") + needs_manual_debugging_setup = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err) + if needs_manual_debugging_setup: + print("Chromium-based browser remote debugging is not reachable for the current profile.") _print_local_debugging_setup(sys.stdout) + return 1 else: print(f"attach failed: {first_err}") print("retrying once (the browser may still be starting up)...") try: - ensure_daemon(wait=_SETUP_RETRY_WAIT, _open_inspect=False) + ensure_daemon(wait=_SETUP_RETRY_WAIT, _show_debugging_guidance=False) print("daemon is up.") return 0 except RuntimeError as error: @@ -509,7 +521,10 @@ def row(label: str, ok: bool, detail: str = "") -> None: elif target_available: next_action = "setup; run `flocks browser --setup`" else: - next_action = "start Chrome/Chromium/Edge or provide BU_CDP_URL/BU_CDP_WS, then run `flocks browser --setup`" + next_action = ( + "start Chrome/Chromium/Edge/Brave or provide BU_CDP_URL/BU_CDP_WS, " + "then run `flocks browser --setup`" + ) print(f"{BROWSER_LABEL} doctor") print(f" platform {platform.system()} {platform.release()}") @@ -528,7 +543,7 @@ def row(label: str, ok: bool, detail: str = "") -> None: row( "browser running", browser_running, - "" if browser_running else "start Chrome, Chromium, or Edge and rerun `flocks browser --setup`", + "" if browser_running else "start Chrome, Chromium, Edge, or Brave and rerun `flocks browser --setup`", ) row( "daemon alive", diff --git a/flocks/browser/daemon.py b/flocks/browser/daemon.py index aa617b6a8..8a60a5469 100644 --- a/flocks/browser/daemon.py +++ b/flocks/browser/daemon.py @@ -23,6 +23,12 @@ from .utils import load_env_file +FLOCKS_DEBUG_PROFILE_NAMES = ( + "chrome-debug-profile", + "edge-debug-profile", + "chromium-debug-profile", + "brave-debug-profile", +) AGENT_WORKSPACE = Path(os.environ.get("BH_AGENT_WORKSPACE", DEFAULT_AGENT_WORKSPACE)).expanduser() BUF = 500 INTERNAL = INTERNAL_URL_PREFIXES @@ -53,11 +59,11 @@ def profile_dirs( system = system or platform.system() home = home or Path.home() environ = os.environ if environ is None else environ - flocks_debug_profile = home / ".flocks/chrome-debug-profile" + flocks_debug_profiles = [home / ".flocks" / name for name in FLOCKS_DEBUG_PROFILE_NAMES] if system == "Darwin": support = home / "Library/Application Support" return [ - flocks_debug_profile, + *flocks_debug_profiles, support / "Google/Chrome", support / "Google/Chrome Canary", support / "Comet", @@ -72,7 +78,7 @@ def profile_dirs( if system == "Windows": local = Path(environ.get("LOCALAPPDATA", str(home / "AppData/Local"))).expanduser() return [ - flocks_debug_profile, + *flocks_debug_profiles, local / "Google/Chrome/User Data", local / "Google/Chrome Beta/User Data", local / "Google/Chrome Dev/User Data", @@ -87,7 +93,7 @@ def profile_dirs( local / "BraveSoftware/Brave-Browser-Nightly/User Data", ] return [ - flocks_debug_profile, + *flocks_debug_profiles, home / ".config/google-chrome", home / ".config/google-chrome-beta", home / ".config/google-chrome-unstable", @@ -204,13 +210,13 @@ def get_ws_url() -> str: if profile_errors: details = "; ".join(dict.fromkeys(profile_errors)) raise RuntimeError( - "Chrome remote debugging is not reachable for any detected profile: " - f"{details} — for manual setup, start Chrome with --remote-debugging-port and a non-default " + "Chromium-based browser remote debugging is not reachable for any detected profile: " + f"{details} — for manual setup, start the browser with --remote-debugging-port and a non-default " "--user-data-dir, then verify http://127.0.0.1:9222/json/version" ) raise RuntimeError( "DevToolsActivePort not found in " - f"{[str(path) for path in profiles]} — start Chrome with --remote-debugging-port and a non-default " + f"{[str(path) for path in profiles]} — start a Chromium-based browser with --remote-debugging-port and a non-default " "--user-data-dir, or set BU_CDP_WS for a remote browser" ) @@ -285,7 +291,9 @@ async def start(self) -> None: f"{hint}" ) from error raise RuntimeError( - f"CDP WS handshake failed: {error} -- click Allow in your browser if prompted, then retry" + f"CDP WS handshake failed: {error} -- restart your Chromium-based browser with " + "--remote-debugging-port and a non-default --user-data-dir, then verify " + "http://127.0.0.1:9222/json/version" ) await self.attach_first_page() orig = self.cdp._event_registry.handle_event diff --git a/tests/browser/test_admin.py b/tests/browser/test_admin.py index 39d3b42e6..0ee22f407 100644 --- a/tests/browser/test_admin.py +++ b/tests/browser/test_admin.py @@ -1,3 +1,7 @@ +from pathlib import Path + +import pytest + from flocks.browser import admin @@ -59,9 +63,9 @@ def test_generic_remote_debugging_message_triggers_prompt() -> None: def test_current_remote_debugging_unreachable_message_triggers_prompt() -> None: msg = ( - "Chrome remote debugging is not reachable for any detected profile: " + "Chromium-based browser remote debugging is not reachable for any detected profile: " "/tmp/chrome: 127.0.0.1:9222 (connection refused) — for manual setup, " - "start Chrome with --remote-debugging-port and a non-default --user-data-dir" + "start the browser with --remote-debugging-port and a non-default --user-data-dir" ) assert admin._needs_chrome_remote_debugging_prompt(msg) @@ -73,7 +77,38 @@ def test_local_debugging_setup_lines_use_windows_user_data_dir() -> None: assert "does not reliably show it" in lines assert "--remote-debugging-port=9222" in lines assert '--user-data-dir="$env:USERPROFILE\\.flocks\\chrome-debug-profile"' in lines + assert "msedge.exe" in lines + assert '--user-data-dir="$env:USERPROFILE\\.flocks\\edge-debug-profile"' in lines + assert "chromium.exe" in lines + assert "brave.exe" in lines assert "http://127.0.0.1:9222/json/version" in lines + assert "rerun `flocks browser --setup`" in lines + + +def test_local_debugging_setup_lines_list_chromium_browser_candidates() -> None: + mac_lines = "\n".join(admin._local_debugging_setup_lines("Darwin")) + linux_lines = "\n".join(admin._local_debugging_setup_lines("Linux")) + + assert "Microsoft\\ Edge.app" in mac_lines + assert "Chromium.app" in mac_lines + assert "Brave\\ Browser.app" in mac_lines + assert "microsoft-edge" in linux_lines + assert "chromium" in linux_lines + assert "brave-browser" in linux_lines + + +def test_browser_skill_docs_do_not_reintroduce_inspect_allow_setup_flow() -> None: + repo_root = Path(__file__).resolve().parents[2] + docs = [ + repo_root / ".flocks/plugins/skills/browser-use/SKILL.md", + repo_root / ".flocks/plugins/skills/browser-use/references/cdp-direct.md", + repo_root / ".flocks/plugins/skills/browser-use/references/cdp-setup.md", + ] + text = "\n".join(path.read_text(encoding="utf-8") for path in docs) + + assert "chrome://inspect/#remote-debugging" not in text + assert "edge://inspect/#remote-debugging" not in text + assert "Allow remote debugging" not in text def test_daemon_endpoint_names_discovers_default_and_named_sessions(tmp_path, monkeypatch) -> None: @@ -208,6 +243,40 @@ def fake_popen(*args, **kwargs): assert len(spawned) == 2 +def test_ensure_daemon_prints_manual_guidance_without_blind_retry(tmp_path, monkeypatch, capsys) -> None: + spawned = [] + restarted = [] + + class FakeProcess: + def poll(self): + return 1 + + def fake_popen(*args, **kwargs): + spawned.append((args, kwargs)) + return FakeProcess() + + monkeypatch.setenv("FLOCKS_ROOT", str(tmp_path)) + monkeypatch.setattr(admin.ipc, "endpoint_reachable", lambda name: False) + monkeypatch.setattr(admin, "daemon_alive", lambda name: False) + monkeypatch.setattr("subprocess.Popen", fake_popen) + monkeypatch.setattr( + admin, + "_log_tail", + lambda name: "Chromium-based browser remote debugging is not reachable for any detected profile", + ) + monkeypatch.setattr(admin, "restart_daemon", lambda name=None: restarted.append(name)) + monkeypatch.setattr(admin, "_local_debugging_setup_lines", lambda: ["debug browser instructions"]) + + with pytest.raises(RuntimeError): + admin.ensure_daemon(wait=1.0, name="manual-session") + + err = capsys.readouterr().err + assert "local Chromium-based browser remote debugging is not reachable" in err + assert "debug browser instructions" in err + assert len(spawned) == 1 + assert restarted == ["manual-session"] + + def test_run_doctor_prints_active_browser_connections_and_active_pages(monkeypatch, capsys) -> None: monkeypatch.setattr(admin, "_version", lambda: "0.1.0") monkeypatch.setattr(admin, "_install_mode", lambda: "git") @@ -294,14 +363,17 @@ def test_run_doctor_suggests_setup_when_target_exists_but_daemon_missing(monkeyp def test_run_setup_uses_generic_missing_browser_wording(monkeypatch, capsys) -> None: monkeypatch.setattr(admin, "daemon_alive", lambda: False) monkeypatch.setattr(admin, "_chrome_running", lambda: False) + monkeypatch.setattr(admin, "_local_debugging_setup_lines", lambda: ["debug browser instructions"]) assert admin.run_setup() == 1 out = capsys.readouterr().out - assert "no Chrome/Chromium/Edge process detected" in out + assert "no Chrome/Chromium/Edge/Brave process detected" in out + assert "start a Chromium-based browser with remote debugging" in out + assert "debug browser instructions" in out -def test_run_setup_uses_generic_remote_debugging_wording(monkeypatch, capsys) -> None: +def test_run_setup_prints_remote_debugging_guidance_without_blind_retry(monkeypatch, capsys) -> None: monkeypatch.setattr(admin, "daemon_alive", lambda: False) monkeypatch.setattr(admin, "_chrome_running", lambda: True) monkeypatch.setattr(admin, "_is_local_chrome_mode", lambda env=None: True) @@ -319,12 +391,13 @@ def fake_ensure_daemon(*args, **kwargs): monkeypatch.setattr(admin, "ensure_daemon", fake_ensure_daemon) - assert admin.run_setup() == 0 + assert admin.run_setup() == 1 out = capsys.readouterr().out - assert "browser remote debugging is not reachable for the current profile." in out + assert "Chromium-based browser remote debugging is not reachable for the current profile." in out assert "debug browser instructions" in out assert "opening your browser's inspect page" not in out + assert calls["count"] == 1 def test_run_setup_restarts_stale_existing_local_daemon(monkeypatch, capsys) -> None: @@ -342,7 +415,7 @@ def test_run_setup_restarts_stale_existing_local_daemon(monkeypatch, capsys) -> assert "browser connection is stale; restarting" in out assert "daemon is up." in out assert restarted == [None] - assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_open_inspect": False}] + assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_show_debugging_guidance": False}] def test_run_setup_retries_at_most_once(monkeypatch, capsys) -> None: @@ -362,8 +435,8 @@ def fake_ensure_daemon(**kwargs): out = capsys.readouterr() assert "retrying once" in out.out assert ensure_calls == [ - {"wait": admin._SETUP_ATTACH_WAIT, "_open_inspect": False}, - {"wait": admin._SETUP_RETRY_WAIT, "_open_inspect": False}, + {"wait": admin._SETUP_ATTACH_WAIT, "_show_debugging_guidance": False}, + {"wait": admin._SETUP_RETRY_WAIT, "_show_debugging_guidance": False}, ] @@ -379,7 +452,7 @@ def test_run_setup_allows_explicit_remote_cdp_without_local_browser(monkeypatch, out = capsys.readouterr().out assert "attaching via BU_CDP_WS" in out assert "daemon is up." in out - assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_open_inspect": False}] + assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_show_debugging_guidance": False}] def test_run_setup_restarts_existing_daemon_for_explicit_remote_cdp(monkeypatch, capsys) -> None: @@ -400,7 +473,7 @@ def test_run_setup_restarts_existing_daemon_for_explicit_remote_cdp(monkeypatch, assert "restarting to attach via BU_CDP_URL" in out assert "daemon is up." in out assert restarted == [None] - assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_open_inspect": False}] + assert ensure_calls == [{"wait": admin._SETUP_ATTACH_WAIT, "_show_debugging_guidance": False}] def test_run_doctor_uses_generic_browser_wording_when_missing(monkeypatch, capsys) -> None: @@ -415,8 +488,8 @@ def test_run_doctor_uses_generic_browser_wording_when_missing(monkeypatch, capsy out = capsys.readouterr().out assert "[FAIL] browser running" in out - assert "start Chrome, Chromium, or Edge and rerun `flocks browser --setup`" in out - assert "next action start Chrome/Chromium/Edge or provide BU_CDP_URL/BU_CDP_WS" in out + assert "start Chrome, Chromium, Edge, or Brave and rerun `flocks browser --setup`" in out + assert "next action start Chrome/Chromium/Edge/Brave or provide BU_CDP_URL/BU_CDP_WS" in out def test_run_doctor_points_to_debug_browser_instructions_when_daemon_missing(monkeypatch, capsys) -> None: @@ -563,8 +636,22 @@ def test_chrome_running_on_windows_detects_chromium_process(monkeypatch) -> None assert admin._chrome_running() +def test_chrome_running_on_windows_detects_brave_process(monkeypatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr("subprocess.check_output", lambda *args, **kwargs: b"brave.exe\r\n") + + assert admin._chrome_running() + + def test_chrome_running_on_non_windows_matches_text_output(monkeypatch) -> None: monkeypatch.setattr("platform.system", lambda: "Darwin") monkeypatch.setattr("subprocess.check_output", lambda *args, **kwargs: "Google Chrome\n") assert admin._chrome_running() + + +def test_chrome_running_on_non_windows_detects_brave_process(monkeypatch) -> None: + monkeypatch.setattr("platform.system", lambda: "Darwin") + monkeypatch.setattr("subprocess.check_output", lambda *args, **kwargs: "Brave Browser\n") + + assert admin._chrome_running() diff --git a/tests/browser/test_daemon.py b/tests/browser/test_daemon.py index d837773f7..b872a506a 100644 --- a/tests/browser/test_daemon.py +++ b/tests/browser/test_daemon.py @@ -163,7 +163,12 @@ def test_is_real_page_accepts_normal_https_pages() -> None: def test_profile_dirs_only_returns_paths_for_requested_os() -> None: home = Path.home() / "profile-test-home" local_app_data = home / "AppData/Local" - flocks_debug_profile = home / ".flocks/chrome-debug-profile" + flocks_debug_profiles = [ + home / ".flocks/chrome-debug-profile", + home / ".flocks/edge-debug-profile", + home / ".flocks/chromium-debug-profile", + home / ".flocks/brave-debug-profile", + ] mac_profiles = daemon.profile_dirs(system="Darwin", home=home, environ={}) linux_profiles = daemon.profile_dirs(system="Linux", home=home, environ={}) @@ -173,12 +178,12 @@ def test_profile_dirs_only_returns_paths_for_requested_os() -> None: environ={"LOCALAPPDATA": str(local_app_data)}, ) - assert mac_profiles[0] == flocks_debug_profile - assert linux_profiles[0] == flocks_debug_profile - assert windows_profiles[0] == flocks_debug_profile - assert all("Library" in path.parts and "Application Support" in path.parts for path in mac_profiles[1:]) - assert all(".config" in path.parts or ".var" in path.parts for path in linux_profiles[1:]) - assert all(path.is_relative_to(local_app_data) for path in windows_profiles[1:]) + assert mac_profiles[:4] == flocks_debug_profiles + assert linux_profiles[:4] == flocks_debug_profiles + assert windows_profiles[:4] == flocks_debug_profiles + assert all("Library" in path.parts and "Application Support" in path.parts for path in mac_profiles[4:]) + assert all(".config" in path.parts or ".var" in path.parts for path in linux_profiles[4:]) + assert all(path.is_relative_to(local_app_data) for path in windows_profiles[4:]) def test_get_ws_url_skips_unreachable_profile_and_uses_next_candidate(tmp_path, monkeypatch) -> None: From f0ac4d927357ab94082ee55aabab0d1c6fb9b9de Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 21 Jul 2026 16:46:52 +0800 Subject: [PATCH 70/86] feat(workflow): persist configurable service ports Allow explicit port selection for local and Docker workflow services, then reuse the persisted port across updates, manual restarts, and recovery. Preserve existing runtimes on port conflicts, clean failed release state, expose the setting in the UI, and cover lifecycle regressions. --- flocks/server/routes/workflow.py | 60 +++- flocks/workflow/center.py | 274 ++++++++++++------ flocks/workflow/service_port.py | 50 ++++ .../routes/test_workflow_publish_api.py | 173 ++++++++++- tests/workflow/test_workflow_center.py | 13 +- .../test_workflow_center_lifecycle.py | 220 +++++++++++++- webui/src/api/workflow.ts | 16 +- webui/src/locales/en-US/workflow.json | 5 + webui/src/locales/zh-CN/workflow.json | 5 + webui/src/pages/Task/ServicesSection.test.tsx | 5 +- webui/src/pages/Task/ServicesSection.tsx | 4 +- .../tabs/IntegrationTab.test.tsx | 61 +++- .../WorkflowDetail/tabs/IntegrationTab.tsx | 50 +++- 13 files changed, 813 insertions(+), 123 deletions(-) create mode 100644 flocks/workflow/service_port.py diff --git a/flocks/server/routes/workflow.py b/flocks/server/routes/workflow.py index 1bf338498..4b7bf9227 100644 --- a/flocks/server/routes/workflow.py +++ b/flocks/server/routes/workflow.py @@ -22,11 +22,13 @@ import uuid from flocks.workflow.models import Workflow, Node, Edge +from flocks.workflow.service_port import resolve_service_port from flocks.workflow.runner import RunWorkflowResult, run_workflow from flocks.workflow.center import ( WorkflowCenterError, WorkflowNotFoundError, WorkflowNotPublishedError, + WorkflowPortUnavailableError, get_workflow_health, invoke_published_workflow, list_registry_entries, @@ -260,6 +262,7 @@ class WorkflowCenterPublishRequest(BaseModel): None, description="Service driver. Defaults to FLOCKS_WORKFLOW_SERVICE_DRIVER or local.", ) + port: Optional[int] = Field(None, ge=1, le=65535, description="Stable host listening port") class WorkflowCenterInvokeRequest(BaseModel): @@ -804,6 +807,7 @@ def _publish_for_config(service: Optional[Dict[str, Any]]) -> Dict[str, Any]: "enabled": bool(service) and status_value not in {"stopped", "unpublished"}, "status": status_value, "driver": service.get("driver") if service else None, + "port": resolve_service_port(service) if service else None, "serviceUrl": service.get("serviceUrl") if service else None, "invokeUrl": service.get("invokeUrl") if service else None, "containerName": service.get("containerName") if service else None, @@ -1782,6 +1786,7 @@ async def workflow_center_publish(workflow_id: str, req: Optional[WorkflowCenter workflow_id, image=req.image if req else None, driver=req.driver if req else None, + port=req.port if req else None, ) return result except WorkflowNotFoundError as e: @@ -2219,9 +2224,13 @@ async def _prepare_workflow_api_registry(workflow_id: str) -> tuple[Dict[str, An "sourceType": "main_storage", "workflowPath": str(workflow_path), "fingerprint": fp, - "publishStatus": "unpublished", + "publishStatus": existing_registry.get("publishStatus", "unpublished"), "registeredAt": existing_registry.get("registeredAt", now_ms), "updatedAt": now_ms, + "activeReleaseId": existing_registry.get("activeReleaseId"), + "serviceKey": existing_registry.get("serviceKey"), + "serviceUrl": existing_registry.get("serviceUrl"), + "servicePort": existing_registry.get("servicePort"), } await WorkflowStore.kv_put(f"{_REGISTRY_PREFIX_MAIN}{workflow_id}", registry_entry) return data, now_ms @@ -2262,6 +2271,7 @@ async def _normalize_listed_api_service(key: Any, entry: Any) -> Optional[Dict[s service["driver"] = runtime.get("driver") or service.get("driver") service["containerName"] = runtime.get("containerName") or service.get("containerName", "") service["image"] = runtime.get("image") or service.get("image") + service["port"] = resolve_service_port(runtime) or resolve_service_port(service) return service status = str(service.get("status") or "").strip().lower() @@ -2276,6 +2286,33 @@ async def _normalize_listed_api_service(key: Any, entry: Any) -> Optional[Dict[s return service +async def _record_api_publish_failure( + workflow_id: str, + existing_service: Dict[str, Any], + error: Exception, +) -> None: + """Mark a failed restart only when no prior runtime survived the attempt.""" + if not existing_service: + return + try: + runtime = await WorkflowStore.kv_get(_runtime_key_main(workflow_id)) + if isinstance(runtime, dict) and runtime: + return + failed_service = { + **existing_service, + "status": "error", + "lastStartError": str(error), + "lastStartAttemptAt": int(time.time() * 1000), + "health": {"ok": False, "reason": "publish_failed"}, + } + await WorkflowStore.kv_put(_api_service_key(workflow_id), failed_service) + except Exception as persist_error: + log.warning( + "workflow.api.publish_failure_persist_failed", + {"id": workflow_id, "error": str(persist_error)}, + ) + + async def reconcile_published_workflow_api_services() -> Dict[str, int]: """Restart persisted workflow API services after the main server restarts.""" stats = {"checked": 0, "healthy": 0, "restarted": 0, "failed": 0, "skipped": 0} @@ -2315,6 +2352,7 @@ async def reconcile_published_workflow_api_services() -> Dict[str, int]: image=service.get("image") or None, driver=_service_driver_from_record(service), api_key=service.get("apiKey") or None, + port=resolve_service_port(service), ) service_url = active_record.get("serviceUrl", "") @@ -2329,6 +2367,7 @@ async def reconcile_published_workflow_api_services() -> Dict[str, int]: "containerName": active_record.get("containerName", ""), "driver": active_record.get("driver") or service.get("driver"), "image": active_record.get("image") or service.get("image"), + "port": resolve_service_port(active_record) or resolve_service_port(service), "restartedAt": int(time.time() * 1000), } ) @@ -2357,6 +2396,7 @@ class WorkflowServiceResponse(BaseModel): containerName: Optional[str] = None driver: Optional[Literal["local", "docker"]] = None image: Optional[str] = None + port: Optional[int] = None class KafkaConfigRequest(BaseModel): @@ -2450,6 +2490,7 @@ async def publish_workflow_as_api( Writes the workflow JSON to disk, registers it with the workflow center, starts the selected runtime, and returns the service URL and generated API key. """ + existing_service: Dict[str, Any] = {} try: data, now_ms = await _prepare_workflow_api_registry(workflow_id) @@ -2458,6 +2499,7 @@ async def publish_workflow_as_api( # enforce the key returned to callers. existing_service = await WorkflowStore.kv_get(_api_service_key(workflow_id)) or {} api_key = existing_service.get("apiKey") or (uuid.uuid4().hex + uuid.uuid4().hex) + requested_port = req.port if req and req.port is not None else resolve_service_port(existing_service) # Use center.py to publish the selected runtime. active_record = await publish_workflow( @@ -2465,6 +2507,7 @@ async def publish_workflow_as_api( image=req.image if req else None, driver=req.driver if req else None, api_key=api_key, + port=requested_port, ) service_url = active_record.get("serviceUrl", "") @@ -2472,6 +2515,7 @@ async def publish_workflow_as_api( container_name = active_record.get("containerName", "") driver = active_record.get("driver") or (req.driver if req else None) image = active_record.get("image") or (req.image if req else None) + host_port = resolve_service_port(active_record) or requested_port service_info = { "workflowId": workflow_id, @@ -2484,6 +2528,7 @@ async def publish_workflow_as_api( "containerName": container_name, "driver": driver, "image": image, + "port": host_port, } await WorkflowStore.kv_put(_api_service_key(workflow_id), service_info) @@ -2491,9 +2536,13 @@ async def publish_workflow_as_api( return service_info except WorkflowNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) + except WorkflowPortUnavailableError as e: + await _record_api_publish_failure(workflow_id, existing_service, e) + raise HTTPException(status_code=409, detail=str(e)) except HTTPException: raise except WorkflowCenterError as e: + await _record_api_publish_failure(workflow_id, existing_service, e) log.error("workflow.publish.center_error", {"id": workflow_id, "error": str(e)}) raise HTTPException(status_code=500, detail=f"发布失败: {str(e)}") except Exception as e: @@ -2536,7 +2585,8 @@ async def get_workflow_service(workflow_id: str): Returns null if not published. """ try: - return await WorkflowStore.kv_get(_api_service_key(workflow_id)) # None / null if not found + service = await WorkflowStore.kv_get(_api_service_key(workflow_id)) + return await _normalize_listed_api_service(_api_service_key(workflow_id), service) except Exception as e: log.error("workflow.service.get.error", {"id": workflow_id, "error": str(e)}) raise HTTPException(status_code=500, detail=f"Failed to get service info: {str(e)}") @@ -2559,6 +2609,12 @@ async def delete_workflow_service(workflow_id: str): await WorkflowStore.kv_remove(_api_service_key(workflow_id)) except Exception: pass + else: + registry_key = f"{_REGISTRY_PREFIX_MAIN}{workflow_id}" + registry = await WorkflowStore.kv_get(registry_key) + if isinstance(registry, dict) and "servicePort" in registry: + registry.pop("servicePort", None) + await WorkflowStore.kv_put(registry_key, registry) log.info("workflow.api.service_deleted", {"id": workflow_id}) return {"ok": True, "workflowId": workflow_id} diff --git a/flocks/workflow/center.py b/flocks/workflow/center.py index ad05aff3b..3773e5aca 100644 --- a/flocks/workflow/center.py +++ b/flocks/workflow/center.py @@ -30,6 +30,7 @@ from flocks.utils.log import Log from flocks.workflow.models import Workflow from flocks.workflow.requirements import resolve_python_package_index_url +from flocks.workflow.service_port import collect_service_ports, resolve_service_port from flocks.workflow.visibility import is_hidden_workflow log = Log.create(service="workflow.center") @@ -69,6 +70,10 @@ class WorkflowNotPublishedError(WorkflowCenterError): """Raised when invoking a workflow that is not published.""" +class WorkflowPortUnavailableError(WorkflowCenterError): + """Raised when a requested workflow service port cannot be used.""" + + def _key_to_string(key: Any) -> str: if isinstance(key, list): return "/".join(str(part) for part in key) @@ -172,37 +177,7 @@ def _is_port_available(port: int) -> bool: return False -def _port_from_service_url(value: Any) -> Optional[int]: - if not value: - return None - try: - parsed = urlparse(str(value)) - return parsed.port - except (TypeError, ValueError): - return None - - -def _ports_from_service_record(record: Any) -> set[int]: - if not isinstance(record, dict): - return set() - - ports: set[int] = set() - for key in ("hostPort", "port"): - try: - port = int(record.get(key) or 0) - except (TypeError, ValueError): - port = 0 - if 1 <= port <= 65535: - ports.add(port) - - for key in ("serviceUrl", "invokeUrl"): - port = _port_from_service_url(record.get(key)) - if port is not None: - ports.add(port) - return ports - - -async def _reserved_service_ports() -> set[int]: +async def _reserved_service_ports(*, exclude_workflow_id: Optional[str] = None) -> set[int]: ports: set[int] = set() for prefix in (_RUNTIME_PREFIX, _API_SERVICE_PREFIX, _REGISTRY_PREFIX): try: @@ -217,7 +192,12 @@ async def _reserved_service_ports() -> set[int]: except Exception as exc: log.warning("workflow.port.read_reserved_failed", {"key": _key_to_string(key), "error": str(exc)}) continue - ports.update(_ports_from_service_record(record)) + if exclude_workflow_id and isinstance(record, dict): + record_workflow_id = str(record.get("workflowId") or "") + key_value = _key_to_string(key) + if record_workflow_id == exclude_workflow_id or key_value.endswith(f"/{exclude_workflow_id}"): + continue + ports.update(collect_service_ports(record)) return ports @@ -234,14 +214,43 @@ def _release_port_reservation(port: Optional[int]) -> None: _IN_FLIGHT_PORT_RESERVATIONS.pop(port, None) -async def _allocate_port() -> int: +def _select_publish_port( + requested_port: Optional[int], + registry: Dict[str, Any], + previous_instance: Any, +) -> tuple[Optional[int], Optional[int]]: + """Return the desired port and the port currently bound by this workflow.""" + current_port = resolve_service_port(previous_instance, keys=("hostPort", "port", "servicePort")) + configured_port = resolve_service_port(registry, keys=("servicePort", "port", "hostPort")) + desired_port = requested_port if requested_port is not None else configured_port or current_port + return desired_port, current_port + + +async def _allocate_port( + preferred_port: Optional[int] = None, + *, + workflow_id: Optional[str] = None, + allow_bound_port: Optional[int] = None, +) -> int: start = int(os.getenv("FLOCKS_WORKFLOW_SERVICE_PORT_START", str(_DEFAULT_PORT_START))) end = int(os.getenv("FLOCKS_WORKFLOW_SERVICE_PORT_END", str(_DEFAULT_PORT_END))) if start > end: raise WorkflowCenterError("Invalid workflow service port range") async with _PORT_ALLOCATION_LOCK: - reserved_ports = await _reserved_service_ports() + reserved_ports = await _reserved_service_ports(exclude_workflow_id=workflow_id) reserved_ports.update(_reserved_in_flight_ports()) + if preferred_port is not None: + if isinstance(preferred_port, bool) or not 1 <= preferred_port <= 65535: + raise WorkflowCenterError("Workflow service port must be between 1 and 65535") + bound_by_current_runtime = preferred_port == allow_bound_port + if preferred_port in reserved_ports or ( + not bound_by_current_runtime and not _is_port_available(preferred_port) + ): + raise WorkflowPortUnavailableError( + f"Workflow service port {preferred_port} is already reserved or in use" + ) + _IN_FLIGHT_PORT_RESERVATIONS[preferred_port] = time.time() + _PORT_RESERVATION_TTL_S + return preferred_port for port in range(start, end + 1): if port not in reserved_ports and _is_port_available(port): _IN_FLIGHT_PORT_RESERVATIONS[port] = time.time() + _PORT_RESERVATION_TTL_S @@ -306,6 +315,7 @@ async def _scan_workflow_dir( "activeReleaseId": existing.get("activeReleaseId"), "serviceKey": existing.get("serviceKey"), "serviceUrl": existing.get("serviceUrl"), + "servicePort": existing.get("servicePort"), } await WorkflowStore.kv_put(_registry_key(workflow_id), entry) by_id[workflow_id] = entry @@ -737,22 +747,58 @@ async def _stop_runtime_record( } -async def _stop_existing_runtime_for_publish(workflow_id: str) -> None: - """Best-effort cleanup before starting a replacement service.""" +async def _stop_existing_runtime_for_publish(workflow_id: str) -> bool: + """Stop the current runtime and report whether an old process existed.""" runtime = await WorkflowStore.kv_get(_runtime_key(workflow_id)) if isinstance(runtime, dict) and runtime: await _stop_runtime_record(workflow_id, runtime, update_registry=False) - else: + return True + legacy_pid = await WorkflowStore.kv_get(_local_pid_key(workflow_id)) + if legacy_pid: await _stop_local_service(workflow_id) + return True + return False + + +async def _remove_runtime_records_for_release(workflow_id: str, release_id: str) -> None: + """Remove only runtime pointers created by the failed release.""" + for key in (_runtime_key(workflow_id), _active_release_key(workflow_id)): + record = await WorkflowStore.kv_get(key) + if isinstance(record, dict) and record.get("releaseId") == release_id: + await WorkflowStore.kv_remove(key) + + +async def _restore_registry_after_publish_failure( + workflow_id: str, + previous_registry: Dict[str, Any], + *, + previous_runtime_survived: bool, +) -> None: + registry = dict(previous_registry) + if not previous_runtime_survived: + registry.update( + { + "publishStatus": "failed", + "serviceUrl": None, + "updatedAt": _now_ms(), + } + ) + await WorkflowStore.kv_put(_registry_key(workflow_id), registry) -async def publish_workflow_local(workflow_id: str, *, api_key: Optional[str] = None) -> Dict[str, Any]: +async def publish_workflow_local( + workflow_id: str, + *, + api_key: Optional[str] = None, + port: Optional[int] = None, +) -> Dict[str, Any]: """Publish a workflow as a local subprocess using the current Python env. This is the default driver for development: no Docker, instant startup, uses the same .venv as the main server. """ registry = await _read_registry(workflow_id) + previous_registry = dict(registry) workflow_path = Path(str(registry["workflowPath"])) if not workflow_path.exists(): raise WorkflowCenterError(f"Workflow file not found: {workflow_path}") @@ -761,22 +807,31 @@ async def publish_workflow_local(workflow_id: str, *, api_key: Optional[str] = N Workflow.from_dict(workflow_json) release_id = uuid.uuid4().hex - now_ms = _now_ms() - registry["publishStatus"] = "publishing" - registry["updatedAt"] = now_ms - await WorkflowStore.kv_put(_registry_key(workflow_id), registry) - - release_snapshot_file = await _write_release_snapshot(workflow_id, release_id, workflow_json) - - await _stop_existing_runtime_for_publish(workflow_id) - - host_port = await _allocate_port() - service_url = _host_service_url(host_port) service_key = workflow_id runtime_api_key = api_key or _generate_api_key() + previous_runtime = await WorkflowStore.kv_get(_runtime_key(workflow_id)) or await WorkflowStore.kv_get( + _active_release_key(workflow_id) + ) + preferred_port, previous_port = _select_publish_port(port, registry, previous_runtime) + host_port: Optional[int] = None proc: asyncio.subprocess.Process | None = None + previous_runtime_stopped = False try: + host_port = await _allocate_port( + preferred_port, + workflow_id=workflow_id, + allow_bound_port=previous_port, + ) + release_snapshot_file = await _write_release_snapshot(workflow_id, release_id, workflow_json) + registry["publishStatus"] = "publishing" + registry["updatedAt"] = _now_ms() + await WorkflowStore.kv_put(_registry_key(workflow_id), registry) + + previous_runtime_stopped = await _stop_existing_runtime_for_publish(workflow_id) + if not _is_port_available(host_port): + raise WorkflowPortUnavailableError(f"Workflow service port {host_port} is still in use after restart") + service_url = _host_service_url(host_port) env = os.environ.copy() env[_SERVICE_API_KEY_ENV] = runtime_api_key proc = await asyncio.create_subprocess_exec( @@ -838,28 +893,34 @@ async def publish_workflow_local(workflow_id: str, *, api_key: Optional[str] = N } await WorkflowStore.kv_put(_active_release_key(workflow_id), active_record) await WorkflowStore.kv_put(_runtime_key(workflow_id), active_record) - _release_port_reservation(host_port) + + registry["publishStatus"] = "active" + registry["activeReleaseId"] = release_id + registry["serviceKey"] = service_key + registry["serviceUrl"] = service_url + registry["servicePort"] = host_port + registry["updatedAt"] = _now_ms() + await WorkflowStore.kv_put(_registry_key(workflow_id), registry) except Exception as exc: - _release_port_reservation(host_port) if proc is not None: try: - _signal_local_process(proc.pid, signal.SIGTERM, proc.pid) - await _wait_for_pid_exit(proc.pid, 1.0) + await _stop_local_service(workflow_id) except Exception: pass - registry["publishStatus"] = "failed" - registry["updatedAt"] = _now_ms() - await WorkflowStore.kv_put(_registry_key(workflow_id), registry) + if _pid_is_running(proc.pid): + _signal_local_process(proc.pid, signal.SIGTERM, proc.pid) + await _wait_for_pid_exit(proc.pid, 1.0) + await _remove_runtime_records_for_release(workflow_id, release_id) + await _restore_registry_after_publish_failure( + workflow_id, + previous_registry, + previous_runtime_survived=bool(previous_runtime) and not previous_runtime_stopped, + ) if isinstance(exc, WorkflowCenterError): raise raise WorkflowCenterError(str(exc)) from exc - - registry["publishStatus"] = "active" - registry["activeReleaseId"] = release_id - registry["serviceKey"] = service_key - registry["serviceUrl"] = service_url - registry["updatedAt"] = _now_ms() - await WorkflowStore.kv_put(_registry_key(workflow_id), registry) + finally: + _release_port_reservation(host_port) log.info("workflow.local.published", {"id": workflow_id, "port": host_port, "pid": proc.pid}) return active_record @@ -892,14 +953,15 @@ async def publish_workflow( image: Optional[str] = None, driver: Optional[str] = None, api_key: Optional[str] = None, + port: Optional[int] = None, ) -> Dict[str, Any]: """Publish a workflow using the configured service driver (local or docker).""" resolved_driver = (driver or _service_driver()).strip().lower() if resolved_driver == "docker": - return await _publish_workflow_docker(workflow_id, image=image, api_key=api_key) + return await _publish_workflow_docker(workflow_id, image=image, api_key=api_key, port=port) if resolved_driver != "local": raise WorkflowCenterError(f"Unsupported workflow service driver: {resolved_driver}") - return await publish_workflow_local(workflow_id, api_key=api_key) + return await publish_workflow_local(workflow_id, api_key=api_key, port=port) async def stop_workflow_service(workflow_id: str) -> Dict[str, Any]: @@ -920,9 +982,11 @@ async def _publish_workflow_docker( image: Optional[str] = None, *, api_key: Optional[str] = None, + port: Optional[int] = None, ) -> Dict[str, Any]: """Publish a registered workflow as a Docker service container.""" registry = await _read_registry(workflow_id) + previous_registry = dict(registry) workflow_path = Path(str(registry["workflowPath"])) if not workflow_path.exists(): raise WorkflowCenterError(f"Workflow file not found: {workflow_path}") @@ -932,10 +996,6 @@ async def _publish_workflow_docker( release_id = uuid.uuid4().hex now_ms = _now_ms() - registry["publishStatus"] = "publishing" - registry["updatedAt"] = now_ms - await WorkflowStore.kv_put(_registry_key(workflow_id), registry) - release_snapshot_file = await _write_release_snapshot(workflow_id, release_id, workflow_json) release_runtime_dir = release_snapshot_file.parent has_requirements_snapshot = await _write_requirements_snapshot(release_runtime_dir) @@ -950,14 +1010,14 @@ async def _publish_workflow_docker( "activatedAt": None, "deactivatedAt": None, } - await WorkflowStore.kv_put(_release_key(workflow_id, release_id), release_record) - previous_runtime = await WorkflowStore.kv_get(_runtime_key(workflow_id)) or {} previous_active = await WorkflowStore.kv_get(_active_release_key(workflow_id)) or {} - previous_container_name = previous_active.get("containerName") - previous_release_id = previous_active.get("releaseId") + previous_instance = previous_runtime or previous_active + previous_container_name = previous_instance.get("containerName") + previous_release_id = previous_instance.get("releaseId") + preferred_port, previous_port = _select_publish_port(port, registry, previous_instance) - host_port = await _allocate_port() + host_port: Optional[int] = None container_name = _workflow_container_name(workflow_id, release_id) image_name = image or os.getenv("FLOCKS_WORKFLOW_SERVICE_IMAGE", _DEFAULT_IMAGE) runtime_install = os.getenv("FLOCKS_WORKFLOW_SERVICE_RUNTIME_INSTALL", "1").strip().lower() not in { @@ -981,7 +1041,7 @@ async def _publish_workflow_docker( "--name", container_name, "-p", - f"{host_port}:{_SERVICE_CONTAINER_PORT}", + "", "-v", f"{project_root}:/app:ro", "-v", @@ -1085,7 +1145,28 @@ async def _publish_workflow_docker( ] ) + previous_stopped = False + publish_started = False try: + host_port = await _allocate_port( + preferred_port, + workflow_id=workflow_id, + allow_bound_port=previous_port, + ) + port_binding_index = cmd.index("-p") + 1 + cmd[port_binding_index] = f"{host_port}:{_SERVICE_CONTAINER_PORT}" + + registry["publishStatus"] = "publishing" + registry["updatedAt"] = now_ms + await WorkflowStore.kv_put(_registry_key(workflow_id), registry) + await WorkflowStore.kv_put(_release_key(workflow_id, release_id), release_record) + publish_started = True + + if previous_instance and host_port == previous_port: + await _stop_runtime_record(workflow_id, previous_instance, update_registry=False) + previous_stopped = True + if not _is_port_available(host_port): + raise WorkflowPortUnavailableError(f"Workflow service port {host_port} is still in use after restart") stdout, _, _ = await exec_docker(cmd) container_id = stdout.strip() service_url = _host_service_url(host_port) @@ -1118,39 +1199,52 @@ async def _publish_workflow_docker( } await WorkflowStore.kv_put(_active_release_key(workflow_id), active_record) await WorkflowStore.kv_put(_runtime_key(workflow_id), active_record) - _release_port_reservation(host_port) registry["publishStatus"] = "active" registry["activeReleaseId"] = release_id registry["serviceKey"] = service_key registry["serviceUrl"] = service_url + registry["servicePort"] = host_port registry["updatedAt"] = _now_ms() await WorkflowStore.kv_put(_registry_key(workflow_id), registry) + except Exception as exc: + if publish_started: + await _stop_and_remove_container(container_name) + await _remove_runtime_records_for_release(workflow_id, release_id) + release_record["status"] = "failed" + release_record["deactivatedAt"] = _now_ms() + await WorkflowStore.kv_put(_release_key(workflow_id, release_id), release_record) + + await _restore_registry_after_publish_failure( + workflow_id, + previous_registry, + previous_runtime_survived=bool(previous_instance) and not previous_stopped, + ) + if isinstance(exc, WorkflowCenterError): + raise + raise WorkflowCenterError(str(exc)) from exc + finally: + _release_port_reservation(host_port) - if isinstance(previous_runtime, dict) and previous_runtime: + if not previous_stopped and isinstance(previous_runtime, dict) and previous_runtime: + try: await _stop_runtime_record( workflow_id, previous_runtime, update_registry=False, clear_runtime_keys=False, ) - elif previous_container_name and previous_container_name != container_name: + except Exception as exc: + log.warning("workflow.publish.previous_runtime_cleanup_failed", {"id": workflow_id, "error": str(exc)}) + elif not previous_stopped and previous_container_name and previous_container_name != container_name: + try: await _stop_and_remove_container(previous_container_name) if previous_release_id: await _mark_release_inactive(workflow_id, previous_release_id) + except Exception as exc: + log.warning("workflow.publish.previous_container_cleanup_failed", {"id": workflow_id, "error": str(exc)}) - return active_record - except Exception as exc: - _release_port_reservation(host_port) - await _stop_and_remove_container(container_name) - release_record["status"] = "failed" - release_record["deactivatedAt"] = _now_ms() - await WorkflowStore.kv_put(_release_key(workflow_id, release_id), release_record) - - registry["publishStatus"] = "failed" - registry["updatedAt"] = _now_ms() - await WorkflowStore.kv_put(_registry_key(workflow_id), registry) - raise WorkflowCenterError(str(exc)) from exc + return active_record async def _stop_workflow_service_docker(workflow_id: str) -> Dict[str, Any]: diff --git a/flocks/workflow/service_port.py b/flocks/workflow/service_port.py new file mode 100644 index 000000000..c1c27c270 --- /dev/null +++ b/flocks/workflow/service_port.py @@ -0,0 +1,50 @@ +"""Shared workflow service port parsing helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping, Sequence +from typing import Any, Optional +from urllib.parse import urlparse + +SERVICE_PORT_KEYS = ("port", "servicePort", "hostPort") +SERVICE_URL_KEYS = ("serviceUrl", "invokeUrl") + + +def _valid_port(value: Any) -> Optional[int]: + if isinstance(value, bool): + return None + try: + port = int(value or 0) + except (TypeError, ValueError): + return None + return port if 1 <= port <= 65535 else None + + +def _iter_service_ports(record: Any, keys: Sequence[str]) -> Iterator[int]: + if not isinstance(record, Mapping): + return + for key in keys: + port = _valid_port(record.get(key)) + if port is not None: + yield port + for key in SERVICE_URL_KEYS: + try: + port = urlparse(str(record.get(key) or "")).port + except (TypeError, ValueError): + port = None + if port is not None: + yield port + + +def resolve_service_port( + record: Any, + *, + keys: Sequence[str] = SERVICE_PORT_KEYS, +) -> Optional[int]: + """Resolve the first valid explicit or URL-derived port in a service record.""" + return next(_iter_service_ports(record, keys), None) + + +def collect_service_ports(record: Any) -> set[int]: + """Collect every valid port represented by a service record.""" + return set(_iter_service_ports(record, SERVICE_PORT_KEYS)) diff --git a/tests/server/routes/test_workflow_publish_api.py b/tests/server/routes/test_workflow_publish_api.py index a715239f4..483298457 100644 --- a/tests/server/routes/test_workflow_publish_api.py +++ b/tests/server/routes/test_workflow_publish_api.py @@ -39,7 +39,14 @@ async def test_publish_workflow_as_api_reuses_key_for_runtime( async def fake_read(key: Any, *_args: Any, **_kwargs: Any) -> Any: if str(key) == workflow_routes._api_service_key(workflow_id): - return {"apiKey": existing_key} + return {"apiKey": existing_key, "serviceUrl": "http://127.0.0.1:19007"} + if str(key) == f"{workflow_routes._REGISTRY_PREFIX_MAIN}{workflow_id}": + return { + "workflowId": workflow_id, + "publishStatus": "active", + "servicePort": 19007, + "serviceUrl": "http://127.0.0.1:19007", + } return None async def fake_write(key: Any, value: Any) -> None: @@ -50,6 +57,7 @@ async def fake_publish_workflow( image: str | None = None, driver: str | None = None, api_key: str | None = None, + port: int | None = None, ) -> dict[str, Any]: publish_calls.append( { @@ -57,6 +65,7 @@ async def fake_publish_workflow( "image": image, "driver": driver, "api_key": api_key, + "port": port, } ) return { @@ -64,6 +73,7 @@ async def fake_publish_workflow( "containerName": "local-wf-1", "driver": driver or "local", "apiKey": api_key, + "hostPort": port, } monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_read) @@ -72,7 +82,7 @@ async def fake_publish_workflow( result = await workflow_routes.publish_workflow_as_api( workflow_id, - workflow_routes.WorkflowCenterPublishRequest(driver="local"), + workflow_routes.WorkflowCenterPublishRequest(driver="local", port=25000), ) assert publish_calls == [ @@ -81,10 +91,105 @@ async def fake_publish_workflow( "image": None, "driver": "local", "api_key": existing_key, + "port": 25000, } ] assert result["apiKey"] == existing_key + assert result["port"] == 25000 assert writes[workflow_routes._api_service_key(workflow_id)]["apiKey"] == existing_key + assert writes[workflow_routes._api_service_key(workflow_id)]["port"] == 25000 + registry = writes[f"{workflow_routes._REGISTRY_PREFIX_MAIN}{workflow_id}"] + assert registry["publishStatus"] == "active" + assert registry["servicePort"] == 19007 + + +@pytest.mark.asyncio +async def test_publish_workflow_as_api_returns_conflict_for_unavailable_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_prepare_registry(_workflow_id: str) -> tuple[dict[str, Any], int]: + return {"name": "Demo Workflow"}, 123 + + workflow_id = "wf-1" + service = { + "workflowId": workflow_id, + "status": "running", + "apiKey": "existing-key", + "port": 25000, + } + writes: list[tuple[Any, Any]] = [] + + async def fake_read(key: Any, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + if str(key) == workflow_routes._api_service_key(workflow_id): + return service + if str(key) == workflow_routes._runtime_key_main(workflow_id): + return {"workflowId": workflow_id, "hostPort": 25000} + return {} + + async def fake_write(key: Any, value: Any) -> None: + writes.append((key, value)) + + async def fake_publish_workflow(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise workflow_routes.WorkflowPortUnavailableError( + "Workflow service port 25000 is already reserved or in use" + ) + + monkeypatch.setattr(workflow_routes, "_prepare_workflow_api_registry", fake_prepare_registry) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_put", fake_write) + monkeypatch.setattr(workflow_routes, "publish_workflow", fake_publish_workflow) + + with pytest.raises(workflow_routes.HTTPException) as exc_info: + await workflow_routes.publish_workflow_as_api( + workflow_id, + workflow_routes.WorkflowCenterPublishRequest(driver="local", port=25000), + ) + + assert exc_info.value.status_code == 409 + assert "25000" in str(exc_info.value.detail) + assert writes == [] + + +@pytest.mark.asyncio +async def test_publish_workflow_as_api_marks_failed_restart_when_runtime_was_stopped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_id = "wf-failed-restart" + service_key = workflow_routes._api_service_key(workflow_id) + store: dict[str, Any] = { + service_key: { + "workflowId": workflow_id, + "status": "running", + "apiKey": "existing-key", + "port": 25000, + } + } + + async def fake_prepare_registry(_workflow_id: str) -> tuple[dict[str, Any], int]: + return {"name": "Demo Workflow"}, 123 + + async def fake_read(key: Any, *_args: Any, **_kwargs: Any) -> Any: + return store.get(str(key)) + + async def fake_write(key: Any, value: Any) -> None: + store[str(key)] = value + + async def fake_publish_workflow(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise workflow_routes.WorkflowCenterError("replacement failed health check") + + monkeypatch.setattr(workflow_routes, "_prepare_workflow_api_registry", fake_prepare_registry) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_put", fake_write) + monkeypatch.setattr(workflow_routes, "publish_workflow", fake_publish_workflow) + + with pytest.raises(workflow_routes.HTTPException) as exc_info: + await workflow_routes.publish_workflow_as_api(workflow_id) + + assert exc_info.value.status_code == 500 + assert store[service_key]["status"] == "error" + assert store[service_key]["port"] == 25000 + assert store[service_key]["health"] == {"ok": False, "reason": "publish_failed"} + assert "replacement failed" in store[service_key]["lastStartError"] @pytest.mark.asyncio @@ -131,6 +236,7 @@ async def fake_publish_workflow( image: str | None = None, driver: str | None = None, api_key: str | None = None, + port: int | None = None, ) -> dict[str, Any]: publish_calls.append( { @@ -138,14 +244,16 @@ async def fake_publish_workflow( "image": image, "driver": driver, "api_key": api_key, + "port": port, } ) return { - "serviceUrl": "http://127.0.0.1:19001", + "serviceUrl": "http://127.0.0.1:19000", "containerName": "flocks-wf-wf-1-rel-1", "driver": driver, "image": image, "apiKey": api_key, + "hostPort": port, } monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_list_keys", fake_list_keys) @@ -165,12 +273,14 @@ async def fake_publish_workflow( "image": "custom-image:latest", "driver": "docker", "api_key": existing_key, + "port": 19000, } ] assert store[service_key]["status"] == "running" assert store[service_key]["apiKey"] == existing_key - assert store[service_key]["serviceUrl"] == "http://127.0.0.1:19001" - assert store[service_key]["invokeUrl"] == "http://127.0.0.1:19001/invoke" + assert store[service_key]["serviceUrl"] == "http://127.0.0.1:19000" + assert store[service_key]["invokeUrl"] == "http://127.0.0.1:19000/invoke" + assert store[service_key]["port"] == 19000 @pytest.mark.asyncio @@ -211,6 +321,7 @@ async def fake_publish_workflow( image: str | None = None, driver: str | None = None, api_key: str | None = None, + port: int | None = None, ) -> dict[str, Any]: publish_calls.append(requested_id) return { @@ -275,7 +386,7 @@ async def fake_health(requested_id: str) -> dict[str, Any]: @pytest.mark.asyncio -async def test_get_workflow_service_does_not_probe_runtime_health( +async def test_get_workflow_service_normalizes_stale_state_without_health_probe( monkeypatch: pytest.MonkeyPatch, ) -> None: workflow_id = "wf-service-read" @@ -288,8 +399,11 @@ async def test_get_workflow_service_does_not_probe_runtime_health( health_calls: list[str] = [] async def fake_read(key: Any, *_args: Any, **_kwargs: Any) -> Any: - assert str(key) == workflow_routes._api_service_key(workflow_id) - return service + if str(key) == workflow_routes._api_service_key(workflow_id): + return service + if str(key) == workflow_routes._runtime_key_main(workflow_id): + return None + raise AssertionError(f"Unexpected storage key: {key}") async def fake_write(key: Any, value: Any) -> None: writes.append((key, value)) @@ -304,7 +418,9 @@ async def fake_health(requested_id: str) -> dict[str, Any]: result = await workflow_routes.get_workflow_service(workflow_id) - assert result is service + assert result["status"] == "stopped" + assert result["health"] == {"ok": False, "stale": True, "reason": "missing_runtime"} + assert service["status"] == "running" assert health_calls == [] assert writes == [] @@ -354,3 +470,42 @@ async def fake_write(key: Any, value: Any) -> None: assert "stoppedAt" not in result[0] assert store[service_key]["status"] == "running" assert writes == [] + + +@pytest.mark.asyncio +async def test_delete_workflow_service_clears_persisted_port(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_id = "wf-delete-service" + service_key = workflow_routes._api_service_key(workflow_id) + registry_key = f"{workflow_routes._REGISTRY_PREFIX_MAIN}{workflow_id}" + store: dict[str, Any] = { + service_key: {"workflowId": workflow_id, "status": "stopped", "port": 25000}, + registry_key: { + "workflowId": workflow_id, + "publishStatus": "stopped", + "servicePort": 25000, + }, + } + + async def fake_read(key: Any, *_args: Any, **_kwargs: Any) -> Any: + return store.get(str(key)) + + async def fake_write(key: Any, value: Any) -> None: + store[str(key)] = value + + async def fake_remove(key: Any) -> bool: + store.pop(str(key), None) + return True + + async def fake_stop(_workflow_id: str) -> dict[str, Any]: + return {"status": "stopped"} + + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_put", fake_write) + monkeypatch.setattr(workflow_routes.WorkflowStore, "kv_remove", fake_remove) + monkeypatch.setattr(workflow_routes, "stop_workflow_service", fake_stop) + + result = await workflow_routes.delete_workflow_service(workflow_id) + + assert result == {"ok": True, "workflowId": workflow_id} + assert service_key not in store + assert "servicePort" not in store[registry_key] diff --git a/tests/workflow/test_workflow_center.py b/tests/workflow/test_workflow_center.py index 7aa12cc8e..6031882d9 100644 --- a/tests/workflow/test_workflow_center.py +++ b/tests/workflow/test_workflow_center.py @@ -163,12 +163,14 @@ async def test_publish_invoke_stop_workflow_service( docker_calls = [] json_post_calls = [] + port_requests = [] async def fake_exec_docker(args, allow_failure=False, **_kwargs): docker_calls.append((args, allow_failure)) return ("container-abc\n", "", 0) - async def fake_allocate_port() -> int: + async def fake_allocate_port(*args, **kwargs) -> int: + port_requests.append((args, kwargs)) return 19123 async def fake_wait_docker_service_healthy(*_args, **_kwargs) -> bool: @@ -187,6 +189,8 @@ def fake_json_post(*args, **kwargs): assert published["status"] == "active" assert published["hostPort"] == 19123 assert len(published["apiKey"]) == 64 + registry = await center.WorkflowStore.kv_get(center._registry_key(workflow_id)) + assert registry["servicePort"] == 19123 invoked = await center.invoke_published_workflow(workflow_id, inputs={"k": "v"}) assert invoked["status"] == "SUCCEEDED" @@ -196,6 +200,13 @@ def fake_json_post(*args, **kwargs): stopped = await center.stop_workflow_service(workflow_id) assert stopped["status"] == "stopped" assert stopped["stopped"] is True + registry = await center.WorkflowStore.kv_get(center._registry_key(workflow_id)) + assert registry["servicePort"] == 19123 + + republished = await center.publish_workflow(workflow_id) + assert republished["hostPort"] == 19123 + assert port_requests[-1][0][0] == 19123 + await center.stop_workflow_service(workflow_id) assert any(call[0][:3] == ["run", "-d", "--name"] for call in docker_calls) run_call = next(call for call in docker_calls if call[0][:3] == ["run", "-d", "--name"]) diff --git a/tests/workflow/test_workflow_center_lifecycle.py b/tests/workflow/test_workflow_center_lifecycle.py index 43d11ab83..bcaf6eee9 100644 --- a/tests/workflow/test_workflow_center_lifecycle.py +++ b/tests/workflow/test_workflow_center_lifecycle.py @@ -139,7 +139,7 @@ async def test_allocate_port_skips_reserved_service_records(monkeypatch) -> None }, "workflow_registry/wf-registry": { "workflowId": "wf-registry", - "serviceUrl": "http://127.0.0.1:19002", + "servicePort": 19002, "publishStatus": "active", }, } @@ -179,6 +179,220 @@ async def fake_list_keys(_prefix): center._IN_FLIGHT_PORT_RESERVATIONS.clear() +@pytest.mark.asyncio +async def test_allocate_preferred_port_reuses_current_workflow_reservation(monkeypatch) -> None: + store: dict[str, Any] = { + "workflow_api_service/wf-current": { + "workflowId": "wf-current", + "port": 25000, + "status": "stopped", + } + } + + async def fake_list_keys(prefix): + return [key for key in store if key.startswith(str(prefix))] + + async def fake_read(key): + return store.get(str(key)) + + center._IN_FLIGHT_PORT_RESERVATIONS.clear() + monkeypatch.setenv("FLOCKS_WORKFLOW_SERVICE_PORT_START", "19000") + monkeypatch.setenv("FLOCKS_WORKFLOW_SERVICE_PORT_END", "19001") + monkeypatch.setattr(center.WorkflowStore, "kv_list_keys", fake_list_keys) + monkeypatch.setattr(center.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(center, "_is_port_available", lambda _port: True) + + try: + assert await center._allocate_port(25000, workflow_id="wf-current") == 25000 + finally: + center._IN_FLIGHT_PORT_RESERVATIONS.clear() + + +@pytest.mark.asyncio +async def test_allocate_preferred_port_rejects_other_workflow_reservation(monkeypatch) -> None: + store: dict[str, Any] = { + "workflow_api_service/wf-other": { + "workflowId": "wf-other", + "port": 25000, + "status": "stopped", + } + } + + async def fake_list_keys(prefix): + return [key for key in store if key.startswith(str(prefix))] + + async def fake_read(key): + return store.get(str(key)) + + center._IN_FLIGHT_PORT_RESERVATIONS.clear() + monkeypatch.setattr(center.WorkflowStore, "kv_list_keys", fake_list_keys) + monkeypatch.setattr(center.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(center, "_is_port_available", lambda _port: True) + + with pytest.raises(center.WorkflowPortUnavailableError, match="25000"): + await center._allocate_port(25000, workflow_id="wf-current") + + +@pytest.mark.asyncio +async def test_allocate_preferred_port_allows_port_bound_by_current_runtime(monkeypatch) -> None: + async def fake_list_keys(_prefix): + return [] + + center._IN_FLIGHT_PORT_RESERVATIONS.clear() + monkeypatch.setattr(center.WorkflowStore, "kv_list_keys", fake_list_keys) + monkeypatch.setattr(center, "_is_port_available", lambda _port: False) + + try: + assert ( + await center._allocate_port( + 25000, + workflow_id="wf-current", + allow_bound_port=25000, + ) + == 25000 + ) + finally: + center._IN_FLIGHT_PORT_RESERVATIONS.clear() + + +@pytest.mark.asyncio +async def test_local_port_conflict_keeps_previous_runtime_and_registry(monkeypatch, tmp_path) -> None: + workflow_id = "wf-local-conflict" + workflow_path = tmp_path / "workflow.json" + workflow_path.write_text( + json.dumps( + { + "id": workflow_id, + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "outputs['ok'] = True"}], + "edges": [], + } + ), + encoding="utf-8", + ) + registry = { + "workflowId": workflow_id, + "workflowPath": str(workflow_path), + "publishStatus": "active", + "servicePort": 25000, + "serviceUrl": "http://127.0.0.1:25000", + } + runtime = { + "workflowId": workflow_id, + "releaseId": "rel-old", + "driver": "local", + "hostPort": 25000, + "serviceUrl": "http://127.0.0.1:25000", + } + store: dict[str, Any] = { + f"workflow_registry/{workflow_id}": registry, + f"workflow_runtime/{workflow_id}": runtime, + } + stop_calls: list[str] = [] + + async def fake_read(key): + return store.get(str(key)) + + async def fake_write(key, value): + store[str(key)] = value + + async def reject_port(*_args, **_kwargs): + raise center.WorkflowPortUnavailableError("port conflict") + + async def fake_stop(_workflow_id): + stop_calls.append(_workflow_id) + return True + + monkeypatch.setattr(center.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(center.WorkflowStore, "kv_put", fake_write) + monkeypatch.setattr(center, "_allocate_port", reject_port) + monkeypatch.setattr(center, "_stop_existing_runtime_for_publish", fake_stop) + + with pytest.raises(center.WorkflowPortUnavailableError, match="port conflict"): + await center.publish_workflow_local(workflow_id, port=25000) + + assert stop_calls == [] + assert store[f"workflow_runtime/{workflow_id}"] is runtime + assert store[f"workflow_registry/{workflow_id}"] == registry + + +@pytest.mark.asyncio +async def test_docker_port_conflict_keeps_previous_runtime_and_registry(monkeypatch, tmp_path) -> None: + workflow_id = "wf-docker-conflict" + workflow_path = tmp_path / "workflow.json" + workflow_path.write_text( + json.dumps( + { + "id": workflow_id, + "start": "n1", + "nodes": [{"id": "n1", "type": "python", "code": "outputs['ok'] = True"}], + "edges": [], + } + ), + encoding="utf-8", + ) + release_dir = tmp_path / "release" + release_dir.mkdir() + release_snapshot = release_dir / "workflow.json" + registry = { + "workflowId": workflow_id, + "workflowPath": str(workflow_path), + "publishStatus": "active", + "servicePort": 25000, + "serviceUrl": "http://127.0.0.1:25000", + } + runtime = { + "workflowId": workflow_id, + "releaseId": "rel-old", + "driver": "docker", + "containerName": "old-container", + "hostPort": 25000, + "serviceUrl": "http://127.0.0.1:25000", + } + store: dict[str, Any] = { + f"workflow_registry/{workflow_id}": registry, + f"workflow_runtime/{workflow_id}": runtime, + } + docker_calls: list[Any] = [] + + async def fake_read(key): + return store.get(str(key)) + + async def fake_write(key, value): + store[str(key)] = value + + async def fake_snapshot(*_args, **_kwargs): + return release_snapshot + + async def no_requirements(*_args, **_kwargs): + return False + + async def reject_port(*_args, **_kwargs): + raise center.WorkflowPortUnavailableError("port conflict") + + async def fake_exec_docker(*args, **kwargs): + docker_calls.append((args, kwargs)) + return ("", "", 0) + + monkeypatch.setattr(center.WorkflowStore, "kv_get", fake_read) + monkeypatch.setattr(center.WorkflowStore, "kv_put", fake_write) + monkeypatch.setattr(center, "_write_release_snapshot", fake_snapshot) + monkeypatch.setattr(center, "_write_requirements_snapshot", no_requirements) + monkeypatch.setattr(center, "_service_cache_dir", lambda _name: tmp_path) + monkeypatch.setattr(center.Config, "get_config_path", lambda: tmp_path / "missing-config") + monkeypatch.setattr(center, "resolve_python_package_index_url", lambda: None) + monkeypatch.setattr(center, "_allocate_port", reject_port) + monkeypatch.setattr(center, "exec_docker", fake_exec_docker) + + with pytest.raises(center.WorkflowPortUnavailableError, match="port conflict"): + await center._publish_workflow_docker(workflow_id, port=26000) + + assert docker_calls == [] + assert store[f"workflow_runtime/{workflow_id}"] is runtime + assert store[f"workflow_registry/{workflow_id}"] == registry + assert not any(key.startswith(f"workflow_release/{workflow_id}/") for key in store) + + @pytest.mark.asyncio async def test_publish_workflow_local_releases_reserved_port_on_spawn_failure( monkeypatch, @@ -212,12 +426,12 @@ async def fake_write(key, value): store[str(key)] = value async def fake_stop_existing_runtime_for_publish(_workflow_id): - return None + return False async def fake_write_release_snapshot(_workflow_id, _release_id, _workflow_json): return workflow_path - async def fake_allocate_port(): + async def fake_allocate_port(*_args, **_kwargs): center._IN_FLIGHT_PORT_RESERVATIONS[19000] = 9999999999.0 return 19000 diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index b081435f9..9553723a6 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -262,10 +262,24 @@ export interface WorkflowService { publishedAt: number; containerName?: string; driver?: 'local' | 'docker'; + port?: number; } export type WorkflowServiceDriver = 'local' | 'docker'; +export function resolveWorkflowServicePort( + service?: Pick | null, +): number | undefined { + if (service?.port && service.port >= 1 && service.port <= 65535) return service.port; + if (!service?.serviceUrl) return undefined; + try { + const port = Number(new URL(service.serviceUrl).port); + return port >= 1 && port <= 65535 ? port : undefined; + } catch { + return undefined; + } +} + export interface WorkflowIntegrationConfig { version: number; kind: string; @@ -444,7 +458,7 @@ export const workflowAPI = { export: (id: string) => client.get(`/api/workflow/${id}/export`), - publish: (id: string, data?: { driver?: WorkflowServiceDriver }) => + publish: (id: string, data?: { driver?: WorkflowServiceDriver; port?: number }) => client.post(`/api/workflow/${id}/publish`, data, { timeout: 600000 }), unpublish: (id: string) => diff --git a/webui/src/locales/en-US/workflow.json b/webui/src/locales/en-US/workflow.json index 308320a1c..e2188904c 100644 --- a/webui/src/locales/en-US/workflow.json +++ b/webui/src/locales/en-US/workflow.json @@ -302,6 +302,11 @@ "driverLocal": "Local process", "driverDocker": "Docker container", "applyDriver": "Apply runtime", + "applyPublishConfig": "Apply publish settings", + "servicePort": "Listening port", + "servicePortPlaceholder": "Auto-assign (19000–19999)", + "servicePortDesc": "Leave blank to auto-assign on the first publish. The resolved port is then shown, saved, and reused for updates, restarts, and recovery.", + "servicePortInvalid": "The listening port must be an integer between 1 and 65535.", "recommended": "Recommended", "driverLocalDesc": "Recommended first; starts faster for local development and debugging", "driverDockerDesc": "Runs isolated, best for validating container deployment", diff --git a/webui/src/locales/zh-CN/workflow.json b/webui/src/locales/zh-CN/workflow.json index 1fa408bb4..4d7803069 100644 --- a/webui/src/locales/zh-CN/workflow.json +++ b/webui/src/locales/zh-CN/workflow.json @@ -302,6 +302,11 @@ "driverLocal": "本地进程", "driverDocker": "Docker 容器", "applyDriver": "应用运行方式", + "applyPublishConfig": "应用发布设置", + "servicePort": "监听端口", + "servicePortPlaceholder": "自动分配(19000–19999)", + "servicePortDesc": "首次发布可留空自动分配;发布后会回填并保存实际端口,更新、重启和服务恢复都会继续使用。", + "servicePortInvalid": "监听端口必须是 1–65535 之间的整数。", "recommended": "推荐", "driverLocalDesc": "优先推荐,启动更快,适合本机开发和调试", "driverDockerDesc": "隔离运行,适合验证容器化部署", diff --git a/webui/src/pages/Task/ServicesSection.test.tsx b/webui/src/pages/Task/ServicesSection.test.tsx index 78c9e21d6..634f21395 100644 --- a/webui/src/pages/Task/ServicesSection.test.tsx +++ b/webui/src/pages/Task/ServicesSection.test.tsx @@ -11,7 +11,8 @@ const mocks = vi.hoisted(() => ({ toastError: vi.fn(), })); -vi.mock('@/api/workflow', () => ({ +vi.mock('@/api/workflow', async (importOriginal) => ({ + ...(await importOriginal()), workflowAPI: { listServices: mocks.listServices, publish: mocks.publish, @@ -113,7 +114,7 @@ describe('ServicesSection', () => { await user.click(restartButton); await waitFor(() => { - expect(mocks.publish).toHaveBeenCalledWith('wf-1', { driver: 'docker' }); + expect(mocks.publish).toHaveBeenCalledWith('wf-1', { driver: 'docker', port: 19000 }); }); expect(screen.getByText('服务驱动')).toBeInTheDocument(); expect(screen.getByText('Docker')).toBeInTheDocument(); diff --git a/webui/src/pages/Task/ServicesSection.tsx b/webui/src/pages/Task/ServicesSection.tsx index 18a89a25e..32094b658 100644 --- a/webui/src/pages/Task/ServicesSection.tsx +++ b/webui/src/pages/Task/ServicesSection.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Globe, StopCircle, Loader2, RefreshCw, ExternalLink } from 'lucide-react'; -import { workflowAPI, WorkflowService } from '@/api/workflow'; +import { resolveWorkflowServicePort, workflowAPI, WorkflowService } from '@/api/workflow'; import { useToast } from '@/components/common/Toast'; import EmptyState from '@/components/common/EmptyState'; import CopyButton from '@/components/common/CopyButton'; @@ -164,7 +164,7 @@ export default function ServicesSection() { setRestartingIds(prev => new Set(prev).add(service.workflowId)); try { const publishRequest = service.driver === 'local' || service.driver === 'docker' - ? { driver: service.driver } + ? { driver: service.driver, port: resolveWorkflowServicePort(service) } : undefined; await workflowAPI.publish(service.workflowId, publishRequest); await fetchServices(); diff --git a/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.test.tsx b/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.test.tsx index 31756100f..4bbb8dd7f 100644 --- a/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.test.tsx +++ b/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.test.tsx @@ -66,7 +66,8 @@ const { workflowAPI, workflowAPIEndpoints } = vi.hoisted(() => ({ }, })); -vi.mock('@/api/workflow', () => ({ +vi.mock('@/api/workflow', async (importOriginal) => ({ + ...(await importOriginal()), workflowAPI, workflowAPIEndpoints, })); @@ -104,6 +105,11 @@ vi.mock('react-i18next', () => ({ 'detail.run.driverLocal': '本地进程', 'detail.run.driverDocker': 'Docker 容器', 'detail.run.applyDriver': '应用运行方式', + 'detail.run.applyPublishConfig': '应用发布设置', + 'detail.run.servicePort': '监听端口', + 'detail.run.servicePortPlaceholder': '自动分配', + 'detail.run.servicePortDesc': '保存并复用端口', + 'detail.run.servicePortInvalid': '端口无效', 'detail.run.driverLocalDesc': 'local desc', 'detail.run.driverDockerDesc': 'docker desc', 'detail.run.apiKeyHide': '隐藏', @@ -234,6 +240,20 @@ describe('IntegrationTab trigger workspace', () => { expect(screen.queryByText('Workflow Poller')).not.toBeInTheDocument(); }); + it('publishes the API service on a manually selected port', async () => { + const user = userEvent.setup(); + render(); + + const apiCard = await screen.findByTestId('api-publish-card'); + await user.click(within(apiCard).getByRole('button', { name: '配置' })); + await user.type(within(apiCard).getByRole('spinbutton', { name: '监听端口' }), '25000'); + await user.click(within(apiCard).getByRole('button', { name: '发布' })); + + await waitFor(() => { + expect(workflowAPI.publish).toHaveBeenCalledWith('wf-1', { driver: 'local', port: 25000 }); + }); + }); + it('renders runtime publish and trigger records with delete actions below the Rex guide', async () => { const user = userEvent.setup(); const service = { @@ -403,19 +423,46 @@ describe('IntegrationTab trigger workspace', () => { const apiCard = await screen.findByTestId('api-publish-card'); await user.click(within(apiCard).getByRole('button', { name: '配置' })); await user.click(within(apiCard).getByRole('button', { name: 'Docker 容器' })); - expect(within(apiCard).getByRole('button', { name: '应用运行方式' })).toBeInTheDocument(); + expect(within(apiCard).getByRole('button', { name: '应用发布设置' })).toBeInTheDocument(); - await user.click(within(apiCard).getByRole('button', { name: '应用运行方式' })); + await user.click(within(apiCard).getByRole('button', { name: '应用发布设置' })); await waitFor(() => { - expect(workflowAPI.publish).toHaveBeenCalledWith('wf-1', { driver: 'docker' }); + expect(workflowAPI.publish).toHaveBeenCalledWith('wf-1', { driver: 'docker', port: 8080 }); }); await waitFor(() => { - expect(within(apiCard).queryByRole('button', { name: '应用运行方式' })).not.toBeInTheDocument(); + expect(within(apiCard).queryByRole('button', { name: '应用发布设置' })).not.toBeInTheDocument(); }); expect(within(apiCard).getByText('http://127.0.0.1:19000/invoke')).toBeInTheDocument(); }); + it('does not silently treat a cleared persisted port as unchanged', async () => { + const user = userEvent.setup(); + workflowAPI.getService.mockResolvedValue({ + data: { + workflowId: 'wf-1', + workflowName: 'Demo Workflow', + serviceUrl: 'http://127.0.0.1:8080', + invokeUrl: 'http://127.0.0.1:8080/invoke', + apiKey: 'secret', + status: 'running', + publishedAt: Date.now(), + driver: 'local', + port: 8080, + }, + }); + + render(); + + const apiCard = await screen.findByTestId('api-publish-card'); + await user.click(within(apiCard).getByRole('button', { name: '配置' })); + await user.clear(within(apiCard).getByRole('spinbutton', { name: '监听端口' })); + await user.click(within(apiCard).getByRole('button', { name: '应用发布设置' })); + + expect(screen.getByText('端口无效')).toBeInTheDocument(); + expect(workflowAPI.publish).not.toHaveBeenCalled(); + }); + it('lets stopping supersede an in-flight driver switch publish', async () => { const user = userEvent.setup(); const service = { @@ -448,7 +495,7 @@ describe('IntegrationTab trigger workspace', () => { const apiCard = await screen.findByTestId('api-publish-card'); await user.click(within(apiCard).getByRole('button', { name: '配置' })); await user.click(within(apiCard).getByRole('button', { name: 'Docker 容器' })); - await user.click(within(apiCard).getByRole('button', { name: '应用运行方式' })); + await user.click(within(apiCard).getByRole('button', { name: '应用发布设置' })); expect(within(apiCard).getByRole('button', { name: '发布中,请稍候...' })).toBeInTheDocument(); await user.click(within(apiCard).getByRole('button', { name: '停用' })); @@ -696,7 +743,7 @@ describe('IntegrationTab trigger workspace', () => { render(); - await user.click(await screen.findByRole('button', { name: '删除' })); + await user.click(await screen.findByRole('button', { name: '删除 Webhook Trigger' })); await waitFor(() => { expect(workflowAPI.deleteTrigger).toHaveBeenCalledWith('wf-1', 'hook-1'); diff --git a/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.tsx b/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.tsx index 48646b4f9..86b681a55 100644 --- a/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.tsx +++ b/webui/src/pages/WorkflowDetail/tabs/IntegrationTab.tsx @@ -26,6 +26,7 @@ import { } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { + resolveWorkflowServicePort, workflowAPI, workflowAPIEndpoints, Workflow, @@ -943,16 +944,24 @@ function PublishSection({ const [deleting, setDeleting] = useState(false); const [error, setError] = useState(''); const [driver, setDriver] = useState('local'); + const [portInput, setPortInput] = useState(''); const [apiKeyVisible, setApiKeyVisible] = useState(false); const [configExpanded, setConfigExpanded] = useState(false); const operationSeqRef = useRef(0); const serviceDriver = service ? normalizeServiceDriver(service.driver) : null; + const servicePort = resolveWorkflowServicePort(service); + const requestedPort = portInput.trim() ? Number(portInput) : undefined; const driverChanged = Boolean(serviceDriver && driver !== serviceDriver); + const portChanged = Boolean(service && requestedPort !== servicePort); + const publishConfigChanged = driverChanged || portChanged; const applyService = useCallback((nextService: WorkflowService | null) => { setService(nextService); if (nextService) { setDriver(normalizeServiceDriver(nextService.driver)); + setPortInput(String(resolveWorkflowServicePort(nextService) ?? '')); + } else { + setPortInput(''); } }, []); @@ -972,12 +981,23 @@ function PublishSection({ }, [loadService]); const handlePublish = async () => { + if (service && requestedPort === undefined) { + setError(t('detail.run.servicePortInvalid')); + return; + } + if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535)) { + setError(t('detail.run.servicePortInvalid')); + return; + } const operationSeq = operationSeqRef.current + 1; operationSeqRef.current = operationSeq; setPublishing(true); setError(''); try { - const res = await workflowAPI.publish(workflowId, { driver }); + const res = await workflowAPI.publish(workflowId, { + driver, + ...(requestedPort !== undefined ? { port: requestedPort } : {}), + }); if (operationSeqRef.current === operationSeq) { applyService(res.data); } @@ -1065,7 +1085,7 @@ function PublishSection({ ); - const renderDriverSelector = (options?: { showApply?: boolean }) => ( + const renderPublishConfig = (options?: { showApply?: boolean }) => (
{t('detail.run.serviceDriver')}
@@ -1090,7 +1110,7 @@ function PublishSection({ ); })}
- {options?.showApply && driverChanged ? ( + {options?.showApply && publishConfigChanged ? ( ) : null}
{driver === 'local' ? t('detail.run.driverLocalDesc') : t('detail.run.driverDockerDesc')}
+ + setPortInput(event.target.value)} + disabled={publishing || stopping || deleting} + placeholder={t('detail.run.servicePortPlaceholder')} + className="mt-2 h-10 w-full rounded-lg border border-gray-300 bg-white px-3.5 font-mono text-xs text-zinc-700 outline-none focus:border-gray-500 disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ {t('detail.run.servicePortDesc')} +
); @@ -1181,7 +1219,7 @@ function PublishSection({
{configExpanded ? (
- {renderDriverSelector({ showApply: true })} + {renderPublishConfig({ showApply: true })}
Invoke URL
@@ -1252,7 +1290,7 @@ function PublishSection({ {configExpanded ? (
- {renderDriverSelector()} + {renderPublishConfig()}
) : null}
From 4309569ff59cc95340abf66ff2e2a6f9f9cf57ee Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 17:52:14 +0800 Subject: [PATCH 71/86] fix(webui): sync workflow chat model selection --- .../WorkflowDetail/tabs/ChatTab.test.tsx | 153 +++++++++++++++++- .../src/pages/WorkflowDetail/tabs/ChatTab.tsx | 89 +++++++++- 2 files changed, 239 insertions(+), 3 deletions(-) diff --git a/webui/src/pages/WorkflowDetail/tabs/ChatTab.test.tsx b/webui/src/pages/WorkflowDetail/tabs/ChatTab.test.tsx index 894f218e8..72cad565f 100644 --- a/webui/src/pages/WorkflowDetail/tabs/ChatTab.test.tsx +++ b/webui/src/pages/WorkflowDetail/tabs/ChatTab.test.tsx @@ -7,11 +7,13 @@ import type { ComponentProps } from 'react'; import ChatTab from './ChatTab'; import { workflowAPI } from '@/api/workflow'; import { setStoredSessions } from '../sessionStorage'; +import { __resetChatModelOptionsResourcesForTesting } from '@/components/common/ChatPromptSelectors'; const { capturedSessionChatProps, capturedSessionOptions, mockClientGet, + mockClientPatch, mockCreate, mockCreateAndSend, mockReset, @@ -24,6 +26,7 @@ const { capturedSessionChatProps: [] as any[], capturedSessionOptions: [] as any[], mockClientGet: vi.fn(), + mockClientPatch: vi.fn(), mockCreate: vi.fn(), mockCreateAndSend: vi.fn(), mockReset: vi.fn(), @@ -53,7 +56,7 @@ vi.mock('@/hooks/useSessionChat', () => ({ })); vi.mock('@/api/client', () => ({ - default: { get: mockClientGet }, + default: { get: mockClientGet, patch: mockClientPatch }, })); vi.mock('@/api/workflow', () => ({ @@ -266,10 +269,12 @@ function renderChatTab(props: Partial> = {}) { describe('WorkflowDetail ChatTab', () => { beforeEach(() => { vi.clearAllMocks(); + __resetChatModelOptionsResourcesForTesting(); capturedSessionChatProps.length = 0; capturedSessionOptions.length = 0; localStorage.clear(); mockClientGet.mockResolvedValue({ data: {} }); + mockClientPatch.mockResolvedValue({ data: {} }); mockCreateAndSend.mockResolvedValue(undefined); mockUseAgents.mockReturnValue({ agents: [ @@ -565,6 +570,152 @@ describe('WorkflowDetail ChatTab', () => { expect(screen.queryByRole('button', { name: /Explore/i })).not.toBeInTheDocument(); }); + it('hydrates the workflow workbench model picker from the active session pinned model', async () => { + setStoredSessions(workflow.id, [ + { id: 'workflow-session-kimi', title: 'Existing', createdAt: Date.now() }, + ]); + mockUseProviders.mockReturnValue({ + providers: [{ id: 'moonshot', name: 'Moonshot', configured: true }], + connectedIds: ['moonshot'], + loading: false, + error: null, + refetch: vi.fn(), + }); + mockModelListDefinitions.mockResolvedValue({ + data: { + models: [{ + provider_id: 'moonshot', + id: 'kimi-k2.6', + name: 'kimi-k2.6', + model_type: 'chat', + capabilities: { supports_vision: false }, + limits: { context_window: 128000 }, + }], + }, + }); + mockClientGet.mockResolvedValue({ + data: { + id: 'workflow-session-kimi', + provider: 'moonshot', + model: 'kimi-k2.6', + model_pinned: true, + }, + }); + + renderChatTab(); + + await waitFor(() => { + const latestProps = capturedSessionChatProps[capturedSessionChatProps.length - 1]; + expect(latestProps.sessionId).toBe('workflow-session-kimi'); + expect(latestProps.model).toEqual({ providerID: 'moonshot', modelID: 'kimi-k2.6' }); + }); + expect(await screen.findByText('kimi-k2.6')).toBeInTheDocument(); + }); + + it('falls back consistently when the active session pinned model is no longer available', async () => { + setStoredSessions(workflow.id, [ + { id: 'workflow-session-stale-model', title: 'Existing', createdAt: Date.now() }, + ]); + mockUseProviders.mockReturnValue({ + providers: [{ id: 'minimax', name: 'MiniMax', configured: true }], + connectedIds: ['minimax'], + loading: false, + error: null, + refetch: vi.fn(), + }); + mockModelListDefinitions.mockResolvedValue({ + data: { + models: [{ + provider_id: 'minimax', + id: 'minimax-m3', + name: 'minimax-m3', + model_type: 'chat', + capabilities: { supports_vision: false }, + limits: { context_window: 64000 }, + }], + }, + }); + mockClientGet.mockResolvedValue({ + data: { + id: 'workflow-session-stale-model', + provider: 'moonshot', + model: 'kimi-k2.6', + model_pinned: true, + }, + }); + + renderChatTab(); + + await waitFor(() => { + const latestProps = capturedSessionChatProps[capturedSessionChatProps.length - 1]; + expect(latestProps.sessionId).toBe('workflow-session-stale-model'); + expect(latestProps.model).toEqual({ providerID: 'minimax', modelID: 'minimax-m3' }); + }); + expect(await screen.findByText('minimax-m3')).toBeInTheDocument(); + }); + + it('pins model picker changes back to the active workflow chat session', async () => { + const user = userEvent.setup(); + setStoredSessions(workflow.id, [ + { id: 'workflow-session-model-switch', title: 'Existing', createdAt: Date.now() }, + ]); + mockUseProviders.mockReturnValue({ + providers: [ + { id: 'minimax', name: 'MiniMax', configured: true }, + { id: 'moonshot', name: 'Moonshot', configured: true }, + ], + connectedIds: ['minimax', 'moonshot'], + loading: false, + error: null, + refetch: vi.fn(), + }); + mockModelListDefinitions.mockResolvedValue({ + data: { + models: [ + { + provider_id: 'moonshot', + id: 'kimi-k2.6', + name: 'kimi-k2.6', + model_type: 'chat', + capabilities: { supports_vision: false }, + limits: { context_window: 128000 }, + }, + { + provider_id: 'minimax', + id: 'minimax-m3', + name: 'minimax-m3', + model_type: 'chat', + capabilities: { supports_vision: false }, + limits: { context_window: 64000 }, + }, + ], + }, + }); + mockClientGet.mockResolvedValue({ + data: { + id: 'workflow-session-model-switch', + provider: 'moonshot', + model: 'kimi-k2.6', + model_pinned: true, + }, + }); + + renderChatTab(); + + await user.click(await screen.findByRole('button', { name: /kimi-k2\.6/i })); + await user.click(await screen.findByRole('button', { name: /minimax-m3/i })); + + expect(mockClientPatch).toHaveBeenCalledWith('/api/session/workflow-session-model-switch', { + provider: 'minimax', + model: 'minimax-m3', + model_pinned: true, + }); + await waitFor(() => { + const latestProps = capturedSessionChatProps[capturedSessionChatProps.length - 1]; + expect(latestProps.model).toEqual({ providerID: 'minimax', modelID: 'minimax-m3' }); + }); + }); + it('keeps the workflow composer compact enough for guide shortcuts above it', () => { renderChatTab(); diff --git a/webui/src/pages/WorkflowDetail/tabs/ChatTab.tsx b/webui/src/pages/WorkflowDetail/tabs/ChatTab.tsx index fec66f1ea..46bdb7a86 100644 --- a/webui/src/pages/WorkflowDetail/tabs/ChatTab.tsx +++ b/webui/src/pages/WorkflowDetail/tabs/ChatTab.tsx @@ -53,6 +53,8 @@ type WorkflowPromptParams = Record & { backendConfigAccessGuide: string; }; +type PromptModel = { providerID: string; modelID: string }; + function withBackendConfigAccessGuide( t: TranslateFn, params: Record, @@ -109,18 +111,47 @@ export default function ChatTab({ const workflowIdRef = useRef(workflow.id); workflowIdRef.current = workflow.id; const historyBtnRef = useRef(null); + const sessionModelHydrationRef = useRef(0); + const [sessionPinnedModel, setSessionPinnedModel] = useState(null); const { agents: workflowChatAgents } = useChatAgentOptions({ allowedAgentNames: WORKFLOW_CHAT_AGENT_NAMES, }); const { groupedOptions: groupedChatModelOptions, loading: loadingChatModels, + options: chatModelOptions, selectedModelOption, selectedPromptModel, setSelectedModelKey, } = useChatModelOptions(); + const sessionPinnedModelKey = sessionPinnedModel + ? `${sessionPinnedModel.providerID}::${sessionPinnedModel.modelID}` + : null; + const hasSessionPinnedModelOption = Boolean( + sessionPinnedModelKey && chatModelOptions.some((option) => option.key === sessionPinnedModelKey), + ); + const effectiveSessionPinnedModel = hasSessionPinnedModelOption ? sessionPinnedModel : null; const effectiveSupportsVision = selectedModelOption?.supportsVision ?? defaultSupportsVision; + const applySessionModel = useCallback((session: unknown) => { + const data = session as { + provider?: unknown; + model?: unknown; + model_pinned?: unknown; + } | null; + const providerID = typeof data?.provider === 'string' ? data.provider : ''; + const modelID = typeof data?.model === 'string' ? data.model : ''; + + if (data?.model_pinned && providerID && modelID) { + setSessionPinnedModel({ providerID, modelID }); + setSelectedModelKey(`${providerID}::${modelID}`); + return; + } + + setSessionPinnedModel(null); + setSelectedModelKey(null); + }, [setSelectedModelKey]); + useEffect(() => { workflowRevisionRef.current = workflowRevisionKey(workflow); }, [workflow]); @@ -171,6 +202,39 @@ export default function ChatTab({ onSessionChange?.(sessionId ?? null); }, [onSessionChange, sessionId]); + useEffect(() => { + if (!sessionPinnedModelKey) return; + if (!chatModelOptions.some((option) => option.key === sessionPinnedModelKey)) return; + setSelectedModelKey(sessionPinnedModelKey); + }, [chatModelOptions, sessionPinnedModelKey, setSelectedModelKey]); + + useEffect(() => { + sessionModelHydrationRef.current += 1; + const hydrationId = sessionModelHydrationRef.current; + + if (!sessionId) { + setSessionPinnedModel(null); + setSelectedModelKey(null); + return; + } + + let cancelled = false; + client.get(`/api/session/${sessionId}`) + .then((res) => { + if (cancelled || hydrationId !== sessionModelHydrationRef.current) return; + applySessionModel(res.data); + }) + .catch(() => { + if (cancelled || hydrationId !== sessionModelHydrationRef.current) return; + setSessionPinnedModel(null); + setSelectedModelKey(null); + }); + + return () => { + cancelled = true; + }; + }, [applySessionModel, sessionId]); + // Load stored sessions and validate only the active one (lightweight check) useEffect(() => { let cancelled = false; @@ -313,6 +377,27 @@ export default function ChatTab({ hasCreatedRef.current = true; }, []); + const handleSelectModel = useCallback((option: { + key: string; + providerID: string; + modelID: string; + }) => { + sessionModelHydrationRef.current += 1; + const nextModel = { providerID: option.providerID, modelID: option.modelID }; + setSessionPinnedModel(nextModel); + setSelectedModelKey(option.key); + + if (!sessionId) return; + + client.patch(`/api/session/${sessionId}`, { + provider: option.providerID, + model: option.modelID, + model_pinned: true, + }).catch((err) => { + console.warn('[WorkflowDetail] failed to pin workflow chat model', err); + }); + }, [sessionId, setSelectedModelKey]); + // Helper: fetch fresh workflow and notify parent if updated const checkWorkflowUpdate = useCallback(async () => { if (!onWorkflowUpdated) return; @@ -477,7 +562,7 @@ export default function ChatTab({ onSSEEvent={handleSSEEvent} supportsVision={effectiveSupportsVision} contextWindowTokens={selectedModelOption?.contextWindowTokens ?? null} - model={selectedPromptModel} + model={effectiveSessionPinnedModel ?? selectedPromptModel} onCreateAndSend={!sessionId ? handleCreateAndSend : undefined} composerTextareaMinHeight={48} composerTextareaMaxHeight={120} @@ -492,7 +577,7 @@ export default function ChatTab({ groupedOptions={groupedChatModelOptions} loading={loadingChatModels} selectedModelOption={selectedModelOption} - onSelectModel={(option) => setSelectedModelKey(option.key)} + onSelectModel={handleSelectModel} /> } conversationBottomSlot={({ sendPrompt, sending, streaming }) => ( From b011120c43171786f07d7b580250f5ccebef5a0d Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 17:56:38 +0800 Subject: [PATCH 72/86] fix: show visible error when model is unavailable --- flocks/session/runner.py | 185 +++++++++++++++++- tests/session/test_runner_step.py | 161 +++++++++++++++ .../src/components/common/SessionChat.test.ts | 29 +++ webui/src/components/common/SessionChat.tsx | 8 +- 4 files changed, 377 insertions(+), 6 deletions(-) diff --git a/flocks/session/runner.py b/flocks/session/runner.py index 7ff7d86b9..69aec5144 100644 --- a/flocks/session/runner.py +++ b/flocks/session/runner.py @@ -26,7 +26,7 @@ from flocks.utils.log import Log from flocks.utils.id import Identifier from flocks.session.session import Session, SessionInfo -from flocks.session.message import Message, MessageInfo, MessageRole +from flocks.session.message import Message, MessageInfo, MessageRole, TextPart from flocks.session.prompt import SessionPrompt from flocks.session.core.status import SessionStatus, SessionStatusRetry, SessionStatusBusy from flocks.session.core.defaults import ( @@ -320,6 +320,139 @@ def _build_tool_loop_halt_message( "summarize the blocker, or answer directly instead of repeating the exact same call." ) + @staticmethod + def _build_session_error_dict( + message: str, + *, + name: str = "SessionError", + display_message: Optional[str] = None, + data: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + error_data = {"message": message} + if data: + error_data.update(data) + if display_message: + error_data["displayMessage"] = display_message + return { + "name": name, + "message": message, + "data": error_data, + } + + async def _publish_assistant_error_message( + self, + assistant_msg: MessageInfo, + *, + agent_name: str, + parent_id: str, + error_dict: Dict[str, Any], + text_part: Optional[TextPart] = None, + ) -> None: + if not self.callbacks.event_publish_callback: + return + now_ms = int(time.time() * 1000) + await self.callbacks.event_publish_callback("message.updated", { + "info": { + "id": assistant_msg.id, + "sessionID": self.session.id, + "role": "assistant", + "time": {"created": now_ms, "completed": now_ms}, + "parentID": parent_id, + "modelID": self.model_id, + "providerID": self.provider_id, + "agent": agent_name, + "mode": agent_name, + "finish": "error", + "error": error_dict, + "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + } + }) + if text_part is not None: + await self.callbacks.event_publish_callback("message.part.updated", { + "part": { + "id": text_part.id, + "messageID": text_part.messageID, + "sessionID": text_part.sessionID, + "type": "text", + "text": text_part.text, + } + }) + + async def _create_error_assistant_message( + self, + *, + last_user: MessageInfo, + agent: AgentInfo, + error_message: str, + error_dict: Dict[str, Any], + visible_text: Optional[str] = None, + ) -> MessageInfo: + text = visible_text or error_message + part_id = Identifier.ascending("part") + now_ms = int(time.time() * 1000) + assistant_msg = await Message.create( + session_id=self.session.id, + role=MessageRole.ASSISTANT, + content=text, + agent=agent.name, + model_id=self.model_id, + provider_id=self.provider_id, + parent_id=last_user.id, + finish="error", + error=error_dict, + time={"created": now_ms, "completed": now_ms}, + part_id=part_id, + ) + parts = await Message.parts(assistant_msg.id, self.session.id) + text_part = next( + ( + part for part in parts + if isinstance(part, TextPart) and part.id == part_id + ), + TextPart( + id=part_id, + sessionID=self.session.id, + messageID=assistant_msg.id, + type="text", + text=text, + ), + ) + await self._publish_assistant_error_message( + assistant_msg, + agent_name=agent.name, + parent_id=last_user.id, + error_dict=error_dict, + text_part=text_part, + ) + return assistant_msg + + async def _ensure_visible_error_text( + self, + assistant_msg: MessageInfo, + error_message: str, + ) -> Optional[TextPart]: + if not error_message: + return None + + parts = await Message.parts(assistant_msg.id, self.session.id) + for part in parts: + if getattr(part, "type", None) == "text" and str(getattr(part, "text", "") or "").strip(): + return None + + text_part = next((part for part in parts if getattr(part, "type", None) == "text"), None) + if isinstance(text_part, TextPart): + text_part.text = error_message + else: + text_part = TextPart( + id=Identifier.ascending("part"), + sessionID=self.session.id, + messageID=assistant_msg.id, + type="text", + text=error_message, + ) + stored = await Message.store_part(self.session.id, assistant_msg.id, text_part) + return stored if isinstance(stored, TextPart) else text_part + def _update_tool_loop_guard( self, result: StepResult, @@ -1004,9 +1137,22 @@ async def _process_step( provider = Provider.get(self.provider_id) if not provider: error = f"Provider {self.provider_id} not found" + error_dict = self._build_session_error_dict( + error, + name="ProviderUnavailableError", + display_message="Model is unavailable. Please check the provider connection and model configuration.", + data={"providerID": self.provider_id, "modelID": self.model_id}, + ) + await self._create_error_assistant_message( + last_user=last_user, + agent=agent, + error_message=error, + error_dict=error_dict, + visible_text=error_dict["data"]["displayMessage"], + ) if self.callbacks.on_error: - await self.callbacks.on_error(error) - return StepResult(action="stop", error=error) + await self.callbacks.on_error(error_dict["data"]["displayMessage"]) + return StepResult(action="stop", error=error_dict["data"]["displayMessage"]) # Apply config-based provider options (api_key/base_url) try: @@ -1019,9 +1165,22 @@ async def _process_step( if not provider.is_configured(): error = f"Provider {self.provider_id} not configured" + error_dict = self._build_session_error_dict( + error, + name="ProviderConfigurationError", + display_message="Model is unavailable. Please check the provider connection and model configuration.", + data={"providerID": self.provider_id, "modelID": self.model_id}, + ) + await self._create_error_assistant_message( + last_user=last_user, + agent=agent, + error_message=error, + error_dict=error_dict, + visible_text=error_dict["data"]["displayMessage"], + ) if self.callbacks.on_error: - await self.callbacks.on_error(error) - return StepResult(action="stop", error=error) + await self.callbacks.on_error(error_dict["data"]["displayMessage"]) + return StepResult(action="stop", error=error_dict["data"]["displayMessage"]) # Build prompts and tools tools_started_at = time.perf_counter() @@ -1323,6 +1482,14 @@ async def device_asset_prompt_factory() -> Optional[str]: error=empty_error_dict, finish="error", ) + text_part = await self._ensure_visible_error_text(assistant_msg, empty_error_msg) + await self._publish_assistant_error_message( + assistant_msg, + agent_name=agent.name, + parent_id=last_user.id, + error_dict=empty_error_dict, + text_part=text_part, + ) return StepResult(action="stop", error=empty_error_msg) tool_loop_guard = self._update_tool_loop_guard( @@ -1438,6 +1605,14 @@ async def device_asset_prompt_factory() -> Optional[str]: error=error_dict, finish="error", ) + text_part = await self._ensure_visible_error_text(assistant_msg, final_error_message) + await self._publish_assistant_error_message( + assistant_msg, + agent_name=agent.name, + parent_id=last_user.id, + error_dict=error_dict, + text_part=text_part, + ) return StepResult(action="stop", error=final_error_message) diff --git a/tests/session/test_runner_step.py b/tests/session/test_runner_step.py index 656fc3350..6109a1aaa 100644 --- a/tests/session/test_runner_step.py +++ b/tests/session/test_runner_step.py @@ -2485,6 +2485,167 @@ async def chat_stream(self, **kwargs): # noqa: ANN003 run_after_mock.assert_not_awaited() +@pytest.mark.asyncio +async def test_process_step_persists_visible_error_when_provider_missing(monkeypatch): + runner = _make_runner("ses_runner_missing_provider_error") + user = await Message.create( + runner.session.id, + MessageRole.USER, + "hello", + agent="rex", + model={"providerID": "missing-provider", "modelID": "missing-model"}, + ) + messages = await Message.list(runner.session.id) + agent = SimpleNamespace(name="rex", tools=[], steps=5, prompt=None) + events = [] + callback_errors = [] + + async def publish_event(event_name, payload): + events.append((event_name, payload)) + + async def on_error(error): + callback_errors.append(error) + + monkeypatch.setattr(runner_mod.Agent, "get", AsyncMock(return_value=agent)) + monkeypatch.setattr(runner_mod.Provider, "get", staticmethod(lambda _provider_id: None)) + + runner.provider_id = "missing-provider" + runner.model_id = "missing-model" + runner.callbacks = RunnerCallbacks( + on_error=on_error, + event_publish_callback=publish_event, + ) + + result = await runner._process_step(messages, user) + messages_with_parts = await Message.list_with_parts(runner.session.id) + assistant = next(item for item in messages_with_parts if item.info.role == MessageRole.ASSISTANT) + visible_text_parts = [ + part for part in assistant.parts + if getattr(part, "type", None) == "text" and getattr(part, "text", "").strip() + ] + + assert result.action == "stop" + assert result.error == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + assert callback_errors == [runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE] + assert assistant.info.finish == "error" + assert assistant.info.error["name"] == "ProviderUnavailableError" + assert visible_text_parts + assert visible_text_parts[0].text == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + assert any(event_name == "message.part.updated" for event_name, _payload in events) + + +@pytest.mark.asyncio +async def test_process_step_persists_visible_error_when_provider_not_configured(monkeypatch): + runner = _make_runner("ses_runner_unconfigured_provider_error") + user = await Message.create( + runner.session.id, + MessageRole.USER, + "hello", + agent="rex", + model={"providerID": "unconfigured-provider", "modelID": "unconfigured-model"}, + ) + messages = await Message.list(runner.session.id) + agent = SimpleNamespace(name="rex", tools=[], steps=5, prompt=None) + events = [] + callback_errors = [] + + class UnconfiguredProvider: + def is_configured(self): + return False + + async def publish_event(event_name, payload): + events.append((event_name, payload)) + + async def on_error(error): + callback_errors.append(error) + + monkeypatch.setattr(runner_mod.Agent, "get", AsyncMock(return_value=agent)) + monkeypatch.setattr(runner_mod.Provider, "get", staticmethod(lambda _provider_id: UnconfiguredProvider())) + monkeypatch.setattr(runner_mod.Provider, "apply_config", AsyncMock(return_value=None)) + + runner.provider_id = "unconfigured-provider" + runner.model_id = "unconfigured-model" + runner.callbacks = RunnerCallbacks( + on_error=on_error, + event_publish_callback=publish_event, + ) + + result = await runner._process_step(messages, user) + messages_with_parts = await Message.list_with_parts(runner.session.id) + assistant = next(item for item in messages_with_parts if item.info.role == MessageRole.ASSISTANT) + visible_text_parts = [ + part for part in assistant.parts + if getattr(part, "type", None) == "text" and getattr(part, "text", "").strip() + ] + + assert result.action == "stop" + assert result.error == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + assert callback_errors == [runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE] + assert assistant.info.finish == "error" + assert assistant.info.error["name"] == "ProviderConfigurationError" + assert visible_text_parts + assert visible_text_parts[0].text == runner_mod.CONNECTION_ERROR_DISPLAY_MESSAGE + assert any(event_name == "message.part.updated" for event_name, _payload in events) + + +@pytest.mark.asyncio +async def test_process_step_persists_visible_error_when_model_returns_empty_stream(monkeypatch): + runner = _make_runner("ses_runner_empty_stream_error") + user = await Message.create( + runner.session.id, + MessageRole.USER, + "hello", + agent="rex", + model={"providerID": "empty-provider", "modelID": "empty-model"}, + ) + messages = await Message.list(runner.session.id) + agent = SimpleNamespace(name="rex", tools=[], steps=5, prompt=None, model=None) + events = [] + + class EmptyProvider: + def is_configured(self): + return True + + async def chat_stream(self, **kwargs): + del kwargs + if False: + yield None + + async def publish_event(event_name, payload): + events.append((event_name, payload)) + + monkeypatch.setattr(runner_mod.Agent, "get", AsyncMock(return_value=agent)) + monkeypatch.setattr(runner_mod.Provider, "get", staticmethod(lambda _provider_id: EmptyProvider())) + monkeypatch.setattr(runner_mod.Provider, "apply_config", AsyncMock(return_value=None)) + monkeypatch.setattr(runner_mod.SessionPrompt, "build_system_prompts", AsyncMock(return_value=[])) + monkeypatch.setattr(SessionRunner, "_build_callable_tool_schema", AsyncMock(return_value=[])) + monkeypatch.setattr(runner_mod.SessionRetry, "sleep", AsyncMock(return_value=None)) + + runner.provider_id = "empty-provider" + runner.model_id = "empty-model" + runner.callbacks = RunnerCallbacks(event_publish_callback=publish_event) + + result = await runner._process_step(messages, user) + messages_with_parts = await Message.list_with_parts(runner.session.id) + assistant = next(item for item in messages_with_parts if item.info.role == MessageRole.ASSISTANT) + visible_text = "\n".join( + getattr(part, "text", "") + for part in assistant.parts + if getattr(part, "type", None) == "text" and getattr(part, "text", "").strip() + ) + + assert result.action == "stop" + assert "returned an empty response" in result.error + assert assistant.info.finish == "error" + assert assistant.info.error["name"] == "EmptyResponseError" + assert "returned an empty response" in visible_text + assert any( + event_name == "message.part.updated" + and "returned an empty response" in _payload["part"]["text"] + for event_name, _payload in events + ) + + @pytest.mark.asyncio async def test_process_step_uses_loaded_tool_schema_names_for_prompt_guidance(monkeypatch): runner = _make_runner("ses_runner_prompt_guidance_tool_names") diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index de716c8fd..6cc2d6fd7 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -1363,6 +1363,35 @@ describe('SessionChat error rendering', () => { expect(screen.getByText('Connection error.')).toBeInTheDocument(); expect(container.querySelectorAll('.animate-bounce')).toHaveLength(0); }); + + it('renders assistant error messages when the only part is blank text', () => { + useSessionMessagesMock.mockReturnValue({ + messages: [ + makeMessage({ + id: 'assistant-error-with-blank-text', + role: 'assistant', + parts: [{ id: 'blank-text', type: 'text', text: '' }] as Message['parts'], + finish: 'error', + error: { + name: 'EmptyResponseError', + data: { message: 'Model returned an empty response.' }, + } as any, + }), + ], + loading: false, + refetch: vi.fn(), + addMessage: vi.fn(), + updateMessage: vi.fn(), + updateMessagePart: vi.fn(), + replaceMessageText: vi.fn(), + truncateAfterMessage: vi.fn(), + }); + + const { container } = render(React.createElement(SessionChat, { sessionId: 'sess-1' })); + + expect(screen.getByText('Model returned an empty response.')).toBeInTheDocument(); + expect(container.querySelectorAll('.animate-bounce')).toHaveLength(0); + }); }); describe('SessionChat intermediate process collapse', () => { diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index c32b35d26..a2362a42b 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -3884,6 +3884,12 @@ function ChatMessageBubbleInner({ const iconButtonClass = 'group/action relative inline-flex h-6 w-6 items-center justify-center rounded-full border border-gray-200/80 bg-white/80 text-gray-400 transition-colors duration-150 hover:border-gray-300 hover:text-gray-700 disabled:opacity-40 disabled:cursor-not-allowed dark:border-zinc-800 dark:bg-zinc-900/80 dark:text-zinc-500 dark:hover:border-zinc-700 dark:hover:text-zinc-200'; const tooltipClass = 'pointer-events-none absolute bottom-full left-1/2 z-10 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 shadow-sm transition-opacity duration-150 group-hover/action:opacity-100'; const messageErrorText = isUser ? '' : getMessageErrorText(message); + const hasOnlyBlankTextParts = parts.length > 0 && parts.every((part) => + part.type === 'text' && !String(part.text || '').trim() + ); + const shouldRenderAssistantErrorState = !isUser && !!messageErrorText && ( + parts.length === 0 || hasOnlyBlankTextParts + ); const avatarSize = compact ? 'w-7 h-7 text-xs' : 'w-8 h-8 text-sm'; const avatar = isUser ? ( @@ -3901,7 +3907,7 @@ function ChatMessageBubbleInner({
{/* Empty / loading state */} - {parts.length === 0 && ( + {(parts.length === 0 || shouldRenderAssistantErrorState) && ( isUser ? (
From 64285f6f531c4ad2220883f69786317ea67a13cc Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Tue, 21 Jul 2026 17:57:18 +0800 Subject: [PATCH 73/86] test(workflow): isolate spawn failure from socket state Stub workflow port availability so the spawn-failure regression test always reaches the mocked subprocess error regardless of host port usage. --- tests/workflow/test_workflow_center_lifecycle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/workflow/test_workflow_center_lifecycle.py b/tests/workflow/test_workflow_center_lifecycle.py index bcaf6eee9..9b90ee305 100644 --- a/tests/workflow/test_workflow_center_lifecycle.py +++ b/tests/workflow/test_workflow_center_lifecycle.py @@ -444,6 +444,7 @@ async def fake_create_subprocess_exec(*_args, **_kwargs): monkeypatch.setattr(center, "_stop_existing_runtime_for_publish", fake_stop_existing_runtime_for_publish) monkeypatch.setattr(center, "_write_release_snapshot", fake_write_release_snapshot) monkeypatch.setattr(center, "_allocate_port", fake_allocate_port) + monkeypatch.setattr(center, "_is_port_available", lambda _port: True) monkeypatch.setattr(center.asyncio, "create_subprocess_exec", fake_create_subprocess_exec) try: From 67095323aa94cdc4d0b58cfcaa7a72b4e1e12369 Mon Sep 17 00:00:00 2001 From: luguili Date: Tue, 21 Jul 2026 18:06:15 +0800 Subject: [PATCH 74/86] fix(edr): capture login token and streamline skill routing --- .../plugins/skills/sangfor-edr-use/SKILL.md | 127 ++++---------- .../references/cdp-workflow.md | 10 +- .../sangfor_edr_webcli/sangfor_edr.handler.py | 160 +++++++++++++++--- .../sangfor_edr_webcli/sangfor_edr_auth.yaml | 5 +- tests/tool/test_sangfor_edr_handler.py | 84 +++++++++ 5 files changed, 259 insertions(+), 127 deletions(-) diff --git a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md index 9ba6e0b06..479c8a18a 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/SKILL.md +++ b/.flocks/plugins/skills/sangfor-edr-use/SKILL.md @@ -1,102 +1,37 @@ ---- -name: sangfor-edr-use -description: 用于处理深信服 EDR(终端检测与响应)相关任务,通过浏览器(CDP 直连)进行以下任务:终端状态查询、终端概况统计、失陷设备排查、设备运行状态查看等。只要用户提到 深信服 EDR、EDR、sangfor EDR 等需求时,必须先加载本 skill。本 skill 是 EDR 平台操作的唯一决策入口:在未阅读本 skill 前,不要直接使用 browser-use skill。 ---- - -# 深信服 EDR Use - -## First +--- +name: sangfor-edr-use +description: 用于处理深信服 EDR(终端检测与响应)相关任务,通过 Flocks browser/CDP 完成登录态复用、终端状态查询、概况统计、失陷设备排查和设备运行状态查看。只要用户提到深信服 EDR、EDR 或 sangfor EDR,必须先加载本 skill;本 skill 是 EDR 任务的唯一入口,未阅读前不要直接使用 browser-use。 +--- -### 登录态处理规则 +# 深信服 EDR Use -当用户需要打开深信服 EDR 页面,或需要通过 Web2CLI 抓取 EDR 页面请求时,必须按下面顺序处理登录态,不要一开始就要求用户提供账密: +本 skill 只负责任务入口和登录分流;CDP 操作、浏览器启动、验证码、selector、tab/iframe 处理和页面数据提取见 [references/cdp-workflow.md](references/cdp-workflow.md)。 -1. 先调用 `sangfor_edr_auth` 的 `action=status_auth_state` 或 `action=validate_auth_state`,检查 `~/.flocks/browser/sangfor-edr/auth-state.json` 是否存在且可用。凡是需要访问浏览器的 action,工具会自动启动 daemon,并替换陈旧或协议不兼容的 daemon 实例。 -2. 如果登录态可用,直接复用该 state,继续打开 EDR 页面或执行 Web2CLI 抓取流程。 -3. 如果 state 不存在或已失效,但本地已经保存了 EDR 地址、用户名和密码,调用 `sangfor_edr_auth` 的 `action=ensure_auth_state` 自动刷新登录态。 -4. 如果没有可用 state,也没有保存账密配置,再引导用户选择: - - 提供 EDR 访问地址、用户名和密码,自动登录并保存账密配置,后续 state 失效时可直接自动刷新; - - 不提供账密,走浏览器手动登录流程。打开 EDR 页面后由用户在可视化浏览器中完成登录,登录成功后保存完整浏览器登录态(包括 cookies、localStorage 等)。 +## 登录分流 -无论采用哪种方式,只要获得可用登录态,就继续原有浏览器 / Web2CLI 流程:加载登录态、打开目标 EDR 页面、按需注入 Web2CLI hook、执行页面操作并导出捕获到的请求。后续再次打开页面时,仍必须先校验 `auth-state.json`;若登录态失效且已保存账密,则自动重新走 CDP 登录并刷新 state。 +需要打开 EDR 页面、抓取页面请求或调用 EDR API 采集工具时,按以下顺序执行: -若自动登录过程中出现验证码识别失败、MFA 校验、页面选择器变化、未检测到登录成功或有效 `sessionid` 等情况,立即回退到原有浏览器手动登录流程。 +1. 调用 `sangfor_edr_auth(action=status_auth_state)`,确认 `validation.valid`、`can_auto_refresh` 和 `has_saved_token`。不要先向用户索要账密。 +2. `validation.valid=true`: + - 仅浏览器/CDP任务:直接继续; + - 需要 API token:`has_saved_token=true` 才继续,否则调用 `refresh_auth_state` 补齐 token。 +3. `validation.valid=false` 且 `can_auto_refresh=true`:调用 `ensure_auth_state` 自动登录。 +4. 没有可用 state 或账密时:调用 `ensure_auth_state` 打开登录页;返回 `manual_login_required` 后,让用户在该工具打开的同一个 EDR tab 中完成登录、MFA 或 UKey。 +5. 用户完成手动登录后,调用 `complete_manual_login`;只有返回 `manual_login_captured_auth_state` 且 `token_saved=true`,才认为浏览器登录和 API 登录准备均完成。 +6. 返回 `browser_daemon_not_ready` 或 `auth_state_load_failed_browser_daemon_not_ready`:依次执行 `flocks browser --setup`、`flocks browser --doctor`,再重试原 action。 -若返回 `browser_daemon_not_ready` 或 `auth_state_load_failed_browser_daemon_not_ready`,依次执行 `flocks browser --setup` 和 `flocks browser --doctor`,然后重试原 action。不要手工创建或修改 `bu.port`:该文件保存的是 Flocks daemon IPC 端口,不是 Chrome remote-debugging 端口。 - -> ⚠️ **EDR 没有开放 API**,所有操作必须通过浏览器(CDP 直连)完成。 - -## 浏览器模式使用指南 - -请阅读以下文档获取完整流程: -- [references/cdp-workflow.md](references/cdp-workflow.md) - -### CDP 模式适用场景 - -- **首页仪表盘**(`/ui/#/index`):设备 CPU/内存/硬盘使用率、终端概况(在线/离线/服务器/PC)、失陷设备统计 -- **威胁资产分析**:已失陷终端列表(需点击"已失陷终端"标签页,不是默认的"全部") -- 页面详情、交互式筛选 - -### 可用工具脚本 - -| 脚本路径 | 功能 | 必需参数 | -|---------|------|---------| -| `references/fetch_edr_system_state.py` | 设备状态抓取 | `--url {EDR_URL}` | - -脚本位于 skill 目录的 `references/` 下,无硬编码 URL 或敏感信息。 - -### 执行示例 - -脚本位于 `/skills/sangfor-edr-use/references/fetch_edr_system_state.py`,请按当前平台选择对应命令。 - -**Windows(PowerShell)** - -```powershell -powershell -Command "& '\Scripts\python.exe' '\skills\sangfor-edr-use\references\fetch_edr_system_state.py' --url '{EDR_URL}'" -``` - -**macOS / Linux(bash / zsh)** - -```bash -"/bin/python" "/skills/sangfor-edr-use/references/fetch_edr_system_state.py" --url "{EDR_URL}" -``` - -**占位符说明** - -| 占位符 | Windows 典型值 | macOS/Linux 典型值 | -|--------|---------------|-------------------| -| `` | `D:\Flocks Project\flocks\.venv` | `~/Flocks/flocks/.venv`(取决于实际安装位置) | -| `` | `%USERPROFILE%\.flocks\plugins` | `~/.flocks/plugins` | - -> 必须使用 Flocks 虚拟环境(`.venv`)执行;系统 Python 可能缺少依赖。如不确定 venv 位置,先执行 `flocks --version` 或检查 Flocks 安装目录。 - -## 关键坑点(必须避免) - -| 坑 | 原因 | 解法 | -|---|---|---| -| `flocks browser -c js(...)` 返回空文本 | daemon session 指向错误的 tab | 用 Python socket 直连 daemon,通过 `Runtime.evaluate` 在正确 context 执行 | -| `flocks browser -c new_tab()` 后后续命令无响应 | tab 切换导致 session 错位 | 用 `switch_tab(targetId)` 明确切到 EDR tab | -| `bu.port` 不存在或端口失效 | daemon 未启动或状态陈旧 | 先重试 `sangfor_edr_auth`;仍失败则运行 `flocks browser --setup` 和 `flocks browser --doctor`,禁止手工改 `bu.port` | -| 多行代码转义失败 | PowerShell 引号嵌套 | 使用 `fetch_edr_system_state.py` 脚本,无需手动转义 | -| EDR 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | -| 失陷设备数量不匹配 | 读取的是"全部"筛选而非"已失陷"筛选 | 需点击"已失陷终端"标签页获取准确数量 | - -## 失陷设备查询 SOP - -**问题**:首页仪表盘显示"已失陷 N 台",但威胁资产分析页面默认只列出部分。 - -**成功路径**: -1. 在 EDR 首页仪表盘确认"已失陷 N 台"的数量 -2. 如需具体清单,进入 `威胁资产分析` → 点击 `已失陷终端` 标签页(不是默认的"全部") -3. 只有点击"已失陷终端"标签页,列表数量才会与仪表盘一致 - -**⚠️ 必须避免**:直接读取威胁资产分析的默认"全部"筛选结果作为失陷设备清单,这是错误的。 - -## 执行规范 - -**必须使用 Flocks 虚拟环境(`.venv`)执行 Python 脚本,禁止使用系统 Python。** - -- ✅ 正确:`/bin/python`(Unix)或 `\Scripts\python.exe`(Windows) -- ❌ 禁止:`python script.py` / `python3 script.py`(直接调用 PATH 中的 Python) - -**原因**:Flocks 虚拟环境包含了所有项目依赖,系统 Python 可能缺少必要的包。完整跨平台示例见上一节"执行示例"。 +认证工具会在登录提交前监听 `launch_login.php` 的 fetch/XHR 响应,将 `data.token` 保存到 Secret Manager;token 不得回显、写入日志或写入 `auth-state.json`。后续 API 工具应从 Secret Manager 读取对应 token,并同时使用同一 EDR 的 cookies。 + +`bu.port` 是 Flocks browser daemon 的 IPC 端口文件,不是 Chrome 的 remote-debugging 端口;禁止手工创建或修改它。 + +## 任务边界 + +- 当前 `sangfor_edr_v1_0_0` 的页面业务仍通过浏览器/CDP完成;需要 API 采集时,必须先完成 token readiness 检查。 +- 首页仪表盘可用于设备 CPU/内存/硬盘、终端概况和失陷统计。 +- 查询失陷终端清单时,必须进入“威胁资产分析”并选择“已失陷终端”,不能使用默认“全部”列表。 +- 设备状态抓取脚本位于 `references/fetch_edr_system_state.py`;执行前必须完成上述登录分流。 + +## 执行约束 + +- 运行 skill 内 Python 脚本时必须使用 Flocks 虚拟环境;不要使用系统 Python。 +- 需要具体 CDP 命令、平台启动方式、验证码/selector 配置、tab/iframe 处理或页面关键词时,读取 `references/cdp-workflow.md`,不要在本文件重复展开。 diff --git a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md index ddbcd6d05..aa07e31e1 100644 --- a/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md +++ b/.flocks/plugins/skills/sangfor-edr-use/references/cdp-workflow.md @@ -52,9 +52,9 @@ chromium --remote-debugging-port=9222 1. 调用 `sangfor_edr_auth`,`action=status_auth_state`,确认固定 state 是否存在、是否可用,以及是否已有可自动刷新的账密配置。 2. 如果返回的 `validation.valid` 为 `true`,直接复用已保存 state。 -3. 如果 state 不存在或失效,但 `can_auto_refresh` 为 `true`,调用 `sangfor_edr_auth`,`action=ensure_auth_state`,通过 browser daemon / CDP 驱动真实 EDR 登录页自动登录。验证码图片在浏览器会话中获取,OCR 识别后填入页面;登录成功后保存浏览器 state。 +3. 如果 state 不存在或失效,但 `can_auto_refresh` 为 `true`,调用 `sangfor_edr_auth`,`action=ensure_auth_state`,通过 browser daemon / CDP 驱动真实 EDR 登录页自动登录。验证码图片在浏览器会话中获取,OCR 识别后填入页面;登录成功后保存 browser state,并从 `launch_login.php` 响应捕获 `data.token` 到 Secret Manager(`sangfor_edr_token`)。 4. 如果没有可用 state,也没有保存账密配置,再询问用户:提供 EDR 地址、用户名、密码后自动登录并保存,或不提供账密改走手动登录。 -5. 用户不提供账密、自动登录失败、MFA/验证码/OCR/DOM 选择器异常时,由用户在 Chrome 中手动登录 EDR(如需 MFA,完成认证),登录成功后保存浏览器 state。 +5. 用户不提供账密、自动登录失败、MFA/验证码/OCR/DOM 选择器异常时,由用户在已打开的 Chrome 中完成登录,再调用 `action=complete_manual_login` 自动校验并保存 browser state。 --- @@ -86,11 +86,11 @@ flocks browser --setup 推荐先调用工具做状态检查: -- `action=status_auth_state`:返回 `auth_state_exists`、`validation.valid`、`can_auto_refresh` 等非敏感状态。 +- `action=status_auth_state`:返回 `auth_state_exists`、`validation.valid`、`can_auto_refresh`、`has_saved_token` 等非敏感状态。 - `validation.valid=true`:直接继续 Step 4。 - `validation.valid=false` 且 `can_auto_refresh=true`:调用 `action=ensure_auth_state` 自动打开真实登录页、识别验证码、填入账密并保存新的 state。 - `validation.valid=false` 且 `can_auto_refresh=false`:再询问用户是提供账密自动登录并保存,还是手动登录。 -- 用户拒绝提供账密或自动登录失败:提示用户在 Chrome 中手动登录,登录成功后执行 `flocks browser state save ~/.flocks/browser/sangfor-edr/auth-state.json`。 +- 用户拒绝提供账密或自动登录失败:提示用户在已打开的 Chrome 中完成登录,然后调用 `action=complete_manual_login`;成功后继续原任务。 ### Step 4:打开目标页面 用户或工具在 Chrome 中打开: @@ -204,7 +204,7 @@ print(text_result["result"]["result"]["value"]) | EDR tab 未找到 | 页面未打开或 URL 不匹配 | 确保 Chrome 中打开了 EDR 首页 | | 页面数据为空 | EDR 内容在跨域 iframe 中 | 用 CDP direct 方式 attach 到 EDR tab,在正确 frame context 执行 JS | | 页面显示登录框 | 会话已失效 | 若已保存账密,调用 `sangfor_edr_auth` 自动刷新 state;否则提示用户手动登录 EDR | -| 自动登录失败 | 验证码识别失败、MFA、页面选择器变化或登录成功检测失败 | 回退到浏览器手动登录,登录后保存 state | +| 自动登录失败 | 验证码识别失败、MFA、页面选择器变化或登录成功检测失败 | 在已打开的浏览器中完成登录,再调用 `action=complete_manual_login` | | `browser_daemon_not_ready` | daemon 无法启动或连接 Chrome | 执行 `flocks browser --setup` 和 `flocks browser --doctor`,然后重试;不要修改 `bu.port` | --- diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py index 2968452c3..2680c140e 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr.handler.py @@ -32,6 +32,7 @@ LEGACY_SERVICE_ID = "sangfor_edr" USERNAME_SECRET_ID = "sangfor_edr_username" PASSWORD_SECRET_ID = "sangfor_edr_password" +TOKEN_SECRET_ID = "sangfor_edr_token" DEFAULT_AUTH_STATE_PATH = "~/.flocks/browser/sangfor-edr/auth-state.json" DEFAULT_LOGIN_PATH = "/ui/login.php" DEFAULT_INDEX_PATH = "/ui/#/index" @@ -246,6 +247,7 @@ def _saved_auto_login_status(params: dict[str, Any]) -> dict[str, Any]: "has_base_url": has_base_url, "has_saved_username": has_username, "has_saved_password": has_password, + "has_saved_token": bool(secrets.get(TOKEN_SECRET_ID)), "can_auto_refresh": has_base_url and has_username and has_password, } @@ -357,6 +359,76 @@ def _browser_page_open_result(exc: Exception, cfg: RuntimeConfig) -> dict[str, A } +def _manual_login_result(cfg: RuntimeConfig, reason: str, error: str = "") -> dict[str, Any]: + return { + "success": False, + "valid": False, + "status": "manual_login_required", + "reason": reason, + "error": error, + "login_url": _login_url(cfg), + "auth_state_path": str(cfg.auth_state_path), + "browser_left_open": True, + "next_action": "complete login in the opened browser, then call `complete_manual_login`", + } + + +def _install_login_token_capture() -> bool: + script = r"""(() => { + const key = "__flocks_sangfor_edr_token"; + sessionStorage.removeItem(key); + if (window.__flocksSangforEdrTokenHooked) return true; + window.__flocksSangforEdrTokenHooked = true; + const capture = (text) => { + try { + const token = JSON.parse(text)?.data?.token; + if (token !== undefined && token !== null && String(token)) sessionStorage.setItem(key, String(token)); + } catch (_) {} + }; + const originalFetch = window.fetch; + window.fetch = async function(...args) { + const response = await originalFetch.apply(this, args); + const url = String(args[0]?.url || args[0] || ""); + if (url.includes("/launch_login.php")) { + try { capture(await response.clone().text()); } catch (_) {} + } + return response; + }; + const originalOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, ...rest) { + this.__flocksEdrLogin = String(url || "").includes("/launch_login.php"); + return originalOpen.call(this, method, url, ...rest); + }; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(...args) { + if (this.__flocksEdrLogin) this.addEventListener("load", () => { + try { capture(this.responseType && this.responseType !== "text" ? JSON.stringify(this.response) : this.responseText); } + catch (_) {} + }, {once: true}); + return originalSend.apply(this, args); + }; + return true; +})()""" + try: + return bool(helpers.js(script)) + except Exception: + return False + + +def _save_captured_login_token(timeout: float = 2.0) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + token = str(helpers.js("sessionStorage.getItem('__flocks_sangfor_edr_token') || ''") or "").strip() + if token: + _get_secret_manager().set(TOKEN_SECRET_ID, token) + return True + except Exception: + return False + time.sleep(0.1) + return False + + def _page_text() -> str: try: return str(helpers.js("document.body ? document.body.innerText : ''") or "") @@ -808,6 +880,15 @@ def _wait_for_login_success(cfg: RuntimeConfig) -> bool: return False +def _manual_login_reason() -> str: + text = _page_text().lower() + if any(marker in text for marker in ("动态口令", "短信验证码", "二次认证", "mfa", "otp")): + return "mfa_required" + if any(marker in text for marker in ("ukey", "usb key", "证书登录")): + return "certificate_login_required" + return "login_form_not_ready" + + def _missing_login_inputs(cfg: RuntimeConfig) -> list[str]: missing = [] if not cfg.username: @@ -819,15 +900,6 @@ def _missing_login_inputs(cfg: RuntimeConfig) -> list[str]: def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = "") -> dict[str, Any]: missing = _missing_login_inputs(cfg) - if missing: - return { - "success": False, - "status": "manual_login_required", - "reason": "missing_cdp_login_credentials", - "missing": missing, - "auth_state_path": str(cfg.auth_state_path), - } - try: _ensure_browser_daemon() except Exception as exc: @@ -836,23 +908,28 @@ def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = " _open_page(_login_url(cfg)) except Exception as exc: return _browser_page_open_result(exc, cfg) - form_state = _wait_for_login_form_ready(cfg) + _install_login_token_capture() + if missing: + result = _manual_login_result(cfg, "missing_cdp_login_credentials") + result["missing"] = missing + return result + try: + form_state = _wait_for_login_form_ready(cfg) + except Exception as exc: + return _manual_login_result(cfg, _manual_login_reason(), str(exc)) last_error = "" for attempt in range(1, cfg.max_captcha_retry + 1): try: + _install_login_token_capture() code = captcha_code.strip() if not code: if not cfg.auto_ocr_code: - return { - "success": False, - "status": "manual_login_required", - "reason": "captcha_code_required", - "auth_state_path": str(cfg.auth_state_path), - } + return _manual_login_result(cfg, "captcha_code_required") code = _ocr_verify_code(_captcha_image_from_browser(cfg)) filled = _set_login_form_values(cfg, code) if _wait_for_login_success(cfg): + token_saved = _save_captured_login_token() saved = _save_auth_state(cfg) return { "success": True, @@ -862,6 +939,7 @@ def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = " "form": form_state, "filled": {key: bool(value) for key, value in filled.items()}, "saved": saved, + "token_saved": token_saved, } last_error = "login_success_check_timeout" except Exception as exc: @@ -875,13 +953,39 @@ def _refresh_auth_state_with_cdp_login(cfg: RuntimeConfig, captcha_code: str = " except Exception: pass - return { - "success": False, - "status": "manual_login_required", - "reason": "browser_cdp_login_failed", - "last_error": last_error, - "auth_state_path": str(cfg.auth_state_path), - } + return _manual_login_result(cfg, "browser_cdp_login_failed", last_error) + + +def _complete_manual_login(cfg: RuntimeConfig) -> dict[str, Any]: + try: + _ensure_browser_daemon() + except Exception as exc: + return _browser_daemon_result(exc, cfg.auth_state_path) + try: + info = helpers.page_info() + if urlparse(str(info.get("url") or "")).netloc != urlparse(cfg.base_url).netloc: + _open_page(_index_url(cfg)) + if not _is_logged_in(cfg): + return _manual_login_result(cfg, "manual_login_not_completed") + token_saved = _save_captured_login_token() + return { + "success": True, + "valid": True, + "status": "manual_login_captured_auth_state", + "auth_state_path": str(cfg.auth_state_path), + "saved": _save_auth_state(cfg), + "token_saved": token_saved, + } + except Exception as exc: + return { + "success": False, + "valid": False, + "status": "manual_login_capture_failed", + "reason": "manual_login_capture_failed", + "error": str(exc), + "auth_state_path": str(cfg.auth_state_path), + "next_action": "keep the browser open and retry `complete_manual_login`", + } # ── Tool actions ───────────────────────────────────────────────────────────── @@ -932,10 +1036,18 @@ async def handle(ctx: ToolContext) -> ToolResult: error=None if validation.get("valid") else str(validation.get("reason") or "auth_state_invalid"), ) + if action == "complete_manual_login": + result = _complete_manual_login(cfg) + return ToolResult( + success=bool(result.get("success")), + output=result, + error=None if result.get("success") else result.get("reason"), + ) + if action not in {"ensure_auth_state", "refresh_auth_state"}: return ToolResult( success=False, - error="Unsupported Sangfor EDR auth action. Use status_auth_state, ensure_auth_state, validate_auth_state, or refresh_auth_state.", + error="Unsupported Sangfor EDR auth action. Use status_auth_state, ensure_auth_state, validate_auth_state, refresh_auth_state, or complete_manual_login.", ) force_refresh = action == "refresh_auth_state" or _coerce_bool(params.get("force_refresh"), default=False) diff --git a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml index cf7d94caa..1b3d060ba 100644 --- a/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml +++ b/.flocks/plugins/tools/device/sangfor_edr_webcli/sangfor_edr_auth.yaml @@ -19,13 +19,14 @@ inputSchema: properties: action: type: string - enum: [status_auth_state, ensure_auth_state, validate_auth_state, refresh_auth_state] + enum: [status_auth_state, ensure_auth_state, validate_auth_state, refresh_auth_state, complete_manual_login] default: ensure_auth_state description: > Authentication action. status_auth_state returns non-sensitive saved state/credential availability; ensure_auth_state reuses existing state when it appears valid and refreshes when needed; validate_auth_state - checks only; refresh_auth_state always performs CDP-assisted browser login. + checks only; refresh_auth_state always performs CDP-assisted browser login; + complete_manual_login validates a user-completed login and saves its browser state. captcha_code: type: string description: Optional manual captcha code. When omitted, OCR is used if enabled. diff --git a/tests/tool/test_sangfor_edr_handler.py b/tests/tool/test_sangfor_edr_handler.py index 83cb56b80..3eb286367 100644 --- a/tests/tool/test_sangfor_edr_handler.py +++ b/tests/tool/test_sangfor_edr_handler.py @@ -97,3 +97,87 @@ def test_refresh_distinguishes_page_open_failure(tmp_path, monkeypatch): assert result["status"] == "browser_login_page_open_failed" assert result["reason"] == "browser_login_page_open_failed" assert result["login_url"] == "https://edr.example.com/ui/login.php" + + +def test_refresh_falls_back_to_manual_login_when_form_is_missing(tmp_path, monkeypatch): + handler = _load_handler() + cfg = _cfg(handler, tmp_path / "auth-state.json") + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: None) + monkeypatch.setattr(handler, "_open_page", lambda url: None) + monkeypatch.setattr(handler, "_wait_for_login_form_ready", lambda cfg: (_ for _ in ()).throw(RuntimeError("missing form"))) + monkeypatch.setattr(handler, "_page_text", lambda: "请输入动态口令") + + result = handler._refresh_auth_state_with_cdp_login(cfg) + + assert result["status"] == "manual_login_required" + assert result["reason"] == "mfa_required" + assert result["browser_left_open"] is True + + +def test_complete_manual_login_saves_state(tmp_path, monkeypatch): + handler = _load_handler() + cfg = _cfg(handler, tmp_path / "auth-state.json") + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: None) + monkeypatch.setattr(handler.helpers, "page_info", lambda: {"url": "https://edr.example.com/ui/#/index"}) + monkeypatch.setattr(handler, "_is_logged_in", lambda cfg: True) + monkeypatch.setattr(handler, "_save_auth_state", lambda cfg: {"cookies": 1}) + monkeypatch.setattr(handler, "_save_captured_login_token", lambda: True) + + result = handler._complete_manual_login(cfg) + + assert result["status"] == "manual_login_captured_auth_state" + assert result["saved"] == {"cookies": 1} + assert result["token_saved"] is True + + +def test_complete_manual_login_keeps_waiting_when_not_logged_in(tmp_path, monkeypatch): + handler = _load_handler() + cfg = _cfg(handler, tmp_path / "auth-state.json") + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: None) + monkeypatch.setattr(handler.helpers, "page_info", lambda: {"url": "https://edr.example.com/ui/login.php"}) + monkeypatch.setattr(handler, "_is_logged_in", lambda cfg: False) + + result = handler._complete_manual_login(cfg) + + assert result["status"] == "manual_login_required" + assert result["reason"] == "manual_login_not_completed" + + +def test_missing_credentials_opens_browser_for_manual_login(tmp_path, monkeypatch): + handler = _load_handler() + cfg = _cfg(handler, tmp_path / "auth-state.json") + cfg.username = "" + opened = [] + monkeypatch.setattr(handler, "_ensure_browser_daemon", lambda: None) + monkeypatch.setattr(handler, "_open_page", lambda url: opened.append(url)) + + result = handler._refresh_auth_state_with_cdp_login(cfg) + + assert opened == ["https://edr.example.com/ui/login.php"] + assert result["reason"] == "missing_cdp_login_credentials" + assert result["missing"] == ["username"] + + +def test_captured_login_token_is_saved_as_secret(monkeypatch): + handler = _load_handler() + saved = {} + monkeypatch.setattr(handler.helpers, "js", lambda script: "token-value") + monkeypatch.setattr( + handler, + "_get_secret_manager", + lambda: type("Secrets", (), {"set": lambda self, key, value: saved.update({key: value})})(), + ) + + assert handler._save_captured_login_token(timeout=0.1) is True + assert saved == {"sangfor_edr_token": "token-value"} + + +def test_login_token_capture_hooks_fetch_and_xhr(monkeypatch): + handler = _load_handler() + scripts = [] + monkeypatch.setattr(handler.helpers, "js", lambda script: scripts.append(script) or True) + + assert handler._install_login_token_capture() is True + assert "/launch_login.php" in scripts[0] + assert "window.fetch" in scripts[0] + assert "XMLHttpRequest.prototype" in scripts[0] From d538a64fb570c2929e77ae7159bdf3adb0d8c4be Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Tue, 21 Jul 2026 18:17:54 +0800 Subject: [PATCH 75/86] fix: pass slack app token to preflight --- flocks/channel/builtin/slack/channel.py | 2 +- tests/channel/test_slack.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py index cc1d63fc4..6f537417d 100644 --- a/flocks/channel/builtin/slack/channel.py +++ b/flocks/channel/builtin/slack/channel.py @@ -280,7 +280,7 @@ async def _verify_app_token(self, app_token: str) -> None: assert AsyncWebClient is not None client = AsyncWebClient(token=app_token) try: - await client.apps_connections_open() + await client.apps_connections_open(app_token=app_token) except Exception as exc: message = _format_slack_start_error(exc, phase="socket_mode") self.mark_disconnected(message) diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py index 2e9b7a904..31ae7323c 100644 --- a/tests/channel/test_slack.py +++ b/tests/channel/test_slack.py @@ -626,7 +626,8 @@ class FakeWebClient: def __init__(self, token): self.token = token - async def apps_connections_open(self): + async def apps_connections_open(self, *, app_token): + assert app_token == "xapp-bad" raise FakeSlackError() class FakeHandler: From 54ecc119fd6ae726d3d626b44bade8b4617fae37 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Wed, 22 Jul 2026 10:06:21 +0800 Subject: [PATCH 76/86] fix(tool): reset and sync auto-disable state Clear repeated failure history when a tool is manually re-enabled. Sync the WebUI detail state immediately when a test response reports automatic disablement, and cover both paths with regression tests. --- flocks/server/routes/tool.py | 2 + tests/server/test_tool_setting_routes.py | 23 +++++++++- .../src/pages/Tool/ToolDetailDrawer.test.tsx | 43 ++++++++++++++++++- webui/src/pages/Tool/index.tsx | 21 +++++++-- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index e217cbf3f..f4e682b62 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -665,6 +665,8 @@ def _set_global_tool_enabled(tool: Any, desired: bool) -> bool: }) tool.info.enabled = new_enabled + if new_enabled: + ToolRegistry._reset_failure_state(tool.info.name) return new_enabled diff --git a/tests/server/test_tool_setting_routes.py b/tests/server/test_tool_setting_routes.py index 023360fe9..79f7c1cf2 100644 --- a/tests/server/test_tool_setting_routes.py +++ b/tests/server/test_tool_setting_routes.py @@ -58,7 +58,7 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): overlay/service-gate combinations without touching real plugin YAML. """ config_dir = tmp_path / ".flocks" / "config" - config_dir.mkdir(parents=True) + config_dir.mkdir(parents=True, exist_ok=True) monkeypatch.setenv("FLOCKS_CONFIG_DIR", str(config_dir)) from flocks.config.config import Config @@ -68,6 +68,7 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): saved_tools = dict(ToolRegistry._tools) saved_defaults = dict(ToolRegistry._enabled_defaults) + saved_failure_state = dict(ToolRegistry._failure_state) saved_initialized = ToolRegistry._initialized enabled_tool = _stub_api_tool("onesec_dns_test", enabled=True) @@ -80,6 +81,7 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): enabled_tool.info.name: True, disabled_tool.info.name: False, } + ToolRegistry._failure_state = {} # Skip plugin discovery — our stub registry is enough. ToolRegistry._initialized = True @@ -100,6 +102,7 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): ToolRegistry._tools = saved_tools ToolRegistry._enabled_defaults = saved_defaults + ToolRegistry._failure_state = saved_failure_state ToolRegistry._initialized = saved_initialized @@ -192,6 +195,24 @@ def test_reload_path_refreshes_enabled_default(self, tool_client): class TestUpdateTool: + def test_manual_reenable_clears_repeated_failure_count(self, tool_client): + client, enabled_tool, _ = tool_client + _set_service(enabled=True) + enabled_tool.info.enabled = False + ToolRegistry._failure_state[enabled_tool.info.name] = { + "key": "same-failure", + "count": ToolRegistry._failure_disable_threshold, + } + + res = client.patch( + f"/api/tools/{enabled_tool.info.name}", + json={"enabled": True}, + ) + + assert res.status_code == 200 + assert res.json()["enabled"] is True + assert enabled_tool.info.name not in ToolRegistry._failure_state + def test_overlay_persisted_when_differs_from_default(self, tool_client): client, _, disabled_tool = tool_client _set_service(enabled=True) diff --git a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx index 518e6998d..7da6bdc61 100644 --- a/webui/src/pages/Tool/ToolDetailDrawer.test.tsx +++ b/webui/src/pages/Tool/ToolDetailDrawer.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ToolDetailDrawer } from './index'; vi.mock('react-i18next', () => ({ @@ -25,6 +25,7 @@ vi.mock('react-i18next', () => ({ 'toolDetail.no': '否', 'toolDetail.close': '关闭', 'toolDetail.testTool': '测试工具', + 'toolDetail.runTest': '执行测试', 'toolDetail.enabled': '启用', 'toolDetail.disabled': '禁用', }; @@ -49,6 +50,46 @@ vi.mock('@/api/tool', () => ({ })); describe('ToolDetailDrawer', () => { + const enabledTool = { + name: 'failing_custom_tool', + description: 'Always fails', + source: 'plugin_py', + category: 'custom', + enabled: true, + parameters: [], + } as any; + + const drawerProps = { + tool: enabledTool, + testParams: '{}', + testResult: null, + testing: false, + onClose: vi.fn(), + onTestParamsChange: vi.fn(), + onTest: vi.fn(), + }; + + it('disables test execution as soon as the result reports auto-disable', async () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: '测试' })); + expect(screen.getByRole('button', { name: '执行测试' })).toBeEnabled(); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: '执行测试' })).toBeDisabled(); + }); + }); + it('wraps long descriptions and parameter text without clipping', () => { const longDescription = 'OneSEC DNS grouped tool. dns_search_blocked_queries_by_super_long_keyword_with_no_breaks_and_more_details_to_force_wrapping'; const longParamDescription = 'DNS 分组动作名 dns_search_blocked_queries_by_super_long_keyword_with_no_breaks_and_more_details_to_force_wrapping'; diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index cd0c44400..1eec91f8c 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -420,7 +420,16 @@ export default function ToolPage() { setTestResult(null); const params = JSON.parse(testParams); const response = await toolAPI.test(selectedTool.name, params); - setTestResult(response.data); + const result = response.data; + setTestResult(result); + if (result.metadata?.disabled === true) { + setSelectedTool((current) => ( + current?.name === selectedTool.name + ? { ...current, enabled: false } + : current + )); + void refreshToolDataAfterMutation(); + } } catch (err: any) { setTestResult({ success: false, error: err.message }); } finally { @@ -3538,6 +3547,12 @@ export function ToolDetailDrawer({ setEnabledDefault(tool.enabled_default ?? tool.enabled); }, [tool.enabled, tool.enabled_customized, tool.enabled_default]); + useEffect(() => { + if (testResult?.metadata?.disabled === true) { + setEnabled(false); + } + }, [testResult]); + useEffect(() => { setFixturesLoading(true); toolAPI.listFixtures(tool.name) @@ -3799,14 +3814,14 @@ export function ToolDetailDrawer({
- {!tool.enabled && ( + {!enabled && (

{t('toolDetail.disabledNote')}

)} {canDirectTest ? null : ( From ec73d7576873bdeb90bfafe60d9b819152defe4d Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Wed, 22 Jul 2026 10:21:47 +0800 Subject: [PATCH 77/86] fix: support slack inbound media --- flocks/channel/builtin/slack/channel.py | 18 +- flocks/channel/builtin/slack/inbound.py | 66 ++++- flocks/channel/builtin/slack/inbound_media.py | 256 ++++++++++++++++++ flocks/channel/builtin/slack/manifest.py | 1 + flocks/channel/inbound/dispatcher.py | 11 +- tests/channel/test_channel.py | 29 ++ tests/channel/test_slack.py | 201 +++++++++++++- 7 files changed, 568 insertions(+), 14 deletions(-) create mode 100644 flocks/channel/builtin/slack/inbound_media.py diff --git a/flocks/channel/builtin/slack/channel.py b/flocks/channel/builtin/slack/channel.py index 6f537417d..512ff7fb1 100644 --- a/flocks/channel/builtin/slack/channel.py +++ b/flocks/channel/builtin/slack/channel.py @@ -145,7 +145,7 @@ def meta(self) -> ChannelMeta: def capabilities(self) -> ChannelCapabilities: return ChannelCapabilities( chat_types=[ChatType.DIRECT, ChatType.GROUP, ChatType.CHANNEL], - media=False, + media=True, threads=True, reactions=False, edit=False, @@ -371,14 +371,19 @@ def _register_handlers(self) -> None: return @self._app.event("message") - async def handle_message_event(event, say): - await self._handle_slack_event(event) + async def handle_message_event(event, body, say): + await self._handle_slack_event(event, body=body) @self._app.event("app_mention") - async def handle_app_mention_event(event, say): - await self._handle_slack_event(event) + async def handle_app_mention_event(event, body, say): + await self._handle_slack_event(event, body=body) - async def _handle_slack_event(self, event: dict[str, Any]) -> None: + async def _handle_slack_event( + self, + event: dict[str, Any], + *, + body: Optional[dict[str, Any]] = None, + ) -> None: if not self._on_message: return inbound = build_inbound_message( @@ -386,6 +391,7 @@ async def _handle_slack_event(self, event: dict[str, Any]) -> None: bot_user_id=self._bot_user_id, config=self._config, known_thread_ids=set(self._known_thread_ids.keys()), + body=body, ) if inbound is None: return diff --git a/flocks/channel/builtin/slack/inbound.py b/flocks/channel/builtin/slack/inbound.py index 18db230e0..2587e2012 100644 --- a/flocks/channel/builtin/slack/inbound.py +++ b/flocks/channel/builtin/slack/inbound.py @@ -146,18 +146,43 @@ def _extract_attachments_text(attachments: Any) -> str: return _join_parts(parts) +def _file_display_name(item: dict[str, Any]) -> str: + return str(item.get("name") or item.get("title") or item.get("id") or "file") + + def _extract_files_text(files: Any) -> str: if not isinstance(files, list) or not files: return "" names = [ - str(item.get("name") or item.get("title") or item.get("id") or "file") + _file_display_name(item) for item in files if isinstance(item, dict) ] - if names: - return "[Slack files: " + ", ".join(names) + "]" - return "" + if not names: + return "" + + first = next((item for item in files if isinstance(item, dict)), {}) + mimetype = str(first.get("mimetype") or "").lower() + label = "图片消息" if mimetype.startswith("image/") else "文件消息" + return f"[{label}: " + ", ".join(names) + "]" + + +def _first_file_media(files: Any) -> tuple[Optional[str], Optional[str]]: + if not isinstance(files, list): + return None, None + for item in files: + if not isinstance(item, dict): + continue + url = str(item.get("url_private_download") or item.get("url_private") or "").strip() + file_id = str(item.get("id") or "").strip() + if not url and file_id: + url = f"slack://file/{file_id}" + if not url: + continue + mimetype = str(item.get("mimetype") or item.get("filetype") or "").strip() or None + return url, mimetype + return None, None def extract_text(event: dict[str, Any]) -> str: @@ -202,6 +227,28 @@ def extract_text(event: dict[str, Any]) -> str: return "" +def _extract_body_team_id(body: Optional[dict[str, Any]]) -> str: + if not isinstance(body, dict): + return "" + for key in ("team_id", "team"): + value = body.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(value, dict): + nested = str(value.get("id") or "").strip() + if nested: + return nested + authorizations = body.get("authorizations") + if isinstance(authorizations, list): + for authorization in authorizations: + if not isinstance(authorization, dict): + continue + team_id = str(authorization.get("team_id") or "").strip() + if team_id: + return team_id + return "" + + def is_own_or_ignored_bot_message( event: dict[str, Any], *, @@ -229,6 +276,7 @@ def build_inbound_message( bot_user_id: Optional[str], config: dict[str, Any], known_thread_ids: set[str], + body: Optional[dict[str, Any]] = None, ) -> Optional[InboundMessage]: """Convert a Slack message/app_mention event into a Flocks InboundMessage.""" subtype = event.get("subtype") @@ -261,7 +309,12 @@ def build_inbound_message( # as channel surfaces while preserving group-trigger behavior in the # dispatcher (all non-DIRECT chats use the same trigger policy). chat_type = ChatType.CHANNEL - account_id = str(event.get("team") or event.get("team_id") or "default") + account_id = str( + event.get("team") + or event.get("team_id") + or _extract_body_team_id(body) + or "default" + ) thread_ts = str(event.get("thread_ts") or "") is_thread_reply = bool(thread_ts and thread_ts != ts) mentioned = bool(bot_user_id and f"<@{bot_user_id}>" in text) @@ -284,6 +337,7 @@ def build_inbound_message( mention_text = text sender_name = str(event.get("username") or event.get("user_name") or "") or None + media_url, media_mime = _first_file_media(event.get("files")) return InboundMessage( channel_id="slack", @@ -294,6 +348,8 @@ def build_inbound_message( chat_id=channel_id, chat_type=chat_type, text=text, + media_url=media_url, + media_mime=media_mime, reply_to_id=str(event.get("parent_user_id") or "") or None, thread_id=thread_ts or None, mentioned=mentioned, diff --git a/flocks/channel/builtin/slack/inbound_media.py b/flocks/channel/builtin/slack/inbound_media.py new file mode 100644 index 000000000..03af97f07 --- /dev/null +++ b/flocks/channel/builtin/slack/inbound_media.py @@ -0,0 +1,256 @@ +"""Slack inbound media download helpers.""" + +from __future__ import annotations + +import datetime +import mimetypes +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional +from urllib.parse import unquote, urlparse + +import httpx + +from flocks.channel.base import InboundMessage +from flocks.channel.media_filename import sanitize_filename +from flocks.utils.log import Log + +from .config import resolve_bot_token + +log = Log.create(service="channel.slack.media") + +_DEFAULT_MAX_INBOUND_MEDIA_BYTES = 30 * 1024 * 1024 + + +class SlackInboundMediaTooLarge(ValueError): + """Slack 入站媒体超过允许大小。""" + + +@dataclass +class DownloadedInboundMedia: + filename: str + mime: str + url: str + source: dict + + +def _media_storage_dir(account_id: str) -> Path: + return ( + Path.home() + / ".flocks" + / "data" + / "channel_media" + / "slack" + / account_id + / datetime.date.today().isoformat() + ) + + +def _sanitize_filename(name: str) -> str: + return sanitize_filename(name) + + +def _guess_mime_from_ext(filename: str) -> Optional[str]: + _, ext = os.path.splitext(filename) + if ext: + return mimetypes.guess_type(filename)[0] + return None + + +def _filename_from_content_disposition(value: str) -> Optional[str]: + match = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', value, re.I) + if not match: + return None + return unquote(match.group(1).strip()) + + +def _max_size_error(max_bytes: int) -> SlackInboundMediaTooLarge: + return SlackInboundMediaTooLarge( + f"Slack inbound media too large: >{max_bytes // (1024 * 1024)}MB" + ) + + +def _iter_raw_files(msg: InboundMessage) -> list[dict[str, Any]]: + raw = msg.raw if isinstance(msg.raw, dict) else {} + files = raw.get("files") + if not isinstance(files, list): + return [] + return [item for item in files if isinstance(item, dict)] + + +def _find_file_object(msg: InboundMessage, media_url: str) -> dict[str, Any]: + parsed = urlparse(media_url) + ref_file_id = parsed.path.lstrip("/") if parsed.scheme == "slack" else "" + for item in _iter_raw_files(msg): + if ref_file_id and str(item.get("id") or "") == ref_file_id: + return item + for key in ("url_private_download", "url_private"): + if media_url and str(item.get(key) or "") == media_url: + return item + return {} + + +def _guess_filename( + msg: InboundMessage, + media_url: str, + file_obj: dict[str, Any], + cd_filename: Optional[str] = None, +) -> str: + for key in ("name", "title"): + candidate = str(file_obj.get(key) or "").strip() + if candidate: + return _sanitize_filename(candidate) + if cd_filename: + return _sanitize_filename(cd_filename) + basename = os.path.basename(urlparse(media_url).path) + if basename and "." in basename: + return _sanitize_filename(unquote(basename)) + file_id = str(file_obj.get("id") or "").strip() + suffix = file_id or msg.message_id or "unknown" + return _sanitize_filename(f"slack_file_{suffix[:16]}") + + +async def _resolve_slack_file_url( + *, + media_url: str, + bot_token: str, +) -> tuple[str, dict[str, Any]]: + parsed = urlparse(media_url) + if parsed.scheme != "slack": + return media_url, {} + file_id = parsed.path.lstrip("/") + if not file_id: + return "", {} + try: + from slack_sdk.web.async_client import AsyncWebClient + except Exception as exc: + raise RuntimeError("slack-sdk is required to resolve Slack file metadata") from exc + + response = await AsyncWebClient(token=bot_token).files_info(file=file_id) + file_obj = response.get("file") if hasattr(response, "get") else None + if not isinstance(file_obj, dict): + raise RuntimeError("Slack files.info returned no file object") + url = str(file_obj.get("url_private_download") or file_obj.get("url_private") or "").strip() + if not url: + raise RuntimeError("Slack files.info returned no private download URL") + return url, file_obj + + +async def _download_file_limited( + *, + media_url: str, + bot_token: str, + max_bytes: int, +) -> tuple[bytes, Optional[str], Optional[str]]: + chunks: list[bytes] = [] + total = 0 + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: + async with client.stream( + "GET", + media_url, + headers={"Authorization": f"Bearer {bot_token}"}, + ) as resp: + resp.raise_for_status() + headers = resp.headers + content_type = headers.get("content-type") or headers.get("Content-Type") + if content_type and "text/html" in content_type.lower(): + raise RuntimeError( + "Slack returned HTML instead of media; check bot token scopes and file permissions" + ) + content_length = headers.get("content-length") or headers.get("Content-Length") + if content_length and content_length.isdigit() and int(content_length) > max_bytes: + raise _max_size_error(max_bytes) + content_disposition = ( + headers.get("content-disposition") + or headers.get("Content-Disposition") + or "" + ) + cd_filename = _filename_from_content_disposition(content_disposition) + async for chunk in resp.aiter_bytes(8192): + total += len(chunk) + if total > max_bytes: + raise _max_size_error(max_bytes) + chunks.append(chunk) + return b"".join(chunks), cd_filename, content_type + + +async def download_inbound_media( + msg: InboundMessage, + config: dict, + *, + max_bytes: int = _DEFAULT_MAX_INBOUND_MEDIA_BYTES, +) -> Optional[DownloadedInboundMedia]: + media_url = msg.media_url or "" + if not media_url: + return None + + bot_token = resolve_bot_token(config) + if not bot_token: + log.warning("slack.media.no_token", { + "account_id": msg.account_id, + "message_id": msg.message_id, + }) + return None + + file_obj = _find_file_object(msg, media_url) + try: + resolved_url, resolved_file = await _resolve_slack_file_url( + media_url=media_url, + bot_token=bot_token, + ) + if resolved_file: + file_obj = {**file_obj, **resolved_file} + if not resolved_url: + return None + buffer, cd_filename, response_mime = await _download_file_limited( + media_url=resolved_url, + bot_token=bot_token, + max_bytes=max_bytes, + ) + except SlackInboundMediaTooLarge as exc: + log.warning("slack.media.file_too_large", { + "message_id": msg.message_id, + "error": str(exc), + }) + return None + except Exception as exc: + log.warning("slack.media.download_failed", { + "message_id": msg.message_id, + "media_url": media_url[:200], + "error": str(exc), + }) + return None + + filename = _guess_filename(msg, media_url, file_obj, cd_filename) + mime = ( + msg.media_mime + or str(file_obj.get("mimetype") or "").strip() + or response_mime + or _guess_mime_from_ext(filename) + or "application/octet-stream" + ) + if "." not in filename: + ext = mimetypes.guess_extension(mime.split(";", 1)[0].strip()) or "" + if ext: + filename = f"{filename}{ext}" + + storage_dir = _media_storage_dir(msg.account_id or "default") + storage_dir.mkdir(parents=True, exist_ok=True) + msg_id = msg.message_id or "unknown" + file_path = storage_dir / _sanitize_filename(f"{msg_id}_{filename}") + file_path.write_bytes(buffer) + + return DownloadedInboundMedia( + filename=filename, + mime=mime, + url=file_path.resolve().as_uri(), + source={ + "channel": "slack", + "account_id": msg.account_id, + "message_id": msg.message_id, + "media_url": msg.media_url, + "file_id": str(file_obj.get("id") or "") or None, + }, + ) diff --git a/flocks/channel/builtin/slack/manifest.py b/flocks/channel/builtin/slack/manifest.py index 56aca831b..e39405494 100644 --- a/flocks/channel/builtin/slack/manifest.py +++ b/flocks/channel/builtin/slack/manifest.py @@ -19,6 +19,7 @@ def build_slack_app_manifest( "channels:history", "channels:read", "chat:write", + "files:read", "groups:history", "groups:read", "im:history", diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index 1b8105891..cc116b49e 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -1534,7 +1534,7 @@ def _is_placeholder_text(text: str) -> bool: ) if text in placeholders: return True - return text.startswith("[文件消息:") + return text.startswith("[文件消息:") or text.startswith("[图片消息:") # Best-effort eager registration at import time. Channels that need @@ -1563,6 +1563,12 @@ def _is_placeholder_text(text: str) -> bool: except Exception: # pragma: no cover pass +try: + from flocks.channel.builtin.slack import inbound_media as _slack_inbound_media + register_inbound_media_downloader("slack", _slack_inbound_media.download_inbound_media) +except Exception: # pragma: no cover + pass + async def _download_channel_media(msg: "InboundMessage", config: dict) -> Any: """Dispatch inbound media download to the appropriate channel handler. @@ -1585,4 +1591,7 @@ async def _download_channel_media(msg: "InboundMessage", config: dict) -> Any: if channel_id == "telegram": from flocks.channel.builtin.telegram import inbound_media as _telegram_inbound_media return await _telegram_inbound_media.download_inbound_media(msg, config) + if channel_id == "slack": + from flocks.channel.builtin.slack import inbound_media as _slack_inbound_media + return await _slack_inbound_media.download_inbound_media(msg, config) return None diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index fcd98361f..11f30a511 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -1633,6 +1633,34 @@ async def fake_telegram(msg, config): assert result is not None assert captured["msg"].channel_id == "telegram" + @pytest.mark.asyncio + async def test_dispatch_to_slack_downloader(self, monkeypatch): + from flocks.channel.inbound import dispatcher as dispatch_mod + + captured = {} + + async def fake_slack(msg, config): + captured["msg"] = msg + captured["config"] = config + return SimpleNamespace( + filename="slack.png", mime="image/png", + url="file:///tmp/slack.png", source={"channel": "slack"}, + ) + + import flocks.channel.builtin.slack.inbound_media as slack_inb + monkeypatch.setattr(slack_inb, "download_inbound_media", fake_slack) + + result = await dispatch_mod._download_channel_media( + InboundMessage( + channel_id="slack", account_id="T1", message_id="m", + sender_id="u", media_url="https://files.slack.com/x", + ), + {"botToken": "xoxb-test"}, + ) + assert result is not None + assert captured["msg"].channel_id == "slack" + assert captured["config"] == {"botToken": "xoxb-test"} + @pytest.mark.asyncio async def test_unknown_channel_returns_none(self): from flocks.channel.inbound import dispatcher as dispatch_mod @@ -1655,6 +1683,7 @@ class TestIsPlaceholderText: def test_recognises_channel_placeholders(self): from flocks.channel.inbound.dispatcher import _is_placeholder_text assert _is_placeholder_text("[图片消息]") is True + assert _is_placeholder_text("[图片消息: screenshot.png]") is True assert _is_placeholder_text("[文件消息]") is True assert _is_placeholder_text("[文件消息: report.pdf]") is True assert _is_placeholder_text("[Image]") is True diff --git a/tests/channel/test_slack.py b/tests/channel/test_slack.py index 31ae7323c..1d5bdf4bc 100644 --- a/tests/channel/test_slack.py +++ b/tests/channel/test_slack.py @@ -8,6 +8,7 @@ from flocks.channel.base import ChatType, NonRetryableChannelError, OutboundContext import flocks.channel.builtin.slack.channel as slack_mod +import flocks.channel.builtin.slack.inbound_media as slack_media_mod from flocks.channel.builtin.slack.channel import SlackChannel from flocks.channel.builtin.slack.format import markdown_to_slack_mrkdwn from flocks.channel.builtin.slack.inbound import build_inbound_message, slack_thread_cache_key @@ -19,7 +20,7 @@ def test_plugin_exports_slack_channel(): assert plugin.meta().id == "slack" assert plugin.meta().label == "Slack" assert "sl" in plugin.meta().aliases - assert plugin.capabilities().media is False + assert plugin.capabilities().media is True assert plugin.capabilities().rich_text is True assert plugin.capabilities().self_managed_connection is True @@ -34,6 +35,7 @@ def test_slack_manifest_matches_socket_mode_setup_needs(): "channels:history", "channels:read", "chat:write", + "files:read", "groups:history", "groups:read", "im:history", @@ -280,7 +282,98 @@ def test_build_inbound_appends_attachments_and_file_names(): assert "review alert" in msg.text assert "Alert context" in msg.text assert "Severity: high" in msg.text - assert "[Slack files: screenshot.png]" in msg.text + assert "[文件消息: screenshot.png]" in msg.text + + +def test_build_inbound_image_file_sets_media_url_and_mime(): + msg = build_inbound_message( + { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.25", + "team": "T1", + "files": [ + { + "id": "F123", + "name": "screenshot.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/files-pri/T1-F123/download/screenshot.png", + }, + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.text == "[图片消息: screenshot.png]" + assert msg.media_url == "https://files.slack.com/files-pri/T1-F123/download/screenshot.png" + assert msg.media_mime == "image/png" + + +def test_build_inbound_file_stub_uses_slack_file_uri(): + msg = build_inbound_message( + { + "channel": "C123", + "channel_type": "channel", + "user": "U123", + "ts": "171.26", + "text": "<@UBOT> inspect", + "files": [ + { + "id": "F_STUB", + "name": "stub.pdf", + "mimetype": "application/pdf", + "file_access": "check_file_info", + }, + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + assert msg.media_url == "slack://file/F_STUB" + assert msg.media_mime == "application/pdf" + + +def test_build_inbound_uses_socket_mode_body_team_id_for_stable_dm_sessions(): + from flocks.channel.inbound.session_binding import _resolve_session_key + + event_without_team = { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.27", + "text": "hello", + } + event_with_team = { + **event_without_team, + "ts": "171.28", + "team": "T1", + } + + first = build_inbound_message( + event_without_team, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + body={"team_id": "T1"}, + ) + second = build_inbound_message( + event_with_team, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert first is not None + assert second is not None + assert first.account_id == second.account_id == "T1" + assert _resolve_session_key(first) == _resolve_session_key(second) def test_build_inbound_group_dm_uses_group_chat_type(): @@ -707,3 +800,107 @@ async def on_message(msg): assert len(dispatched) == 1 assert dispatched[0].mention_text == "ping" + + +@pytest.mark.asyncio +async def test_handle_slack_event_passes_socket_body_to_inbound_parser(): + plugin = SlackChannel() + plugin._config = {} + plugin._bot_user_id = "UBOT" + dispatched = [] + + async def on_message(msg): + dispatched.append(msg) + + plugin._on_message = on_message + + await plugin._handle_slack_event( + { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.29", + "text": "hello", + }, + body={"authorizations": [{"team_id": "T_BODY"}]}, + ) + + assert len(dispatched) == 1 + assert dispatched[0].account_id == "T_BODY" + + +@pytest.mark.asyncio +async def test_slack_inbound_media_downloads_private_file_with_bot_token(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + captured = {} + + class FakeResponse: + headers = { + "content-type": "image/png", + "content-disposition": 'attachment; filename="from-slack.png"', + } + + def raise_for_status(self): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def aiter_bytes(self, size): + yield b"png-bytes" + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def stream(self, method, url, headers): + captured["method"] = method + captured["url"] = url + captured["headers"] = headers + return FakeResponse() + + monkeypatch.setattr(slack_media_mod.httpx, "AsyncClient", FakeClient) + + msg = build_inbound_message( + { + "channel": "D123", + "channel_type": "im", + "user": "U123", + "ts": "171.30", + "team": "T1", + "files": [ + { + "id": "F123", + "name": "screenshot.png", + "mimetype": "image/png", + "url_private_download": "https://files.slack.com/files-pri/T1-F123/download/screenshot.png", + } + ], + }, + bot_user_id="UBOT", + config={}, + known_thread_ids=set(), + ) + + assert msg is not None + media = await slack_media_mod.download_inbound_media( + msg, + {"botToken": "xoxb-test"}, + ) + + assert media is not None + assert captured["headers"] == {"Authorization": "Bearer xoxb-test"} + assert captured["url"].startswith("https://files.slack.com/") + assert media.filename == "screenshot.png" + assert media.mime == "image/png" + assert media.url.startswith("file://") + assert media.source["channel"] == "slack" From 58ebf0019727f0cceedc90527becb83af9bebfed Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 11:32:26 +0800 Subject: [PATCH 78/86] fix(mcp): fail calls when connection owner exits --- flocks/mcp/client.py | 22 ++++++++++++++++++---- tests/mcp/test_mcp_client.py | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/flocks/mcp/client.py b/flocks/mcp/client.py index 8acdf8e33..02a4c2a55 100644 --- a/flocks/mcp/client.py +++ b/flocks/mcp/client.py @@ -781,11 +781,25 @@ async def _submit_command_on_owner_loop( command = _ClientCommand(action=action, payload=payload, response=response) await self._command_queue.put(command) - if owner_task.done() and not response.done(): - owner_error = self._owner_error or RuntimeError(f"Client not connected: {self.name}") - response.set_exception(owner_error) + done, _ = await asyncio.wait( + {response, owner_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if response in done: + return await response + + response.cancel() + try: + owner_error = owner_task.exception() + except asyncio.CancelledError: + owner_error = None + + if owner_error is not None: + raise RuntimeError( + f"Connection lost: {self.name}: {_extract_root_cause(owner_error)}" + ) from owner_error - return await response + raise RuntimeError(f"Connection lost: {self.name}") async def _await_task(self, task: asyncio.Task[Any]) -> None: """Await a task safely from the owner loop or another loop.""" diff --git a/tests/mcp/test_mcp_client.py b/tests/mcp/test_mcp_client.py index 6cb6bbfd6..f708d44b8 100644 --- a/tests/mcp/test_mcp_client.py +++ b/tests/mcp/test_mcp_client.py @@ -295,3 +295,30 @@ async def fake_remote(startup_future): assert cancelled.is_set() is True assert client._owner_task is None assert client._command_queue is None + + +class TestMcpClientOwnerFailure: + @pytest.mark.asyncio + async def test_call_tool_fails_when_owner_exits_during_request(self): + client = McpClient( + name="demo", + server_type="remote", + url="https://example.com/mcp", + ) + client._connected = True + client._owner_loop = asyncio.get_running_loop() + client._command_queue = asyncio.Queue() + + async def failing_owner() -> None: + await client._command_queue.get() + raise RuntimeError("HTTP 504 from https://example.com/mcp") + + owner_task = asyncio.create_task(failing_owner()) + owner_task.add_done_callback(client._handle_owner_task_done) + client._owner_task = owner_task + + with pytest.raises(RuntimeError, match="HTTP 504"): + await asyncio.wait_for( + client.call_tool("demo_tool", {"value": "x"}), + timeout=1.0, + ) From a819549f94d5c53173d787a963d91d242e317d62 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 11:42:08 +0800 Subject: [PATCH 79/86] fix(mcp): cancel responses with interrupted callers --- flocks/mcp/client.py | 13 +++++++++---- tests/mcp/test_mcp_client.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/flocks/mcp/client.py b/flocks/mcp/client.py index 02a4c2a55..4a4bf5443 100644 --- a/flocks/mcp/client.py +++ b/flocks/mcp/client.py @@ -781,10 +781,15 @@ async def _submit_command_on_owner_loop( command = _ClientCommand(action=action, payload=payload, response=response) await self._command_queue.put(command) - done, _ = await asyncio.wait( - {response, owner_task}, - return_when=asyncio.FIRST_COMPLETED, - ) + try: + done, _ = await asyncio.wait( + {response, owner_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + response.cancel() + raise + if response in done: return await response diff --git a/tests/mcp/test_mcp_client.py b/tests/mcp/test_mcp_client.py index f708d44b8..3cd12fc25 100644 --- a/tests/mcp/test_mcp_client.py +++ b/tests/mcp/test_mcp_client.py @@ -322,3 +322,38 @@ async def failing_owner() -> None: client.call_tool("demo_tool", {"value": "x"}), timeout=1.0, ) + + @pytest.mark.asyncio + async def test_call_tool_cancels_response_when_caller_is_cancelled(self): + client = McpClient( + name="demo", + server_type="remote", + url="https://example.com/mcp", + ) + client._connected = True + client._owner_loop = asyncio.get_running_loop() + client._command_queue = asyncio.Queue() + command_seen = asyncio.get_running_loop().create_future() + + async def blocked_owner() -> None: + command = await client._command_queue.get() + command_seen.set_result(command) + await asyncio.Future() + + owner_task = asyncio.create_task(blocked_owner()) + client._owner_task = owner_task + + try: + call_task = asyncio.create_task(client.call_tool("demo_tool", {})) + command = await asyncio.wait_for(command_seen, timeout=1.0) + call_task.cancel() + + with pytest.raises(asyncio.CancelledError): + await call_task + + assert command.response is not None + assert command.response.cancelled() is True + finally: + owner_task.cancel() + with pytest.raises(asyncio.CancelledError): + await owner_task From 01bb6aee891ea348f7336667d2ad906773217816 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 12:33:53 +0800 Subject: [PATCH 80/86] fix(session): restore task context and bundled skills --- flocks/project/project.py | 81 ++------ flocks/server/app.py | 4 +- flocks/server/routes/project.py | 78 ++++---- flocks/server/routes/session.py | 120 +++++++----- flocks/skill/skill.py | 71 +++++-- tests/project/test_project.py | 31 ++- tests/server/routes/test_project_routes.py | 53 +++-- tests/server/routes/test_session_routes.py | 52 +++-- tests/server/routes/test_skill_routes.py | 31 +++ tests/server/test_asgi_middleware.py | 37 ++++ tests/skill/test_skill.py | 65 +++++++ webui/src/locales/en-US/session.json | 4 +- webui/src/locales/zh-CN/session.json | 4 +- webui/src/pages/Session/index.test.tsx | 5 +- webui/src/pages/Session/index.tsx | 215 ++++++++++----------- 15 files changed, 513 insertions(+), 338 deletions(-) create mode 100644 tests/server/routes/test_skill_routes.py diff --git a/flocks/project/project.py b/flocks/project/project.py index 98eea8800..edfa1aa19 100644 --- a/flocks/project/project.py +++ b/flocks/project/project.py @@ -2,7 +2,7 @@ Projects are UI/session groupings bound to existing directories. Legacy project records in Storage are intentionally ignored; sessions that do not reference a -registered project are presented under the virtual default project. +registered project remain ordinary sessions rather than becoming a project. """ from __future__ import annotations @@ -27,7 +27,11 @@ log = Log.create(service="project") +# Legacy stored project ID used by ordinary sessions. It is not a ProjectInfo +# entry and never resolves to a worktree. DEFAULT_PROJECT_ID = "default" +# Session-manager wire ID for the Tasks section; also not a project. +TASK_SESSION_GROUP_ID = "tasks" DEFAULT_PROJECT_NAME = "默认" _RESERVED_PROJECT_NAMES = {"default", DEFAULT_PROJECT_NAME.casefold()} @@ -151,12 +155,12 @@ class ProjectRegistry(BaseModel): model_config = ConfigDict(populate_by_name=True) - default_worktree: str = Field(alias="defaultWorktree") + default_worktree: Optional[str] = Field(None, alias="defaultWorktree") projects: List[ProjectRegistryEntry] = Field(default_factory=list) class Project: - """Project registry and virtual default-project operations.""" + """User-scoped registry of explicitly created projects.""" _lock = asyncio.Lock() _session_stats_cache: ClassVar[ @@ -223,23 +227,6 @@ def _path_status(worktree: str) -> Literal["available", "missing", "unreadable"] return "unreadable" return "available" - @classmethod - def default_worktree_candidate(cls) -> str: - """Resolve the initial default directory without using the server cwd.""" - - configured = os.getenv("FLOCKS_DEFAULT_PROJECT_DIR") - if configured: - path = Path(configured).expanduser() - else: - configured_workspace = os.getenv("FLOCKS_WORKSPACE_DIR") - path = ( - Path(configured_workspace).expanduser() - if configured_workspace - else cls._flocks_root() / "workspace" - ) - path.mkdir(parents=True, exist_ok=True) - return str(path.resolve()) - @classmethod def allowed_roots(cls, default_worktree: Optional[str] = None) -> List[Path]: """Return canonical roots available to the server-side folder picker.""" @@ -328,20 +315,15 @@ def _read_registry_file(cls, path: Path) -> Optional[ProjectRegistry]: def _read_registry( cls, owner_id: str, - *, - default_worktree: Optional[str] = None, ) -> ProjectRegistry: path = cls.registry_path(owner_id) registry = cls._read_registry_file(path) if registry is not None: + # The pre-task/project split stored a virtual default worktree. + # Keep reading that field for compatibility but never use it. + registry.default_worktree = None return registry - - candidate = default_worktree or cls.default_worktree_candidate() - try: - candidate = cls.validate_worktree(candidate, default_worktree=candidate) - except ValueError: - candidate = cls.default_worktree_candidate() - return ProjectRegistry(defaultWorktree=candidate, projects=[]) + return ProjectRegistry(projects=[]) @classmethod def _write_registry(cls, owner_id: str, registry: ProjectRegistry) -> None: @@ -356,7 +338,7 @@ def _write_registry(cls, owner_id: str, registry: ProjectRegistry) -> None: try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump( - registry.model_dump(by_alias=True), + registry.model_dump(by_alias=True, exclude_none=True), handle, ensure_ascii=False, indent=2, @@ -400,33 +382,17 @@ def _entry_to_info( isShared=entry.shared_local, ) - @classmethod - def _default_to_info(cls, registry: ProjectRegistry) -> ProjectInfo: - now = cls._now_ms() - return ProjectInfo( - id=DEFAULT_PROJECT_ID, - worktree=registry.default_worktree, - name=DEFAULT_PROJECT_NAME, - vcs="git" if (Path(registry.default_worktree) / ".git").exists() else None, - time=ProjectTime(created=now, updated=now), - isDefault=True, - pathStatus=cls._path_status(registry.default_worktree), - canDelete=False, - ) - @classmethod async def ensure_registry( cls, owner_id: str, - *, - default_worktree: Optional[str] = None, ) -> ProjectRegistry: - """Load a registry and persist its initial default directory once.""" + """Load a registry and create an empty one when needed.""" async with cls._lock: path = cls.registry_path(owner_id) with _registry_cross_process_lock(path): - registry = cls._read_registry(owner_id, default_worktree=default_worktree) + registry = cls._read_registry(owner_id) if not path.exists(): cls._write_registry(owner_id, registry) return registry @@ -446,13 +412,9 @@ async def create( registry = cls._read_registry(owner_id) normalized_worktree = cls.validate_worktree( worktree, - default_worktree=registry.default_worktree, create_if_missing=True, ) - if normalized_worktree == cls._normalized_worktree(registry.default_worktree): - raise ProjectPathConflictError(cls._default_to_info(registry)) - for entry in registry.projects: if cls._normalized_worktree(entry.worktree) == normalized_worktree: raise ProjectPathConflictError(cls._entry_to_info(entry)) @@ -486,12 +448,11 @@ async def list( cls, *, owner_id: str, - default_worktree: Optional[str] = None, ) -> List[ProjectInfo]: - registry = await cls.ensure_registry(owner_id, default_worktree=default_worktree) + registry = await cls.ensure_registry(owner_id) projects = [cls._entry_to_info(entry) for entry in registry.projects] projects.sort(key=lambda item: item.time.updated, reverse=True) - return [cls._default_to_info(registry), *projects] + return projects @classmethod def _all_registry_entries(cls) -> List[ProjectRegistryEntry]: @@ -518,11 +479,10 @@ async def list_visible( cls, *, owner_id: str, - default_worktree: Optional[str] = None, ) -> List[ProjectInfo]: """List owned projects plus projects shared by other local users.""" - owned = await cls.list(owner_id=owner_id, default_worktree=default_worktree) + owned = await cls.list(owner_id=owner_id) owned_ids = {project.id for project in owned} shared = [ cls._entry_to_info(entry, can_write=False) @@ -582,11 +542,8 @@ async def get( project_id: str, *, owner_id: str, - default_worktree: Optional[str] = None, ) -> Optional[ProjectInfo]: - registry = await cls.ensure_registry(owner_id, default_worktree=default_worktree) - if project_id == DEFAULT_PROJECT_ID: - return cls._default_to_info(registry) + registry = await cls.ensure_registry(owner_id) entry = next((item for item in registry.projects if item.id == project_id), None) return cls._entry_to_info(entry) if entry else None @@ -648,7 +605,7 @@ def registered_project_ids(cls, owner_id: str) -> set[str]: def effective_project_id(cls, owner_id: str, stored_project_id: Optional[str]) -> str: if stored_project_id and stored_project_id in cls.visible_project_ids(owner_id): return stored_project_id - return DEFAULT_PROJECT_ID + return TASK_SESSION_GROUP_ID @classmethod def get_session_stats_cache( diff --git a/flocks/server/app.py b/flocks/server/app.py index 84a10b163..d60990514 100644 --- a/flocks/server/app.py +++ b/flocks/server/app.py @@ -867,9 +867,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if not directory: directory = request.headers.get("x-flocks-directory") if not directory: - from flocks.project.project import Project - - directory = Project.default_worktree_candidate() + directory = os.getcwd() try: directory = unquote(directory) diff --git a/flocks/server/routes/project.py b/flocks/server/routes/project.py index ea6144a85..9143f1f0d 100644 --- a/flocks/server/routes/project.py +++ b/flocks/server/routes/project.py @@ -11,7 +11,6 @@ from flocks.auth.context import AuthUser from flocks.project.project import ( - DEFAULT_PROJECT_ID, Project, ProjectDeletionError, ProjectInfo, @@ -60,15 +59,12 @@ class FolderBrowserResponse(BaseModel): async def _list_project_summaries(user: AuthUser, search: Optional[str]) -> List[ProjectInfo]: owner_id = user.id - projects = await Project.list_visible( - owner_id=owner_id, - default_worktree=Project.default_worktree_candidate(), - ) + projects = await Project.list_visible(owner_id=owner_id) shared_project_ids = Project.shared_project_ids() term = search.strip().casefold() if search else None stats = Project.get_session_stats_cache(owner_id, term or "") if stats is None: - registered_ids = {project.id for project in projects if not project.is_default} + registered_ids = {project.id for project in projects} counts = {project.id: 0 for project in projects} matched_counts = {project.id: 0 for project in projects} last_activity: dict[str, int] = {} @@ -85,11 +81,9 @@ async def _list_project_summaries(user: AuthUser, search: Optional[str]) -> List continue if session.parent_id or session.category not in _MANAGER_CATEGORIES: continue - project_id = ( - session.project_id - if session.project_id in registered_ids - else DEFAULT_PROJECT_ID - ) + if session.project_id not in registered_ids: + continue + project_id = session.project_id counts[project_id] = counts.get(project_id, 0) + 1 if term is None or term in session.title.casefold(): matched_counts[project_id] = matched_counts.get(project_id, 0) + 1 @@ -122,7 +116,7 @@ async def _list_project_summaries(user: AuthUser, search: Optional[str]) -> List @router.get("", response_model=List[ProjectInfo], include_in_schema=False) @router.get("/", response_model=List[ProjectInfo], summary="List projects") async def list_projects(request: Request, search: Optional[str] = Query(None)): - """List the virtual default project and user-registered folders.""" + """List user-registered project folders.""" user = require_user(request) try: @@ -155,24 +149,19 @@ async def create_project(request: Request, payload: ProjectCreateRequest): raise HTTPException(status_code=500, detail=str(exc)) from exc -@router.get("/current", response_model=ProjectInfo, summary="Get default project") +@router.get("/current", response_model=ProjectInfo, summary="Get current project") async def get_current_project(request: Request, project_id: Optional[str] = Query(None, alias="projectID")): - """Return a requested registered project, falling back to default.""" + """Return a requested project or the current runtime context.""" user = require_user(request) - project = await Project.get( - project_id or DEFAULT_PROJECT_ID, - owner_id=user.id, - default_worktree=Project.default_worktree_candidate(), - ) - if project is None: - project = await Project.get( - DEFAULT_PROJECT_ID, - owner_id=user.id, - default_worktree=Project.default_worktree_candidate(), - ) + if project_id: + project = await Project.get(project_id, owner_id=user.id) + else: + from flocks.project.instance import Instance + + project = Instance.get_project() if project is None: - raise HTTPException(status_code=500, detail="Default project is unavailable") + raise HTTPException(status_code=404, detail="Current project is unavailable") return project @@ -180,16 +169,8 @@ async def get_current_project(request: Request, project_id: Optional[str] = Quer async def browse_project_folders(request: Request, path: Optional[str] = Query(None)): """List directories under configured roots without exposing file contents.""" - user = require_user(request) - default_project = await Project.get( - DEFAULT_PROJECT_ID, - owner_id=user.id, - default_worktree=Project.default_worktree_candidate(), - ) - if default_project is None: - raise HTTPException(status_code=500, detail="Default project is unavailable") - - roots = Project.allowed_roots(default_project.worktree) + require_user(request) + roots = Project.allowed_roots() if not roots: raise HTTPException(status_code=400, detail="No project roots are available") @@ -302,12 +283,32 @@ async def unshare_project_local(project_id: str, request: Request): raise HTTPException(status_code=404, detail=str(exc)) from exc -@router.delete("/{project_id}", response_model=bool, summary="Remove project") +@router.delete("/{project_id}", response_model=bool, summary="Delete project") async def delete_project(project_id: str, request: Request): - """Remove a project registration while preserving sessions and files.""" + """Remove a project and its sessions while preserving project files.""" user = require_user(request) try: + if await Project.get(project_id, owner_id=user.id) is None: + raise ValueError(f"Project {project_id} not found") + + project_sessions = [ + session + for session in await Session.list_all_unfiltered() + if session.project_id == project_id + ] + if any(not SessionPolicy.can_delete(session, user) for session in project_sessions): + raise HTTPException( + status_code=403, + detail="Only session owners can delete project sessions", + ) + + from flocks.server.routes.session import delete_session_for_user + + for session in project_sessions: + if await Session.get(project_id, session.id) is not None: + await delete_session_for_user(session.id, user) + return await Project.delete(project_id, owner_id=user.id) except ProjectDeletionError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @@ -321,7 +322,6 @@ async def get_project(project_id: str, request: Request): project = await Project.get( project_id, owner_id=user.id, - default_worktree=Project.default_worktree_candidate(), ) if project is None: raise HTTPException(status_code=404, detail=f"Project {project_id} not found") diff --git a/flocks/server/routes/session.py b/flocks/server/routes/session.py index ee332a65a..2f9f2acad 100644 --- a/flocks/server/routes/session.py +++ b/flocks/server/routes/session.py @@ -16,7 +16,7 @@ from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field, ConfigDict -from flocks.auth.context import get_current_auth_user, set_current_auth_user, reset_current_auth_user +from flocks.auth.context import AuthUser, get_current_auth_user, reset_current_auth_user, set_current_auth_user from flocks.server.routes._timing import log_route_timing from flocks.audit import emit_audit_event from flocks.license import assert_license_active @@ -457,33 +457,29 @@ async def _get_session_by_id_unfiltered(session_id: str) -> Optional[SessionMode async def _resolve_session_working_directory(session: SessionModel) -> str: - """Keep a valid historical cwd, otherwise use the user's default project.""" + """Keep a valid historical cwd, otherwise use the request context.""" if session.directory: directory = Path(session.directory).expanduser() if directory.is_dir() and os.access(directory, os.R_OK | os.X_OK): return str(directory.resolve()) - from flocks.project.project import DEFAULT_PROJECT_ID, Project + from flocks.project.instance import Instance - current_user = get_current_auth_user() - owner_id = current_user.id if current_user else API_TOKEN_SERVICE_USER_ID - default_project = await Project.get( - DEFAULT_PROJECT_ID, - owner_id=owner_id, - default_worktree=Project.default_worktree_candidate(), - ) - if default_project is None or default_project.path_status != "available": + fallback_directory = Instance.get_directory() or os.getcwd() + fallback_path = Path(fallback_directory).expanduser() + if not fallback_path.is_dir() or not os.access(fallback_path, os.R_OK | os.X_OK): raise HTTPException( status_code=status.HTTP_409_CONFLICT, - detail="The session directory and default project directory are unavailable", + detail="The session directory and current request directory are unavailable", ) + fallback_directory = str(fallback_path.resolve()) log.warn( "session.directory.fallback", { "sessionID": session.id, "stored_directory": session.directory, - "fallback_directory": default_project.worktree, + "fallback_directory": fallback_directory, }, ) try: @@ -495,7 +491,7 @@ async def _resolve_session_working_directory(session: SessionModel) -> str: "sessionID": session.id, "kind": "directory-fallback", "storedDirectory": session.directory, - "fallbackDirectory": default_project.worktree, + "fallbackDirectory": fallback_directory, }, ) except Exception as exc: @@ -503,7 +499,7 @@ async def _resolve_session_working_directory(session: SessionModel) -> str: "session.directory.fallback_notice_failed", {"sessionID": session.id, "error": str(exc)}, ) - return default_project.worktree + return fallback_directory async def _publish_context_usage_update( @@ -594,7 +590,11 @@ async def list_sessions( """List all sessions with optional filters""" started_at = time.perf_counter() current_user = require_user(request) - from flocks.project.project import DEFAULT_PROJECT_ID, Project + from flocks.project.project import ( + DEFAULT_PROJECT_ID, + Project, + TASK_SESSION_GROUP_ID, + ) list_started_at = time.perf_counter() all_sessions = await Session.list_all_unfiltered() list_elapsed_ms = (time.perf_counter() - list_started_at) * 1000 @@ -621,9 +621,14 @@ async def list_sessions( effective_project_id = ( session.project_id if session.project_id in visible_project_ids - else DEFAULT_PROJECT_ID + else TASK_SESSION_GROUP_ID + ) + requested_group_id = ( + TASK_SESSION_GROUP_ID + if projectID == DEFAULT_PROJECT_ID + else projectID ) - if projectID is not None and effective_project_id != projectID: + if requested_group_id is not None and effective_project_id != requested_group_id: continue if (roots or manager) and session.parent_id: continue @@ -706,25 +711,37 @@ async def create_session(http_request: Request, request: Optional[SessionCreateR if request is None: request = SessionCreateRequest() - from flocks.project.project import DEFAULT_PROJECT_ID, Project - - project_id = request.projectID or DEFAULT_PROJECT_ID - project = await Project.get( - project_id, - owner_id=current_user.id, - default_worktree=Project.default_worktree_candidate(), + from flocks.project.instance import Instance + from flocks.project.project import ( + DEFAULT_PROJECT_ID, + Project, + TASK_SESSION_GROUP_ID, ) - if not project: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Project {project_id} not found", - ) - if project.path_status != "available": - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Project directory is {project.path_status}", - ) - directory = project.worktree + + directory = Instance.get_directory() or os.getcwd() + instance_project = Instance.get_project() + project_id = instance_project.id if instance_project else DEFAULT_PROJECT_ID + + # "default" is accepted for compatibility with older WebUI clients, but + # ordinary sessions use the current request context rather than a virtual + # project's worktree. + if request.projectID and request.projectID not in { + DEFAULT_PROJECT_ID, + TASK_SESSION_GROUP_ID, + }: + project = await Project.get(request.projectID, owner_id=current_user.id) + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project {request.projectID} not found", + ) + if project.path_status != "available": + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Project directory is {project.path_status}", + ) + project_id = project.id + directory = project.worktree # Trigger command:new hook if creating from parent (like /new command) if request.parentID: @@ -944,12 +961,17 @@ async def update_session_todos(sessionID: str, todos: List[TodoInfo], request: R async def delete_session(sessionID: str, request: Request) -> bool: """Delete session by ID (returns true)""" current_user = require_user(request) - session = await _get_session_by_id_unfiltered(sessionID) + return await delete_session_for_user(sessionID, current_user) + + +async def delete_session_for_user(session_id: str, current_user: AuthUser) -> bool: + """Delete one session using the normal lifecycle cleanup.""" + session = await _get_session_by_id_unfiltered(session_id) if not session: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session {sessionID} not found" + detail=f"Session {session_id} not found" ) if not SessionPolicy.can_delete(session, current_user): @@ -958,12 +980,12 @@ async def delete_session(sessionID: str, request: Request) -> bool: from flocks.session.goal import GoalManager from flocks.session.interaction_queue import InteractionQueue - await _abort_session_processing(sessionID) - await InteractionQueue.clear(sessionID) - await GoalManager.clear(sessionID) - await _wait_for_sessions_idle([sessionID]) - await _abort_and_wait_descendant_sessions(session.project_id, sessionID) - await Session.delete(session.project_id, sessionID) + await _abort_session_processing(session_id) + await InteractionQueue.clear(session_id) + await GoalManager.clear(session_id) + await _wait_for_sessions_idle([session_id]) + await _abort_and_wait_descendant_sessions(session.project_id, session_id) + await Session.delete(session.project_id, session_id) from flocks.project.project import Project Project.invalidate_session_stats() @@ -976,20 +998,20 @@ async def delete_session(sessionID: str, request: Request) -> bool: # the upload cleanup is incidental. try: import shutil - uploads_root = _session_uploads_dir(sessionID) + uploads_root = _session_uploads_dir(session_id) if uploads_root.exists() and uploads_root.is_dir(): shutil.rmtree(uploads_root, ignore_errors=True) log.info("session.uploads.cleaned", { - "session_id": sessionID, + "session_id": session_id, "path": str(uploads_root), }) except Exception as exc: log.warn("session.uploads.cleanup_failed", { - "session_id": sessionID, + "session_id": session_id, "error": str(exc), }) - log.info("session.deleted", {"session_id": sessionID}) + log.info("session.deleted", {"session_id": session_id}) try: await emit_audit_event( "session_action", @@ -999,7 +1021,7 @@ async def delete_session(sessionID: str, request: Request) -> bool: "actor_name": current_user.username, "user_name": current_user.username, "username": current_user.username, - "session_id": sessionID, + "session_id": session_id, "owner_user_id": current_user.id, "project_id": session.project_id, }, diff --git a/flocks/skill/skill.py b/flocks/skill/skill.py index 3b6be7b08..9ac60b57b 100644 --- a/flocks/skill/skill.py +++ b/flocks/skill/skill.py @@ -204,23 +204,41 @@ class Skill: """ _cache: Optional[Dict[str, SkillInfo]] = None + _cache_key: Optional[tuple[str, str]] = None _cache_lock = threading.Lock() _cache_generation = 0 - _cache_loads: Dict[int, threading.Event] = {} + _cache_loads: Dict[tuple[int, tuple[str, str]], threading.Event] = {} + + @staticmethod + def _source_root() -> Path: + """Return the Flocks source installation root.""" + return Path(__file__).resolve().parents[2] + + @staticmethod + def _cache_context() -> tuple[str, str]: + """Return the normalized request context that scopes discovery.""" + current_dir = Instance.get_directory() or os.getcwd() + worktree = Instance.get_worktree() or current_dir + return ( + os.path.normcase(os.path.realpath(current_dir)), + os.path.normcase(os.path.realpath(worktree)), + ) @classmethod def _all_sync(cls) -> List[SkillInfo]: - """Discover skills once per cache generation without stale refills.""" + """Discover skills once per request context and cache generation.""" + context_key = cls._cache_context() while True: with cls._cache_lock: - if cls._cache is not None: + if cls._cache is not None and cls._cache_key == context_key: return list(cls._cache.values()) generation = cls._cache_generation - completion = cls._cache_loads.get(generation) + load_key = (generation, context_key) + completion = cls._cache_loads.get(load_key) should_discover = completion is None if completion is None: completion = threading.Event() - cls._cache_loads[generation] = completion + cls._cache_loads[load_key] = completion if not should_discover: completion.wait() @@ -230,16 +248,21 @@ def _all_sync(cls) -> List[SkillInfo]: discovered = cls._discover() except BaseException: with cls._cache_lock: - cls._cache_loads.pop(generation, None) + cls._cache_loads.pop(load_key, None) completion.set() raise with cls._cache_lock: - if generation == cls._cache_generation and cls._cache is None: + if generation == cls._cache_generation: cls._cache = discovered - cls._cache_loads.pop(generation, None) + cls._cache_key = context_key + cls._cache_loads.pop(load_key, None) completion.set() - if generation == cls._cache_generation and cls._cache is not None: + if ( + generation == cls._cache_generation + and cls._cache is not None + and cls._cache_key == context_key + ): return list(cls._cache.values()) # The cache was invalidated while discovery was running. Repeat # against the new generation instead of publishing stale data. @@ -425,10 +448,11 @@ def _discover(cls) -> Dict[str, SkillInfo]: Discover all skills. Last wins on name collision. Scan order (lowest → highest priority): - 1. .claude dirs - 2. ~/.flocks/skill[s] built-ins - 3. /.flocks project-bundled skills - 4. ~/.flocks/plugins user customizations + 1. .claude dirs (global ~/.claude + project-level) + 2. ~/.flocks/skill[s] (global built-ins) + 3. /.flocks (bundled with the Flocks installation) + 4. /.flocks (project-level) + 5. ~/.flocks/plugins (user customizations) Source labels: "flocks" — built-in skills inside .flocks/skills/ directories @@ -452,6 +476,7 @@ def _discover(cls) -> Dict[str, SkillInfo]: "plugins/skills/**/SKILL.md", ) global_flocks = os.path.join(home_dir, ".flocks") + source_flocks = str(cls._source_root() / ".flocks") # 1) .claude directories — lowest priority global_claude = os.path.join(home_dir, ".claude") @@ -465,16 +490,29 @@ def _discover(cls) -> Dict[str, SkillInfo]: for pattern in builtin_patterns: cls._scan_directory(global_flocks, pattern, skills, source="flocks") - # 3) Project-bundled .flocks skills — built-in baseline for this project + # 3) Source installation .flocks — available regardless of request cwd. + # Keep plugin skills labelled "project" for the current Skill UI's + # built-in/custom classification. + if os.path.normpath(source_flocks) != os.path.normpath(global_flocks): + for pattern in builtin_patterns: + cls._scan_directory(source_flocks, pattern, skills, source="flocks") + for pattern in plugin_patterns: + cls._scan_directory(source_flocks, pattern, skills, source="project") + + # 4) Project-level .flocks — highest priority + # Built-in skills: source="flocks"; project-installed plugins: source="project" for flocks_dir in cls._find_dirs_up(".flocks", current_dir, worktree): - if os.path.normpath(flocks_dir) == os.path.normpath(global_flocks): + if os.path.normpath(flocks_dir) in { + os.path.normpath(global_flocks), + os.path.normpath(source_flocks), + }: continue for pattern in builtin_patterns: cls._scan_directory(flocks_dir, pattern, skills, source="flocks") for pattern in plugin_patterns: cls._scan_directory(flocks_dir, pattern, skills, source="project") - # 4) User-installed plugins — explicit customizations win over project bundles + # 5) User-installed plugins — explicit customizations win over project bundles if os.path.isdir(global_flocks): for pattern in plugin_patterns: cls._scan_directory(global_flocks, pattern, skills, source="user") @@ -554,6 +592,7 @@ def clear_cache(cls) -> None: """Clear the skill cache (for testing or forced refresh)""" with cls._cache_lock: cls._cache = None + cls._cache_key = None cls._cache_generation += 1 log.info("skill.cache.cleared") diff --git a/tests/project/test_project.py b/tests/project/test_project.py index d5c931414..bd9e5e7a2 100644 --- a/tests/project/test_project.py +++ b/tests/project/test_project.py @@ -11,6 +11,7 @@ ProjectDeletionError, ProjectNameConflictError, ProjectPathConflictError, + TASK_SESSION_GROUP_ID, ) from flocks.storage.storage import Storage @@ -18,16 +19,13 @@ @pytest.fixture def project_root(tmp_path, monkeypatch): root = tmp_path / ".flocks" - workspace = tmp_path / "workspace" - workspace.mkdir() monkeypatch.setenv("FLOCKS_ROOT", str(root)) monkeypatch.setenv("FLOCKS_PROJECT_ROOTS", str(tmp_path)) - monkeypatch.setenv("FLOCKS_DEFAULT_PROJECT_DIR", str(workspace)) return tmp_path @pytest.mark.asyncio -async def test_list_projects_uses_json_registry_and_virtual_default(project_root): +async def test_list_projects_uses_json_registry_without_virtual_default(project_root): labs = project_root / "labs" labs.mkdir() created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) @@ -35,14 +33,15 @@ async def test_list_projects_uses_json_registry_and_virtual_default(project_root with patch.object(Storage, "list_entries", new=AsyncMock()) as list_entries: projects = await Project.list(owner_id="user-1") - assert [project.id for project in projects] == [DEFAULT_PROJECT_ID, created.id] - assert projects[0].name == "默认" - assert projects[0].is_default is True - assert projects[1].worktree == str(labs.resolve()) + assert [project.id for project in projects] == [created.id] + assert projects[0].worktree == str(labs.resolve()) + assert projects[0].is_default is False + assert await Project.get(DEFAULT_PROJECT_ID, owner_id="user-1") is None list_entries.assert_not_awaited() registry = json.loads(Project.registry_path("user-1").read_text(encoding="utf-8")) - assert registry["defaultWorktree"] == str((project_root / "workspace").resolve()) + assert "defaultWorktree" not in registry + assert not (project_root / "workspace").exists() assert registry["projects"][0]["id"].startswith("prj_") uuid.UUID(registry["projects"][0]["id"].removeprefix("prj_")) @@ -57,7 +56,7 @@ async def test_create_rejects_duplicate_canonical_directory(project_root): await Project.create(owner_id="user-1", name="Other", worktree=str(labs / ".")) assert exc_info.value.project.id == existing.id - assert len((await Project.list(owner_id="user-1"))) == 2 + assert len((await Project.list(owner_id="user-1"))) == 1 @pytest.mark.asyncio @@ -139,16 +138,16 @@ async def test_delete_rejects_default_project(project_root): @pytest.mark.asyncio -async def test_effective_project_maps_legacy_and_removed_projects_to_default(project_root): +async def test_effective_project_maps_legacy_and_removed_projects_to_tasks(project_root): labs = project_root / "labs" labs.mkdir() created = await Project.create(owner_id="user-1", name="Labs", worktree=str(labs)) assert Project.effective_project_id("user-1", created.id) == created.id - assert Project.effective_project_id("user-1", "old-git-project") == DEFAULT_PROJECT_ID + assert Project.effective_project_id("user-1", "old-git-project") == TASK_SESSION_GROUP_ID await Project.delete(created.id, owner_id="user-1") - assert Project.effective_project_id("user-1", created.id) == DEFAULT_PROJECT_ID + assert Project.effective_project_id("user-1", created.id) == TASK_SESSION_GROUP_ID @pytest.mark.asyncio @@ -178,7 +177,7 @@ async def test_concurrent_project_creates_keep_every_registry_entry(project_root )) projects = await Project.list(owner_id="user-1") - assert {project.id for project in projects[1:]} == {project.id for project in created} + assert {project.id for project in projects} == {project.id for project in created} @pytest.mark.asyncio @@ -194,12 +193,12 @@ async def test_registry_writes_do_not_create_backup(project_root): @pytest.mark.asyncio -async def test_corrupt_registry_only_returns_default(project_root): +async def test_corrupt_registry_returns_no_projects(project_root): await Project.ensure_registry("user-1") registry_path = Project.registry_path("user-1") registry_path.write_text("{broken", encoding="utf-8") projects = await Project.list(owner_id="user-1") - assert [project.id for project in projects] == [DEFAULT_PROJECT_ID] + assert projects == [] assert registry_path.read_text(encoding="utf-8") == "{broken" diff --git a/tests/server/routes/test_project_routes.py b/tests/server/routes/test_project_routes.py index a154a2244..f147ebf13 100644 --- a/tests/server/routes/test_project_routes.py +++ b/tests/server/routes/test_project_routes.py @@ -30,10 +30,8 @@ async def test_project_crud_uses_registry_without_project_database_rows( list_response = await client.get("/api/project") assert list_response.status_code == status.HTTP_200_OK projects = list_response.json() - assert projects[0]["id"] == "default" - assert projects[0]["name"] == "默认" - assert projects[0]["isDefault"] is True - assert [item["id"] for item in projects[1:]] == [project["id"]] + assert [item["id"] for item in projects] == [project["id"]] + assert all(item["isDefault"] is False for item in projects) rename_response = await client.patch( f"/api/project/{project['id']}", @@ -42,10 +40,21 @@ async def test_project_crud_uses_registry_without_project_database_rows( assert rename_response.status_code == status.HTTP_200_OK assert rename_response.json()["name"] == "Security Labs" + retained_file = worktree / "keep.txt" + retained_file.write_text("project files stay", encoding="utf-8") + session_response = await client.post( + "/api/session", + json={"title": "Delete with project", "projectID": project["id"]}, + ) + assert session_response.status_code == status.HTTP_200_OK + session_id = session_response.json()["id"] + delete_response = await client.delete(f"/api/project/{project['id']}") assert delete_response.status_code == status.HTTP_200_OK assert worktree.exists() - assert [item["id"] for item in (await client.get("/api/project")).json()] == ["default"] + assert retained_file.read_text(encoding="utf-8") == "project files stay" + assert (await client.get("/api/project")).json() == [] + assert (await client.get(f"/api/session/{session_id}")).status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio @@ -85,7 +94,7 @@ async def test_duplicate_project_directory_returns_existing_project( assert first.status_code == status.HTTP_200_OK assert second.status_code == status.HTTP_200_OK assert second.json()["id"] == first.json()["id"] - assert len((await client.get("/api/project")).json()) == 2 + assert len((await client.get("/api/project")).json()) == 1 @pytest.mark.asyncio @@ -212,6 +221,7 @@ async def test_project_local_sharing_includes_existing_and_future_sessions( async def test_project_counts_and_session_lists_are_user_scoped( client: AsyncClient, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ): from flocks.server.routes import project as project_routes from flocks.server.routes import session as session_routes @@ -219,8 +229,20 @@ async def test_project_counts_and_session_lists_are_user_scoped( alice = AuthUser(id="usr_alice", username="alice", role="member", status="active") bob = AuthUser(id="usr_bob", username="bob", role="member", status="active") + alice_worktree = tmp_path / "alice-project" + alice_worktree.mkdir() + monkeypatch.setattr(project_routes, "require_user", lambda _request: alice) + project_response = await client.post( + "/api/project", + json={"name": "Alice Project", "worktree": str(alice_worktree)}, + ) + alice_project = project_response.json() + monkeypatch.setattr(session_routes, "require_user", lambda _request: alice) - alice_session = await client.post("/api/session", json={"title": "Alice session"}) + alice_session = await client.post( + "/api/session", + json={"title": "Alice session", "projectID": alice_project["id"]}, + ) assert alice_session.status_code == status.HTTP_200_OK monkeypatch.setattr(session_routes, "require_user", lambda _request: bob) @@ -228,15 +250,13 @@ async def test_project_counts_and_session_lists_are_user_scoped( assert bob_session.status_code == status.HTTP_200_OK monkeypatch.setattr(session_routes, "require_user", lambda _request: alice) - monkeypatch.setattr(project_routes, "require_user", lambda _request: alice) - projects = (await client.get("/api/project")).json() - assert projects[0]["id"] == "default" + assert projects[0]["id"] == alice_project["id"] assert projects[0]["sessionCount"] == 1 second_alice_session = await client.post( "/api/session", - json={"title": "Second Alice session"}, + json={"title": "Second Alice session", "projectID": alice_project["id"]}, ) assert second_alice_session.status_code == status.HTTP_200_OK refreshed_projects = (await client.get("/api/project")).json() @@ -257,10 +277,19 @@ async def test_project_counts_and_session_lists_are_user_scoped( @pytest.mark.asyncio async def test_session_title_update_invalidates_project_search_stats( client: AsyncClient, + tmp_path: Path, ): + worktree = tmp_path / "search-project" + worktree.mkdir() + project = ( + await client.post( + "/api/project", + json={"name": "Search Project", "worktree": str(worktree)}, + ) + ).json() created = await client.post( "/api/session", - json={"title": "Initial title"}, + json={"title": "Initial title", "projectID": project["id"]}, ) assert created.status_code == status.HTTP_200_OK diff --git a/tests/server/routes/test_session_routes.py b/tests/server/routes/test_session_routes.py index 6cbfffeee..05d0470f1 100644 --- a/tests/server/routes/test_session_routes.py +++ b/tests/server/routes/test_session_routes.py @@ -37,19 +37,14 @@ @pytest.mark.asyncio -async def test_missing_session_directory_uses_default_and_publishes_notice( +async def test_missing_session_directory_uses_cwd_and_publishes_notice( tmp_path, monkeypatch: pytest.MonkeyPatch, ): - from flocks.project.project import Project from flocks.server.routes import event as event_routes from flocks.server.routes import session as session_routes - default_project = SimpleNamespace( - worktree=str(tmp_path), - path_status="available", - ) - monkeypatch.setattr(Project, "get", AsyncMock(return_value=default_project)) + monkeypatch.chdir(tmp_path) publish_event = AsyncMock() monkeypatch.setattr(event_routes, "publish_event", publish_event) @@ -84,6 +79,23 @@ async def test_create_session_minimal(self, client: AsyncClient): assert "projectID" in data assert "directory" in data + @pytest.mark.asyncio + async def test_create_ordinary_session_uses_process_cwd( + self, + client: AsyncClient, + tmp_path, + monkeypatch: pytest.MonkeyPatch, + ): + """A task session must not resolve through a virtual default project.""" + process_cwd = tmp_path / "server-cwd" + process_cwd.mkdir() + monkeypatch.chdir(process_cwd) + + response = await client.post("/api/session", json={}) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["directory"] == str(process_cwd) + @pytest.mark.asyncio async def test_create_session_with_title(self, client: AsyncClient): """Created session reflects the provided title.""" @@ -325,12 +337,12 @@ async def test_create_session_in_user_managed_project( assert row["directory"] == project["worktree"] @pytest.mark.asyncio - async def test_legacy_session_is_grouped_under_default_without_rewrite( + async def test_legacy_session_is_grouped_under_tasks_without_rewrite( self, client: AsyncClient, tmp_path, ): - """Legacy project IDs are projected to Default while storage stays unchanged.""" + """Legacy project IDs are projected to Tasks while storage stays unchanged.""" legacy = await Session.create( project_id="legacy-git-project", directory=str(tmp_path), @@ -343,25 +355,25 @@ async def test_legacy_session_is_grouped_under_default_without_rewrite( "view": "list", "manager": "true", "roots": "true", - "projectID": "default", + "projectID": "tasks", }, ) assert response.status_code == status.HTTP_200_OK row = next(item for item in response.json() if item["id"] == legacy.id) assert row["projectID"] == "legacy-git-project" - assert row["effectiveProjectID"] == "default" + assert row["effectiveProjectID"] == "tasks" stored = await Session.get("legacy-git-project", legacy.id) assert stored is not None assert stored.project_id == "legacy-git-project" @pytest.mark.asyncio - async def test_removed_project_sessions_fall_back_to_default( + async def test_deleted_project_sessions_are_removed( self, client: AsyncClient, tmp_path, ): - """Removing a project keeps its sessions and maps them to Default.""" + """Deleting a project removes its sessions but preserves its directory.""" worktree = tmp_path / "removable-project" worktree.mkdir() project_response = await client.post( @@ -379,15 +391,15 @@ async def test_removed_project_sessions_fall_back_to_default( assert delete_response.status_code == status.HTTP_200_OK assert worktree.exists() - default_response = await client.get( + session_get_response = await client.get(f"/api/session/{session_id}") + assert session_get_response.status_code == status.HTTP_404_NOT_FOUND + + tasks_response = await client.get( "/api/session", - params={"view": "list", "manager": "true", "projectID": "default"}, + params={"view": "list", "manager": "true", "projectID": "tasks"}, ) - row = next(item for item in default_response.json() if item["id"] == session_id) - assert row["projectID"] == project["id"] - assert row["effectiveProjectID"] == "default" - stored = await Session.get(project["id"], session_id) - assert stored is not None + assert all(item["id"] != session_id for item in tasks_response.json()) + assert await Session.get(project["id"], session_id) is None @pytest.mark.asyncio async def test_get_session(self, client: AsyncClient, session_id: str): diff --git a/tests/server/routes/test_skill_routes.py b/tests/server/routes/test_skill_routes.py new file mode 100644 index 000000000..2148dbf7e --- /dev/null +++ b/tests/server/routes/test_skill_routes.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import pytest +from httpx import AsyncClient + +from flocks.skill.skill import Skill + + +@pytest.mark.asyncio +async def test_ordinary_request_keeps_bundled_skills_visible( + client: AsyncClient, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bundled skills stay visible when an ordinary request starts elsewhere.""" + ordinary_cwd = tmp_path / "ordinary-cwd" + ordinary_cwd.mkdir() + monkeypatch.chdir(ordinary_cwd) + Skill.clear_cache() + + response = await client.get("/api/skills") + + assert response.status_code == 200 + source_skills_root = Path(Skill._source_root()) / ".flocks" + bundled_skills = [ + skill + for skill in response.json() + if Path(skill["location"]).is_relative_to(source_skills_root) + ] + assert bundled_skills + assert any(skill["source"] == "project" for skill in bundled_skills) diff --git a/tests/server/test_asgi_middleware.py b/tests/server/test_asgi_middleware.py index 227545326..3644f8557 100644 --- a/tests/server/test_asgi_middleware.py +++ b/tests/server/test_asgi_middleware.py @@ -382,3 +382,40 @@ async def send(_message): await asyncio.gather(*requests, return_exceptions=True) assert len(cleared_tokens) == 11 + + +@pytest.mark.asyncio +async def test_instance_context_without_directory_uses_process_cwd( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +) -> None: + """Ordinary requests keep the pre-project-management cwd behavior.""" + from flocks.project.instance import Instance + + process_cwd = tmp_path / "server-cwd" + process_cwd.mkdir() + monkeypatch.chdir(process_cwd) + provided_directories: list[str] = [] + + async def provide(*, directory, init, fn): + provided_directories.append(directory) + return await fn() + + async def endpoint(_scope, _receive, _send) -> None: + return None + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(_message): + return None + + monkeypatch.setattr(Instance, "provide", provide) + + await server_app._InstanceContextMiddleware(endpoint)( + _http_scope("/api/skill"), + receive, + send, + ) + + assert provided_directories == [str(process_cwd)] diff --git a/tests/skill/test_skill.py b/tests/skill/test_skill.py index c993c511f..045253ab0 100644 --- a/tests/skill/test_skill.py +++ b/tests/skill/test_skill.py @@ -368,6 +368,71 @@ async def test_discover_project_source_for_project_plugins(tmp_path): assert skill.source == "project", f"expected 'project', got {skill.source!r}" +@pytest.mark.asyncio +async def test_discover_source_root_plugins_from_unrelated_cwd(tmp_path): + """Bundled source-root skills remain visible outside the source checkout.""" + source_root = tmp_path / "source" + ordinary_cwd = tmp_path / "ordinary-cwd" + ordinary_cwd.mkdir() + bundled_skill = source_root / ".flocks" / "plugins" / "skills" / "bundled-skill" + _write_skill_md(bundled_skill / "SKILL.md", "bundled-skill") + + module_file = source_root / "flocks" / "skill" / "skill.py" + with ( + patch("os.path.expanduser", return_value=str(tmp_path / "home")), + patch("flocks.skill.skill.__file__", str(module_file)), + patch( + "flocks.skill.skill.Instance.get_directory", + return_value=str(ordinary_cwd), + ), + patch( + "flocks.skill.skill.Instance.get_worktree", + return_value=str(ordinary_cwd), + ), + ): + skills = await Skill.all() + + skill = next((item for item in skills if item.name == "bundled-skill"), None) + assert skill is not None, "source-root bundled skill not discovered" + assert skill.source == "project" + + +@pytest.mark.asyncio +async def test_skill_cache_does_not_leak_across_project_contexts(tmp_path): + """An ordinary-session discovery must not poison a project lookup.""" + fake_home = tmp_path / "home" + ordinary_cwd = tmp_path / "ordinary-cwd" + project_dir = tmp_path / "project" + source_root = tmp_path / "source" + ordinary_cwd.mkdir() + project_dir.mkdir() + _write_skill_md( + project_dir / ".flocks" / "plugins" / "skills" / "project-only" / "SKILL.md", + "project-only", + ) + + context = {"directory": str(ordinary_cwd)} + module_file = source_root / "flocks" / "skill" / "skill.py" + with ( + patch("os.path.expanduser", return_value=str(fake_home)), + patch("flocks.skill.skill.__file__", str(module_file)), + patch( + "flocks.skill.skill.Instance.get_directory", + side_effect=lambda: context["directory"], + ), + patch( + "flocks.skill.skill.Instance.get_worktree", + side_effect=lambda: context["directory"], + ), + ): + ordinary_skills = await Skill.all() + context["directory"] = str(project_dir) + project_skills = await Skill.all() + + assert "project-only" not in {skill.name for skill in ordinary_skills} + assert "project-only" in {skill.name for skill in project_skills} + + @pytest.mark.asyncio async def test_discover_flocks_source_for_builtin_skills(tmp_path): """Skills under .flocks/skills/ (not plugins/) must get source='flocks'.""" diff --git a/webui/src/locales/en-US/session.json b/webui/src/locales/en-US/session.json index 4d8acb9ba..2263f1f67 100644 --- a/webui/src/locales/en-US/session.json +++ b/webui/src/locales/en-US/session.json @@ -285,8 +285,8 @@ "copyPathAction": "Copy path", "deleteAction": "Delete", "deleteTitle": "Delete Project", - "deleteDescription": "Remove project “{{project}}”? Conversations and files will be kept, and its conversations will move to Default.", - "confirmDelete": "Remove", + "deleteDescription": "Delete project “{{project}}”? Its conversations will be deleted, but the project folder and files will be kept.", + "confirmDelete": "Delete", "defaultDeleteDisabled": "The default project cannot be deleted", "deleteSuccess": "Project deleted", "deleteFailed": "Failed to delete project", diff --git a/webui/src/locales/zh-CN/session.json b/webui/src/locales/zh-CN/session.json index 322af418f..6e2ff9a60 100644 --- a/webui/src/locales/zh-CN/session.json +++ b/webui/src/locales/zh-CN/session.json @@ -285,8 +285,8 @@ "copyPathAction": "复制路径", "deleteAction": "删除", "deleteTitle": "删除项目", - "deleteDescription": "确定移除项目“{{project}}”吗?不会删除会话和项目文件夹,原项目中的会话将归入默认项目。", - "confirmDelete": "移除", + "deleteDescription": "确定删除项目“{{project}}”吗?项目中的会话将被删除,但项目文件夹和文件会保留。", + "confirmDelete": "删除", "defaultDeleteDisabled": "默认项目不能删除", "deleteSuccess": "项目已删除", "deleteFailed": "删除项目失败", diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index d1a0a8045..0b2b857ed 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -360,7 +360,7 @@ describe('SessionPage session actions menu', () => { expect(tasksSection?.parentElement).toBe(projectsSection?.parentElement); expect(projectsSection).not.toContainElement(tasksHeading); expect(useSessions).toHaveBeenLastCalledWith('', { - projectIds: ['default'], + projectIds: ['tasks'], pageSize: 20, }); expect(screen.queryByText('defaultProjectName')).not.toBeInTheDocument(); @@ -383,7 +383,6 @@ describe('SessionPage session actions menu', () => { await waitFor(() => { expect(client.post).toHaveBeenCalledWith('/api/session', { title: 'New Session', - projectID: 'default', }); }); }); @@ -471,7 +470,7 @@ describe('SessionPage session actions menu', () => { await screen.findByText('Labs'); expect(useSessions).toHaveBeenLastCalledWith('', { - projectIds: ['default', 'prj_labs'], + projectIds: ['tasks', 'prj_labs'], pageSize: 6, }); }); diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index 4c7454d9a..f9b27aba7 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,6 +1,6 @@ import { memo, useState, useEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; import { - MessageSquare, Plus, Trash2, + Plus, Trash2, ChevronDown, ChevronRight, Sparkles, Shield, Search, AlertTriangle, PanelLeftClose, PanelLeft, Bot, Loader2, Workflow as WorkflowIcon, Settings2, CheckSquare, @@ -48,6 +48,7 @@ const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; const SOC_WORKSPACE_COMPONENT_ID = 'soc-workspace'; const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable']); const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; +const TASK_SESSION_GROUP_ID = 'tasks'; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; type ProjectSummary = { id: string; @@ -71,7 +72,6 @@ type ProjectSessionGroup = { worktree: string; sessions: Session[]; sessionCount: number; - isDefault: boolean; pathStatus: 'available' | 'missing' | 'unreadable'; canWrite: boolean; canDelete: boolean; @@ -394,7 +394,7 @@ export default function SessionPage() { const toast = useToast(); const sessionProjectIds = useMemo( - () => projects.map((project) => project.id), + () => [TASK_SESSION_GROUP_ID, ...projects.map((project) => project.id)], [projects], ); const { @@ -410,7 +410,7 @@ export default function SessionPage() { loadMore: loadMoreSessions, } = useSessions(searchQuery, { projectIds: sessionProjectIds, - pageSize: projects.length >= 2 + pageSize: projects.length >= 1 ? MULTI_PROJECT_SESSION_PAGE_SIZE : SINGLE_PROJECT_SESSION_PAGE_SIZE, }); @@ -558,37 +558,27 @@ export default function SessionPage() { } }, [projectsSectionCollapsed, projectsSectionCollapsedStorageKey]); - const defaultProjectId = useMemo( - () => projects.find((project) => project.isDefault)?.id ?? projects[0]?.id ?? null, - [projects], - ); - const projectSessionGroups = useMemo(() => { const registeredIds = new Set(projects.map((project) => project.id)); const sessionsByProject = new Map(); sessions.forEach((session) => { const responseProjectId = session.effectiveProjectID || session.projectID; - const projectId = registeredIds.has(responseProjectId) - ? responseProjectId - : defaultProjectId; - if (!projectId) return; - const groupSessions = sessionsByProject.get(projectId) ?? []; + if (!registeredIds.has(responseProjectId)) return; + const groupSessions = sessionsByProject.get(responseProjectId) ?? []; groupSessions.push(session); - sessionsByProject.set(projectId, groupSessions); + sessionsByProject.set(responseProjectId, groupSessions); }); return projects.map((project) => { - const isDefault = project.isDefault ?? project.id === defaultProjectId; const projectSessions = sessionsByProject.get(project.id) ?? []; return { id: project.id, - label: isDefault ? t('defaultProjectName') : getProjectLabel(project), + label: getProjectLabel(project), worktree: project.worktree, sessions: projectSessions, sessionCount: searchQuery.trim() ? project.matchedSessionCount ?? projectSessions.length : project.sessionCount ?? projectSessions.length, - isDefault, pathStatus: project.pathStatus ?? 'available', canWrite: project.canWrite ?? true, canDelete: project.canDelete ?? true, @@ -597,46 +587,49 @@ export default function SessionPage() { }) .filter((group) => { if (!searchQuery.trim()) return true; - if (group.isDefault) return true; const project = projects.find((item) => item.id === group.id); return (project?.matchedSessionCount ?? group.sessions.length) > 0 || group.id === selectedProjectId; }) .sort((a, b) => { - if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1; const aLatest = projects.find((project) => project.id === a.id)?.lastActivityAt ?? a.sessions[0]?.time?.updated ?? 0; const bLatest = projects.find((project) => project.id === b.id)?.lastActivityAt ?? b.sessions[0]?.time?.updated ?? 0; if (aLatest !== bLatest) return bLatest - aLatest; return a.label.localeCompare(b.label); }); - }, [defaultProjectId, projects, searchQuery, selectedProjectId, sessions, t]); + }, [projects, searchQuery, selectedProjectId, sessions]); - const managedProjectSessionGroups = useMemo( - () => projectSessionGroups.filter((group) => !group.isDefault), - [projectSessionGroups], - ); + const managedProjectSessionGroups = projectSessionGroups; const taskSessionGroup = useMemo( - () => projectSessionGroups.find((group) => group.isDefault) ?? null, - [projectSessionGroups], + () => { + const registeredIds = new Set(projects.map((project) => project.id)); + const taskSessions = sessions.filter((session) => ( + !registeredIds.has(session.effectiveProjectID || session.projectID) + )); + return { + id: TASK_SESSION_GROUP_ID, + label: t('tasksSection'), + worktree: '', + sessions: taskSessions, + sessionCount: taskSessions.length, + pathStatus: 'available' as const, + }; + }, + [projects, sessions, t], ); - const taskGroupCollapsed = taskSessionGroup - ? collapsedProjectIds.has(taskSessionGroup.id) - : false; - const taskGroupSelected = taskSessionGroup?.id === selectedProjectId; - const hasMoreTaskSessions = taskSessionGroup - ? projects.length >= 2 - ? taskSessionGroup.sessions.length < taskSessionGroup.sessionCount - : hasMoreByProject[taskSessionGroup.id] - : false; - - const selectedProjectIDForCreate = selectedProjectId ?? defaultProjectId ?? projectSessionGroups[0]?.id ?? null; + const taskGroupCollapsed = collapsedProjectIds.has(TASK_SESSION_GROUP_ID); + const taskGroupSelected = selectedProjectId === TASK_SESSION_GROUP_ID; + const hasMoreTaskSessions = hasMoreByProject[TASK_SESSION_GROUP_ID] ?? false; - useEffect(() => { - if (projectSessionGroups.length === 0) { - setSelectedProjectId(null); - return; - } + const selectedProjectIDForCreate = selectedProjectId && selectedProjectId !== TASK_SESSION_GROUP_ID + ? selectedProjectId + : null; - if (selectedProjectId && projectSessionGroups.some((group) => group.id === selectedProjectId)) { + useEffect(() => { + const selectableProjectIds = new Set([ + TASK_SESSION_GROUP_ID, + ...projectSessionGroups.map((group) => group.id), + ]); + if (selectedProjectId && selectableProjectIds.has(selectedProjectId)) { return; } @@ -646,16 +639,18 @@ export default function SessionPage() { } catch { storedProjectId = null; } - const fallbackProjectId = storedProjectId && projectSessionGroups.some((group) => group.id === storedProjectId) + const fallbackProjectId = storedProjectId && selectableProjectIds.has(storedProjectId) ? storedProjectId - : defaultProjectId && projectSessionGroups.some((group) => group.id === defaultProjectId) - ? defaultProjectId - : projectSessionGroups[0].id; + : TASK_SESSION_GROUP_ID; setSelectedProjectId(fallbackProjectId); - }, [activeProjectStorageKey, defaultProjectId, projectSessionGroups, selectedProjectId]); + }, [activeProjectStorageKey, projectSessionGroups, selectedProjectId]); useEffect(() => { - if (!selectedProjectId || !projects.some((project) => project.id === selectedProjectId)) return; + if ( + !selectedProjectId + || (selectedProjectId !== TASK_SESSION_GROUP_ID + && !projects.some((project) => project.id === selectedProjectId)) + ) return; try { window.localStorage.setItem(activeProjectStorageKey, selectedProjectId); } catch { @@ -674,7 +669,11 @@ export default function SessionPage() { params: { search: query.trim() || undefined }, }); if (requestSeq !== projectListRequestSeqRef.current) return; - const nextProjects = Array.isArray(listResult.data) ? listResult.data : []; + const nextProjects = Array.isArray(listResult.data) + ? listResult.data.filter((project: ProjectSummary) => ( + project.id !== TASK_SESSION_GROUP_ID && !project.isDefault + )) + : []; setProjects((currentProjects) => { if (!ensureProject?.id || nextProjects.some((project) => project.id === ensureProject.id)) { return nextProjects; @@ -902,7 +901,8 @@ export default function SessionPage() { const handleCreateSession = useCallback(async (projectIdOverride?: string) => { if (creating) return; - const projectID = projectIdOverride ?? selectedProjectIDForCreate; + const targetGroupId = projectIdOverride ?? selectedProjectId ?? TASK_SESSION_GROUP_ID; + const projectID = targetGroupId === TASK_SESSION_GROUP_ID ? null : targetGroupId; setCreating(true); try { const response = await client.post('/api/session', { @@ -912,14 +912,12 @@ export default function SessionPage() { addSession(response.data); await fetchProjects(undefined, searchQuery); setSelectedSessionFallback(response.data); - if (projectID) { - setSelectedProjectId(projectID); - setCollapsedProjectIds(prev => { - const next = new Set(prev); - next.delete(projectID); - return next; - }); - } + setSelectedProjectId(targetGroupId); + setCollapsedProjectIds(prev => { + const next = new Set(prev); + next.delete(targetGroupId); + return next; + }); setSelectedAgent('rex'); setSelectedModelKey(null); setSelectedSessionId(response.data.id); @@ -928,7 +926,7 @@ export default function SessionPage() { } finally { setCreating(false); } - }, [creating, selectedProjectIDForCreate, addSession, fetchProjects, searchQuery, toast, t]); + }, [creating, selectedProjectId, addSession, fetchProjects, searchQuery, toast, t]); const handleCreateSessionInProject = useCallback((projectId: string) => { void handleCreateSession(projectId); @@ -1239,7 +1237,7 @@ export default function SessionPage() { return next; }); setSelectedProjectId((current) => ( - current === projectPendingDelete.id ? defaultProjectId : current + current === projectPendingDelete.id ? TASK_SESSION_GROUP_ID : current )); setProjectPendingDelete(null); await Promise.all([ @@ -1252,7 +1250,7 @@ export default function SessionPage() { } finally { setProjectDeleting(false); } - }, [defaultProjectId, fetchProjects, projectDeleting, projectPendingDelete, refetchSessions, searchQuery, t, toast]); + }, [fetchProjects, projectDeleting, projectPendingDelete, refetchSessions, searchQuery, t, toast]); const handleSubmitProject = useCallback(async () => { if (!projectDialogMode || projectSubmitInFlightRef.current) return; @@ -1569,18 +1567,8 @@ export default function SessionPage() { {/* Session list */}
- {sessions.length === 0 && projectSessionGroups.length === 0 ? ( -
- -

{t('noSessions')}

-
- ) : projectSessionGroups.length === 0 ? ( -
-

{t('noResults', 'No conversations found')}

-
- ) : ( -
-
+
+
- {!isDefaultProject && group.canWrite && ( - + {group.canWrite && ( + )} - {!isDefaultProject && group.canWrite && ( - + {group.canWrite && ( + )} - {!isDefaultProject && group.canDelete && ( -
+ {group.canDelete && ( +
)} - {!isDefaultProject && group.canDelete && ( - + {group.canDelete && ( + )}
)} @@ -1816,7 +1804,7 @@ export default function SessionPage() { type="button" onClick={(event) => { event.stopPropagation(); - handleCreateSessionInProject(taskSessionGroup.id); + void handleCreateSession(TASK_SESSION_GROUP_ID); }} disabled={creating || taskSessionGroup.pathStatus !== 'available'} className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-zinc-600 dark:hover:bg-zinc-900 dark:hover:text-zinc-300" @@ -1873,8 +1861,7 @@ export default function SessionPage() { )}
)} -
- )} +
{/* Bottom:批量操作栏 / 批量选择入口 */} From 7b22834a63d488a6fc2b2cef8de430da757d6605 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 13:12:26 +0800 Subject: [PATCH 81/86] fix(question): prevent remote prompt loss --- .../src/components/common/SessionChat.test.ts | 31 +++++++++++++++++++ webui/src/components/common/SessionChat.tsx | 19 +++++++++++- .../session-chat/usePendingQuestions.ts | 16 +++++++++- webui/src/hooks/usePendingQuestions.test.ts | 25 +++++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/webui/src/components/common/SessionChat.test.ts b/webui/src/components/common/SessionChat.test.ts index de716c8fd..78cd0281e 100644 --- a/webui/src/components/common/SessionChat.test.ts +++ b/webui/src/components/common/SessionChat.test.ts @@ -2973,6 +2973,37 @@ describe('streaming activity helpers', () => { }); describe('SessionChat fallback polling', () => { + it('reconciles pending questions while the session is busy', async () => { + vi.useFakeTimers(); + try { + render(React.createElement(SessionChat, { + sessionId: 'sess-1', + live: true, + })); + + await act(async () => { + await Promise.resolve(); + }); + pendingQuestionsHookMock.fetchPendingQuestions.mockClear(); + + act(() => { + useSSEOptionsRef.current.onEvent({ + type: 'session.status', + properties: { sessionID: 'sess-1', status: { type: 'busy' } }, + }); + }); + pendingQuestionsHookMock.fetchPendingQuestions.mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2_000); + }); + + expect(pendingQuestionsHookMock.fetchPendingQuestions).toHaveBeenCalledWith('sess-1'); + } finally { + vi.useRealTimers(); + } + }); + it('does not finish streaming while fetched messages still contain a running tool', async () => { vi.useFakeTimers(); const refetch = vi.fn(); diff --git a/webui/src/components/common/SessionChat.tsx b/webui/src/components/common/SessionChat.tsx index c32b35d26..fa443b76d 100644 --- a/webui/src/components/common/SessionChat.tsx +++ b/webui/src/components/common/SessionChat.tsx @@ -1087,6 +1087,7 @@ export function getUserAvatarSpacerClassName(_compact: boolean): string { const ABORT_SSE_SETTLE_DELAY = 2000; const SCROLL_BOTTOM_THRESHOLD_PX = 80; const FALLBACK_POLL_MS = 5_000; +const PENDING_QUESTION_RECONCILE_MS = 2_000; const WORKSPACE_UPLOAD_DEST = 'uploads'; const FILE_INPUT_ACCEPT_DOCS = '.txt,.md,.json,.yaml,.yml,.xml,.csv,.pdf,.doc,.docx,.html,.htm,.ppt,.pptx,.xls,.xlsx'; const FILE_INPUT_ACCEPT_ALL = `${FILE_INPUT_ACCEPT_DOCS},${FILE_INPUT_ACCEPT_IMAGES}`; @@ -2122,6 +2123,19 @@ export default function SessionChat({ checkStatus(); }, [sessionId, loading, messages, fetchPendingQuestions]); + // A remote proxy or a reconnect boundary can delay or lose the one-shot + // question.asked event. Reconcile only while the session is active; the + // hook ignores responses made stale by a newer SSE update. + useEffect(() => { + if (!sessionId || (!isStreaming && !sending)) return; + const timer = setInterval(() => { + fetchPendingQuestions(sessionId).catch((err) => { + console.warn('[SessionChat] Failed to reconcile pending questions:', err); + }); + }, PENDING_QUESTION_RECONCILE_MS); + return () => clearInterval(timer); + }, [sessionId, isStreaming, sending, fetchPendingQuestions]); + // Refetch when page becomes visible again useEffect(() => { if (!sessionId) return; @@ -2129,11 +2143,14 @@ export default function SessionChat({ if (document.visibilityState === 'visible') { refetch(); fetchPromptQueue(); + fetchPendingQuestions(sessionId).catch((err) => { + console.warn('[SessionChat] Failed to recover pending questions after visibility change:', err); + }); } }; document.addEventListener('visibilitychange', handler); return () => document.removeEventListener('visibilitychange', handler); - }, [sessionId, refetch, fetchPromptQueue]); + }, [sessionId, refetch, fetchPromptQueue, fetchPendingQuestions]); // Backup refetch when compaction ends — covers SSE reconnect scenarios // where the session.status event may have been missed. diff --git a/webui/src/features/session-chat/usePendingQuestions.ts b/webui/src/features/session-chat/usePendingQuestions.ts index aba9323ea..a9025737b 100644 --- a/webui/src/features/session-chat/usePendingQuestions.ts +++ b/webui/src/features/session-chat/usePendingQuestions.ts @@ -5,7 +5,7 @@ * cleanup so SessionChat does not need to own question runtime state. */ -import { useCallback, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import client from '@/api/client'; import type { QuestionItem } from '@/components/common/QuestionTool'; @@ -34,8 +34,11 @@ function isNotFoundError(error: unknown): boolean { export function usePendingQuestions() { const [pendingQuestions, setPendingQuestions] = useState>({}); + const stateVersionRef = useRef(0); + const fetchSequenceRef = useRef(0); const removeByCallId = useCallback((callID: string) => { + stateVersionRef.current += 1; setPendingQuestions(prev => { const next = { ...prev }; delete next[callID]; @@ -45,6 +48,7 @@ export function usePendingQuestions() { const handleQuestionAsked = useCallback( (callID: string, requestId: string, questions: QuestionItem[]) => { + stateVersionRef.current += 1; setPendingQuestions(prev => ({ ...prev, [callID]: { requestId, questions }, @@ -78,6 +82,7 @@ export function usePendingQuestions() { ); const removeByRequestId = useCallback((requestId: string) => { + stateVersionRef.current += 1; setPendingQuestions(prev => { const next = { ...prev }; for (const [callID, pending] of Object.entries(prev)) { @@ -90,7 +95,15 @@ export function usePendingQuestions() { }, []); const fetchPendingQuestions = useCallback(async (sessionId: string) => { + const fetchSequence = ++fetchSequenceRef.current; + const stateVersion = stateVersionRef.current; const response = await client.get(`/api/question/session/${sessionId}/pending`); + if ( + fetchSequence !== fetchSequenceRef.current + || stateVersion !== stateVersionRef.current + ) { + return; + } const next: Record = {}; for (const item of response.data || []) { const callID = item.tool?.callID; @@ -105,6 +118,7 @@ export function usePendingQuestions() { }, []); const clearAll = useCallback(() => { + stateVersionRef.current += 1; setPendingQuestions({}); }, []); diff --git a/webui/src/hooks/usePendingQuestions.test.ts b/webui/src/hooks/usePendingQuestions.test.ts index cba5d58c1..7f0d2070b 100644 --- a/webui/src/hooks/usePendingQuestions.test.ts +++ b/webui/src/hooks/usePendingQuestions.test.ts @@ -63,4 +63,29 @@ describe('usePendingQuestions', () => { await expect(result.current.submitAnswer('call-1', 'req-1', [['yes']])).rejects.toBe(error); expect(result.current.pendingQuestions['call-1']).toBeDefined(); }); + + it('does not let stale hydration clear a newer SSE question', async () => { + let resolvePendingRequest: ((value: { data: [] }) => void) | undefined; + clientGetMock.mockReturnValueOnce(new Promise((resolve) => { + resolvePendingRequest = resolve; + })); + const { result } = renderHook(() => usePendingQuestions()); + + let hydration: Promise | undefined; + act(() => { + hydration = result.current.fetchPendingQuestions('sess-1'); + }); + act(() => { + result.current.handleQuestionAsked('call-1', 'req-1', [{ question: 'Continue?' }]); + }); + await act(async () => { + resolvePendingRequest?.({ data: [] }); + await hydration; + }); + + expect(result.current.pendingQuestions['call-1']).toEqual({ + requestId: 'req-1', + questions: [{ question: 'Continue?' }], + }); + }); }); From 0d02640c45463ced7ddd6d2fc88ea71c3b510917 Mon Sep 17 00:00:00 2001 From: John Yin <10972267+john-yin2333@user.noreply.gitee.com> Date: Wed, 22 Jul 2026 14:55:56 +0800 Subject: [PATCH 82/86] fix: forward new command prompt to fresh channel session --- flocks/channel/inbound/dispatcher.py | 24 +++++++ tests/channel/test_channel.py | 100 +++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/flocks/channel/inbound/dispatcher.py b/flocks/channel/inbound/dispatcher.py index cc116b49e..44a88f566 100644 --- a/flocks/channel/inbound/dispatcher.py +++ b/flocks/channel/inbound/dispatcher.py @@ -723,6 +723,8 @@ async def _run_session_control(_event, parsed) -> bool: msg=msg, callbacks=callbacks, scope_override=scope_override, + channel_config=channel_config, + initial_text=command_args or None, ) return True if parsed.canonical_name == "compact": @@ -864,6 +866,8 @@ async def _handle_session_command( msg: InboundMessage, callbacks: ChannelDeliveryCallbacks, scope_override: Optional[str], + channel_config: ChannelConfig, + initial_text: Optional[str] = None, ) -> None: from flocks.session.session import Session from flocks.channel.inbound.session_binding import ( @@ -931,6 +935,26 @@ async def _handle_session_command( ] ) ) + if initial_text and initial_text.strip(): + text = initial_text.strip() + resolved_model = await _resolve_session_model(new_binding.session_id) + await self._append_user_message( + new_binding.session_id, + text, + msg, + channel_config, + model=resolved_model, + agent=new_binding.agent_id, + ) + if msg.channel_id == "feishu": + await self._run_agent_with_typing( + new_binding, + new_callbacks, + msg, + channel_config, + ) + else: + await self._run_agent(new_binding, new_callbacks) @staticmethod async def _handle_compact_command( diff --git a/tests/channel/test_channel.py b/tests/channel/test_channel.py index 11f30a511..34a7830cf 100644 --- a/tests/channel/test_channel.py +++ b/tests/channel/test_channel.py @@ -599,6 +599,106 @@ async def fake_deliver(ctx, session_id=None): assert update_mock.await_args.args == ("channel", "session_old") assert update_mock.await_args.kwargs["status"] == "archived" + @pytest.mark.asyncio + async def test_new_command_with_args_sends_args_to_new_session(self, monkeypatch): + from flocks.channel.inbound.dispatcher import InboundDispatcher + from flocks.channel.inbound.session_binding import SessionBinding + + dispatcher = InboundDispatcher() + dispatcher._trigger_command_hook = AsyncMock() + append_mock = AsyncMock() + run_mock = AsyncMock() + monkeypatch.setattr(dispatcher, "_append_user_message", append_mock) + monkeypatch.setattr(dispatcher, "_run_agent", run_mock) + dispatcher.binding_service.rebind = AsyncMock( + return_value=SessionBinding( + channel_id="slack", + account_id="T1", + chat_id="C123", + chat_type=ChatType.CHANNEL, + thread_id="171.1", + session_id="session_new", + agent_id="rex", + created_at=0, + last_message_at=0, + ) + ) + binding = SessionBinding( + channel_id="slack", + account_id="T1", + chat_id="C123", + chat_type=ChatType.CHANNEL, + thread_id="171.1", + session_id="session_old", + agent_id="rex", + created_at=0, + last_message_at=0, + ) + msg = InboundMessage( + channel_id="slack", + account_id="T1", + message_id="171.1", + sender_id="U123", + chat_id="C123", + chat_type=ChatType.CHANNEL, + text="<@UBOT> /new 叫我uuuu", + mention_text="/new 叫我uuuu", + thread_id="171.1", + mentioned=True, + ) + + delivered: list[str] = [] + + async def fake_deliver(ctx, session_id=None): + delivered.append(ctx.text) + + monkeypatch.setattr( + "flocks.channel.outbound.deliver.OutboundDelivery.deliver", + fake_deliver, + ) + monkeypatch.setattr( + "flocks.session.session.Session.get_by_id", + AsyncMock( + return_value=SimpleNamespace( + id="session_old", + project_id="channel", + directory="/tmp/project", + agent="rex", + provider="anthropic", + model="claude-sonnet-4-20250514", + model_pinned=True, + ) + ), + ) + monkeypatch.setattr( + "flocks.session.session.Session.create", + AsyncMock(return_value=SimpleNamespace(id="session_new", agent="rex")), + ) + monkeypatch.setattr( + "flocks.session.session.Session.update", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + "flocks.channel.inbound.dispatcher._resolve_session_model", + AsyncMock(return_value={"providerID": "anthropic", "modelID": "claude"}), + ) + + handled = await dispatcher._handle_feishu_native_command( + binding=binding, + msg=msg, + channel_config=ChannelConfig(enabled=True), + user_text="/new 叫我uuuu", + scope_override=None, + ) + + assert handled is True + assert "已开始全新对话。" in delivered[0] + append_mock.assert_awaited_once() + assert append_mock.await_args.args[:3] == ("session_new", "叫我uuuu", msg) + assert append_mock.await_args.kwargs["agent"] == "rex" + run_mock.assert_awaited_once() + assert run_mock.await_args.args[0].session_id == "session_new" + @pytest.mark.asyncio async def test_reset_alias_matches_new_semantics(self, monkeypatch): from flocks.channel.inbound.dispatcher import InboundDispatcher From 1f11c67d007be9211c1135c909966c0537177df1 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 16:37:47 +0800 Subject: [PATCH 83/86] fix(device): create instances from installed templates --- .../skills/device-integration-guide/SKILL.md | 77 ++---- flocks/tool/device/manage_tool.py | 201 ++++++++++++++- .../test_device_integration_guide_skill.py | 47 ++++ tests/tool/test_device_manage_tool.py | 240 +++++++++++++++++- 4 files changed, 496 insertions(+), 69 deletions(-) create mode 100644 tests/skill/test_device_integration_guide_skill.py diff --git a/.flocks/plugins/skills/device-integration-guide/SKILL.md b/.flocks/plugins/skills/device-integration-guide/SKILL.md index 010594ca3..f528f7bcd 100644 --- a/.flocks/plugins/skills/device-integration-guide/SKILL.md +++ b/.flocks/plugins/skills/device-integration-guide/SKILL.md @@ -18,67 +18,33 @@ description: 指导 Flocks 新建、添加和接入安全设备。Use when the u ## 核心原则 -- 先确认用户是在**新建设备实例**、**整理配置草稿**,还是**测试连通性**。 +- 先确认用户是在**新建设备实例**、**页面配置**,还是**测试连通性**。 - 不要要求用户在聊天里粘贴密码、Token、Cookie、API Key 等敏感凭证。 -- 不要在 skill 中优先引导通过工具写入设备配置;设备接入页面表单和 JSON 草稿是配置写入的主路径。 +- 独立会话中,已安装模板的新设备优先通过 `device_manage(action="create")` 创建;只有敏感凭证需要回到设备接入页面填写。 - 每次修改后,优先用标准连通性测试验证结果。 - 保持回答简短,给出当前动作、结果和下一步。 ## 决策流程 -1. 用户已经在设备列表里有目标设备,并提供了 `device_id`: +1. 已有 `device_id`:非敏感配置用 `device_manage(action="update")`,测试或排障用 `connectivity_test`;敏感字段回到设备接入页面填写。 +2. 没有 `device_id`:先用 `device_manage(action="list")` 排除已有实例,再调用 `device_manage(action="list_templates")` 查询模板。设备实例为空不代表模板不存在。 +3. 按名称、厂商、`plugin_id`、`service_id`、`storage_key` 和描述匹配模板: + - `installed=true`:按 `credential_schema` 整理非敏感字段,然后进入创建流程。 + - `installed=false`:引导用户在 FlockHub 安装返回的 `plugin_id`,安装后重新查询;不要创建自定义模板。 + - 没有匹配模板:进入自定义 API、浏览器或 Workflow 接入。 -- 如果是配置变更,回到设备接入页面表单或输出页面可回填 JSON 草稿。 -- 如果是测试或排障,先走 `device_manage(action="connectivity_test")`。 +## 使用模板直接创建设备 -2. 用户说“添加设备”“接入设备”“新建设备”,但还没有设备实例: +只有 `list_templates` 已返回匹配模板且 `installed=true` 时,才能调用 `device_manage(action="create")`。`create` 会再次校验模板状态,不能用 `update` 代替创建。 -- 如果有已安装模板,引导用户在设备接入页面填写表单。 -- 如果没有合适模板,进入自定义设备接入路径。 -- 如果涉及密钥、密码、Token、Cookie 或浏览器登录态,只说明应该填到页面表单,不要在聊天中收集真实值。 +从模板收集设备名称、机房、SSL 偏好和已声明的非敏感字段;不询问或传递 `storage=secret`、`sensitive=true`、`input_type=password` 的字段。 -3. 用户只描述产品、厂商、控制台地址或 API 文档: +创建成功后: -- 先判断已有模板是否可用。 -- 未安装模板需要先去 FlockHub 安装。 -- 没有合适模板时,按“自定义接入路由”选择 API、浏览器或 Workflow。 - -## 设备列表与目标确认 - -如果用户没有给出 `device_id`,先调用: - -```python -device_manage(action="list") -``` - -从返回结果里确认目标设备、机房、工具集和 `device_id`。 - -如果设备不存在,提醒用户前往「设备接入」页面添加设备;不要伪造 `device_id` 或直接调用业务工具。 - -## 新建设备与页面回填 - -用户在设备接入页面创建或配置设备时,目标是帮助页面得到清晰的表单信息。 - -需要收集的信息: - -- 设备名称。 -- 已安装模板的 `storage_key`。 -- Base URL、Host、端口、协议、租户或区域等非敏感字段。 -- SSL 证书验证偏好:`verify_ssl=true/false`。 -- 需要填写哪些敏感字段,但不收集真实值。 - -当信息足够,并且当前任务是在设备接入页面生成配置草稿时,在回复末尾输出 JSON 代码块供页面一键回填: - -```json -{"storage_key":"","device_name":"<设备名称>","fields":{"base_url":"https://example.local"},"verify_ssl":false} -``` - -JSON 草稿规则: - -- 只包含非敏感字段。 -- 不写真实 API Key、Secret、Token、Cookie、密码。 -- 敏感字段留空或省略,并提示用户稍后在页面表单中填写。 -- 如果没有合适模板,不要输出设备配置 JSON,先进入自定义接入路径。 +- 记录并报告返回的 `device_id`,后续操作始终使用它。 +- 如果返回 `sensitive_fields_to_complete`,告诉用户前往该设备的配置表单填写这些字段,不要在聊天中索要真实值。 +- 用户确认敏感字段已填写后,调用 `device_manage(action="connectivity_test", device_id="")`。 +- 同一轮会话已经拿到成功返回的 `device_id` 后,不要因重试或继续对话再次创建。 ## 自定义接入路由 @@ -90,9 +56,9 @@ JSON 草稿规则: 如果用户已经明确选择 API、浏览器或 Workflow,不要重复询问接入方式。只有无法判断时,才用一句话澄清。 -## 配置草稿与表单更新 +## 页面配置与更新 -如果用户要写入或更新设备配置,应根据当前页面上下文整理表单草稿,让设备接入页面负责落库。 +如果用户正在设备接入页面配置设备,帮助确认需要填写的表单字段,让页面负责保存。独立会话中的已有设备非敏感配置更新使用 `device_manage(action="update")`。 可以整理的非敏感字段包括: @@ -104,7 +70,7 @@ JSON 草稿规则: - `tenant` - `region` -不要在 JSON 草稿或聊天中写入敏感字段: +不要在聊天中索要或回显敏感字段: - `api_key` - `secret` @@ -115,11 +81,11 @@ JSON 草稿规则: 如果用户的目标是补填密钥、修改密码、刷新 Token 或重新登录,只说明应该在设备接入页面对应字段中处理。 -当需要给页面回填时,使用“新建设备与页面回填”中的 JSON 草稿格式。对于已有设备编辑,也只输出非敏感字段和 `verify_ssl`,并说明敏感字段在页面表单内填写。 +对于已有设备编辑,只处理非敏感字段和 `verify_ssl`,并说明敏感字段应在页面表单内填写。 ## 连通性与冒烟验证 -配置在设备接入页面保存后,除非用户明确不需要,继续调用: +设备通过 `create` 返回 `device_id`,或在设备接入页面保存后,除非用户明确不需要,继续调用: ```python device_manage(action="connectivity_test", device_id="") @@ -149,6 +115,5 @@ device_manage(action="connectivity_test", device_id="") - 不要在聊天中索要、保存或复述真实密钥。 - 不要把自定义设备误做成普通 API 服务。 -- 不要对未安装模板输出可回填 JSON。 - 不要跳过 `device_manage(action="connectivity_test")` 就声称设备已可用。 - 不要把卡片状态建立在普通业务工具结果上;卡片状态以标准连通性测试写入结果为准。 diff --git a/flocks/tool/device/manage_tool.py b/flocks/tool/device/manage_tool.py index d6e8bf20d..8153f0ca5 100644 --- a/flocks/tool/device/manage_tool.py +++ b/flocks/tool/device/manage_tool.py @@ -1,16 +1,25 @@ """Built-in device management tool for Rex. -``device_manage`` is the single system-tool entrypoint for device discovery, -non-secret config updates, and standard connectivity checks. Its +``device_manage`` is the single system-tool entrypoint for device and template +discovery, non-secret config updates, and standard connectivity checks. Its ``connectivity_test`` action reuses the existing device test path, so card state stays consistent with the ``POST /api/devices/{id}/test`` endpoint. """ from __future__ import annotations +import asyncio from typing import Any, Optional -from flocks.tool.device.intake import DeviceNotFoundError, test_device, update_device -from flocks.tool.device.models import DeviceIntegrationUpdate +from flocks.tool.device.intake import ( + DeviceNotFoundError, + create_device, + test_device, + update_device, +) +from flocks.tool.device.models import ( + DeviceIntegrationCreate, + DeviceIntegrationUpdate, +) from flocks.tool.registry import ( ParameterType, ToolCategory, @@ -28,6 +37,8 @@ name="device_manage", description=( "管理已接入安全设备。action=list 用于列出机房、设备、device_id 和工具集;" + "action=list_templates 用于列出已有设备模板、安装状态和配置字段;" + "action=create 用于从已安装模板创建设备实例,仅接受非敏感配置;" "action=update 用于写入/更新已有设备实例的非敏感配置字段;" "action=connectivity_test 用于测试指定设备连通性并更新设备卡片状态。" ), @@ -37,9 +48,37 @@ ToolParameter( name="action", type=ParameterType.STRING, - description="操作类型:list 列出设备;update 更新已有设备非敏感配置;connectivity_test 测试设备连通性。", + description=( + "操作类型:list 列出设备实例;list_templates 列出已有设备模板;" + "create 从已安装模板创建设备实例;update 更新已有设备非敏感配置;" + "connectivity_test 测试设备连通性。" + ), required=True, - enum=["list", "update", "connectivity_test"], + enum=[ + "list", + "list_templates", + "create", + "update", + "connectivity_test", + ], + ), + ToolParameter( + name="storage_key", + type=ParameterType.STRING, + description="已安装设备模板的 storage_key。action=create 时必填。", + required=False, + ), + ToolParameter( + name="device_name", + type=ParameterType.STRING, + description="新设备实例名称。action=create 时必填。", + required=False, + ), + ToolParameter( + name="group_id", + type=ParameterType.STRING, + description="目标机房 ID。action=create 时可选,默认使用默认机房。", + required=False, ), ToolParameter( name="device_id", @@ -51,7 +90,8 @@ name="fields", type=ParameterType.OBJECT, description=( - "要更新的非敏感设备配置字段,例如 {\"base_url\":\"https://device.local\"}。" + "要创建或更新的非敏感设备配置字段,例如 " + "{\"base_url\":\"https://device.local\"}。" "禁止传入 api_key、secret、password、token、cookie、auth_state 等敏感字段。" ), required=False, @@ -59,7 +99,7 @@ ToolParameter( name="verify_ssl", type=ParameterType.BOOLEAN, - description="是否开启 SSL 证书验证。仅 action=update 时使用。", + description="是否开启 SSL 证书验证。action=create 或 update 时使用。", required=False, ), ], @@ -67,21 +107,37 @@ async def device_manage( ctx: ToolContext, action: str, + storage_key: Optional[str] = None, + device_name: Optional[str] = None, + group_id: Optional[str] = None, device_id: Optional[str] = None, fields: Optional[dict[str, Any]] = None, verify_ssl: Optional[bool] = None, ) -> ToolResult: - """List devices, update non-secret config, or run a standard probe.""" + """List devices/templates, update non-secret config, or run a probe.""" normalized_action = (action or "").strip() if normalized_action == "list": return await _list_devices() + if normalized_action == "list_templates": + return await _list_device_templates() + if normalized_action == "create": + return await _create_device_from_template( + storage_key, + device_name, + group_id, + fields, + verify_ssl, + ) if normalized_action == "update": return await _update_device_config(ctx, device_id, fields, verify_ssl) if normalized_action == "connectivity_test": return await _connectivity_test(ctx, device_id) return ToolResult( success=False, - error="未知 action,请使用 list、update 或 connectivity_test。", + error=( + "未知 action,请使用 list、list_templates、create、update 或 " + "connectivity_test。" + ), ) @@ -104,6 +160,131 @@ async def _list_devices() -> ToolResult: return ToolResult(success=False, error=f"查询设备列表失败: {exc}") +async def _list_device_templates() -> ToolResult: + """Return the same device template index consumed by the access page.""" + try: + from flocks.tool.device.plugin_index import list_device_templates + + templates = await asyncio.to_thread(list_device_templates, refresh=False) + return ToolResult( + success=True, + output=[template.model_dump(mode="json") for template in templates], + metadata={ + "template_count": len(templates), + "installed_count": sum(template.installed for template in templates), + }, + title="设备模板列表", + ) + except Exception as exc: + log.warn("tool.device_manage.list_templates_failed", {"error": str(exc)}) + return ToolResult(success=False, error=f"查询设备模板失败: {exc}") + + +async def _create_device_from_template( + storage_key: Optional[str], + device_name: Optional[str], + group_id: Optional[str], + fields: Optional[dict[str, Any]], + verify_ssl: Optional[bool], +) -> ToolResult: + target_storage_key = (storage_key or "").strip() + name = (device_name or "").strip() + if not target_storage_key: + return ToolResult(success=False, error="action=create 时 storage_key 不能为空。") + if not name: + return ToolResult(success=False, error="action=create 时 device_name 不能为空。") + + try: + from flocks.tool.device.plugin_index import list_device_templates + + templates = await asyncio.to_thread(list_device_templates, refresh=False) + template = next( + (item for item in templates if item.storage_key == target_storage_key), + None, + ) + except Exception as exc: + log.warn("tool.device_manage.create_template_lookup_failed", {"error": str(exc)}) + return ToolResult(success=False, error=f"查询设备模板失败: {exc}") + + if template is None: + return ToolResult( + success=False, + error=( + f"未找到 storage_key={target_storage_key!r} 的设备模板," + "请先调用 device_manage(action='list_templates')。" + ), + ) + if not template.installed: + return ToolResult( + success=False, + error=( + f"模板 {template.name!r} 尚未安装。请先在 FlockHub 安装 " + f"plugin_id={template.plugin_id!r},然后重新查询模板。" + ), + ) + + schema = { + str(field.get("key") or "").strip(): field + for field in template.credential_schema + if str(field.get("key") or "").strip() + } + normalized_fields = { + str(key).strip(): "" if value is None else str(value) + for key, value in (fields or {}).items() + } + unknown = sorted(set(normalized_fields).difference(schema)) + if unknown: + return ToolResult( + success=False, + error="模板未声明字段:" + ", ".join(f"`{key}`" for key in unknown), + ) + + sensitive_fields = sorted( + key + for key, field in schema.items() + if field.get("storage") == "secret" + or field.get("sensitive") is True + or field.get("input_type") == "password" + or key.lower() in _SENSITIVE_FIELD_KEYS + ) + rejected = sorted(set(normalized_fields).intersection(sensitive_fields)) + if rejected: + return ToolResult( + success=False, + error=( + "拒绝写入敏感字段:" + + ", ".join(f"`{key}`" for key in rejected) + + "。请创建设备后在设备接入页面填写。" + ), + ) + + body = DeviceIntegrationCreate( + name=name, + storage_key=template.storage_key, + service_id=template.service_id, + group_id=(group_id or "").strip() or None, + verify_ssl=bool(verify_ssl) if verify_ssl is not None else False, + fields=normalized_fields, + ) + try: + created = await create_device(body) + except ValueError as exc: + return ToolResult(success=False, error=f"创建设备失败: {exc}") + except Exception as exc: + log.warn("tool.device_manage.create_failed", {"error": str(exc)}) + return ToolResult(success=False, error=f"创建设备失败: {exc}") + + output = created.model_dump(mode="json") + output["device_id"] = created.id + output["sensitive_fields_to_complete"] = sensitive_fields + return ToolResult( + success=True, + output=output, + metadata={"device_id": created.id, "sensitive_fields": sensitive_fields}, + title="设备已创建", + ) + + _SENSITIVE_FIELD_KEYS = frozenset( { "api_key", diff --git a/tests/skill/test_device_integration_guide_skill.py b/tests/skill/test_device_integration_guide_skill.py new file mode 100644 index 000000000..e619f8462 --- /dev/null +++ b/tests/skill/test_device_integration_guide_skill.py @@ -0,0 +1,47 @@ +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL_FILE = ( + PROJECT_ROOT + / ".flocks" + / "plugins" + / "skills" + / "device-integration-guide" + / "SKILL.md" +) + + +def test_device_integration_guide_queries_templates_before_custom_creation() -> None: + content = SKILL_FILE.read_text(encoding="utf-8") + + assert 'device_manage(action="list_templates")' in content + assert "设备实例为空不代表模板不存在" in content + assert "没有匹配模板:进入自定义" in content + + +def test_device_integration_guide_routes_uninstalled_template_to_flockhub() -> None: + content = SKILL_FILE.read_text(encoding="utf-8") + + assert "installed=false" in content + assert "FlockHub 安装返回的 `plugin_id`" in content + assert "不要创建自定义模板" in content + + +def test_device_integration_guide_creates_from_installed_template() -> None: + content = SKILL_FILE.read_text(encoding="utf-8") + + assert 'device_manage(action="create")' in content + assert 'device_manage(\n action="create"' not in content + assert "不能用 `update` 代替创建" in content + assert "只有 `list_templates` 已返回匹配模板且 `installed=true`" in content + assert "sensitive_fields_to_complete" in content + assert "同一轮会话已经拿到成功返回的 `device_id`" in content + + +def test_device_integration_guide_does_not_embed_page_json_protocol() -> None: + content = SKILL_FILE.read_text(encoding="utf-8") + + assert "```json" not in content + assert '"storage_key":""' not in content + assert "一键回填" not in content diff --git a/tests/tool/test_device_manage_tool.py b/tests/tool/test_device_manage_tool.py index 8e7c82861..6df14c80a 100644 --- a/tests/tool/test_device_manage_tool.py +++ b/tests/tool/test_device_manage_tool.py @@ -7,7 +7,7 @@ from flocks.tool.device.intake import DeviceNotFoundError from flocks.tool.device.manage_tool import device_manage -from flocks.tool.device.models import DeviceIntegration, DeviceTestResult +from flocks.tool.device.models import DeviceIntegration, DeviceTemplate, DeviceTestResult from flocks.tool.registry import ToolContext, ToolRegistry @@ -41,19 +41,43 @@ def make_device(**overrides) -> DeviceIntegration: return DeviceIntegration(**data) +def make_template(**overrides) -> DeviceTemplate: + data = { + "plugin_id": "360-waf", + "storage_key": "360_waf_v5_5", + "service_id": "360_waf", + "name": "360 WAF", + "credential_schema": [], + "installed": True, + "state": "installed", + "source": "project", + } + data.update(overrides) + return DeviceTemplate(**data) + + def test_device_manage_is_registered(): tools = {tool.name for tool in ToolRegistry.list_tools()} assert "device_manage" in tools -def test_device_manage_schema_includes_update_action(): +def test_device_manage_schema_includes_template_discovery_action(): tool = ToolRegistry.get("device_manage") assert tool is not None action_param = next(param for param in tool.info.parameters if param.name == "action") - assert action_param.enum == ["list", "update", "connectivity_test"] + assert action_param.enum == [ + "list", + "list_templates", + "create", + "update", + "connectivity_test", + ] assert {param.name for param in tool.info.parameters} >= { "device_id", + "device_name", + "storage_key", + "group_id", "fields", "verify_ssl", } @@ -71,6 +95,216 @@ async def test_device_manage_list_returns_device_inventory(): assert "dev-1" in result.output +@pytest.mark.asyncio +async def test_device_manage_list_templates_returns_existing_template_metadata(): + templates = [ + make_template( + plugin_id="tdp", + storage_key="tdp_v3_3_10", + service_id="tdp", + name="TDP", + version="3.3.10", + vendor="threatbook", + credential_schema=[ + { + "key": "base_url", + "label": "Base URL", + "required": True, + "secret": False, + }, + { + "key": "api_key", + "label": "API Key", + "required": True, + "secret": True, + }, + ], + tool_count=2, + ) + ] + with patch( + "flocks.tool.device.plugin_index.list_device_templates", + return_value=templates, + ) as mocked_list: + result = await device_manage(make_ctx(), action="list_templates") + + mocked_list.assert_called_once_with(refresh=False) + assert result.success is True + assert result.output == [templates[0].model_dump(mode="json")] + assert result.metadata == { + "template_count": 1, + "installed_count": 1, + } + + +@pytest.mark.asyncio +async def test_device_manage_create_uses_installed_template_identity(): + template = make_template( + credential_schema=[ + { + "key": "base_url", + "label": "Base URL", + "storage": "config", + "required": True, + }, + { + "key": "username", + "label": "Username", + "storage": "config", + "required": False, + }, + { + "key": "password", + "label": "Password", + "storage": "secret", + "required": True, + }, + ], + ) + created = make_device( + id="dev-360", + name="360 WAF", + storage_key=template.storage_key, + service_id=template.service_id, + fields={"base_url": "https://192.168.1.100"}, + fields_set={"base_url": True}, + ) + with ( + patch( + "flocks.tool.device.plugin_index.list_device_templates", + return_value=[template], + ), + patch( + "flocks.tool.device.manage_tool.create_device", + AsyncMock(return_value=created), + ) as mocked_create, + ): + result = await device_manage( + make_ctx(), + action="create", + storage_key="360_waf_v5_5", + device_name="360 WAF", + fields={"base_url": "https://192.168.1.100"}, + verify_ssl=False, + ) + + mocked_create.assert_awaited_once() + body = mocked_create.await_args.args[0] + assert body.name == "360 WAF" + assert body.storage_key == "360_waf_v5_5" + assert body.service_id == "360_waf" + assert body.fields == {"base_url": "https://192.168.1.100"} + assert result.success is True + assert result.output["device_id"] == "dev-360" + assert result.metadata["sensitive_fields"] == ["password"] + + +@pytest.mark.asyncio +async def test_device_manage_create_rejects_uninstalled_template(): + template = make_template( + installed=False, + state="available", + source="bundled", + ) + with patch( + "flocks.tool.device.plugin_index.list_device_templates", + return_value=[template], + ): + result = await device_manage( + make_ctx(), + action="create", + storage_key=template.storage_key, + device_name="360 WAF", + ) + + assert result.success is False + assert "尚未安装" in (result.error or "") + assert "360-waf" in (result.error or "") + + +@pytest.mark.asyncio +async def test_device_manage_create_rejects_secret_and_unknown_fields(): + template = make_template( + credential_schema=[ + {"key": "base_url", "storage": "config", "required": True}, + {"key": "password", "storage": "secret", "required": True}, + ], + ) + with patch( + "flocks.tool.device.plugin_index.list_device_templates", + return_value=[template], + ): + secret_result = await device_manage( + make_ctx(), + action="create", + storage_key=template.storage_key, + device_name="360 WAF", + fields={ + "base_url": "https://192.168.1.100", + "password": "do-not-store", + }, + ) + unknown_result = await device_manage( + make_ctx(), + action="create", + storage_key=template.storage_key, + device_name="360 WAF", + fields={ + "base_url": "https://192.168.1.100", + "account": "admin", + }, + ) + + assert secret_result.success is False + assert "敏感字段" in (secret_result.error or "") + assert "password" in (secret_result.error or "") + assert unknown_result.success is False + assert "模板未声明字段" in (unknown_result.error or "") + assert "account" in (unknown_result.error or "") + + +@pytest.mark.asyncio +async def test_device_manage_create_leaves_missing_fields_for_page_completion(): + template = make_template( + credential_schema=[ + { + "key": "base_url", + "storage": "config", + "required": True, + "default_value": "https://default.local", + }, + {"key": "password", "storage": "secret", "required": True}, + ], + ) + created = make_device( + name="360 WAF", + storage_key=template.storage_key, + service_id=template.service_id, + fields={}, + fields_set={}, + ) + with ( + patch( + "flocks.tool.device.plugin_index.list_device_templates", + return_value=[template], + ), + patch( + "flocks.tool.device.manage_tool.create_device", + AsyncMock(return_value=created), + ) as mocked_create, + ): + result = await device_manage( + make_ctx(), + action="create", + storage_key=template.storage_key, + device_name="360 WAF", + ) + + assert result.success is True + assert mocked_create.await_args.args[0].fields == {} + assert result.output["sensitive_fields_to_complete"] == ["password"] + + @pytest.mark.asyncio async def test_device_manage_update_updates_existing_device_non_secret_config(): updated_device = make_device(verify_ssl=True) From 4b076a2ce3fc10543719ea7bd5fc56aa91b22170 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 22 Jul 2026 16:39:13 +0800 Subject: [PATCH 84/86] fix(device): allow auth state configuration --- .../skills/device-integration-guide/SKILL.md | 1 - flocks/tool/device/manage_tool.py | 5 ++-- tests/tool/test_device_manage_tool.py | 25 ++++++++++++++++--- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.flocks/plugins/skills/device-integration-guide/SKILL.md b/.flocks/plugins/skills/device-integration-guide/SKILL.md index f528f7bcd..f02795a9f 100644 --- a/.flocks/plugins/skills/device-integration-guide/SKILL.md +++ b/.flocks/plugins/skills/device-integration-guide/SKILL.md @@ -77,7 +77,6 @@ description: 指导 Flocks 新建、添加和接入安全设备。Use when the u - `password` - `token` - `cookie` -- `auth_state` 如果用户的目标是补填密钥、修改密码、刷新 Token 或重新登录,只说明应该在设备接入页面对应字段中处理。 diff --git a/flocks/tool/device/manage_tool.py b/flocks/tool/device/manage_tool.py index 8153f0ca5..963debab8 100644 --- a/flocks/tool/device/manage_tool.py +++ b/flocks/tool/device/manage_tool.py @@ -92,7 +92,7 @@ description=( "要创建或更新的非敏感设备配置字段,例如 " "{\"base_url\":\"https://device.local\"}。" - "禁止传入 api_key、secret、password、token、cookie、auth_state 等敏感字段。" + "禁止传入 api_key、secret、password、token、cookie 等敏感字段。" ), required=False, ), @@ -297,7 +297,6 @@ async def _create_device_from_template( "access_token", "refresh_token", "cookie", - "auth_state", } ) @@ -325,7 +324,7 @@ def _normalize_update_fields( return {}, ( "拒绝通过 device_manage(action='update') 写入敏感字段:" + ", ".join(f"`{key}`" for key in sorted(rejected)) - + "。请在设备接入页面的配置表单中填写密钥、密码、Token、Cookie 或 auth_state。" + + "。请在设备接入页面的配置表单中填写密钥、密码、Token 或 Cookie。" ) return normalized, None diff --git a/tests/tool/test_device_manage_tool.py b/tests/tool/test_device_manage_tool.py index 6df14c80a..01ed78407 100644 --- a/tests/tool/test_device_manage_tool.py +++ b/tests/tool/test_device_manage_tool.py @@ -153,6 +153,12 @@ async def test_device_manage_create_uses_installed_template_identity(): "storage": "config", "required": False, }, + { + "key": "auth_state", + "label": "Auth State", + "storage": "config", + "required": False, + }, { "key": "password", "label": "Password", @@ -184,7 +190,10 @@ async def test_device_manage_create_uses_installed_template_identity(): action="create", storage_key="360_waf_v5_5", device_name="360 WAF", - fields={"base_url": "https://192.168.1.100"}, + fields={ + "base_url": "https://192.168.1.100", + "auth_state": "ready", + }, verify_ssl=False, ) @@ -193,7 +202,10 @@ async def test_device_manage_create_uses_installed_template_identity(): assert body.name == "360 WAF" assert body.storage_key == "360_waf_v5_5" assert body.service_id == "360_waf" - assert body.fields == {"base_url": "https://192.168.1.100"} + assert body.fields == { + "base_url": "https://192.168.1.100", + "auth_state": "ready", + } assert result.success is True assert result.output["device_id"] == "dev-360" assert result.metadata["sensitive_fields"] == ["password"] @@ -316,7 +328,11 @@ async def test_device_manage_update_updates_existing_device_non_secret_config(): make_ctx(), action="update", device_id="dev-1", - fields={"base_url": "https://device.local", "port": 443}, + fields={ + "base_url": "https://device.local", + "port": 443, + "auth_state": "ready", + }, verify_ssl=True, ) @@ -326,11 +342,12 @@ async def test_device_manage_update_updates_existing_device_non_secret_config(): assert update_body.fields == { "base_url": "https://device.local", "port": "443", + "auth_state": "ready", } assert update_body.verify_ssl is True assert result.success is True assert result.output["device_id"] == "dev-1" - assert result.output["updated_fields"] == ["base_url", "port"] + assert result.output["updated_fields"] == ["auth_state", "base_url", "port"] assert result.metadata["verify_ssl"] is True From d8c90e923326cf99884b3abd36a302d7c269e707 Mon Sep 17 00:00:00 2001 From: stephamie7 <1223696150@qq.com> Date: Wed, 22 Jul 2026 16:57:03 +0800 Subject: [PATCH 85/86] chore/update-version-2026-7-22 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0bb573215..5c58a57f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flocks" -version = "v2026.7.15" +version = "v2026.7.22" description = "AI-Native SecOps platform with multi-agent collaboration" authors = [ {name = "Flocks Team", email = "team@example.com"} diff --git a/uv.lock b/uv.lock index 5e6380561..a5584aa89 100644 --- a/uv.lock +++ b/uv.lock @@ -553,7 +553,7 @@ wheels = [ [[package]] name = "flocks" -version = "2026.7.15" +version = "2026.7.22" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 83e8057b9b890e2255427d9f6c26e2ab573e7057 Mon Sep 17 00:00:00 2001 From: duguwanglong Date: Wed, 22 Jul 2026 17:42:08 +0800 Subject: [PATCH 86/86] fix(tools): synchronize auto-disabled state Persist repeated-failure disables and propagate enabled-state changes across workers and service toggles. Reload the WebUI tool list without refreshing plugins so the disabled state cannot be overwritten. --- flocks/server/routes/provider.py | 29 ++++- flocks/server/routes/tool.py | 74 +++++++----- flocks/tool/registry.py | 111 +++++++++++++++--- tests/server/routes/test_tool_routes.py | 66 +++++++++++ tests/server/test_tool_setting_routes.py | 86 +++++++++++++- .../tool/test_failure_auto_disable_config.py | 33 ++++++ webui/src/hooks/useTools.test.tsx | 33 ++++++ webui/src/hooks/useTools.ts | 18 ++- .../pages/Tool/ToolPageAutoDisable.test.tsx | 108 +++++++++++++++++ webui/src/pages/Tool/index.tsx | 8 +- 10 files changed, 507 insertions(+), 59 deletions(-) create mode 100644 webui/src/pages/Tool/ToolPageAutoDisable.test.tsx diff --git a/flocks/server/routes/provider.py b/flocks/server/routes/provider.py index f05768049..9ec50d8fe 100644 --- a/flocks/server/routes/provider.py +++ b/flocks/server/routes/provider.py @@ -1177,9 +1177,32 @@ def _is_api_service_builtin(provider_id: str, tools: Optional[List[Any]] = None) def _set_api_service_tools_enabled(provider_id: str, enabled: bool) -> int: """Synchronize the enabled state of all tools under an API service.""" - matched_tools = _get_api_service_tool_infos(provider_id) - for tool_info in matched_tools: - tool_info.enabled = enabled + from flocks.tool.registry import ToolRegistry + + ToolRegistry.init() + with ToolRegistry._refresh_lock: + matched_tools = _get_api_service_tool_infos(provider_id) + tool_settings = ConfigWriter.list_tool_settings() + previous_states = { + tool_info.name: bool(tool_info.enabled) + for tool_info in matched_tools + } + for tool_info in matched_tools: + default_enabled = ToolRegistry.get_default_enabled(tool_info.name) + effective_enabled = enabled and ( + default_enabled if default_enabled is not None else True + ) + setting = tool_settings.get(tool_info.name) + if isinstance(setting, dict) and "enabled" in setting: + effective_enabled = enabled and bool(setting["enabled"]) + tool_info.enabled = effective_enabled + if effective_enabled and not previous_states[tool_info.name]: + ToolRegistry._reset_failure_state(tool_info.name) + if any( + previous_states[tool_info.name] != bool(tool_info.enabled) + for tool_info in matched_tools + ): + ToolRegistry._bump_revision("api_service_tool_state") if matched_tools: try: from flocks.server.routes.tool import _invalidate_tool_summary_cache diff --git a/flocks/server/routes/tool.py b/flocks/server/routes/tool.py index f4e682b62..281b8e813 100644 --- a/flocks/server/routes/tool.py +++ b/flocks/server/routes/tool.py @@ -639,35 +639,39 @@ def _get_effective_tool_enabled( def _set_global_tool_enabled(tool: Any, desired: bool) -> bool: """Persist and apply the global enabled state for a registry tool.""" - default = _get_default_enabled(tool.info) - # Service gate: only matters when the user is trying to enable. - # Disabling is always honoured. - service_ok = _service_allows_enable(tool.info) - new_enabled = desired and service_ok - - if desired == default: - removed = ConfigWriter.delete_tool_setting(tool.info.name) - log.info("tool.updated.reset_to_default", { - "name": tool.info.name, - "enabled": new_enabled, - "default": default, - "removed_overlay": removed, - }) - else: - ConfigWriter.set_tool_setting(tool.info.name, {"enabled": desired}) - log.info("tool.updated", { - "name": tool.info.name, - "enabled": new_enabled, - "requested": desired, - "blocked_by_service": desired and not service_ok, - "native": tool.info.native, - "store": "overlay", - }) + with ToolRegistry._refresh_lock: + previous_enabled = bool(tool.info.enabled) + default = _get_default_enabled(tool.info) + # Service gate: only matters when the user is trying to enable. + # Disabling is always honoured. + service_ok = _service_allows_enable(tool.info) + new_enabled = desired and service_ok + + if desired == default: + removed = ConfigWriter.delete_tool_setting(tool.info.name) + log.info("tool.updated.reset_to_default", { + "name": tool.info.name, + "enabled": new_enabled, + "default": default, + "removed_overlay": removed, + }) + else: + ConfigWriter.set_tool_setting(tool.info.name, {"enabled": desired}) + log.info("tool.updated", { + "name": tool.info.name, + "enabled": new_enabled, + "requested": desired, + "blocked_by_service": desired and not service_ok, + "native": tool.info.native, + "store": "overlay", + }) - tool.info.enabled = new_enabled - if new_enabled: - ToolRegistry._reset_failure_state(tool.info.name) - return new_enabled + tool.info.enabled = new_enabled + if new_enabled: + ToolRegistry._reset_failure_state(tool.info.name) + if new_enabled != previous_enabled: + ToolRegistry._bump_revision("tool_setting_update") + return new_enabled # Routes @@ -971,10 +975,16 @@ async def reset_tool_setting(tool_name: str, _admin: object = Depends(require_ad detail=f"Tool not found: {tool_name}", ) - removed = ConfigWriter.delete_tool_setting(tool_name) - default = _get_default_enabled(tool.info) - new_enabled = default and _service_allows_enable(tool.info) - tool.info.enabled = new_enabled + with ToolRegistry._refresh_lock: + removed = ConfigWriter.delete_tool_setting(tool_name) + default = _get_default_enabled(tool.info) + new_enabled = default and _service_allows_enable(tool.info) + previous_enabled = bool(tool.info.enabled) + tool.info.enabled = new_enabled + if new_enabled: + ToolRegistry._reset_failure_state(tool_name) + if new_enabled != previous_enabled: + ToolRegistry._bump_revision("tool_setting_reset") _invalidate_tool_summary_cache() log.info("tool.setting.reset", { diff --git a/flocks/tool/registry.py b/flocks/tool/registry.py index 24f4bffed..b172a847d 100644 --- a/flocks/tool/registry.py +++ b/flocks/tool/registry.py @@ -618,6 +618,7 @@ class ToolRegistry: # diagnostic only because the plugin loader already isolates each source. _plugin_load_errors: List[str] = [] _revision: int = 0 + _config_state_token: Optional[tuple[str, int, int, int]] = None _failure_state: Dict[str, Dict[str, Any]] = {} _failure_disable_threshold: int = 3 _init_lock = threading.Lock() @@ -678,22 +679,80 @@ def register(cls, tool: Tool) -> None: def revision(cls) -> int: """Return the current registry revision. - The revision is bumped when plugin or dynamic tools are reloaded so - long-lived session caches can detect toolset changes. + The revision is bumped when tool membership or enabled state changes + so long-lived session caches can detect toolset changes. """ + cls._ensure_initialized() + cls._sync_configured_enabled_states() with cls._refresh_lock: return cls._revision + @classmethod + def _current_config_state_token(cls) -> tuple[str, int, int, int]: + """Return a cross-process token for the active flocks config file.""" + from flocks.config.config_writer import ConfigWriter + + path = ConfigWriter._get_config_path() + try: + stat = path.stat() + return str(path), stat.st_ino, stat.st_mtime_ns, stat.st_size + except OSError: + return str(path), -1, -1, -1 + + @classmethod + def _sync_configured_enabled_states(cls) -> None: + """Apply config changes written by another server worker. + + Registry revisions and ``ToolInfo.enabled`` values are process-local, + while ``flocks.json`` is shared. Rebuild enabled states whenever the + atomically replaced config file changes so a request handled by a + different worker immediately observes tool/service toggles and + repeated-failure auto-disables. + """ + try: + token = cls._current_config_state_token() + except Exception: + return + + with cls._refresh_lock: + if token == cls._config_state_token: + return + + previous_states = { + name: bool(tool.info.enabled) + for name, tool in cls._tools.items() + } + for name, tool in cls._tools.items(): + default_enabled = cls._enabled_defaults.get(name) + if default_enabled is not None: + tool.info.enabled = default_enabled + cls._sync_api_service_states() + cls._apply_tool_settings() + cls._config_state_token = token + + changed_names = [ + name + for name, tool in cls._tools.items() + if previous_states[name] != bool(tool.info.enabled) + ] + for name in changed_names: + if cls._tools[name].info.enabled: + cls._reset_failure_state(name) + if changed_names: + cls._bump_revision("config_enabled_state_sync") + @classmethod def _bump_revision(cls, reason: str) -> None: """Advance the registry revision and invalidate agent prompt caches.""" - cls._revision += 1 + with cls._refresh_lock: + cls._revision += 1 + revision = cls._revision try: from flocks.agent.registry import Agent Agent.invalidate_cache() except Exception as e: log.debug("tool.revision.agent_invalidate_failed", {"error": str(e)}) - log.debug("tool.registry.revision.bumped", {"revision": cls._revision, "reason": reason}) + log.debug("tool.registry.revision.bumped", {"revision": revision, "reason": reason}) @classmethod def register_function( @@ -766,6 +825,7 @@ def _ensure_initialized(cls) -> None: def get(cls, name: str) -> Optional[Tool]: """Get a tool by name""" cls._ensure_initialized() + cls._sync_configured_enabled_states() with cls._refresh_lock: return cls._tools.get(name) @@ -773,6 +833,7 @@ def get(cls, name: str) -> Optional[Tool]: def list_tools(cls, category: Optional[ToolCategory] = None) -> List[ToolInfo]: """List all registered tools, optionally filtered by category""" cls._ensure_initialized() + cls._sync_configured_enabled_states() with cls._refresh_lock: tools = list(cls._tools.values()) if category: @@ -1017,6 +1078,7 @@ def all_tool_ids(cls) -> List[str]: def snapshot_identity(cls) -> tuple[int, tuple[str, ...]]: """Return a revision/tool-id identity from one consistent registry state.""" cls._ensure_initialized() + cls._sync_configured_enabled_states() with cls._refresh_lock: return cls._revision, tuple(cls._tools.keys()) @@ -1752,7 +1814,7 @@ def refresh_dynamic_tools(cls) -> List[str]: @classmethod def _reset_failure_state(cls, tool_name: str) -> None: """Reset failure tracking for a tool after success.""" - if tool_name in cls._failure_state: + with cls._refresh_lock: cls._failure_state.pop(tool_name, None) @classmethod @@ -1817,23 +1879,34 @@ def _record_failure(cls, tool: Tool, params: Dict[str, Any], error: Optional[str tool_name = tool.info.name key = cls._failure_key(tool_name, params, error) - state = cls._failure_state.get(tool_name, {"key": None, "count": 0}) + with cls._refresh_lock: + state = cls._failure_state.get(tool_name, {"key": None, "count": 0}) - if state.get("key") == key: - state["count"] = state.get("count", 0) + 1 - else: - state = {"key": key, "count": 1} + if state.get("key") == key: + state["count"] = state.get("count", 0) + 1 + else: + state = {"key": key, "count": 1} - cls._failure_state[tool_name] = state + cls._failure_state[tool_name] = state - if state["count"] >= cls._failure_disable_threshold: - tool.info.enabled = False - log.warn("tool.disabled.repeated_error", { - "tool": tool_name, - "count": state["count"], - "error": error, - }) - return True + if state["count"] >= cls._failure_disable_threshold and tool.info.enabled: + tool.info.enabled = False + try: + from flocks.config.config_writer import ConfigWriter + + ConfigWriter.set_tool_setting(tool_name, {"enabled": False}) + except Exception as exc: + log.warn("tool.disabled.repeated_error.persist_failed", { + "tool": tool_name, + "error": str(exc), + }) + cls._bump_revision("failure_auto_disable") + log.warn("tool.disabled.repeated_error", { + "tool": tool_name, + "count": state["count"], + "error": error, + }) + return True return False diff --git a/tests/server/routes/test_tool_routes.py b/tests/server/routes/test_tool_routes.py index faa087fa8..036e8f68c 100644 --- a/tests/server/routes/test_tool_routes.py +++ b/tests/server/routes/test_tool_routes.py @@ -26,6 +26,7 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry.init() existing = ToolRegistry._tools.get(tool.info.name) + existing_default = ToolRegistry._enabled_defaults.get(tool.info.name) ToolRegistry.register(tool) _clear_tool_summary_cache() try: @@ -36,6 +37,10 @@ def _temporary_tool(tool: Tool) -> Iterator[None]: ToolRegistry._tools[tool.info.name] = existing else: ToolRegistry._tools.pop(tool.info.name, None) + if existing_default is not None: + ToolRegistry._enabled_defaults[tool.info.name] = existing_default + else: + ToolRegistry._enabled_defaults.pop(tool.info.name, None) _clear_tool_summary_cache() @@ -405,6 +410,67 @@ async def handler(ctx) -> ToolResult: class TestToolListPageRoute: + @pytest.mark.asyncio + async def test_auto_disable_syncs_cached_page_across_worker_state(self, client: AsyncClient): + from flocks.config.config_writer import ConfigWriter + + async def handler(_ctx, probe_id: str) -> ToolResult: + return ToolResult(success=False, error=f"repeated failure for {probe_id}") + + tool = Tool( + info=ToolInfo( + name="page_auto_disable_cache_tool", + description="NeedleAutoDisableCache", + category=ToolCategory.CUSTOM, + source="plugin_py", + ), + handler=handler, + ) + + with _temporary_tool(tool): + initial = await client.get( + "/api/tools/page", + params={"q": "needleautodisablecache", "limit": 25}, + ) + for _ in range(ToolRegistry._failure_disable_threshold): + result = await client.post( + f"/api/tools/{tool.info.name}/test", + json={"params": {"probe_id": "same-input"}}, + ) + setting = ConfigWriter.get_tool_setting(tool.info.name) + # Simulate a second worker whose in-memory registry and list cache + # still contain the pre-disable state. The persisted config token + # must make the next page request repair both immediately. + tool.info.enabled = True + refreshed = await client.get( + "/api/tools/page", + params={"q": "needleautodisablecache", "limit": 25}, + ) + disabled_after_refresh = tool.info.enabled + + # The reverse transition must also propagate: another worker can + # manually restore the default by deleting the disable overlay. + removed = ConfigWriter.delete_tool_setting(tool.info.name) + tool.info.enabled = False + reenabled = await client.get( + "/api/tools/page", + params={"q": "needleautodisablecache", "limit": 25}, + ) + + assert initial.status_code == 200, initial.text + assert initial.json()["items"][0]["enabled"] is True + assert result.status_code == 200, result.text + assert result.json()["metadata"]["disabled"] is True + assert setting == {"enabled": False} + assert disabled_after_refresh is False + assert refreshed.status_code == 200, refreshed.text + assert refreshed.json()["items"][0]["enabled"] is False + assert removed is True + assert tool.info.enabled is True + assert tool.info.name not in ToolRegistry._failure_state + assert reenabled.status_code == 200, reenabled.text + assert reenabled.json()["items"][0]["enabled"] is True + @pytest.mark.asyncio async def test_list_page_searches_server_side_and_omits_parameter_payload(self, client: AsyncClient): async def handler(ctx, text: str) -> ToolResult: diff --git a/tests/server/test_tool_setting_routes.py b/tests/server/test_tool_setting_routes.py index 79f7c1cf2..8ee244019 100644 --- a/tests/server/test_tool_setting_routes.py +++ b/tests/server/test_tool_setting_routes.py @@ -34,7 +34,10 @@ # ─── Fixtures ──────────────────────────────────────────────────────────────── -def _stub_api_tool(name: str, *, enabled: bool, provider: str = "onesec_api") -> Tool: +_TEST_SERVICE_ID = "test_api_service" + + +def _stub_api_tool(name: str, *, enabled: bool, provider: str = _TEST_SERVICE_ID) -> Tool: async def handler(ctx: ToolContext, value: str = "ok") -> ToolResult: return ToolResult(success=True, output=value) @@ -45,6 +48,7 @@ async def handler(ctx: ToolContext, value: str = "ok") -> ToolResult: category=ToolCategory.CUSTOM, enabled=enabled, provider=provider, + source="api", ), handler=handler, ) @@ -69,6 +73,8 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): saved_tools = dict(ToolRegistry._tools) saved_defaults = dict(ToolRegistry._enabled_defaults) saved_failure_state = dict(ToolRegistry._failure_state) + saved_revision = ToolRegistry._revision + saved_config_state_token = ToolRegistry._config_state_token saved_initialized = ToolRegistry._initialized enabled_tool = _stub_api_tool("onesec_dns_test", enabled=True) @@ -103,10 +109,12 @@ def tool_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): ToolRegistry._tools = saved_tools ToolRegistry._enabled_defaults = saved_defaults ToolRegistry._failure_state = saved_failure_state + ToolRegistry._revision = saved_revision + ToolRegistry._config_state_token = saved_config_state_token ToolRegistry._initialized = saved_initialized -def _set_service(*, enabled: bool, sid: str = "onesec_api") -> None: +def _set_service(*, enabled: bool, sid: str = _TEST_SERVICE_ID) -> None: from flocks.config.config_writer import ConfigWriter ConfigWriter.set_api_service(sid, { "apiKey": "{secret:test_key}", @@ -198,11 +206,16 @@ class TestUpdateTool: def test_manual_reenable_clears_repeated_failure_count(self, tool_client): client, enabled_tool, _ = tool_client _set_service(enabled=True) + revision_before = ToolRegistry.revision() enabled_tool.info.enabled = False ToolRegistry._failure_state[enabled_tool.info.name] = { "key": "same-failure", "count": ToolRegistry._failure_disable_threshold, } + ToolRegistry._failure_state[enabled_tool.info.name] = { + "key": "same-failure", + "count": ToolRegistry._failure_disable_threshold, + } res = client.patch( f"/api/tools/{enabled_tool.info.name}", @@ -212,6 +225,7 @@ def test_manual_reenable_clears_repeated_failure_count(self, tool_client): assert res.status_code == 200 assert res.json()["enabled"] is True assert enabled_tool.info.name not in ToolRegistry._failure_state + assert ToolRegistry.revision() == revision_before + 1 def test_overlay_persisted_when_differs_from_default(self, tool_client): client, _, disabled_tool = tool_client @@ -333,7 +347,75 @@ def test_device_disable_writes_false_override_only( delete_override.assert_not_awaited() +class TestApiServiceToolSync: + def test_service_enable_does_not_resurrect_disabled_tool_overlay( + self, + tool_client, + monkeypatch: pytest.MonkeyPatch, + ): + client, enabled_tool, _ = tool_client + _set_service(enabled=True) + client.patch(f"/api/tools/{enabled_tool.info.name}", json={"enabled": False}) + assert enabled_tool.info.enabled is False + + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr( + provider_routes, + "_get_api_service_tool_infos", + lambda _provider_id: [enabled_tool.info, tool_client[2].info], + ) + + matched = provider_routes._set_api_service_tools_enabled(_TEST_SERVICE_ID, True) + + assert matched == 2 + assert enabled_tool.info.enabled is False + assert _read_settings() == {enabled_tool.info.name: {"enabled": False}} + + def test_service_state_change_bumps_tool_revision( + self, + tool_client, + monkeypatch: pytest.MonkeyPatch, + ): + _, enabled_tool, disabled_tool = tool_client + _set_service(enabled=True) + revision_before = ToolRegistry.revision() + enabled_tool.info.enabled = False + + from flocks.server.routes import provider as provider_routes + + monkeypatch.setattr( + provider_routes, + "_get_api_service_tool_infos", + lambda _provider_id: [enabled_tool.info, disabled_tool.info], + ) + + provider_routes._set_api_service_tools_enabled(_TEST_SERVICE_ID, True) + + assert enabled_tool.info.enabled is True + assert disabled_tool.info.enabled is False + assert enabled_tool.info.name not in ToolRegistry._failure_state + assert ToolRegistry.revision() == revision_before + 1 + + class TestResetToolSetting: + def test_reset_reenable_clears_failure_count_and_bumps_revision(self, tool_client): + client, enabled_tool, _ = tool_client + _set_service(enabled=True) + client.patch(f"/api/tools/{enabled_tool.info.name}", json={"enabled": False}) + ToolRegistry._failure_state[enabled_tool.info.name] = { + "key": "same-failure", + "count": ToolRegistry._failure_disable_threshold, + } + revision_before = ToolRegistry.revision() + + res = client.post(f"/api/tools/{enabled_tool.info.name}/reset") + + assert res.status_code == 200 + assert res.json()["enabled"] is True + assert enabled_tool.info.name not in ToolRegistry._failure_state + assert ToolRegistry.revision() == revision_before + 1 + def test_reset_restores_default_and_removes_overlay(self, tool_client): client, _, disabled_tool = tool_client _set_service(enabled=True) diff --git a/tests/tool/test_failure_auto_disable_config.py b/tests/tool/test_failure_auto_disable_config.py index 2f3bf692b..b54c654cb 100644 --- a/tests/tool/test_failure_auto_disable_config.py +++ b/tests/tool/test_failure_auto_disable_config.py @@ -1,8 +1,12 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + import pytest from flocks.config.config import Config, ConfigInfo, ToolFailureConfig +from flocks.config.config_writer import ConfigWriter from flocks.tool.registry import ( Tool, ToolCategory, @@ -34,7 +38,9 @@ def isolated_failure_tracking(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(ToolRegistry, "_initialized", True) monkeypatch.setattr(ToolRegistry, "_tools", {tool.info.name: tool}) monkeypatch.setattr(ToolRegistry, "_failure_state", {}) + monkeypatch.setattr(ToolRegistry, "_revision", 41) monkeypatch.setattr(Config, "_cached_config", ConfigInfo()) + monkeypatch.setattr(ConfigWriter, "set_tool_setting", lambda _name, _setting: None) return tool @@ -72,6 +78,33 @@ async def test_repeated_failures_disable_tool_by_default( "disabled": True, "disabled_reason": "repeated_error", } + assert ToolRegistry.revision() == 42 + + +def test_concurrent_failures_commit_one_disable_transition( + isolated_failure_tracking: Tool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + tool = isolated_failure_tracking + params = {"query": "same"} + error = "synthetic repeated failure" + ToolRegistry._failure_state[tool.info.name] = { + "key": ToolRegistry._failure_key(tool.info.name, params, error), + "count": ToolRegistry._failure_disable_threshold - 1, + } + persist_setting = Mock() + monkeypatch.setattr(ConfigWriter, "set_tool_setting", persist_setting) + + with ThreadPoolExecutor(max_workers=8) as executor: + transitions = list(executor.map( + lambda _: ToolRegistry._record_failure(tool, params, error), + range(8), + )) + + assert transitions.count(True) == 1 + assert tool.info.enabled is False + assert ToolRegistry.revision() == 42 + persist_setting.assert_called_once_with(tool.info.name, {"enabled": False}) @pytest.mark.asyncio diff --git a/webui/src/hooks/useTools.test.tsx b/webui/src/hooks/useTools.test.tsx index 62fa7f2f8..a346b2e4a 100644 --- a/webui/src/hooks/useTools.test.tsx +++ b/webui/src/hooks/useTools.test.tsx @@ -414,6 +414,39 @@ describe('useTools', () => { expect(listPageMock).toHaveBeenCalledTimes(2); }); + it('reloads an auto-disabled tool without refreshing plugins', async () => { + listPageMock + .mockResolvedValueOnce({ + data: { + items: [{ name: 'tool-alpha', description: 'alpha tool', category: 'custom', source: 'custom', enabled: true }], + total: 1, + offset: 0, + limit: 20, + facets: emptyFacets, + }, + }) + .mockResolvedValueOnce({ + data: { + items: [{ name: 'tool-alpha', description: 'alpha tool', category: 'custom', source: 'custom', enabled: false }], + total: 1, + offset: 0, + limit: 20, + facets: emptyFacets, + }, + }); + + const { result } = renderHook(() => useToolPage({ offset: 0, limit: 20 })); + await waitFor(() => expect(result.current.tools[0]?.enabled).toBe(true)); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.tools[0]?.enabled).toBe(false); + expect(refreshMock).not.toHaveBeenCalled(); + expect(listPageMock).toHaveBeenCalledTimes(2); + }); + it('reports a paged plugin refresh failure after reloading the visible page', async () => { listPageMock.mockResolvedValue({ data: { diff --git a/webui/src/hooks/useTools.ts b/webui/src/hooks/useTools.ts index 28b09db34..4d40d93c2 100644 --- a/webui/src/hooks/useTools.ts +++ b/webui/src/hooks/useTools.ts @@ -111,6 +111,11 @@ function refreshToolPlugins(): Promise { return toolRefreshInFlight; } +function invalidateToolListResources(): void { + toolsResource.invalidate(); + toolPageResources.forEach((resource) => resource.invalidate()); +} + async function refreshToolsResource( fetchVisible: () => Promise, ): Promise { @@ -122,8 +127,7 @@ async function refreshToolsResource( refreshError = error; } - toolsResource.invalidate(); - toolPageResources.forEach((resource) => resource.invalidate()); + invalidateToolListResources(); let listError: unknown; try { await fetchVisible(); @@ -220,6 +224,15 @@ export function useToolPage(params: ToolListPageParams) { [resource], ); + const reload = useCallback(() => { + invalidateToolListResources(); + return resource.fetch({ + force: true, + silent: true, + rejectOnError: true, + }); + }, [resource]); + return { tools: data.items, total: data.total, @@ -230,5 +243,6 @@ export function useToolPage(params: ToolListPageParams) { error, initialized, refetch, + reload, }; } diff --git a/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx new file mode 100644 index 000000000..aad856925 --- /dev/null +++ b/webui/src/pages/Tool/ToolPageAutoDisable.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import ToolPage from './index'; + +const { + getToolMock, + listFixturesMock, + refetchMock, + reloadMock, + testToolMock, +} = vi.hoisted(() => ({ + getToolMock: vi.fn(), + listFixturesMock: vi.fn(), + refetchMock: vi.fn(), + reloadMock: vi.fn(), + testToolMock: vi.fn(), +})); + +const enabledTool = { + name: 'failing_custom_tool', + description: 'Always fails', + source: 'plugin_py', + category: 'custom', + enabled: true, + parameters: [], +}; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + typeof options?.defaultValue === 'string' ? options.defaultValue : key, + i18n: { language: 'zh-CN' }, + }), +})); + +vi.mock('@/components/common/Toast', () => ({ + useToast: () => ({ + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})); + +vi.mock('@/hooks/useTools', () => ({ + useToolPage: () => ({ + tools: [enabledTool], + total: 1, + facets: { + category: { custom: 1 }, + source: { plugin_py: 1 }, + source_groups: {}, + source_name: { Flocks: 1 }, + enabled: { true: 1 }, + }, + loading: false, + error: null, + initialized: true, + refetch: refetchMock, + reload: reloadMock, + }), +})); + +vi.mock('@/api/tool', () => ({ + canDirectlyTestTool: vi.fn(() => true), + toolAPI: { + get: getToolMock, + listFixtures: listFixturesMock, + test: testToolMock, + }, +})); + +vi.mock('@/api/provider', () => ({ + providerAPI: { + listApiServices: vi.fn(() => Promise.resolve({ data: [] })), + }, +})); + +describe('ToolPage auto-disable synchronization', () => { + beforeEach(() => { + vi.clearAllMocks(); + getToolMock.mockResolvedValue({ data: enabledTool }); + listFixturesMock.mockResolvedValue({ data: [] }); + reloadMock.mockResolvedValue(undefined); + testToolMock.mockResolvedValue({ + data: { + success: false, + error: 'synthetic repeated failure', + metadata: { disabled: true, disabled_reason: 'repeated_error' }, + }, + }); + }); + + it('reloads list state without refreshing plugins after an auto-disable result', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: enabledTool.name })); + await screen.findByText(enabledTool.description); + fireEvent.click(screen.getByRole('button', { name: 'toolDetail.tabTest' })); + fireEvent.click(screen.getByRole('button', { name: 'toolDetail.runTest' })); + + await waitFor(() => expect(reloadMock).toHaveBeenCalledTimes(1)); + expect(refetchMock).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'toolDetail.runTest' })).toBeDisabled(); + }); +}); diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index 1eec91f8c..145828e34 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -227,6 +227,7 @@ export default function ToolPage() { error, initialized: toolPageInitialized, refetch, + reload: reloadToolPage, } = useToolPage(toolPageParams); const [hasLoadedToolPage, setHasLoadedToolPage] = useState(false); @@ -428,7 +429,12 @@ export default function ToolPage() { ? { ...current, enabled: false } : current )); - void refreshToolDataAfterMutation(); + void reloadToolPage().catch((err: unknown) => { + toast.error( + t('alert.refreshFailedTitle'), + extractErrorMessage(err, t('alert.unknownError')), + ); + }); } } catch (err: any) { setTestResult({ success: false, error: err.message });