From dfc6a96e1007afa36ed560cfd5e1611078b7620e Mon Sep 17 00:00:00 2001 From: chen qiang <343005560@qq.com> Date: Thu, 27 Aug 2026 10:38:35 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E8=87=B3=201.5.43?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 10 ++ CHANGELOG.md | 11 +++ README.md | 4 + SKILL.md | 16 +++- VERSION | 2 +- references/commands-and-auth.md | 8 ++ scripts/oracle_skill.py | 165 +++++++++++++++++++++++++++++++- tests/test_log_analysis.py | 93 ++++++++++++++++++ 8 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 tests/test_log_analysis.py diff --git a/AGENTS.md b/AGENTS.md index 153f2df..0429491 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,15 @@ # oracle-jump-query Skill Release Standard +## Git 提交说明 + +- 提交信息和发布记录使用简洁、准确的中文。 +- 按可独立验证的小功能节点分别提交,不混入无关修改。 +- Bug 修复通常只说明修复的功能点和结果,不展开代码缺陷细节;排障或防复发所必需的信息除外。 + +Release 1.5.43: 统一 Git 提交与发布记录规范,明确使用简洁中文、按小功能节点提交,并精简 Bug 修复说明。提交 `176bd4594779a8489ad25ab828380b2b7ab3354b` 已于 2026-08-27 10:18 +08:00 推送到私有 Gitea,并同步安装目录;`capabilities --json` 验证版本为 `1.5.43`。 + +Release 1.5.42: added `log_info`, `log_tail`, `log_search`, `log_enable`, and `log_disable`; uses authenticated dedicated transit endpoints and preserves structured log errors. Commit `e90cbc9ce3525970b2f07885d52c4f0172bf9d2f` was pushed to private Gitea at 2026-08-27 09:51 +08:00, pulled into the installed Skill, and verified by `capabilities --json` as 1.5.42. Log content is not stored in Skill config, output archives, or release history. + A published `VERSION` is frozen. Increment it for every later change, update `CHANGELOG.md`, commit and push to Gitea. Skill updates use Git only: pull the commit into the Codex Skill directory with `git pull --ff-only origin main`, then run `capabilities --json`. Do not publish or update this Skill through OSS. `inspection_report` and `inspection_latest --refresh` must run `sys_function_check` before Agent collection. Missing SYS/current-schema functions stop generation and return the complete SQL bundle. Historical report reads do not run this preflight. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d32115..f1cc093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.5.43 (2026-08-27) + +- 统一 Git 提交与发布记录规范:使用简洁、准确的中文,通常按小功能节点分别提交。 +- Bug 修复通常只描述修复的功能点和结果,不展开代码缺陷细节。 + +## 1.5.42 (2026-08-27) + +- Added authenticated `log_info`, `log_tail`, `log_search`, `log_enable`, and `log_disable` commands and capability definitions. +- Added dedicated transit endpoint calls, paths containing spaces, keyword/regex context search, recent/full scan selection, and structured error handling. +- Log excerpts remain read-only and are analyzed by the Skill/model; the Skill does not execute server commands or write customer files. + ## 1.5.37 (2026-08-14) ### Public mirror documentation diff --git a/README.md b/README.md index d2a3779..c50d27d 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ python oracle_skill.py perm 940 12983 | `inspection_latest [clientCode] [--refresh] [--json] [--html [path]] [--markdown [path]]` | 查看最近归档巡检报告并默认导出 HTML;加 `--refresh` 时重新生成 | `inspection_latest HENLO --refresh --markdown` | | `inspection_get [--json] [--html [path]] [--markdown [path]]` | 按归档 ID 查看历史巡检报告并默认导出 HTML | `inspection_get 123 --markdown` | | `agent_update [clientCode] [timeout]` | 触发客户 Agent 自动更新,升级包由 Agent 从 OSS 下载 | `agent_update HENLO 300` | +| `log_info [--client code]` | 验证服务器绝对日志路径和文件元数据 | `log_info "D:\logs\app.log" --client AHMW --json` | +| `log_tail [--client code]` | 有界读取日志末尾内容 | `log_tail "D:\logs\app.log" --client AHMW --lines 200` | +| `log_search ` | 在 Agent 本机按关键词或正则流式搜索日志 | `log_search "D:\logs\app.log" ERROR --client AHMW --json` | +| `log_enable [clientCode]` / `log_disable [clientCode]` | 持久化开启或关闭目标客户日志分析 | `log_enable AHMW` | | `discover [schema] [filter]` | 发现核心业务表 | `discover bosnds3 M_%` | | `nl2sql ` | 自然语言转 SQL | `nl2sql bosnds3 查询花都二店5月销售额` | | `servers` | 列出在线 Agent | `servers` | diff --git a/SKILL.md b/SKILL.md index 32df981..545c1a9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,7 +5,7 @@ description: Oracle 跳板查询技能。通过中转服务查询远程 Oracle # Oracle 跳板查询 -> **版本:v1.5.37** · [更新日志](./CHANGELOG.md) +> **版本:v1.5.43** · [更新日志](./CHANGELOG.md) ## 使用原则 @@ -78,6 +78,10 @@ python scripts/oracle_skill.py login [clientCode] | `awr_status [clientCode] [--json]` | 查看 AWR 授权确认、权限、快照和最近生成状态,不触发生成 | | `awr_list [clientCode] [--json]` | 列出 Agent 已生成并保留的 AWR 报告 | | `awr_download [--output path]` | 经中转机下载 Agent 原始 AWR HTML,不在本地渲染 | +| `log_info [--client code] [--json]` | 验证服务器绝对日志路径并返回文件元数据 | +| `log_tail [--client code] [--lines N] [--json]` | 有界读取服务器日志末尾内容 | +| `log_search [--client code] [--regex] [--scope recent|full] [--json]` | 在 Agent 本机流式筛选日志并返回有限上下文 | +| `log_enable [clientCode]` / `log_disable [clientCode]` | 为有权限的客户持久化开启或关闭日志分析 | | `agent_update [clientCode] [timeout]` | 触发客户 Agent 自动更新;由中转机下发更新动作,Agent 按 OSS latest.json 返回的包地址下载升级 | ## BOS 分析优先路径 @@ -162,8 +166,18 @@ python scripts/oracle_skill.py awr_list WEIRUI python scripts/oracle_skill.py awr_download WEIRUI 20260720 ``` +日志分析默认关闭。用户提供服务器上的单个绝对文本日志路径后,先执行 `log_info`,再按问题选择 `log_tail` 或 `log_search`;默认只扫描末尾 100 MB。日志正文原样返回给当前 Skill 会话但不写入中转审计库: + +```powershell +python scripts/oracle_skill.py log_enable AHMW +python scripts/oracle_skill.py log_info "D:\logs\app.log" --client AHMW --json +python scripts/oracle_skill.py log_search "D:\logs\app.log" ERROR ORA- --client AHMW --before 3 --after 3 --json +``` + ## 内容维护 +Git 提交和发布记录使用简洁、准确的中文描述,并按可独立验证的小功能节点分别提交。Bug 修复通常只说明修复的功能点和用户可见结果,不展开代码层缺陷细节;只有排障或防止复发确有需要时才补充技术原因。每次提交避免混入无关修改。 + 本 skill 的 Git 源地址分为私有主库和公开镜像: ```text diff --git a/VERSION b/VERSION index 4d05cfc..9c583db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.41 +1.5.43 diff --git a/references/commands-and-auth.md b/references/commands-and-auth.md index efd8822..75c1d84 100644 --- a/references/commands-and-auth.md +++ b/references/commands-and-auth.md @@ -73,6 +73,10 @@ python scripts/oracle_skill.py [args] | `awr_status [clientCode]` | 查看 AWR 授权和可用状态,不生成报告 | `python oracle_skill.py awr_status WEIRUI` | | `awr_list [clientCode]` | 列出 Agent 已生成的 AWR 报告 | `python oracle_skill.py awr_list WEIRUI` | | `awr_download ` | 下载 Agent 原始 AWR HTML | `python oracle_skill.py awr_download WEIRUI 20260720` | +| `log_info [--client code]` | 验证单个绝对日志路径和文件元数据 | `python oracle_skill.py log_info "D:\logs\app.log" --client AHMW --json` | +| `log_tail [--client code]` | 有界读取日志末尾内容 | `python oracle_skill.py log_tail "D:\logs\app.log" --client AHMW --lines 200` | +| `log_search ` | 关键词/RE2 正则流式搜索并返回有限上下文 | `python oracle_skill.py log_search "D:\logs\app.log" ERROR --client AHMW --json` | +| `log_enable [clientCode]` / `log_disable [clientCode]` | 开启或关闭目标客户日志分析 | `python oracle_skill.py log_enable AHMW` | | `servers` | 列出在线 Agent | `python oracle_skill.py servers` | | `health` | 检查中转服务状态 | `python oracle_skill.py health` | | `tablespace / tablespaces` | 查询表空间使用情况 | `python oracle_skill.py tablespace` | @@ -139,6 +143,10 @@ curl -X POST https://ts.henlo.net/api/query \ | `execute_query` | 执行 SELECT 查询(只读)—— 表空间查询也复用此 action | | `schema_discovery` | 发现核心业务域 | | `generate_nl2sql_schema` | 生成 NL2SQL Schema 字典 | +| `log_info` | 本机日志文件元数据 | +| `log_tail` | 本机日志有界尾部读取 | +| `log_search` | 本机日志有界流式筛选 | +| `log_config_set` | 只修改日志分析启用开关 | ## 故障排查 diff --git a/scripts/oracle_skill.py b/scripts/oracle_skill.py index 23aecac..453335c 100644 --- a/scripts/oracle_skill.py +++ b/scripts/oracle_skill.py @@ -978,6 +978,83 @@ def cmd_awr_download(client_code: str, date: str, output_path: str = "", as_json print(f"Size: {result['size']} bytes") +def _log_client_code(client_code: str = "") -> str: + cfg = get_config() or {} + return (client_code or cfg.get("client_code") or cfg.get("server_id") or "").strip() + + +def _log_api(endpoint: str, payload: Dict[str, Any], timeout: int = 120) -> Dict[str, Any]: + cfg = get_config() or {} + if not ensure_logged_in(cfg): + return {"success": False, "error": "login required", "error_code": "login_required"} + client_code = _log_client_code(str(payload.get("client_code") or "")) + if not client_code: + return {"success": False, "error": "clientCode is required", "error_code": "invalid_request"} + body = dict(payload) + body["client_code"] = client_code + body["timeout"] = max(1, min(int(timeout or 120), 300)) + try: + resp = requests.post( + f"{str(cfg.get('transit_url') or TRANSIT_URL).rstrip('/')}/api/log/{endpoint}", + json=body, + headers=make_headers(), + timeout=body["timeout"] + 5, + ) + try: + result = resp.json() + except Exception: + result = {"success": False, "error": resp.text or f"HTTP {resp.status_code}"} + if not resp.ok: + result.setdefault("success", False) + result.setdefault("status_code", resp.status_code) + return result + except requests.exceptions.RequestException as e: + return {"success": False, "error": str(e), "error_code": "request_failed"} + + +def log_info(path: str, client_code: str = "", timeout: int = 120) -> Dict[str, Any]: + return _log_api("info", {"client_code": client_code, "path": path}, timeout) + + +def log_tail(path: str, client_code: str = "", lines: int = 200, + max_bytes: int = 256 * 1024, timeout: int = 120) -> Dict[str, Any]: + return _log_api("tail", { + "client_code": client_code, "path": path, "lines": lines, "max_bytes": max_bytes, + }, timeout) + + +def log_search(path: str, patterns: List[str], client_code: str = "", regex: bool = False, + case_sensitive: bool = False, before_lines: int = 3, after_lines: int = 3, + max_matches: int = 100, scan_scope: str = "recent", + recent_bytes: int = 100 * 1024 * 1024, timeout: int = 120) -> Dict[str, Any]: + return _log_api("search", { + "client_code": client_code, "path": path, "patterns": patterns, "regex": regex, + "case_sensitive": case_sensitive, "before_lines": before_lines, "after_lines": after_lines, + "max_matches": max_matches, "scan_scope": scan_scope, "recent_bytes": recent_bytes, + }, timeout) + + +def set_log_analysis_enabled(enabled: bool, client_code: str = "", timeout: int = 30) -> Dict[str, Any]: + return _log_api("config", {"client_code": client_code, "enabled": bool(enabled)}, timeout) + + +def _print_log_result(result: Dict[str, Any], as_json: bool = False): + if as_json or not result.get("success"): + print(json.dumps(result, ensure_ascii=False, indent=2)) + return + data = result.get("data") or {} + if isinstance(data.get("lines"), list): + for entry in data["lines"]: + if isinstance(entry, dict): + marker = ">" if entry.get("match") else " " + print(f"{marker} {entry.get('scan_line', '-')}: {entry.get('text', '')}") + else: + print(entry) + print(f"matches={data.get('matches', '-')} complete={data.get('complete', True)} stop_reason={data.get('stop_reason', '')}") + else: + print(json.dumps(data, ensure_ascii=False, indent=2)) + + def _inspection_report_payload(result: Dict[str, Any]) -> Dict[str, Any]: data = result.get("data") or {} if isinstance(data, dict) and data.get("report_json"): @@ -3351,6 +3428,45 @@ def cmd_capabilities(args): "required": [] } }, + { + "name": "log_info", + "description": "Validate one absolute server log path and return bounded file metadata without Oracle access.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string", "description": "Absolute log file path on the target server."}, + "clientCode": {"type": "string", "description": "Optional client/server code."} + }, "required": ["path"]} + }, + { + "name": "log_tail", + "description": "Read a bounded tail from one server log file.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}, "clientCode": {"type": "string"}, + "lines": {"type": "integer", "description": "Default 200, maximum 2000."}, + "maxBytes": {"type": "integer", "description": "Default 262144, maximum 1048576."} + }, "required": ["path"]} + }, + { + "name": "log_search", + "description": "Stream-search a server log with keywords or RE2 regular expressions and bounded context.", + "parameters": {"type": "object", "properties": { + "path": {"type": "string"}, "patterns": {"type": "array", "items": {"type": "string"}}, + "clientCode": {"type": "string"}, "regex": {"type": "boolean"}, + "caseSensitive": {"type": "boolean"}, "beforeLines": {"type": "integer"}, + "afterLines": {"type": "integer"}, "maxMatches": {"type": "integer"}, + "scanScope": {"type": "string", "enum": ["recent", "full"]}, + "recentBytes": {"type": "integer"}, "timeout": {"type": "integer"} + }, "required": ["path", "patterns"]} + }, + { + "name": "log_enable", + "description": "Enable persisted log analysis for an authorized client.", + "parameters": {"type": "object", "properties": {"clientCode": {"type": "string"}}, "required": []} + }, + { + "name": "log_disable", + "description": "Disable persisted log analysis for an authorized client.", + "parameters": {"type": "object", "properties": {"clientCode": {"type": "string"}}, "required": []} + }, { "name": "awr_list", "description": "List AWR reports already generated and stored by the target Agent.", @@ -3419,7 +3535,7 @@ def cmd_capabilities(args): "analyze", "list", "source", "describe", "search", "query", "discover", "nl2sql", "perm", "qperm", "login", "logout", - "status", "switch", "clients", "device_register", "device_login", "ops", "ops_report", "inspection_report", "inspection_latest", "inspection_get", "awr_status", "awr_list", "awr_download", "agent_update", "capabilities" + "status", "switch", "clients", "device_register", "device_login", "ops", "ops_report", "inspection_report", "inspection_latest", "inspection_get", "awr_status", "awr_list", "awr_download", "log_info", "log_tail", "log_search", "log_enable", "log_disable", "agent_update", "capabilities" ], "python_version": "3.6+", "dependencies": ["requests"] @@ -3612,6 +3728,53 @@ def main(): output_path = sys.argv[output_index + 1] cmd_awr_download(sys.argv[2], sys.argv[3], output_path=output_path, as_json="--json" in sys.argv[4:]) + elif cmd == "log_info": + parser = argparse.ArgumentParser(prog="oracle_skill.py log_info") + parser.add_argument("path") + parser.add_argument("--client", default="") + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(sys.argv[2:]) + _print_log_result(log_info(args.path, args.client, args.timeout), args.json) + + elif cmd == "log_tail": + parser = argparse.ArgumentParser(prog="oracle_skill.py log_tail") + parser.add_argument("path") + parser.add_argument("--client", default="") + parser.add_argument("--lines", type=int, default=200) + parser.add_argument("--max-bytes", type=int, default=256 * 1024) + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(sys.argv[2:]) + _print_log_result(log_tail(args.path, args.client, args.lines, args.max_bytes, args.timeout), args.json) + + elif cmd == "log_search": + parser = argparse.ArgumentParser(prog="oracle_skill.py log_search") + parser.add_argument("path") + parser.add_argument("patterns", nargs="+") + parser.add_argument("--client", default="") + parser.add_argument("--regex", action="store_true") + parser.add_argument("--case-sensitive", action="store_true") + parser.add_argument("--before", type=int, default=3) + parser.add_argument("--after", type=int, default=3) + parser.add_argument("--max-matches", type=int, default=100) + parser.add_argument("--scope", choices=("recent", "full"), default="recent") + parser.add_argument("--recent-bytes", type=int, default=100 * 1024 * 1024) + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--json", action="store_true") + args = parser.parse_args(sys.argv[2:]) + _print_log_result(log_search( + args.path, args.patterns, args.client, args.regex, args.case_sensitive, + args.before, args.after, args.max_matches, args.scope, args.recent_bytes, args.timeout, + ), args.json) + + elif cmd in ("log_enable", "log_disable"): + parser = argparse.ArgumentParser(prog=f"oracle_skill.py {cmd}") + parser.add_argument("client", nargs="?", default="") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(sys.argv[2:]) + _print_log_result(set_log_analysis_enabled(cmd == "log_enable", args.client), args.json) + elif cmd == "agent_update": timeout = int(sys.argv[3]) if len(sys.argv) >= 4 and sys.argv[3].isdigit() else 300 cmd_agent_update(sys.argv[2] if len(sys.argv) >= 3 else "", timeout) diff --git a/tests/test_log_analysis.py b/tests/test_log_analysis.py new file mode 100644 index 0000000..23de662 --- /dev/null +++ b/tests/test_log_analysis.py @@ -0,0 +1,93 @@ +import io +import json +import unittest +from contextlib import redirect_stdout +from unittest import mock + +from scripts import oracle_skill as skill + + +class FakeResponse: + def __init__(self, payload, ok=True, status_code=200): + self._payload = payload + self.ok = ok + self.status_code = status_code + self.text = "" + + def json(self): + return self._payload + + +class LogAnalysisSkillTests(unittest.TestCase): + def config(self): + return { + "access_token": "token", + "expires_at": "2999-01-01T00:00:00+08:00", + "transit_url": "http://transit.example", + "client_code": "AHMW", + } + + def test_log_info_preserves_path_with_spaces(self): + response = FakeResponse({"success": True, "data": {"size": 12}}) + path = r"D:\Program Files\BOS\logs\app log.txt" + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "post", return_value=response + ) as post: + result = skill.log_info(path, "AHMW") + self.assertTrue(result["success"]) + self.assertEqual(post.call_args.args[0], "http://transit.example/api/log/info") + self.assertEqual(post.call_args.kwargs["json"]["path"], path) + self.assertEqual(post.call_args.kwargs["json"]["client_code"], "AHMW") + self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer token") + + def test_log_search_forwards_filters_and_full_scope(self): + response = FakeResponse({"success": True, "data": {"matches": 1, "lines": []}}) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "post", return_value=response + ) as post: + result = skill.log_search( + r"D:\logs\app.log", ["ERROR", r"ORA-\d+"], "AHMW", regex=True, + case_sensitive=True, before_lines=5, after_lines=7, max_matches=20, + scan_scope="full", timeout=300, + ) + self.assertTrue(result["success"]) + payload = post.call_args.kwargs["json"] + self.assertEqual(payload["patterns"], ["ERROR", r"ORA-\d+"]) + self.assertTrue(payload["regex"]) + self.assertEqual(payload["scan_scope"], "full") + self.assertEqual(payload["timeout"], 300) + + def test_log_enable_uses_authenticated_customer_endpoint(self): + response = FakeResponse({"success": True, "data": {"enabled": True, "persisted": True}}) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "post", return_value=response + ) as post: + result = skill.set_log_analysis_enabled(True, "AHMW") + self.assertTrue(result["success"]) + self.assertEqual(post.call_args.args[0], "http://transit.example/api/log/config") + self.assertEqual(post.call_args.kwargs["json"]["enabled"], True) + + def test_log_error_code_is_preserved(self): + response = FakeResponse( + {"success": False, "error": "log file not found", "error_code": "file_not_found"}, + ok=False, status_code=404, + ) + with mock.patch.object(skill, "get_config", return_value=self.config()), mock.patch.object( + skill.requests, "post", return_value=response + ): + result = skill.log_info(r"D:\missing.log", "AHMW") + self.assertFalse(result["success"]) + self.assertEqual(result["error_code"], "file_not_found") + self.assertEqual(result["status_code"], 404) + + def test_capabilities_lists_all_log_commands(self): + output = io.StringIO() + with redirect_stdout(output): + skill.cmd_capabilities(type("Args", (), {"json": True})()) + payload = json.loads(output.getvalue()) + names = {item["name"] for item in payload["commands"]} + self.assertTrue({"log_info", "log_tail", "log_search", "log_enable", "log_disable"}.issubset(names)) + + +if __name__ == "__main__": + unittest.main()