已合并
refactor: 调整runtime 中代码规范 #85
refactor: 调整runtime 中代码规范 #85
已合并
GYHHelloworld创建于 4月13日
29 个文件变更+523-327
@@ -47,4 +47,3 @@ def setup_error_file_logging() -> Path:
47 lg.setLevel(logging.ERROR)47 lg.setLevel(logging.ERROR)
48 48 
49 return log_path49 return log_path
50- 
@@ -23,14 +23,22 @@ from typing import Any, Dict, List, Optional
23 23 
24 24 
25_AGUI_TEXT_DELTA_FLUSH_CHARS = max(1, int(os.environ.get("AGUI_TEXT_DELTA_FLUSH_CHARS", "24")))25_AGUI_TEXT_DELTA_FLUSH_CHARS = max(1, int(os.environ.get("AGUI_TEXT_DELTA_FLUSH_CHARS", "24")))
26-_AGUI_TEXT_DELTA_FLUSH_ON_TAIL = tuple(26+ 
27- marker.strip()27+ 
28- for marker in os.environ.get(28+def _agui_flush_tail_markers() -> tuple[str, ...]:
29+ raw = os.environ.get(
29 "AGUI_TEXT_DELTA_FLUSH_ON_TAIL",30 "AGUI_TEXT_DELTA_FLUSH_ON_TAIL",
30 ".,,,,,。,!,?,!,?,;,;,\n",31 ".,,,,,。,!,?,!,?,;,;,\n",
31- ).split(",")32+ )
32- if marker.strip()33+ out: list[str] = []
33-)34+ for marker in raw.split(","):
35+ m = marker.strip()
36+ if m:
37+ out.append(m)
38+ return tuple(out)
39+ 
40+ 
41+_AGUI_TEXT_DELTA_FLUSH_ON_TAIL = _agui_flush_tail_markers()
34 42 
35 43 
36@dataclass44@dataclass
@@ -177,7 +177,8 @@ def _setup_logging():
177 log_dir = os.path.join(venv_path, "logs")177 log_dir = os.path.join(venv_path, "logs")
178 log_level_name = os.environ.get("LOWCODE_AGENT_LOG_LEVEL", "INFO").upper()178 log_level_name = os.environ.get("LOWCODE_AGENT_LOG_LEVEL", "INFO").upper()
179 log_level = getattr(logging, log_level_name, logging.INFO)179 log_level = getattr(logging, log_level_name, logging.INFO)
180- disable_global_stream_log = os.environ.get("LOWCODE_AGENT_DISABLE_GLOBAL_STREAM_LOG", "1").lower() in ("1", "true", "yes", "on")180+ _disable_raw = os.environ.get("LOWCODE_AGENT_DISABLE_GLOBAL_STREAM_LOG", "1").lower()
181+ disable_global_stream_log = _disable_raw in ("1", "true", "yes", "on")
181 182 
182 # 确保日志目录存在183 # 确保日志目录存在
183 os.makedirs(log_dir, exist_ok=True)184 os.makedirs(log_dir, exist_ok=True)
@@ -191,17 +192,17 @@ def _setup_logging():
191 global _ALLOWED_LOG_HANDLER_IDS192 global _ALLOWED_LOG_HANDLER_IDS
192 193 
193 # 配置 root logger194 # 配置 root logger
194- logger = logging.getLogger("lowcode_agent")195+ agent_logger = logging.getLogger("lowcode_agent")
195- logger.setLevel(log_level)196+ agent_logger.setLevel(log_level)
196 197 
197 # 避免重复添加 handler198 # 避免重复添加 handler
198- if not logger.handlers:199+ if not agent_logger.handlers:
199 # 文件 handler200 # 文件 handler
200 file_handler = logging.FileHandler(log_file, encoding='utf-8')201 file_handler = logging.FileHandler(log_file, encoding='utf-8')
201 file_handler.setLevel(log_level)202 file_handler.setLevel(log_level)
202 file_formatter = logging.Formatter(log_format, datefmt=date_format)203 file_formatter = logging.Formatter(log_format, datefmt=date_format)
203 file_handler.setFormatter(file_formatter)204 file_handler.setFormatter(file_formatter)
204- logger.addHandler(file_handler)205+ agent_logger.addHandler(file_handler)
205 _ALLOWED_LOG_HANDLER_IDS = {id(file_handler)}206 _ALLOWED_LOG_HANDLER_IDS = {id(file_handler)}
206 207 
207 # ==================== 捕获 openjiuwen 模块日志 ====================208 # ==================== 捕获 openjiuwen 模块日志 ====================
@@ -235,7 +236,7 @@ def _setup_logging():
235 def _strict_add_handler(self, hdlr):236 def _strict_add_handler(self, hdlr):
236 target_name = getattr(self, "name", "")237 target_name = getattr(self, "name", "")
237 if target_name in _STRICT_LOGGER_HANDLER_NAMES and id(hdlr) not in _ALLOWED_LOG_HANDLER_IDS:238 if target_name in _STRICT_LOGGER_HANDLER_NAMES and id(hdlr) not in _ALLOWED_LOG_HANDLER_IDS:
238- return239+ return None
239 return original_add_handler(self, hdlr)240 return original_add_handler(self, hdlr)
240 logging.Logger.addHandler = _strict_add_handler241 logging.Logger.addHandler = _strict_add_handler
241 setattr(logging.Logger, "_lowcode_strict_add_handler_patched", True)242 setattr(logging.Logger, "_lowcode_strict_add_handler_patched", True)
@@ -264,15 +265,18 @@ def _setup_logging():
264 if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler):265 if isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler):
265 target_logger.removeHandler(h)266 target_logger.removeHandler(h)
266 267 
267- logger.info("=" * 60)268+ agent_logger.info("=" * 60)
268- logger.info(f"Lowcode Agent Runner 启动")269+ agent_logger.info("Lowcode Agent Runner 启动")
269- logger.info(f"虚拟环境路径: {venv_path}")270+ agent_logger.info("虚拟环境路径: %s", venv_path)
270- logger.info(f"日志文件路径: {log_file}")271+ agent_logger.info("日志文件路径: %s", log_file)
271- logger.info(f"全局 StreamHandler 移除: {'开启' if disable_global_stream_log else '关闭'}")272+ agent_logger.info(
272- logger.info(f"已捕获 openjiuwen 模块日志: {', '.join(openjiuwen_loggers)}")273+ "全局 StreamHandler 移除: %s",
273- logger.info("=" * 60)274+ "开启" if disable_global_stream_log else "关闭",
275+ )
276+ agent_logger.info("已捕获 openjiuwen 模块日志: %s", ", ".join(openjiuwen_loggers))
277+ agent_logger.info("=" * 60)
274 278 
275- return logger279+ return agent_logger
276 280 
277 281 
278def _summarize_chunk_for_log(chunk) -> str:282def _summarize_chunk_for_log(chunk) -> str:
@@ -417,7 +421,6 @@ async def init():
417 421 
418 # 启动 Runner422 # 启动 Runner
419 logger.info("启动 Runner...")423 logger.info("启动 Runner...")
420- from openjiuwen.core.runner import Runner
421 424 
422 # 设置工作流超时时间(支持从环境变量获取,默认 5 分钟)425 # 设置工作流超时时间(支持从环境变量获取,默认 5 分钟)
423 workflow_timeout = os.environ.get("WORKFLOW_EXECUTE_TIMEOUT", "300")426 workflow_timeout = os.environ.get("WORKFLOW_EXECUTE_TIMEOUT", "300")
@@ -8,7 +8,14 @@ from __future__ import annotations
8 8 
9from typing import Any, Dict, Optional9from typing import Any, Dict, Optional
10 10 
11-from openjiuwen_studio.core.common.dsl import McpConfig, McpTransport, Param, PluginCodeConfig, PluginType, RestfulApiSchema11+from openjiuwen_studio.core.common.dsl import (
12+ McpConfig,
13+ McpTransport,
14+ Param,
15+ PluginCodeConfig,
16+ PluginType,
17+ RestfulApiSchema,
18+)
12from openjiuwen_studio.core.executor.plugin.plugin_mgr import PluginManager19from openjiuwen_studio.core.executor.plugin.plugin_mgr import PluginManager
13from openjiuwen_studio.core.executor.plugin.plugin_tools import CodeTool, McpTool, ServiceTool20from openjiuwen_studio.core.executor.plugin.plugin_tools import CodeTool, McpTool, ServiceTool
14 21 
@@ -99,7 +106,10 @@ def _build_service_tool(plugin_data: Dict[str, Any], tool_data: Dict[str, Any])
99 params=[_build_param(p) for p in (tool_data.get("request_params") or tool_data.get("inputs") or [])],106 params=[_build_param(p) for p in (tool_data.get("request_params") or tool_data.get("inputs") or [])],
100 path=path,107 path=path,
101 headers=dict(tool_data.get("headers") or plugin_data.get("headers") or {}),108 headers=dict(tool_data.get("headers") or plugin_data.get("headers") or {}),
102- method=method_map.get(str(tool_data.get("method") or "").lower(), str(tool_data.get("method") or "GET").upper()),109+ method=method_map.get(
110+ str(tool_data.get("method") or "").lower(),
111+ str(tool_data.get("method") or "GET").upper(),
112+ ),
103 response=[_build_param(p) for p in (tool_data.get("response_params") or tool_data.get("outputs") or [])],113 response=[_build_param(p) for p in (tool_data.get("response_params") or tool_data.get("outputs") or [])],
104 )114 )
105 )115 )
@@ -177,7 +187,10 @@ class RuntimePluginManager(PluginManager):
177 if record is None and tool_id == plugin_id:187 if record is None and tool_id == plugin_id:
178 record = self._plugin_default_tool.get((plugin_id, ""))188 record = self._plugin_default_tool.get((plugin_id, ""))
179 if record is None:189 if record is None:
180- raise ValueError(f"Runtime plugin tool not found: plugin_id={plugin_id}, tool_id={tool_id}, version={version}")190+ raise ValueError(
191+ f"Runtime plugin tool not found: plugin_id={plugin_id}, "
192+ f"tool_id={tool_id}, version={version}"
193+ )
181 194 
182 plugin_data, tool_data, plugin_type = record195 plugin_data, tool_data, plugin_type = record
183 if plugin_type == PluginType.SERVICE:196 if plugin_type == PluginType.SERVICE:
@@ -8,13 +8,17 @@
8 8 
9import asyncio9import asyncio
10import json10import json
11-import sys
12from pathlib import Path11from pathlib import Path
13 12 
14import click13import click
15from dotenv import load_dotenv14from dotenv import load_dotenv
16 15 
17-from openjiuwen_runtime.management.manager import DeploymentManager16+from openjiuwen_runtime.management import (
17+ DeployAgentParams,
18+ DeployPluginParams,
19+ DeploymentManager,
20+ ListDeploymentsParams,
21+)
18from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus22from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus
19from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler23from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
20 24 
@@ -76,27 +80,30 @@ def deploy(ctx, python_file_path, name, port):
76 80 
77 async def _deploy():81 async def _deploy():
78 result = await manager.deploy_agent(82 result = await manager.deploy_agent(
79- name=name,83+ DeployAgentParams(
80- version="1.0.0",84+ name=name,
81- python_file_path=python_file_path,85+ version="1.0.0",
82- port=port,86+ extras={"python_file_path": python_file_path, "port": port},
87+ )
83 )88 )
84 click.echo(json.dumps(result, indent=2, ensure_ascii=False))89 click.echo(json.dumps(result, indent=2, ensure_ascii=False))
85 90 
86 asyncio.run(_deploy())91 asyncio.run(_deploy())
87 92 
88 93 
89-@agent.command()94+@agent.command(name="list")
90@click.option("--status", type=click.Choice(["pending", "running", "stopped", "failed"]), help="过滤状态")95@click.option("--status", type=click.Choice(["pending", "running", "stopped", "failed"]), help="过滤状态")
91@click.pass_context96@click.pass_context
92-def list(ctx, status):97+def list_deployments(ctx, status):
93 """查询 Agent 列表"""98 """查询 Agent 列表"""
94 manager = ctx.obj["manager"]99 manager = ctx.obj["manager"]
95 100 
96 async def _list():101 async def _list():
97 deployments = await manager.list_deployments(102 deployments = await manager.list_deployments(
98- deployment_type=DeploymentType.AGENT,103+ ListDeploymentsParams(
99- deployment_status=DeploymentStatus(status) if status else None,104+ deployment_type=DeploymentType.AGENT,
105+ deployment_status=DeploymentStatus(status) if status else None,
106+ )
100 )107 )
101 108 
102 if not deployments:109 if not deployments:
@@ -107,11 +114,15 @@ def list(ctx, status):
107 click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}")114 click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}")
108 click.echo("-" * 120)115 click.echo("-" * 120)
109 for dep in deployments:116 for dep in deployments:
110- name = dep.get('name') or '-'117+ name = dep.get("name") or "-"
111- dep_status = dep['status'].value if hasattr(dep['status'], 'value') else str(dep['status'])118+ dep_status = dep["status"].value if hasattr(dep["status"], "value") else str(dep["status"])
112- package_name = dep.get('package_name') or '-'119+ package_name = dep.get("package_name") or "-"
113- url = dep.get('url') or '-'120+ url = dep.get("url") or "-"
114- click.echo(f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} {dep['port']:<6} {package_name:<20} {url:<30}")121+ row = (
122+ f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} "
123+ f"{dep['port']:<6} {package_name:<20} {url:<30}"
124+ )
125+ click.echo(row)
115 126 
116 asyncio.run(_list())127 asyncio.run(_list())
117 128 
@@ -129,7 +140,7 @@ def get(ctx, deployment_id):
129 click.echo(json.dumps(deployment, indent=2, ensure_ascii=False))140 click.echo(json.dumps(deployment, indent=2, ensure_ascii=False))
130 else:141 else:
131 click.echo(f"Deployment {deployment_id} not found", err=True)142 click.echo(f"Deployment {deployment_id} not found", err=True)
132- sys.exit(1)143+ raise click.Abort()
133 144 
134 asyncio.run(_get())145 asyncio.run(_get())
135 146 
@@ -147,7 +158,7 @@ def delete(ctx, deployment_id):
147 click.echo(f"Deployment {deployment_id} deleted")158 click.echo(f"Deployment {deployment_id} deleted")
148 else:159 else:
149 click.echo(f"Deployment {deployment_id} not found", err=True)160 click.echo(f"Deployment {deployment_id} not found", err=True)
150- sys.exit(1)161+ raise click.Abort()
151 162 
152 asyncio.run(_delete())163 asyncio.run(_delete())
153 164 
@@ -180,27 +191,30 @@ def deploy(ctx, python_file_path, name, port):
180 191 
181 async def _deploy():192 async def _deploy():
182 result = await manager.deploy_plugin(193 result = await manager.deploy_plugin(
183- name=name,194+ DeployPluginParams(
184- version="1.0.0",195+ name=name,
185- python_file_path=python_file_path,196+ version="1.0.0",
186- port=port,197+ extras={"python_file_path": python_file_path, "port": port},
198+ )
187 )199 )
188 click.echo(json.dumps(result, indent=2, ensure_ascii=False))200 click.echo(json.dumps(result, indent=2, ensure_ascii=False))
189 201 
190 asyncio.run(_deploy())202 asyncio.run(_deploy())
191 203 
192 204 
193-@plugin.command()205+@plugin.command(name="list")
194@click.option("--status", type=click.Choice(["pending", "running", "stopped", "failed"]), help="过滤状态")206@click.option("--status", type=click.Choice(["pending", "running", "stopped", "failed"]), help="过滤状态")
195@click.pass_context207@click.pass_context
196-def list(ctx, status):208+def list_plugin_deployments(ctx, status):
197 """查询 Plugin 列表"""209 """查询 Plugin 列表"""
198 manager = ctx.obj["manager"]210 manager = ctx.obj["manager"]
199 211 
200 async def _list():212 async def _list():
201 deployments = await manager.list_deployments(213 deployments = await manager.list_deployments(
202- deployment_type=DeploymentType.PLUGIN,214+ ListDeploymentsParams(
203- deployment_status=DeploymentStatus(status) if status else None,215+ deployment_type=DeploymentType.PLUGIN,
216+ deployment_status=DeploymentStatus(status) if status else None,
217+ )
204 )218 )
205 219 
206 if not deployments:220 if not deployments:
@@ -211,11 +225,15 @@ def list(ctx, status):
211 click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}")225 click.echo(f"{'ID':<30} {'Name':<20} {'Status':<10} {'Port':<6} {'Package':<20} {'URL'}")
212 click.echo("-" * 120)226 click.echo("-" * 120)
213 for dep in deployments:227 for dep in deployments:
214- name = dep.get('name') or '-'228+ name = dep.get("name") or "-"
215- dep_status = dep['status'].value if hasattr(dep['status'], 'value') else str(dep['status'])229+ dep_status = dep["status"].value if hasattr(dep["status"], "value") else str(dep["status"])
216- package_name = dep.get('package_name') or '-'230+ package_name = dep.get("package_name") or "-"
217- url = dep.get('url') or '-'231+ url = dep.get("url") or "-"
218- click.echo(f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} {dep['port']:<6} {package_name:<20} {url:<30}")232+ row = (
233+ f"{dep['deployment_id']:<30} {name:<20} {dep_status:<10} "
234+ f"{dep['port']:<6} {package_name:<20} {url:<30}"
235+ )
236+ click.echo(row)
219 237 
220 asyncio.run(_list())238 asyncio.run(_list())
221 239 
@@ -233,7 +251,7 @@ def get(ctx, deployment_id):
233 click.echo(json.dumps(deployment, indent=2, ensure_ascii=False))251 click.echo(json.dumps(deployment, indent=2, ensure_ascii=False))
234 else:252 else:
235 click.echo(f"Deployment {deployment_id} not found", err=True)253 click.echo(f"Deployment {deployment_id} not found", err=True)
236- sys.exit(1)254+ raise click.Abort()
237 255 
238 asyncio.run(_get())256 asyncio.run(_get())
239 257 
@@ -251,7 +269,7 @@ def delete(ctx, deployment_id):
251 click.echo(f"Deployment {deployment_id} deleted")269 click.echo(f"Deployment {deployment_id} deleted")
252 else:270 else:
253 click.echo(f"Deployment {deployment_id} not found", err=True)271 click.echo(f"Deployment {deployment_id} not found", err=True)
254- sys.exit(1)272+ raise click.Abort()
255 273 
256 asyncio.run(_delete())274 asyncio.run(_delete())
257 275 
@@ -10,6 +10,8 @@ from .packaging import (
10 uninstall_package,10 uninstall_package,
11)11)
12from .docker_utils import (12from .docker_utils import (
13+ DockerImageBuildParams,
14+ DockerfileGenerateParams,
13 build_docker_image,15 build_docker_image,
14 push_docker_image,16 push_docker_image,
15 tag_docker_image,17 tag_docker_image,
@@ -25,6 +27,8 @@ __all__ = [
25 "get_python_path",27 "get_python_path",
26 "install_package",28 "install_package",
27 "uninstall_package",29 "uninstall_package",
30+ "DockerImageBuildParams",
31+ "DockerfileGenerateParams",
28 "build_docker_image",32 "build_docker_image",
29 "push_docker_image",33 "push_docker_image",
30 "tag_docker_image",34 "tag_docker_image",
@@ -84,13 +84,22 @@ class Settings(BaseSettings):
84 self.deploy_path = Path(self.DEPLOY_DIR)84 self.deploy_path = Path(self.DEPLOY_DIR)
85 if not self.deploy_path.is_absolute():85 if not self.deploy_path.is_absolute():
86 self.deploy_path = PROJECT_ROOT / self.deploy_path86 self.deploy_path = PROJECT_ROOT / self.deploy_path
87- self.DEPLOY_DIR = str(self.deploy_path.resolve())87+ # 与 .env 中 DEPLOY_DIR 键名一致,保持大写属性名(Pydantic 字段)
88+ object.__setattr__(
89+ self,
90+ "DEPLOY_DIR",
91+ str(self.deploy_path.resolve()),
92+ )
88 93 
89 # 处理 DIST_DIR94 # 处理 DIST_DIR
90 self.dist_path = Path(self.DIST_DIR)95 self.dist_path = Path(self.DIST_DIR)
91 if not self.dist_path.is_absolute():96 if not self.dist_path.is_absolute():
92 self.dist_path = PROJECT_ROOT / self.dist_path97 self.dist_path = PROJECT_ROOT / self.dist_path
93- self.DIST_DIR = str(self.dist_path.resolve())98+ object.__setattr__(
99+ self,
100+ "DIST_DIR",
101+ str(self.dist_path.resolve()),
102+ )
94 103 
95 # 自动创建目录(可选,非常实用)104 # 自动创建目录(可选,非常实用)
96 self.deploy_path.mkdir(parents=True, exist_ok=True)105 self.deploy_path.mkdir(parents=True, exist_ok=True)
@@ -36,6 +36,10 @@ class SQLAlchemyHandler(DBHandler):
36 self._table_models: dict[str, Any] = {}36 self._table_models: dict[str, Any] = {}
37 logger.debug("SQLAlchemyHandler created")37 logger.debug("SQLAlchemyHandler created")
38 38 
39+ def is_table_registered(self, table_name: str) -> bool:
40+ """是否已通过 init_table 注册过对应 ORM 模型(供测试等场景使用)。"""
41+ return table_name in self._table_models
42+ 
39 async def connect(self) -> None:43 async def connect(self) -> None:
40 logger.info("Connecting to database")44 logger.info("Connecting to database")
41 # 关闭 aiosqlite 的 DEBUG 日志45 # 关闭 aiosqlite 的 DEBUG 日志
@@ -2,47 +2,57 @@
2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved2# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3 3 
4import asyncio4import asyncio
5+from dataclasses import dataclass
5from pathlib import Path6from pathlib import Path
6from typing import Optional7from typing import Optional
7 8 
8 9 
9-async def build_docker_image(10+@dataclass
10- context_path: str,11+class DockerImageBuildParams:
11- dockerfile: str = "Dockerfile",12+ """docker build 参数"""
12- image_name: str = None,13+ 
13- tag: str = "latest",14+ context_path: str
14- build_args: Optional[dict] = None,15+ dockerfile: str = "Dockerfile"
15- docker_host: Optional[str] = None,16+ image_name: Optional[str] = None
16-) -> tuple[bool, str]:17+ tag: str = "latest"
18+ build_args: Optional[dict] = None
19+ docker_host: Optional[str] = None
20+ 
21+ 
22+@dataclass
23+class DockerfileGenerateParams:
24+ """生成 Dockerfile 内容的参数"""
25+ 
26+ base_image: str = "python:3.10-slim"
27+ workdir: str = "/app"
28+ package_name: Optional[str] = None
29+ port: int = 8000
30+ entrypoint: Optional[str] = None
31+ extra_commands: Optional[list] = None
32+ 
33+ 
34+async def build_docker_image(params: DockerImageBuildParams) -> tuple[bool, str]:
17 """35 """
18 构建Docker镜像36 构建Docker镜像
19- 37+ 
20- Args:
21- context_path: 构建上下文路径
22- dockerfile: Dockerfile路径,相对于context_path
23- image_name: 镜像名称
24- tag: 镜像标签
25- build_args: 构建参数
26- docker_host: Docker主机地址
27-
28 Returns:38 Returns:
29 tuple[bool, str]: (是否成功, 输出信息)39 tuple[bool, str]: (是否成功, 输出信息)
30 """40 """
31- context_path = Path(context_path).resolve()41+ context_path = Path(params.context_path).resolve()
32 if not context_path.exists():42 if not context_path.exists():
33 return False, f"Context path not found: {context_path}"43 return False, f"Context path not found: {context_path}"
34 44 
35 cmd = ["docker"]45 cmd = ["docker"]
36- if docker_host:46+ if params.docker_host:
37- cmd.extend(["-H", docker_host])47+ cmd.extend(["-H", params.docker_host])
38 48 
39- cmd.extend(["build", "-t", f"{image_name}:{tag}"])49+ cmd.extend(["build", "-t", f"{params.image_name}:{params.tag}"])
40 50 
41- if dockerfile != "Dockerfile":51+ if params.dockerfile != "Dockerfile":
42- cmd.extend(["-f", dockerfile])52+ cmd.extend(["-f", params.dockerfile])
43 53 
44- if build_args:54+ if params.build_args:
45- for key, value in build_args.items():55+ for key, value in params.build_args.items():
46 cmd.extend(["--build-arg", f"{key}={value}"])56 cmd.extend(["--build-arg", f"{key}={value}"])
47 57 
48 cmd.append(str(context_path))58 cmd.append(str(context_path))
@@ -67,13 +77,13 @@ async def push_docker_image(
67) -> tuple[bool, str]:77) -> tuple[bool, str]:
68 """78 """
69 推送Docker镜像79 推送Docker镜像
70- 80+ 
71 Args:81 Args:
72 image_name: 镜像名称82 image_name: 镜像名称
73 tag: 镜像标签83 tag: 镜像标签
74 registry: 镜像仓库地址84 registry: 镜像仓库地址
75 docker_host: Docker主机地址85 docker_host: Docker主机地址
76- 86+ 
77 Returns:87 Returns:
78 tuple[bool, str]: (是否成功, 输出信息)88 tuple[bool, str]: (是否成功, 输出信息)
79 """89 """
@@ -108,14 +118,14 @@ async def tag_docker_image(
108) -> tuple[bool, str]:118) -> tuple[bool, str]:
109 """119 """
110 标记Docker镜像120 标记Docker镜像
111- 121+ 
112 Args:122 Args:
113 source_image: 源镜像名称123 source_image: 源镜像名称
114 target_image: 目标镜像名称124 target_image: 目标镜像名称
115 source_tag: 源标签125 source_tag: 源标签
116 target_tag: 目标标签126 target_tag: 目标标签
117 docker_host: Docker主机地址127 docker_host: Docker主机地址
118- 128+ 
119 Returns:129 Returns:
120 tuple[bool, str]: (是否成功, 输出信息)130 tuple[bool, str]: (是否成功, 输出信息)
121 """131 """
@@ -149,13 +159,13 @@ async def remove_docker_image(
149) -> tuple[bool, str]:159) -> tuple[bool, str]:
150 """160 """
151 删除Docker镜像161 删除Docker镜像
152- 162+ 
153 Args:163 Args:
154 image_name: 镜像名称164 image_name: 镜像名称
155 tag: 镜像标签165 tag: 镜像标签
156 force: 是否强制删除166 force: 是否强制删除
157 docker_host: Docker主机地址167 docker_host: Docker主机地址
158- 168+ 
159 Returns:169 Returns:
160 tuple[bool, str]: (是否成功, 输出信息)170 tuple[bool, str]: (是否成功, 输出信息)
161 """171 """
@@ -180,53 +190,38 @@ async def remove_docker_image(
180 return False, stderr.decode().strip()190 return False, stderr.decode().strip()
181 191 
182 192 
183-def generate_dockerfile(193+def generate_dockerfile(params: DockerfileGenerateParams) -> str:
184- base_image: str = "python:3.10-slim",
185- workdir: str = "/app",
186- package_name: str = None,
187- port: int = 8000,
188- entrypoint: Optional[str] = None,
189- extra_commands: Optional[list] = None,
190-) -> str:
191 """194 """
192 生成Dockerfile内容195 生成Dockerfile内容
193- 196+ 
194- Args:
195- base_image: 基础镜像
196- workdir: 工作目录
197- package_name: 包名
198- port: 暴露端口
199- entrypoint: 入口命令
200- extra_commands: 额外命令
201-
202 Returns:197 Returns:
203 str: Dockerfile内容198 str: Dockerfile内容
204 """199 """
205 lines = [200 lines = [
206- f"FROM {base_image}",201+ f"FROM {params.base_image}",
207 "",202 "",
208- f"WORKDIR {workdir}",203+ f"WORKDIR {params.workdir}",
209 "",204 "",
210 ]205 ]
211 206 
212- if package_name:207+ if params.package_name:
213 lines.extend([208 lines.extend([
214- f"COPY dist/{package_name}*.whl /tmp/",209+ f"COPY dist/{params.package_name}*.whl /tmp/",
215 "RUN pip install /tmp/*.whl",210 "RUN pip install /tmp/*.whl",
216 "",211 "",
217 ])212 ])
218 213 
219- if extra_commands:214+ if params.extra_commands:
220- for cmd in extra_commands:215+ for cmd in params.extra_commands:
221 lines.append(f"RUN {cmd}")216 lines.append(f"RUN {cmd}")
222 lines.append("")217 lines.append("")
223 218 
224- lines.append(f"EXPOSE {port}")219+ lines.append(f"EXPOSE {params.port}")
225 lines.append("")220 lines.append("")
226 221 
227- if entrypoint:222+ if params.entrypoint:
228- lines.append(f'ENTRYPOINT {entrypoint}')223+ lines.append(f'ENTRYPOINT {params.entrypoint}')
229- elif package_name:224+ elif params.package_name:
230- lines.append(f'ENTRYPOINT ["python", "-m", "{package_name}"]')225+ lines.append(f'ENTRYPOINT ["python", "-m", "{params.package_name}"]')
231 226 
232 return "\n".join(lines)227 return "\n".join(lines)
@@ -15,7 +15,12 @@ def is_port_available(port: int, host: str = "0.0.0.0") -> bool:
15 return False15 return False
16 16 
17 17 
18-def allocate_port(start_port: int = 8090, max_port: int = 9090, host: str = "127.0.0.1", exclude_ports: set[int] | None = None) -> int:18+def allocate_port(
19+ start_port: int = 8090,
20+ max_port: int = 9090,
21+ host: str = "127.0.0.1",
22+ exclude_ports: set[int] | None = None,
23+) -> int:
19 """分配可用端口24 """分配可用端口
20 25 
21 Args:26 Args:
@@ -31,7 +31,8 @@ class VirtualEnvironmentManager:
31 31 
32 负责为每个部署创建、管理和清理独立的虚拟环境。32 负责为每个部署创建、管理和清理独立的虚拟环境。
33 """33 """
34- def get_venv_path(self, deployment_id: str) -> Path:34+ @staticmethod
35+ def get_venv_path(deployment_id: str) -> Path:
35 """36 """
36 根据部署ID获取虚拟环境路径37 根据部署ID获取虚拟环境路径
37 38 
@@ -122,7 +123,7 @@ class VirtualEnvironmentManager:
122 return venv_path123 return venv_path
123 except subprocess.CalledProcessError as e:124 except subprocess.CalledProcessError as e:
124 logger.error("Failed to create virtual environment: %s", e.stderr)125 logger.error("Failed to create virtual environment: %s", e.stderr)
125- raise RuntimeError(f"Failed to create venv: {e}")126+ raise RuntimeError(f"Failed to create venv: {e}") from e
126 127 
127 def get_python_executable(self, deployment_id: str) -> Path:128 def get_python_executable(self, deployment_id: str) -> Path:
128 """129 """
@@ -220,14 +221,16 @@ class VirtualEnvironmentManager:
220 return True221 return True
221 222 
222 logger.error("WHL package not found in installed list: %s", whl_path)223 logger.error("WHL package not found in installed list: %s", whl_path)
223- raise RuntimeError(f"Failed to install WHL package: package not in pip list")224+ raise RuntimeError(
225+ "Failed to install WHL package: package not in pip list"
226+ ) from None
224 else:227 else:
225 logger.error("Failed to verify installation: %s", result.stderr)228 logger.error("Failed to verify installation: %s", result.stderr)
226- raise RuntimeError(f"Failed to verify WHL installation")229+ raise RuntimeError("Failed to verify WHL installation") from None
227 230 
228 except Exception as e:231 except Exception as e:
229 logger.error("Failed to install WHL: %s", e)232 logger.error("Failed to install WHL: %s", e)
230- raise RuntimeError(f"Failed to install WHL package: {e}")233+ raise RuntimeError(f"Failed to install WHL package: {e}") from e
231 234 
232 def delete_venv(self, deployment_id: str) -> bool:235 def delete_venv(self, deployment_id: str) -> bool:
233 """236 """
@@ -7,11 +7,11 @@ import os
7import tempfile7import tempfile
8import unittest8import unittest
9 9 
10-from openjiuwen_runtime.management.sdk.db.sqlite_handler import SQLiteHandler10+from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
11-from openjiuwen_runtime.management.sdk.models.table_def import (11+from openjiuwen_runtime.foundation.db.table_def import (
12- TableDefinition,
13 ColumnDefinition,12 ColumnDefinition,
14 IndexDefinition,13 IndexDefinition,
14+ TableDefinition,
15)15)
16 16 
17 17 
@@ -51,7 +51,7 @@ class TestSQLiteHandler(unittest.IsolatedAsyncioTestCase):
51 async def test_init_table(self):51 async def test_init_table(self):
52 """测试初始化表"""52 """测试初始化表"""
53 await self.handler.init_table(self.test_table_def)53 await self.handler.init_table(self.test_table_def)
54- self.assertIn("test_table", self.handler._table_models)54+ self.assertTrue(self.handler.is_table_registered("test_table"))
55 55 
56 async def test_create(self):56 async def test_create(self):
57 """测试创建记录"""57 """测试创建记录"""
@@ -3,10 +3,18 @@
3 3 
4"""OpenJiuwen Runtime Management SDK"""4"""OpenJiuwen Runtime Management SDK"""
5 5 
6-from .manager import DeploymentManager, DeployMode
7-from .models.enums import DeploymentType, DeploymentStatus
8-from .models.schemas import DeploymentInfo, DEPLOYMENT_TABLE_NAME, DeploymentFields
9from openjiuwen_runtime.foundation.db.handler import DBHandler6from openjiuwen_runtime.foundation.db.handler import DBHandler
7+from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler
8+from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
9+ 
10+from .manager import DeploymentManager
11+from .models.deployment_params import (
12+ DeployAgentParams,
13+ DeployPluginParams,
14+ ListDeploymentsParams,
15+)
16+from .models.enums import DeployMode, DeploymentType, DeploymentStatus
17+from .models.schemas import DeploymentInfo, DEPLOYMENT_TABLE_NAME, DeploymentFields
10from .deployments import (18from .deployments import (
11 CommonParams,19 CommonParams,
12 DeployContext,20 DeployContext,
@@ -33,13 +41,14 @@ from .deployments import (
33 K8sStrategy,41 K8sStrategy,
34)42)
35 43 
36-from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
37-from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler
38- 
39__all__ = [44__all__ = [
40 # Manager45 # Manager
41 "DeploymentManager",46 "DeploymentManager",
42 "DeployMode",47 "DeployMode",
48+ # Deployment params
49+ "DeployAgentParams",
50+ "DeployPluginParams",
51+ "ListDeploymentsParams",
43 # Enums52 # Enums
44 "DeploymentType",53 "DeploymentType",
45 "DeploymentStatus",54 "DeploymentStatus",
@@ -8,10 +8,11 @@ from typing import Any, Optional, Generic, TypeVar
8from datetime import datetime8from datetime import datetime
9from openjiuwen_runtime.foundation.log import get_logger9from openjiuwen_runtime.foundation.log import get_logger
10from openjiuwen_runtime.foundation.db.handler import DBHandler10from openjiuwen_runtime.foundation.db.handler import DBHandler
11-from .models import DeployContext, DeployResult
12-from .deployer import Deployer
13-from ...models.enums import DeploymentStatus
14from openjiuwen_runtime.foundation.db.table_def import TableDefinition11from openjiuwen_runtime.foundation.db.table_def import TableDefinition
12+ 
13+from .deployer import Deployer
14+from .models import DeployContext, DeployResult
15+from ...models.enums import DeploymentStatus
15from ...models.schemas import DEPLOYMENT_TABLE_NAME, DeploymentFields16from ...models.schemas import DEPLOYMENT_TABLE_NAME, DeploymentFields
16 17 
17T = TypeVar("T")18T = TypeVar("T")
@@ -12,19 +12,19 @@
12 12 
13import asyncio13import asyncio
14import os14import os
15-import subprocess
16import shutil15import shutil
16+import subprocess
17from pathlib import Path17from pathlib import Path
18from typing import Optional18from typing import Optional
19 19 
20+from openjiuwen_runtime.foundation.config import settings
21+from openjiuwen_runtime.foundation.log import get_logger
22+ 
20from ..base.deployer import Deployer23from ..base.deployer import Deployer
21from ..base.models import DeployContext, DeployResult24from ..base.models import DeployContext, DeployResult
22from .models import DockerParams25from .models import DockerParams
23from ...models.enums import DeploymentStatus26from ...models.enums import DeploymentStatus
24 27 
25-from openjiuwen_runtime.foundation.config import settings
26-from openjiuwen_runtime.foundation.log import get_logger
27- 
28logger = get_logger(__name__)28logger = get_logger(__name__)
29 29 
30 30 
@@ -6,14 +6,13 @@
6from datetime import datetime6from datetime import datetime
7from typing import Any7from typing import Any
8 8 
9-from ..base.strategy import BaseDeploymentStrategy9+from openjiuwen_runtime.foundation.log import get_logger
10-from ..base.models import DeployContext, CommonParams
11-from .models import DockerParams, DockerInfo, DOCKER_TABLE_DEF
12-from .deployer import DockerDeployer
13- 
14from openjiuwen_runtime.foundation.log.utils import mask_userdata10from openjiuwen_runtime.foundation.log.utils import mask_userdata
15 11 
16-from openjiuwen_runtime.foundation.log import get_logger12+from ..base.models import CommonParams, DeployContext
13+from ..base.strategy import BaseDeploymentStrategy
14+from .deployer import DockerDeployer
15+from .models import DOCKER_TABLE_DEF, DockerInfo, DockerParams
17 16 
18logger = get_logger(__name__)17logger = get_logger(__name__)
19 18 
@@ -7,6 +7,8 @@ import asyncio
7import os7import os
8from typing import Optional8from typing import Optional
9 9 
10+from openjiuwen_runtime.foundation.log import get_logger
11+ 
10from ..base.deployer import Deployer12from ..base.deployer import Deployer
11from ..base.models import DeployContext, DeployResult13from ..base.models import DeployContext, DeployResult
12from .models import K8sParams14from .models import K8sParams
@@ -15,9 +15,11 @@ import os
15import shutil15import shutil
16import subprocess16import subprocess
17import sys17import sys
18+from pathlib import Path
18from typing import Dict19from typing import Dict
19 20 
20from openjiuwen_runtime.foundation.config import settings21from openjiuwen_runtime.foundation.config import settings
22+from openjiuwen_runtime.foundation.log import get_logger
21from openjiuwen_runtime.foundation.log.utils import mask_userdata23from openjiuwen_runtime.foundation.log.utils import mask_userdata
22from openjiuwen_runtime.foundation.venv_manager import VirtualEnvironmentManager24from openjiuwen_runtime.foundation.venv_manager import VirtualEnvironmentManager
23 25 
@@ -25,11 +27,15 @@ from ...models.enums import DeploymentStatus
25from ..base.deployer import Deployer27from ..base.deployer import Deployer
26from ..base.models import DeployContext, DeployResult28from ..base.models import DeployContext, DeployResult
27from .models import SubprocessParams29from .models import SubprocessParams
28-from openjiuwen_runtime.foundation.log import get_logger
29 30 
30logger = get_logger(__name__)31logger = get_logger(__name__)
31 32 
32 33 
34+def _windows_system32_exe(filename: str) -> str:
35+ system_root = os.environ.get("SYSTEMROOT", r"C:\Windows")
36+ return str(Path(system_root) / "System32" / filename)
37+ 
38+ 
33class LocalSubprocessDeployer(Deployer[SubprocessParams]):39class LocalSubprocessDeployer(Deployer[SubprocessParams]):
34 """本地进程部署器"""40 """本地进程部署器"""
35 41 
@@ -47,12 +53,12 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]):
47 """通过 PID 终止进程(跨进程有效)"""53 """通过 PID 终止进程(跨进程有效)"""
48 try:54 try:
49 if sys.platform == "win32":55 if sys.platform == "win32":
50- cmd = f"taskkill /F /PID {pid}"56+ taskkill = _windows_system32_exe("taskkill.exe")
51 result = subprocess.run(57 result = subprocess.run(
52- cmd,58+ [taskkill, "/F", "/PID", str(pid)],
53- shell=True,59+ shell=False,
54 capture_output=True,60 capture_output=True,
55- text=True61+ text=True,
56 )62 )
57 success = result.returncode == 063 success = result.returncode == 0
58 if success:64 if success:
@@ -62,9 +68,9 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]):
62 return success68 return success
63 else:69 else:
64 result = subprocess.run(70 result = subprocess.run(
65- ["kill", "-9", str(pid)],71+ ["/bin/kill", "-9", str(pid)],
66 capture_output=True,72 capture_output=True,
67- text=True73+ text=True,
68 )74 )
69 success = result.returncode == 075 success = result.returncode == 0
70 if success:76 if success:
@@ -79,16 +85,17 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]):
79 def _check_process_by_pid(self, pid: int) -> bool:85 def _check_process_by_pid(self, pid: int) -> bool:
80 """通过 PID 检查进程是否运行"""86 """通过 PID 检查进程是否运行"""
81 if sys.platform == "win32":87 if sys.platform == "win32":
88+ tasklist = _windows_system32_exe("tasklist.exe")
82 result = subprocess.run(89 result = subprocess.run(
83- ["tasklist", "/FI", f"PID eq {pid}"],90+ [tasklist, "/FI", f"PID eq {pid}"],
84 capture_output=True,91 capture_output=True,
85- text=True92+ text=True,
86 )93 )
87 return str(pid) in result.stdout94 return str(pid) in result.stdout
88 else:95 else:
89 result = subprocess.run(96 result = subprocess.run(
90- ["kill", "-0", str(pid)],97+ ["/bin/kill", "-0", str(pid)],
91- capture_output=True98+ capture_output=True,
92 )99 )
93 return result.returncode == 0100 return result.returncode == 0
94 101 
@@ -191,7 +198,9 @@ class LocalSubprocessDeployer(Deployer[SubprocessParams]):
191 if process.poll() is not None:198 if process.poll() is not None:
192 # 进程已经退出,读取错误信息199 # 进程已经退出,读取错误信息
193 stdout, stderr = process.communicate()200 stdout, stderr = process.communicate()
194- error_msg = stderr.decode('utf-8', errors='ignore') or stdout.decode('utf-8', errors='ignore') or "Unknown error"201+ stderr_txt = stderr.decode("utf-8", errors="ignore")
202+ stdout_txt = stdout.decode("utf-8", errors="ignore")
203+ error_msg = stderr_txt or stdout_txt or "Unknown error"
195 logger.error("Process exited for %s: %s", deployment_id, error_msg)204 logger.error("Process exited for %s: %s", deployment_id, error_msg)
196 raise RuntimeError(f"Process exited: {error_msg}")205 raise RuntimeError(f"Process exited: {error_msg}")
197 206 
@@ -3,8 +3,8 @@
3 3 
4import asyncio4import asyncio
5import uuid5import uuid
6+from dataclasses import dataclass, field
6from datetime import datetime7from datetime import datetime
7-from enum import Enum
8from typing import Any, Optional8from typing import Any, Optional
9from urllib import request as urllib_request9from urllib import request as urllib_request
10from urllib.parse import urljoin, urlparse, urlunparse10from urllib.parse import urljoin, urlparse, urlunparse
@@ -21,7 +21,12 @@ from .deployments import (
21 DockerStrategy,21 DockerStrategy,
22 K8sStrategy,22 K8sStrategy,
23)23)
24-from .models.enums import DeploymentType, DeploymentStatus24+from .models.deployment_params import (
25+ DeployAgentParams,
26+ DeployPluginParams,
27+ ListDeploymentsParams,
28+)
29+from .models.enums import DeployMode, DeploymentType, DeploymentStatus
25from .models.schemas import (30from .models.schemas import (
26 DeploymentInfo,31 DeploymentInfo,
27 DeploymentCreate,32 DeploymentCreate,
@@ -32,11 +37,17 @@ from .models.schemas import (
32logger = get_logger(__name__)37logger = get_logger(__name__)
33 38 
34 39 
35-class DeployMode(str, Enum):40+@dataclass
36- """部署模式"""41+class _DeployExecutionParams:
37- SUBPROCESS = "subprocess"42+ """内部统一部署流程参数(由 deploy_agent / deploy_plugin 组装)"""
38- DOCKER = "docker"43+ 
39- K8S = "k8s"44+ deployment_type: DeploymentType
45+ name: str
46+ version: str
47+ mode: DeployMode
48+ user_id: Optional[str] = None
49+ space_id: Optional[str] = None
50+ extras: dict[str, Any] = field(default_factory=dict)
40 51 
41 52 
42DEPLOYMENT_TABLE_DEF = TableDefinition(53DEPLOYMENT_TABLE_DEF = TableDefinition(
@@ -124,96 +135,80 @@ class DeploymentManager:
124 await self.db_handler.disconnect()135 await self.db_handler.disconnect()
125 logger.info("DeploymentManager shutdown complete")136 logger.info("DeploymentManager shutdown complete")
126 137
127- async def deploy_agent(138+ async def deploy_agent(self, params: DeployAgentParams) -> DeploymentInfo:
128- self,
129- name: str,
130- version: str,
131- mode: DeployMode = DeployMode.SUBPROCESS,
132- user_id: Optional[str] = None,
133- space_id: Optional[str] = None,
134- **kwargs: Any,
135- ) -> DeploymentInfo:
136 """部署Agent"""139 """部署Agent"""
137 logger.info(140 logger.info(
138 "Deploying agent: name=%s, version=%s, mode=%s, user_id=%s, space_id=%s",141 "Deploying agent: name=%s, version=%s, mode=%s, user_id=%s, space_id=%s",
139- name,142+ params.name,
140- version,143+ params.version,
141- mode,144+ params.mode,
142- user_id,145+ params.user_id,
143- space_id,146+ params.space_id,
144 )147 )
145- kwargs["package_name"] = name148+ extras = dict(params.extras)
149+ extras["package_name"] = params.name
146 return await self._deploy(150 return await self._deploy(
147- deployment_type=DeploymentType.AGENT,151+ _DeployExecutionParams(
148- name=name,152+ deployment_type=DeploymentType.AGENT,
149- version=version,153+ name=params.name,
150- mode=mode,154+ version=params.version,
151- user_id=user_id,155+ mode=params.mode,
152- space_id=space_id,156+ user_id=params.user_id,
153- **kwargs,157+ space_id=params.space_id,
158+ extras=extras,
159+ )
154 )160 )
155 161 
156- async def deploy_plugin(162+ async def deploy_plugin(self, params: DeployPluginParams) -> DeploymentInfo:
157- self,
158- name: str,
159- version: str,
160- mode: DeployMode = DeployMode.SUBPROCESS,
161- url: Optional[str] = None,
162- user_id: Optional[str] = None,
163- space_id: Optional[str] = None,
164- **kwargs: Any,
165- ) -> DeploymentInfo:
166 """部署Plugin"""163 """部署Plugin"""
167 logger.info(164 logger.info(
168 "Deploying plugin: name=%s, version=%s, mode=%s, user_id=%s, space_id=%s",165 "Deploying plugin: name=%s, version=%s, mode=%s, user_id=%s, space_id=%s",
169- name,166+ params.name,
170- version,167+ params.version,
171- mode,168+ params.mode,
172- user_id,169+ params.user_id,
173- space_id,170+ params.space_id,
174 )171 )
172+ extras = dict(params.extras)
173+ extras["url"] = params.url
175 return await self._deploy(174 return await self._deploy(
176- deployment_type=DeploymentType.PLUGIN,175+ _DeployExecutionParams(
177- name=name,176+ deployment_type=DeploymentType.PLUGIN,
178- version=version,177+ name=params.name,
179- mode=mode,178+ version=params.version,
180- url=url,179+ mode=params.mode,
181- user_id=user_id,180+ user_id=params.user_id,
182- space_id=space_id,181+ space_id=params.space_id,
183- **kwargs,182+ extras=extras,
183+ )
184 )184 )
185 185 
186- async def list_deployments(186+ async def list_deployments(self, params: ListDeploymentsParams) -> list[DeploymentInfo]:
187- self,
188- deployment_type: Optional[DeploymentType] = None,
189- deployment_status: Optional[DeploymentStatus] = None,
190- user_id: Optional[str] = None,
191- space_id: Optional[str] = None,
192- limit: int = 100,
193- offset: int = 0,
194- ) -> list[DeploymentInfo]:
195 """列出部署"""187 """列出部署"""
196 logger.debug(188 logger.debug(
197 "Listing deployments: type=%s, status=%s, user_id=%s, space_id=%s, limit=%s, offset=%s",189 "Listing deployments: type=%s, status=%s, user_id=%s, space_id=%s, limit=%s, offset=%s",
198- deployment_type,190+ params.deployment_type,
199- deployment_status,191+ params.deployment_status,
200- user_id,192+ params.user_id,
201- space_id,193+ params.space_id,
202- limit,194+ params.limit,
203- offset,195+ params.offset,
204 )196 )
205 filters = {}197 filters = {}
206- if deployment_type:198+ if params.deployment_type:
207- filters[DeploymentFields.DEPLOYMENT_TYPE] = deployment_type.value199+ filters[DeploymentFields.DEPLOYMENT_TYPE] = params.deployment_type.value
208- if deployment_status:200+ if params.deployment_status:
209- filters[DeploymentFields.DEPLOYMENT_STATUS] = deployment_status.value201+ filters[DeploymentFields.DEPLOYMENT_STATUS] = params.deployment_status.value
210- if user_id:202+ if params.user_id:
211- filters[DeploymentFields.USER_ID] = user_id203+ filters[DeploymentFields.USER_ID] = params.user_id
212- if space_id:204+ if params.space_id:
213- filters[DeploymentFields.SPACE_ID] = space_id205+ filters[DeploymentFields.SPACE_ID] = params.space_id
214 206 
215 records = await self.db_handler.list_records(207 records = await self.db_handler.list_records(
216- DEPLOYMENT_TABLE_NAME, filters=filters if filters else None, limit=limit, offset=offset208+ DEPLOYMENT_TABLE_NAME,
209+ filters=filters if filters else None,
210+ limit=params.limit,
211+ offset=params.offset,
217 )212 )
218 result = [DeploymentInfo.model_validate(r if hasattr(r, "to_dict") else r) for r in records]213 result = [DeploymentInfo.model_validate(r if hasattr(r, "to_dict") else r) for r in records]
219 logger.debug("Found %s deployments", len(result))214 logger.debug("Found %s deployments", len(result))
@@ -384,49 +379,50 @@ class DeploymentManager:
384 f"Deployment {deployment_id} not ready within {timeout_seconds}s (last_status={last_status})"379 f"Deployment {deployment_id} not ready within {timeout_seconds}s (last_status={last_status})"
385 )380 )
386 381 
387- async def _deploy(382+ async def _deploy(self, params: _DeployExecutionParams) -> DeploymentInfo:
388- self,
389- deployment_type: DeploymentType,
390- name: str,
391- version: str,
392- mode: DeployMode,
393- user_id: Optional[str] = None,
394- space_id: Optional[str] = None,
395- **kwargs: Any,
396- ) -> DeploymentInfo:
397 """内部部署方法"""383 """内部部署方法"""
398- deployment_id = kwargs.pop("deployment_id", None) or self._generate_deployment_id()384+ extras = dict(params.extras)
385+ deployment_id = extras.pop("deployment_id", None) or self._generate_deployment_id()
399 now = datetime.utcnow()386 now = datetime.utcnow()
400 387 
401- logger.debug("deployment_id=%s, type=%s, name=%s", deployment_id, deployment_type, name)388+ logger.debug(
389+ "deployment_id=%s, type=%s, name=%s",
390+ deployment_id,
391+ params.deployment_type,
392+ params.name,
393+ )
402 394 
403 create_model = DeploymentCreate(395 create_model = DeploymentCreate(
404 deployment_id=deployment_id,396 deployment_id=deployment_id,
405- version=version,397+ version=params.version,
406- deployment_type=deployment_type,398+ deployment_type=params.deployment_type,
407- name=name,399+ name=params.name,
408- url=kwargs.get("url"),400+ url=extras.get("url"),
409- user_id=user_id,401+ user_id=params.user_id,
410- space_id=space_id,402+ space_id=params.space_id,
411- data=kwargs.get("data"),403+ data=extras.get("data"),
412 )404 )
413 deployment_data = create_model.model_dump()405 deployment_data = create_model.model_dump()
414 deployment_data[DeploymentFields.DEPLOYMENT_STATUS] = DeploymentStatus.PENDING.value406 deployment_data[DeploymentFields.DEPLOYMENT_STATUS] = DeploymentStatus.PENDING.value
415 deployment_data[DeploymentFields.CREATED_AT] = now407 deployment_data[DeploymentFields.CREATED_AT] = now
416 deployment_data[DeploymentFields.UPDATED_AT] = now408 deployment_data[DeploymentFields.UPDATED_AT] = now
417- 409+ 
418 await self.db_handler.create(DEPLOYMENT_TABLE_NAME, deployment_data)410 await self.db_handler.create(DEPLOYMENT_TABLE_NAME, deployment_data)
419 411 
420- strategy = self._get_strategy(mode)412+ strategy = self._get_strategy(params.mode)
421 413 
422 try:414 try:
423 await strategy.create_record(415 await strategy.create_record(
424- self.db_handler, deployment_id, version, **kwargs416+ self.db_handler, deployment_id, params.version, **extras
425 )417 )
426 await strategy.deploy(deployment_id, self.db_handler)418 await strategy.deploy(deployment_id, self.db_handler)
427 419 
428 await self._wait_until_deployment_ready(deployment_id)420 await self._wait_until_deployment_ready(deployment_id)
429- logger.info("Deployment completed: deployment_id=%s, name=%s", deployment_id, name)421+ logger.info(
422+ "Deployment completed: deployment_id=%s, name=%s",
423+ deployment_id,
424+ params.name,
425+ )
430 except Exception as e:426 except Exception as e:
431 logger.error("Deployment failed: deployment_id=%s, error=%s", deployment_id, str(e))427 logger.error("Deployment failed: deployment_id=%s, error=%s", deployment_id, str(e))
432 await self.db_handler.update(428 await self.db_handler.update(
@@ -436,7 +432,7 @@ class DeploymentManager:
436 )432 )
437 433 
438 deployment_record = await self.db_handler.get(434 deployment_record = await self.db_handler.get(
439- DEPLOYMENT_TABLE_NAME, 435+ DEPLOYMENT_TABLE_NAME,
440- {DeploymentFields.DEPLOYMENT_ID: deployment_id}436+ {DeploymentFields.DEPLOYMENT_ID: deployment_id},
441 )437 )
442 return DeploymentInfo.model_validate(deployment_record)438 return DeploymentInfo.model_validate(deployment_record)
@@ -3,14 +3,15 @@
3 3 
4"""数据模型"""4"""数据模型"""
5 5 
6-from .enums import DeploymentType, DeploymentStatus6+from openjiuwen_runtime.foundation.db.table_def import ColumnDefinition, IndexDefinition, TableDefinition
7+ 
8+from .enums import DeploymentStatus, DeploymentType
7from .schemas import (9from .schemas import (
8- DeploymentInfo,
9- DeploymentCreate,
10 DEPLOYMENT_TABLE_NAME,10 DEPLOYMENT_TABLE_NAME,
11+ DeploymentCreate,
11 DeploymentFields,12 DeploymentFields,
13+ DeploymentInfo,
12)14)
13-from openjiuwen_runtime.foundation.db.table_def import TableDefinition, ColumnDefinition, IndexDefinition
14 15 
15__all__ = [16__all__ = [
16 "DeploymentType", 17 "DeploymentType",
@@ -0,0 +1,46 @@
1+# coding: utf-8
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026-2026. All rights reserved
3+ 
4+"""部署相关请求参数(收敛 Manager 方法形参数量)"""
5+ 
6+from dataclasses import dataclass, field
7+from typing import Any, Optional
8+ 
9+from .enums import DeployMode, DeploymentStatus, DeploymentType
10+ 
11+ 
12+@dataclass
13+class ListDeploymentsParams:
14+ """列出部署的过滤与分页条件"""
15+ 
16+ deployment_type: Optional[DeploymentType] = None
17+ deployment_status: Optional[DeploymentStatus] = None
18+ user_id: Optional[str] = None
19+ space_id: Optional[str] = None
20+ limit: int = 100
21+ offset: int = 0
22+ 
23+ 
24+@dataclass
25+class DeployAgentParams:
26+ """部署 Agent 的参数(策略扩展字段放入 extras)"""
27+ 
28+ name: str
29+ version: str
30+ mode: DeployMode = DeployMode.SUBPROCESS
31+ user_id: Optional[str] = None
32+ space_id: Optional[str] = None
33+ extras: dict[str, Any] = field(default_factory=dict)
34+ 
35+ 
36+@dataclass
37+class DeployPluginParams:
38+ """部署 Plugin 的参数(策略扩展字段放入 extras)"""
39+ 
40+ name: str
41+ version: str
42+ mode: DeployMode = DeployMode.SUBPROCESS
43+ url: Optional[str] = None
44+ user_id: Optional[str] = None
45+ space_id: Optional[str] = None
46+ extras: dict[str, Any] = field(default_factory=dict)
@@ -18,3 +18,10 @@ class DeploymentStatus(str, Enum):
18 RUNNING = "running"18 RUNNING = "running"
19 STOPPED = "stopped"19 STOPPED = "stopped"
20 FAILED = "failed"20 FAILED = "failed"
21+ 
22+ 
23+class DeployMode(str, Enum):
24+ """部署模式"""
25+ SUBPROCESS = "subprocess"
26+ DOCKER = "docker"
27+ K8S = "k8s"
@@ -10,7 +10,11 @@ import unittest
10from pathlib import Path10from pathlib import Path
11 11 
12from openjiuwen_runtime.foundation.log import get_logger12from openjiuwen_runtime.foundation.log import get_logger
13-from openjiuwen_runtime.management import DeploymentManager, DeployMode13+from openjiuwen_runtime.management import (
14+ DeployAgentParams,
15+ DeploymentManager,
16+ DeployMode,
17+)
14from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler18from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
15from openjiuwen_runtime.foundation.packaging import package_python_to_whl19from openjiuwen_runtime.foundation.packaging import package_python_to_whl
16 20 
@@ -56,11 +60,12 @@ class ManagerTest(unittest.IsolatedAsyncioTestCase):
56 self.assertTrue(os.path.exists(whl_path), f"WHL file not found: {whl_path}")60 self.assertTrue(os.path.exists(whl_path), f"WHL file not found: {whl_path}")
57 61 
58 deployment_info = await self.manager.deploy_agent(62 deployment_info = await self.manager.deploy_agent(
59- name="test_simple_agent",63+ DeployAgentParams(
60- version="1.0.0",64+ name="test_simple_agent",
61- mode=DeployMode.SUBPROCESS,65+ version="1.0.0",
62- package_name="simple_agent",66+ mode=DeployMode.SUBPROCESS,
63- whl_path=whl_path,67+ extras={"package_name": "simple_agent", "whl_path": whl_path},
68+ )
64 )69 )
65 70 
66 self.assertIsNotNone(deployment_info)71 self.assertIsNotNone(deployment_info)
@@ -96,11 +101,12 @@ class ManagerTest(unittest.IsolatedAsyncioTestCase):
96 project_root / "resources" / "examples" / "simple_agent" / "openjiuwen_agent-1.0.0-py3-none-any.whl")101 project_root / "resources" / "examples" / "simple_agent" / "openjiuwen_agent-1.0.0-py3-none-any.whl")
97 102 
98 deployment_info = await self.manager.deploy_agent(103 deployment_info = await self.manager.deploy_agent(
99- name="test_simple_agent",104+ DeployAgentParams(
100- version="1.0.0",105+ name="test_simple_agent",
101- mode=DeployMode.SUBPROCESS,106+ version="1.0.0",
102- package_name="simple_agent",107+ mode=DeployMode.SUBPROCESS,
103- whl_path=whl_path,108+ extras={"package_name": "simple_agent", "whl_path": whl_path},
109+ )
104 )110 )
105 111 
106 self.assertIsNotNone(deployment_info)112 self.assertIsNotNone(deployment_info)
@@ -8,9 +8,11 @@ FastAPI 服务器,提供 Agent 部署管理 REST API(支持租户隔离)
8 8 
9import uuid9import uuid
10from contextlib import asynccontextmanager10from contextlib import asynccontextmanager
11+from dataclasses import dataclass
11from pathlib import Path12from pathlib import Path
13+from typing import Annotated
12 14 
13-from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, status15+from fastapi import Depends, FastAPI, HTTPException, Query, Request, UploadFile, status
14from fastapi.responses import JSONResponse16from fastapi.responses import JSONResponse
15 17 
16from openjiuwen_runtime.foundation.log import get_logger18from openjiuwen_runtime.foundation.log import get_logger
@@ -18,8 +20,16 @@ from openjiuwen_runtime.foundation.config import settings
18from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler20from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler
19from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler21from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
20from openjiuwen_runtime.foundation.port_utils import allocate_port, is_port_available22from openjiuwen_runtime.foundation.port_utils import allocate_port, is_port_available
21-from openjiuwen_runtime.management.manager import DeploymentManager, DeployMode23+from openjiuwen_runtime.management.manager import DeploymentManager
22-from openjiuwen_runtime.management.models.enums import DeploymentType, DeploymentStatus24+from openjiuwen_runtime.management.models.deployment_params import (
25+ DeployAgentParams,
26+ ListDeploymentsParams,
27+)
28+from openjiuwen_runtime.management.models.enums import (
29+ DeployMode,
30+ DeploymentStatus,
31+ DeploymentType,
32+)
23 33 
24from .middleware.tenant import TenantContextMiddleware, get_tenant_context34from .middleware.tenant import TenantContextMiddleware, get_tenant_context
25from .utils import mask_userdata35from .utils import mask_userdata
@@ -125,16 +135,32 @@ def get_deploy_type(mode: str) -> str:
125 return mode135 return mode
126 136 
127 137 
138+@dataclass
139+class AgentDeployQuery:
140+ """Agent 部署请求中的查询参数(用于收敛 FastAPI 路由形参个数)。"""
141+ 
142+ name: str
143+ mode: str
144+ port: int | None
145+ userdata: str | None
146+ 
147+ 
148+def _parse_agent_deploy_query(
149+ name: str = Query(..., description="部署名称(=包名)"),
150+ mode: str = Query(default="subprocess", description="部署器类型"),
151+ port: int | None = Query(default=None, description="服务端口,不填则自动分配"),
152+ userdata: str | None = Query(default=None, description="用户自定义数据"),
153+) -> AgentDeployQuery:
154+ return AgentDeployQuery(name=name, mode=mode, port=port, userdata=userdata)
155+ 
156+ 
128# ==================== Agent API ====================157# ==================== Agent API ====================
129 158 
130@app.post("/api/v1/agents/deploy")159@app.post("/api/v1/agents/deploy")
131async def deploy_agent(160async def deploy_agent(
132 request: Request,161 request: Request,
133 file: UploadFile,162 file: UploadFile,
134- name: str = Query(..., description="部署名称(=包名)"),163+ deploy_query: Annotated[AgentDeployQuery, Depends(_parse_agent_deploy_query)],
135- mode: str = Query(default="subprocess", description="部署器类型"),
136- port: int | None = Query(default=None, description="服务端口,不填则自动分配"),
137- userdata: str | None = Query(default=None, description="用户自定义数据"),
138):164):
139 """165 """
140 部署 Agent(JSON 配置,低码方式)166 部署 Agent(JSON 配置,低码方式)
@@ -147,8 +173,10 @@ async def deploy_agent(
147 try:173 try:
148 # 获取租户上下文174 # 获取租户上下文
149 user_id, space_id = get_tenant_context(request)175 user_id, space_id = get_tenant_context(request)
150- logger.info(f"Received agent deploy request: user_id={user_id}, "176+ logger.info(
151- f"space_id={space_id}, name={name}, userdata={mask_userdata(userdata)}")177+ f"Received agent deploy request: user_id={user_id}, "
178+ f"space_id={space_id}, name={deploy_query.name}, userdata={mask_userdata(deploy_query.userdata)}"
179+ )
152 180 
153 deployment_id = str(uuid.uuid4())181 deployment_id = str(uuid.uuid4())
154 logger.info(f"Generated deployment_id: {deployment_id}")182 logger.info(f"Generated deployment_id: {deployment_id}")
@@ -160,21 +188,27 @@ async def deploy_agent(
160 content = await file.read()188 content = await file.read()
161 json_file_path.write_bytes(content)189 json_file_path.write_bytes(content)
162 190 
163- deploy_type = get_deploy_type(mode)191+ deploy_type = get_deploy_type(deploy_query.mode)
164- port, whl_path = prepare_subprocess_deployment(deploy_type, port)192+ port, whl_path = prepare_subprocess_deployment(deploy_type, deploy_query.port)
165 193 
166 # 调用 Manager SDK 部署(传入 ir_path、whl_path 和 userdata)194 # 调用 Manager SDK 部署(传入 ir_path、whl_path 和 userdata)
167 result = await manager.deploy_agent(195 result = await manager.deploy_agent(
168- name=name,196+ DeployAgentParams(
169- version="1.0.0",197+ name=deploy_query.name,
170- user_id=user_id, # 注入租户信息198+ version="1.0.0",
171- space_id=space_id, # 注入租户信息199+ user_id=user_id,
172- ir_path=str(json_file_path), # 用户上传的 JSON 配置文件路径200+ space_id=space_id,
173- whl_path=str(whl_path), # 预编译的 whl 包路径201+ mode=DeployMode(deploy_type),
174- mode=DeployMode(deploy_type),202+ extras={
175- port=port,203+ "ir_path": str(json_file_path),
176- deployment_id=deployment_id,204+ "whl_path": str(whl_path),
177- data={"userdata": userdata} if userdata else None, # 用户自定义数据205+ "port": port,
206+ "deployment_id": deployment_id,
207+ "data": {"userdata": deploy_query.userdata}
208+ if deploy_query.userdata
209+ else None,
210+ },
211+ )
178 )212 )
179 213 
180 # 过滤内部实现细节,只返回用户需要的信息214 # 过滤内部实现细节,只返回用户需要的信息
@@ -210,10 +244,14 @@ async def list_agents(
210 244 
211 try:245 try:
212 deployments = await manager.list_deployments(246 deployments = await manager.list_deployments(
213- deployment_type=DeploymentType.AGENT,247+ ListDeploymentsParams(
214- deployment_status=DeploymentStatus(status_filter) if status_filter else None,248+ deployment_type=DeploymentType.AGENT,
215- user_id=user_id, # 租户过滤249+ deployment_status=DeploymentStatus(status_filter)
216- space_id=space_id, # 租户过滤250+ if status_filter
251+ else None,
252+ user_id=user_id,
253+ space_id=space_id,
254+ )
217 )255 )
218 256 
219 # 过滤内部实现细节257 # 过滤内部实现细节
@@ -6,8 +6,11 @@
6用于模拟 OSS 文件上传功能6用于模拟 OSS 文件上传功能
7"""7"""
8 8 
9+import logging
9from typing import Optional10from typing import Optional
10 11 
12+_LOG = logging.getLogger(__name__)
13+ 
11 14 
12class MockOSSClient:15class MockOSSClient:
13 """Mock OSS 客户端"""16 """Mock OSS 客户端"""
@@ -38,7 +41,7 @@ class MockOSSClient:
38 """41 """
39 # Mock: 假设文件已上传成功,返回一个 mock URL42 # Mock: 假设文件已上传成功,返回一个 mock URL
40 mock_oss_url = f"mock://oss.example.com/{object_key}"43 mock_oss_url = f"mock://oss.example.com/{object_key}"
41- print(f"[Mock OSS] Uploaded {local_file_path} -> {mock_oss_url}")44+ _LOG.info("[Mock OSS] Uploaded %s -> %s", local_file_path, mock_oss_url)
42 return mock_oss_url45 return mock_oss_url
43 46 
44 async def delete_file(self, object_key: str) -> bool:47 async def delete_file(self, object_key: str) -> bool:
@@ -51,5 +54,5 @@ class MockOSSClient:
51 Returns:54 Returns:
52 是否成功删除55 是否成功删除
53 """56 """
54- print(f"[Mock OSS] Deleted {object_key}")57+ _LOG.info("[Mock OSS] Deleted %s", object_key)
55 return True58 return True
@@ -14,7 +14,8 @@ from typing import AsyncIterator, Tuple
14 14 
15# 添加项目根目录到路径15# 添加项目根目录到路径
16PROJECT_ROOT = Path(__file__).resolve().parent.parent16PROJECT_ROOT = Path(__file__).resolve().parent.parent
17-sys.path.insert(0, str(PROJECT_ROOT))17+if str(PROJECT_ROOT) not in sys.path:
18+ sys.path.append(str(PROJECT_ROOT))
18 19 
19from openjiuwen_runtime.foundation.log import get_logger20from openjiuwen_runtime.foundation.log import get_logger
20from openjiuwen_runtime.service import AgentApp, AppGroup21from openjiuwen_runtime.service import AgentApp, AppGroup
@@ -113,38 +113,36 @@ class AppGroup:
113 async def startup():113 async def startup():
114 """按顺序执行所有挂载应用的 init 钩子"""114 """按顺序执行所有挂载应用的 init 钩子"""
115 for prefix, app in self._mounted_apps.items():115 for prefix, app in self._mounted_apps.items():
116- if app._init_hook:116+ try:
117- try:117+ if await app.run_init_hook():
118- await app._init_hook()
119 logger.info(118 logger.info(
120 "[OK] %s 初始化完成 (挂载于 %s)",119 "[OK] %s 初始化完成 (挂载于 %s)",
121 app.app_name,120 app.app_name,
122 prefix,121 prefix,
123 )122 )
124- except Exception as e:123+ except Exception as e:
125- logger.error(124+ logger.error(
126- "[ERROR] %s 初始化失败: %s",125+ "[ERROR] %s 初始化失败: %s",
127- app.app_name,126+ app.app_name,
128- e,127+ e,
129- exc_info=True,128+ exc_info=True,
130- )129+ )
131- raise130+ raise
132 131 
133 @self.app.on_event("shutdown")132 @self.app.on_event("shutdown")
134 async def shutdown():133 async def shutdown():
135 """按逆序执行所有挂载应用的 shutdown 钩子"""134 """按逆序执行所有挂载应用的 shutdown 钩子"""
136 for prefix, app in reversed(list(self._mounted_apps.items())):135 for prefix, app in reversed(list(self._mounted_apps.items())):
137- if app._shutdown_hook:136+ try:
138- try:137+ if await app.run_shutdown_hook():
139- await app._shutdown_hook()
140 logger.info("[OK] %s 关闭完成", app.app_name)138 logger.info("[OK] %s 关闭完成", app.app_name)
141- except Exception as e:139+ except Exception as e:
142- logger.warning(140+ logger.warning(
143- "[WARN] %s 关闭钩子执行失败: %s",141+ "[WARN] %s 关闭钩子执行失败: %s",
144- app.app_name,142+ app.app_name,
145- e,143+ e,
146- exc_info=True,144+ exc_info=True,
147- )145+ )
148 146 
149 def _register_routes(self):147 def _register_routes(self):
150 """注册 AppGroup 特定路由"""148 """注册 AppGroup 特定路由"""
@@ -173,7 +171,7 @@ class AppGroup:
173 171 
174 elif isinstance(app, PluginApp):172 elif isinstance(app, PluginApp):
175 app_info["type"] = "plugin"173 app_info["type"] = "plugin"
176- app_info["tools_count"] = len(app._tools)174+ app_info["tools_count"] = app.tools_count
177 175 
178 else:176 else:
179 app_info["type"] = "base"177 app_info["type"] = "base"
@@ -64,7 +64,7 @@ def _parse_cli_args() -> Dict[str, Any]:
64 file_path = str(Path(args.irpath).resolve())64 file_path = str(Path(args.irpath).resolve())
65 if not Path(args.irpath).exists():65 if not Path(args.irpath).exists():
66 logger.error("配置文件不存在: %s", file_path)66 logger.error("配置文件不存在: %s", file_path)
67- sys.exit(1)67+ parser.error(f"配置文件不存在: {file_path}")
68 result["file"] = file_path68 result["file"] = file_path
69 69 
70 return result70 return result
@@ -105,6 +105,20 @@ class BaseApp:
105 # 注册基础路由105 # 注册基础路由
106 self._register_base_routes()106 self._register_base_routes()
107 107 
108+ async def run_init_hook(self) -> bool:
109+ """执行通过 @init 注册的钩子(供 AppGroup 等外部容器调用)。返回是否实际执行了钩子。"""
110+ if self._init_hook:
111+ await self._init_hook()
112+ return True
113+ return False
114+ 
115+ async def run_shutdown_hook(self) -> bool:
116+ """执行通过 @shutdown 注册的钩子(供 AppGroup 等外部容器调用)。返回是否实际执行了钩子。"""
117+ if self._shutdown_hook:
118+ await self._shutdown_hook()
119+ return True
120+ return False
121+ 
108 def init(self, func: Callable) -> Callable:122 def init(self, func: Callable) -> Callable:
109 """123 """
110 初始化钩子装饰器124 初始化钩子装饰器
@@ -68,6 +68,11 @@ class PluginApp(BaseApp):
68 # 注册 Plugin 特定路由68 # 注册 Plugin 特定路由
69 self._register_plugin_routes()69 self._register_plugin_routes()
70 70 
71+ @property
72+ def tools_count(self) -> int:
73+ """已注册工具数量(供健康检查等场景使用)。"""
74+ return len(self._tools)
75+ 
71 def register_tool(76 def register_tool(
72 self,77 self,
73 name: str,78 name: str,
@@ -153,7 +158,7 @@ class PluginApp(BaseApp):
153 raise HTTPException(158 raise HTTPException(
154 status_code=500,159 status_code=500,
155 detail=f"Tool execution failed: {str(e)}",160 detail=f"Tool execution failed: {str(e)}",
156- )161+ ) from e
157 162 
158 def _get_python_type(self, param_type: str) -> type:163 def _get_python_type(self, param_type: str) -> type:
159 """164 """