diff --git a/.gitignore b/.gitignore index 9293b4f..0e1aa7b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ outputs/ scripts/config.json scripts/*.token +scripts/update-state.json +scripts/update-state.json.* +scripts/update-state.lock diff --git a/AGENTS.md b/AGENTS.md index 89bb38b..ff1f52c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,7 @@ # oracle-jump-query Skill Release Standard +Release 1.5.51: CLI 每次启动记录最后使用时间,按本机日期每日首次使用自动抓取 origin/main 并仅快进安装,成功后重新启动当前命令;本机状态与并发锁不提交 Git。保留本机配置,源码修改、暂存修改、分支分叉或配置冲突时暂停安装;失败不阻止业务命令。本次新增本机更新逻辑,不修改 Agent、transit-server、HTTP 接口、登录配置或审计 action、字段及保存策略,命令数保持 32。发布时同步私有主库、安装目录和无私有历史公共镜像。 + 取号 CODE 定义在 `AD_SEQUENCE` 表;1.5.50 的编号规则分析路径为 `DOCNO -> AD_COLUMN.SEQUENCENAME -> AD_SEQUENCE`。 Release 1.5.50: 明确取单号函数 `Get_SequenceNo('', AD_CLIENT_ID)`,默认 `CODE` 取单据 `DOCNO` 字段对应的 `AD_COLUMN.SEQUENCENAME`,客户 ID 使用单据实际值;取号仅生成 SQL 示例,不通过查询通道调用。本次仅更新 Skill 文档,不修改 Agent、transit-server、CLI/HTTP 接口、配置或审计 action、字段及保存策略;命令数保持 32。发布时同步私有主库、安装目录和无私有历史公共镜像。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c47460..7180568 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.5.51 (2026-09-30) + +- CLI 每次启动记录最后使用时间;按本机日期,每个使用日首次启动自动抓取 `origin/main`,仅快进安装更新并重新启动当前命令,同日不重复检测。 +- 使用独立本机状态文件和并发锁,保留本机配置;源码修改、暂存修改、分支分叉或配置冲突时暂停安装,抓取失败和超时继续业务命令,更新提示不污染 JSON 输出。 +- 本次新增 Skill 本地更新逻辑,不修改 Agent、transit-server、HTTP 接口、登录配置格式或审计 action、字段及保存策略;CLI 命令数保持 32。使用状态仅本机保存,不写入中转审计库。 + ## 1.5.50 (2026-09-30) - 明确取单号函数 `Get_SequenceNo('', AD_CLIENT_ID)`,默认 `CODE` 来自该单据 `DOCNO` 字段对应的 `AD_COLUMN.SEQUENCENAME`;客户 ID 使用单据实际值,不固定为 `37`。 diff --git a/README.md b/README.md index ddc1217..cb0c125 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ python scripts/oracle_skill.py ops_report HENLO - 如果表存在 `DOCNO` 字段,先到 `AD_COLUMN` 查询该字段的 `SEQUENCENAME`。 - 取单号函数为 `Get_SequenceNo('', AD_CLIENT_ID)`;默认 `CODE` 取该单据 `DOCNO` 字段对应的 `AD_COLUMN.SEQUENCENAME` 值,第二个参数取单据实际的 `AD_CLIENT_ID`,确认是 `37` 时才写 `37`。配置为空时不猜测 `CODE`,特殊业务按实际配置和过程逻辑核实;只生成 SQL 示例,不通过查询通道调用取号函数。 - 取号 `CODE` 的定义在 `AD_SEQUENCE` 表,分析编号规则时沿 `DOCNO -> AD_COLUMN.SEQUENCENAME -> AD_SEQUENCE` 核实。 + - 如果表存在 `AD_CLIENT_ID` 字段,默认值为 `37`。 - 如果表存在 `AD_ORG_ID` 字段,默认值为 `27`。 - 子表 `AD_CLIENT_ID`、`AD_ORG_ID` 默认继承父表对应字段值。 @@ -353,6 +354,14 @@ python scripts/oracle_skill.py version agent --all --json ## 从 Git 更新 Skill +### 每日自动更新与使用记录 + +CLI 启动时记录最后使用时间;按运行电脑本地日期,每个使用日首次启动自动 `fetch origin main`,检测到新版本时仅在 `main` 分支执行快进安装,并重新启动当前命令使用新版本。同一天后续启动不重复抓取;未使用的日期不运行后台任务。每次开始应用 Skill 时先运行 `python scripts/oracle_skill.py capabilities --json`,使只参考业务知识的任务也触发检测。 + +本机状态文件为 `scripts/update-state.json`:`last_used_at` 记录最后使用时间,`last_checked_at` / `last_check_date` 记录检测时间和日期,`status` 记录 `checking`、`up_to_date`、`updated`、`update_blocked`、`check_failed` 或 `not_git`;检测完成时可包含本机和远端提交 ID。状态、锁和临时文件不提交 Git,不保存 token、密钥、SQL 或查询结果。 + +自动更新保留本机 `config.json` 和 `scripts/config.json`;源码有未提交修改、存在暂存修改、分支分叉或配置冲突时暂停安装并继续原命令。抓取超时为 10 秒,认证和网络失败不阻止业务命令,也不在当天每次调用时重试;可手动执行 `git pull --ff-only origin main` 重试。提示写入 stderr,stdout 的 JSON 输出不受影响。Git checkout 使用自己的 `origin`,私有和公共安装均可使用;无 `.git` 的下载目录只记录使用时间。 + Skill 仅通过 Gitea Git 仓库更新,不使用 OSS 或 `skill_update` 命令。源码仓库完成修改并推送私有库后,必须同步更新无历史公共镜像;随后在已安装目录执行: ```powershell diff --git a/SKILL.md b/SKILL.md index f2a5021..9df481f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,10 +5,12 @@ description: Oracle 跳板查询技能。通过中转服务查询远程 Oracle # Oracle 跳板查询 -> **版本:v1.5.50** · [更新日志](./CHANGELOG.md) +> **版本:v1.5.51** · [更新日志](./CHANGELOG.md) ## 使用原则 +每次开始使用本 Skill(包括仅参考业务知识)时,先执行 `python scripts/oracle_skill.py capabilities --json`。CLI 每次启动记录最后使用时间;按运行电脑本地日期,当天第一次启动自动从当前 `origin/main` 抓取更新并尝试快进安装,成功后自动重新启动当前命令。无需登录即可检测;当天后续启动只记录使用时间。没有使用的日期不启动后台任务。 + 使用本 skill 时,先确认当前登录状态和 client。用户指定“恒诺 / 品小二 / 未芮 / 康奈”等服务器时,可以用 `switch ` 按 code 或名称切换;找不到目标服务器时,skill 会先刷新 client 列表再重试匹配。若名称不够准确并匹配到多个 client,只列出候选项,请用户指定更准确的 client code,不要直接猜测切换。 本 skill 的数据库通道默认只读。可以生成 SQL 和说明,但不得通过 `query` / `qperm` 执行写库语句,包括 `INSERT`、`UPDATE`、`DELETE`、`MERGE`、`DDL`、提交存储过程和其他会改变业务数据的调用。用户要求写库时,只生成 SQL,并明确说明未执行。 @@ -216,6 +218,8 @@ python scripts/oracle_skill.py sys_functions BOSNDS3 ## Skill 更新方式 +每日检测和使用记录保存在本机 `scripts/update-state.json`,包含 `last_used_at`、`last_checked_at`、`last_check_date`、`status` 及检测到的提交 ID;文件和并发锁均不提交 Git,不保存登录凭证或查询内容。检测失败仍记为当天已尝试,下次使用日再自动检测;可随时执行下面的手动拉取命令重试。自动安装只允许 `main` 分支快进:源码有未提交修改、存在暂存修改、分支分叉或配置冲突时保留本机文件并继续原命令;本机 `config.json`、`scripts/config.json` 保留原内容。网络抓取设有 10 秒超时,提示只输出到 stderr,`--json` 的 stdout 保持可解析。没有 `.git` 的下载目录仍记录使用时间,但需先安装为 Git checkout 才能自动更新。 + Skill 仅通过 Gitea Git 仓库更新,不使用 OSS 发布或 `skill_update` 命令。源码修改、提交和推送均在源码仓库完成;私有库推送后必须同步无历史公共镜像,已安装目录仅执行拉取与验证: ```powershell diff --git a/VERSION b/VERSION index 88eb60e..99f37f6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.5.50 +1.5.51 diff --git a/scripts/oracle_skill.py b/scripts/oracle_skill.py index 453335c..5b989c0 100644 --- a/scripts/oracle_skill.py +++ b/scripts/oracle_skill.py @@ -3823,4 +3823,7 @@ def main(): if __name__ == "__main__": + from skill_update import on_use + if on_use(SKILL_DIR): + os.execv(sys.executable, [sys.executable] + sys.argv) main() diff --git a/scripts/skill_update.py b/scripts/skill_update.py new file mode 100644 index 0000000..26395cd --- /dev/null +++ b/scripts/skill_update.py @@ -0,0 +1,162 @@ +"""Daily Git update checks and local usage timestamps, independent of login config.""" + +import contextlib +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + + +GIT_TIMEOUT = 10 + + +@contextlib.contextmanager +def _state_lock(path): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a+b") as handle: + if os.name == "nt": + import msvcrt + handle.seek(0, os.SEEK_END) + if not handle.tell(): + handle.write(b"0") + handle.flush() + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def _read_state(path): + try: + with path.open(encoding="utf-8") as handle: + state = json.load(handle) + return state if isinstance(state, dict) else {} + except (OSError, ValueError): + return {} + + +def _write_state(path, state): + fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(state, handle, ensure_ascii=False, indent=2) + handle.write("\n") + os.replace(temporary, str(path)) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _git(root, *args): + env = os.environ.copy() + env["GIT_TERMINAL_PROMPT"] = "0" + env["GCM_INTERACTIVE"] = "never" + result = subprocess.run( + ["git", "-C", str(root), *args], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=GIT_TIMEOUT, env=env, + ) + if result.returncode: + # Git output can contain authenticated remote URLs; never store or print it. + raise RuntimeError("git command failed") + return result.stdout.decode("utf-8", errors="replace").strip() + + +def _check_remote(root): + # A downloaded directory may sit inside an unrelated parent repository. + if not (root / ".git").exists(): + return {"status": "not_git"} + _git(root, "fetch", "--quiet", "origin", "main") + local = _git(root, "rev-parse", "HEAD") + remote = _git(root, "rev-parse", "FETCH_HEAD") + outcome = { + "status": "up_to_date" if local == remote else "update_available", + "local_commit": local, + "remote_commit": remote, + } + if local == remote: + return outcome + if _git(root, "rev-parse", "--abbrev-ref", "HEAD") != "main": + outcome["status"] = "update_blocked" + return outcome + changed = _git(root, "diff", "--name-only").splitlines() + if any(name not in ("config.json", "scripts/config.json") for name in changed) or _git(root, "diff", "--cached", "--name-only"): + outcome["status"] = "update_blocked" + return outcome + try: + _git(root, "merge-base", "--is-ancestor", local, remote) + except RuntimeError: + outcome["status"] = "update_blocked" + return outcome + # Keep configuration bytes local, including clean tracked template config. + saved = {} + for name in ("config.json", "scripts/config.json"): + path = root / name + if path.exists(): + saved[path] = path.read_bytes() + try: + _git(root, "merge", "--ff-only", "--quiet", remote) + except RuntimeError: + outcome["status"] = "update_blocked" + return outcome + finally: + for path, data in saved.items(): + if not path.exists() or path.read_bytes() != data: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + outcome["status"] = "updated" + outcome["local_commit"] = remote + return outcome + + +def on_use(skill_dir, now=None): + """Record every invocation; claim today's check before the network call.""" + root = Path(skill_dir).resolve() + state_path = root / "scripts" / "update-state.json" + lock_path = root / "scripts" / "update-state.lock" + now = now or datetime.now().astimezone() + stamp, day = now.isoformat(timespec="seconds"), now.date().isoformat() + try: + with _state_lock(lock_path): + state = _read_state(state_path) + state["last_used_at"] = stamp + should_check = state.get("last_check_date") != day + if should_check: + state.update(last_check_date=day, last_checked_at=stamp, status="checking") + state.pop("local_commit", None) + state.pop("remote_commit", None) + _write_state(state_path, state) + if not should_check: + return False + try: + outcome = _check_remote(root) + except (OSError, subprocess.SubprocessError, RuntimeError): + outcome = {"status": "check_failed"} + with _state_lock(lock_path): + state = _read_state(state_path) + # Preserve usage from concurrent invocations, and a newer day's claim. + if state.get("last_check_date") == day: + state.update(outcome) + _write_state(state_path, state) + if outcome["status"] == "updated": + print("[Skill] 已快进安装 Git 新版本,重新启动命令。", file=sys.stderr) + elif outcome["status"] == "update_blocked": + print("[Skill] 检测到远端版本变化,本机修改或分支状态阻止快进更新;继续执行原命令。", file=sys.stderr) + elif outcome["status"] == "check_failed": + print("[Skill] 今日更新检测失败,继续执行原命令;下次使用日自动重试。", file=sys.stderr) + return outcome["status"] == "updated" + except OSError: + print("[Skill] 无法保存本机使用记录,继续执行原命令。", file=sys.stderr) + return False diff --git a/tests/test_skill_update.py b/tests/test_skill_update.py new file mode 100644 index 0000000..6e0cfb3 --- /dev/null +++ b/tests/test_skill_update.py @@ -0,0 +1,160 @@ +import contextlib +import io +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from unittest import mock + +from scripts import skill_update + + +class DailyUpdateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + self.now = datetime(2026, 9, 30, 9, tzinfo=timezone(timedelta(hours=8))) + + def state(self, root=None): + return json.loads(((root or self.root) / "scripts/update-state.json").read_text(encoding="utf-8")) + + def git(self, root, *args): + result = subprocess.run( + ["git", "-C", str(root), *args], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + return result.stdout.decode().strip() + + def commit(self, root, message): + self.git(root, "add", "--all") + self.git(root, "-c", "user.name=Update Test", "-c", "user.email=update@example.test", "commit", "-m", message) + + def repository(self): + remote, source, installed = [self.root / name for name in ("remote.git", "source", "installed")] + self.git(self.root, "init", "--bare", str(remote)) + self.git(self.root, "init", "-b", "main", str(source)) + (source / "config.json").write_bytes(b'{"local":"keep"}\n') + (source / "VERSION").write_text("1.0.0\n") + (source / ".gitignore").write_text("scripts/update-state*\nscripts/config.json\n") + self.commit(source, "initial") + self.git(source, "remote", "add", "origin", str(remote)) + self.git(source, "push", "origin", "main") + self.git(self.root, "clone", "-b", "main", str(remote), str(installed)) + (installed / "scripts").mkdir() + (installed / "scripts/config.json").write_bytes(b'{"access_token":"local-only"}') + return source, installed + + def publish(self, source, config=False): + (source / "VERSION").write_text("1.0.1\n") + if config: + (source / "config.json").write_bytes(b'{"new":"template"}\n') + self.commit(source, "update") + self.git(source, "push", "origin", "main") + + def test_once_per_local_day_and_every_usage_timestamp(self): + with mock.patch.object(skill_update, "_check_remote", return_value={"status": "up_to_date"}) as check: + skill_update.on_use(self.root, self.now) + skill_update.on_use(self.root, self.now + timedelta(hours=2)) + self.assertEqual(check.call_count, 1) + self.assertEqual(self.state()["last_used_at"], "2026-09-30T11:00:00+08:00") + skill_update.on_use(self.root, self.now + timedelta(days=1)) + self.assertEqual(check.call_count, 2) + + def test_failed_check_does_not_block_or_repeat_today_or_leak_output(self): + with mock.patch.object(skill_update, "_check_remote", side_effect=RuntimeError("secret-url")) as check: + output, error = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + self.assertFalse(skill_update.on_use(self.root, self.now)) + skill_update.on_use(self.root, self.now) + self.assertEqual(check.call_count, 1) + self.assertEqual(output.getvalue(), "") + self.assertNotIn("secret-url", error.getvalue()) + self.assertNotIn("secret-url", json.dumps(self.state())) + self.assertEqual(self.state()["status"], "check_failed") + + def test_fast_forward_keeps_modified_local_login_configuration(self): + source, installed = self.repository() + (installed / "config.json").write_bytes(b'{"local":"modified"}') + original = (installed / "config.json").read_bytes() + self.publish(source) + self.assertTrue(skill_update.on_use(installed, self.now)) + self.assertEqual(self.git(installed, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD")) + self.assertEqual((installed / "config.json").read_bytes(), original) + self.assertEqual((installed / "scripts/config.json").read_bytes(), b'{"access_token":"local-only"}') + self.assertEqual(self.state(installed)["status"], "updated") + + def test_clean_config_preserved_when_upstream_template_changes(self): + source, installed = self.repository() + original = (installed / "config.json").read_bytes() + self.publish(source, config=True) + self.assertTrue(skill_update.on_use(installed, self.now)) + self.assertEqual((installed / "config.json").read_bytes(), original) + self.assertEqual((installed / "VERSION").read_text(), "1.0.1\n") + + def test_source_edits_block_install_but_do_not_discard_changes(self): + source, installed = self.repository() + self.publish(source) + (installed / "VERSION").write_text("local edit\n") + head = self.git(installed, "rev-parse", "HEAD") + self.assertFalse(skill_update.on_use(installed, self.now)) + self.assertEqual(self.git(installed, "rev-parse", "HEAD"), head) + self.assertEqual((installed / "VERSION").read_text(), "local edit\n") + self.assertEqual(self.state(installed)["status"], "update_blocked") + + def test_conflicting_remote_config_keeps_dirty_local_config(self): + source, installed = self.repository() + (installed / "config.json").write_bytes(b'{"local":"changed"}') + self.publish(source, config=True) + head = self.git(installed, "rev-parse", "HEAD") + self.assertFalse(skill_update.on_use(installed, self.now)) + self.assertEqual(self.git(installed, "rev-parse", "HEAD"), head) + self.assertEqual((installed / "config.json").read_bytes(), b'{"local":"changed"}') + self.assertEqual(self.state(installed)["status"], "update_blocked") + + def test_cli_restarts_into_new_version_and_keeps_stdout_json(self): + source, installed = self.repository() + (source / "scripts").mkdir() + scripts = pathlib.Path(skill_update.__file__).parent + for name in ("oracle_skill.py", "skill_update.py"): + shutil.copyfile(str(scripts / name), str(source / "scripts" / name)) + self.commit(source, "add cli") + self.git(source, "push", "origin", "main") + self.git(installed, "pull", "--ff-only", "origin", "main") + self.publish(source) + result = subprocess.run( + [sys.executable, str(installed / "scripts/oracle_skill.py"), "capabilities", "--json"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, timeout=30, + ) + self.assertEqual(json.loads(result.stdout.decode("utf-8"))["version"], "1.0.1") + self.assertEqual(self.state(installed)["status"], "updated") + self.assertEqual(self.git(installed, "rev-parse", "HEAD"), self.git(source, "rev-parse", "HEAD")) + + def test_diverged_branch_never_resets_local_commit(self): + source, installed = self.repository() + (installed / "VERSION").write_text("local branch\n") + self.commit(installed, "local") + self.publish(source) + head = self.git(installed, "rev-parse", "HEAD") + self.assertFalse(skill_update.on_use(installed, self.now)) + self.assertEqual(self.git(installed, "rev-parse", "HEAD"), head) + self.assertEqual(self.state(installed)["status"], "update_blocked") + + def test_download_without_git_does_not_fetch_parent_repository(self): + with mock.patch.object(skill_update, "_git") as git: + self.assertFalse(skill_update.on_use(self.root, self.now)) + git.assert_not_called() + self.assertEqual(self.state()["status"], "not_git") + + def test_concurrent_invocation_does_not_fetch_twice_or_lose_latest_usage(self): + def checking(root): + skill_update.on_use(root, self.now + timedelta(minutes=1)) + return {"status": "up_to_date"} + with mock.patch.object(skill_update, "_check_remote", side_effect=checking) as check: + skill_update.on_use(self.root, self.now) + self.assertEqual(check.call_count, 1) + self.assertEqual(self.state()["last_used_at"], "2026-09-30T09:01:00+08:00")