From 57914b38b14b4ba34adb37dbeeff697aa9684f21 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:20:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(python):=20=E8=B4=A6=E5=8F=B7=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=96=87=E4=BB=B6=E4=B8=8D=E5=86=8D=E8=A2=AB=E6=B2=99?= =?UTF-8?q?=E7=AE=B1=E9=87=8D=E5=BB=BA=E8=A6=86=E7=9B=96=EF=BC=9BstorePath?= =?UTF-8?q?=20=E7=BB=9F=E4=B8=80=E6=B2=99=E7=AE=B1=20configs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配套 jetbrains PR #7(多账户编辑)。此前执行器每次启动 copytree 会把 项目侧的 account_scoped_overrides.json 整体拷进沙箱,覆盖掉插件账号 编辑刚写入沙箱的值——编辑被旧文件冲掉。 - run_executor.py:copytree 用 ignore 回调把账号文件排除在外,改由 copy_account_file_once 仅在沙箱没有该文件时一次性拷入(临时文件 + 硬链接/排他创建发布,防并发覆盖或暴露半写入文件) - account_store.py:新增 initialize_sandbox_account_file——账号编辑 首次落沙箱前,若沙箱无账号文件则从项目声明的 config_folder(AST 解析 config.py 读取,与 probe 规则一致)初始化;新增 ast/shutil/ tempfile 导入与 project_dir 绝对化 - probe_task_schemas.py:collect_multi_account 的 storePath 一律报沙箱 configs/ 路径(不再用自定义 config_folder 拼接,与执行器沙箱布局 对齐);info 默认补 accountCount/overrideAccounts/overriddenTasks 键, 非 dict 数据可读性判定修正 - 测试断言同步更新 --- python/account_store.py | 76 ++++++++++++++++++- python/probe_task_schemas.py | 55 ++++++++------ python/run_executor.py | 63 +++++++++++++-- python/tests/test_probe_multi_account_path.py | 6 +- 4 files changed, 164 insertions(+), 36 deletions(-) diff --git a/python/account_store.py b/python/account_store.py index d364edc..525f828 100644 --- a/python/account_store.py +++ b/python/account_store.py @@ -7,7 +7,8 @@ 存储位置与执行器一致:传 --run-dir 时 get_relative_path("configs") 改道沙箱 (与 run_executor 的 install_config_path_patch 同手法,store 在导入期固定路径, -patch 必须先于 store import)。不传 --run-dir 则读写项目 configs(仅诊断用途)。 +patch 必须先于 store import)。沙箱文件缺失时,先从项目声明的 config_folder +复制一次账号文件;后续编辑均以沙箱为准。不传 --run-dir 则读写项目文件(仅诊断用途)。 用法: python account_store.py get --run-dir @@ -18,10 +19,13 @@ 输出(最后一行 JSON):{"ok": true, ...} / {"ok": false, "error": "..."} """ import argparse +import ast import importlib import json import os +import shutil import sys +import tempfile sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") @@ -32,6 +36,73 @@ "src.tasks.account_scope_store", ) +ACCOUNT_FILE = "account_scoped_overrides.json" + + +def detect_config_folder(project_dir: str) -> str: + """导入项目之前读取常量 config_folder;与 probe_task_schemas 的规则一致。""" + for candidate in ( + os.path.join(project_dir, "src", "config.py"), + os.path.join(project_dir, "config.py"), + ): + try: + with open(candidate, encoding="utf-8") as stream: + tree = ast.parse(stream.read(), filename=candidate) + except (OSError, SyntaxError): + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values): + if ( + isinstance(key, ast.Constant) + and key.value == "config_folder" + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + return value.value + return "configs" + + +def initialize_sandbox_account_file(project_dir: str, run_dir: str) -> None: + """首次编辑时导入项目账号文件,不覆盖已有沙箱文件。""" + source_folder = detect_config_folder(project_dir) + source_configs = ( + source_folder if os.path.isabs(source_folder) + else os.path.join(project_dir, source_folder) + ) + source = os.path.join(source_configs, ACCOUNT_FILE) + target_dir = os.path.join(os.path.abspath(run_dir), "configs") + target = os.path.join(target_dir, ACCOUNT_FILE) + if not os.path.isfile(source) or os.path.lexists(target): + return + os.makedirs(target_dir, exist_ok=True) + # 先在目标目录写完整临时文件,再用硬链接的「目标必须不存在」语义发布。 + # 避免和正在运行的执行器/另一宿主同时初始化时相互覆盖或暴露半写入文件。 + fd, temporary = tempfile.mkstemp(prefix=".account-store-", suffix=".tmp", dir=target_dir) + os.close(fd) + try: + shutil.copyfile(source, temporary) + try: + os.link(temporary, target) + except FileExistsError: + pass + except OSError: + # 某些文件系统不支持硬链接;仍用排他创建防止覆盖已有编辑。 + created = False + try: + with open(temporary, "rb") as input_file, open(target, "xb") as output_file: + created = True + shutil.copyfileobj(input_file, output_file) + except FileExistsError: + pass + except Exception: + if created: + os.unlink(target) + raise + finally: + os.unlink(temporary) + def apply_sandbox_redirect(run_dir: str) -> None: """把 get_relative_path("configs", ...) 改道沙箱(必须在 store import 前调用)。 @@ -83,9 +154,10 @@ def main() -> None: kwargs[extra[i].lstrip("-")] = extra[i + 1] try: # patch 先于 store import:store 在模块导入期就用 get_relative_path 固定路径 - project_dir = args.project_dir + project_dir = os.path.abspath(args.project_dir) sys.path.insert(0, project_dir) if kwargs.get("run-dir"): + initialize_sandbox_account_file(project_dir, kwargs["run-dir"]) apply_sandbox_redirect(kwargs["run-dir"]) store = load_store_module(project_dir) if args.command == "get": diff --git a/python/probe_task_schemas.py b/python/probe_task_schemas.py index fe5dd95..84caa35 100644 --- a/python/probe_task_schemas.py +++ b/python/probe_task_schemas.py @@ -573,9 +573,9 @@ def resolve_run_dir(project_dir): def collect_multi_account(project_dir, tasks, broken, global_groups): """探测多账户存储,返回只读概要与「打开数据位置」的路径。 - 存储位置与执行器一致:沙箱(`//`)优先——执行器 - 与插件的账号编辑都落沙箱;项目侧文件仅作首次探测回退(执行器启动 - copytree 会把它带进沙箱)。storePath 一律报沙箱路径。 + 存储位置与执行器一致:沙箱 `/configs/` 优先——执行器 + 与插件的账号编辑都落沙箱;项目侧源文件按声明的 config_folder 查找, + 首次启动或编辑时复制到沙箱。storePath 一律报实际沙箱路径。 ⚠️ 沙箱根目录**按宿主解析**(见 `resolve_run_dir`):曾写死 `.vscode/ok-script-toolkit`,JetBrains 宿主会拿到一个根本不存在、也永远不会被 @@ -583,7 +583,7 @@ def collect_multi_account(project_dir, tasks, broken, global_groups): """ config_folder = detect_config_folder(project_dir) sandbox_path = os.path.join( - resolve_run_dir(project_dir), config_folder, "account_scoped_overrides.json" + resolve_run_dir(project_dir), "configs", "account_scoped_overrides.json" ) project_path = os.path.join(project_dir, config_folder, "account_scoped_overrides.json") # store 模块可 import 性:区分「项目不支持账号编辑」与「读取失败(环境问题)」 @@ -600,9 +600,10 @@ def collect_multi_account(project_dir, tasks, broken, global_groups): "available": has_data_file, "storePath": sandbox_path, "hasStoreModule": has_store_module, + "accountCount": 0, + "overrideAccounts": 0, + "overriddenTasks": [], } - if not has_data_file: - return info store_data = {} enabled_tasks = {} rules_module = None @@ -657,30 +658,34 @@ def task_enabled_keys(task): except Exception as e: # noqa: BLE001 broken.append({"task": f"", "error": f"{type(e).__name__}: {e}"}) info["enabledTasks"] = enabled_tasks - data_path = sandbox_path if os.path.isfile(sandbox_path) else project_path - try: - with open(data_path, encoding="utf-8") as fp: - data = json.load(fp) - store_data = data - if isinstance(data, dict): - registry = data.get("account_registry") - accounts = data.get("accounts") - info["accountCount"] = len(registry) if isinstance(registry, dict) else 0 - info["overrideAccounts"] = len(accounts) if isinstance(accounts, dict) else 0 - if isinstance(accounts, dict): - tasks = set() - for value in accounts.values(): - if isinstance(value, dict): - tasks.update(value.keys()) - info["overriddenTasks"] = sorted(str(item) for item in tasks) - except (OSError, ValueError): - info["readable"] = False + if has_data_file: + data_path = sandbox_path if os.path.isfile(sandbox_path) else project_path + try: + with open(data_path, encoding="utf-8") as fp: + data = json.load(fp) + if isinstance(data, dict): + store_data = data + registry = data.get("account_registry") + accounts = data.get("accounts") + info["accountCount"] = len(registry) if isinstance(registry, dict) else 0 + info["overrideAccounts"] = len(accounts) if isinstance(accounts, dict) else 0 + if isinstance(accounts, dict): + stored_tasks = set() + for value in accounts.values(): + if isinstance(value, dict): + stored_tasks.update(value.keys()) + info["overriddenTasks"] = sorted(str(item) for item in stored_tasks) + else: + info["readable"] = False + except (OSError, ValueError): + info["readable"] = False # 全局配置组的按账号覆盖(ok-end-field 滑索/键位 Proxy 模式:组覆盖存在 # accounts[acc_id][组名])——收录条件:组名在已知 Proxy 列表 或 存储里出现过 # 该组名的覆盖数据(项目 GUI 是覆盖创建入口,probe 不凭空猜测没出现过的组) stored_names = set() - for account_tasks in (store_data or {}).get("accounts", {}).values(): + stored_accounts = store_data.get("accounts") + for account_tasks in (stored_accounts if isinstance(stored_accounts, dict) else {}).values(): if isinstance(account_tasks, dict): stored_names |= set(account_tasks.keys()) for group in global_groups or []: diff --git a/python/run_executor.py b/python/run_executor.py index c1eb76d..8a0160e 100644 --- a/python/run_executor.py +++ b/python/run_executor.py @@ -65,10 +65,11 @@ 配置沙箱:调试插件绝不允许改动目标项目的 `configs/`。宿主经 `OK_TOOLKIT_RUN_DIR` 传入沙箱根目录(如 `/.vscode/ok-script-toolkit`), `config['config_folder']` 与 `config['screenshots_folder']` 一并改道,ok 框架的读写 -全部落在沙箱内。沙箱初始化时把项目 configs/ 整目录拷进来 —— 任务读到的是 -**项目当前配置 + 插件参数覆盖**;执行器的写入只落沙箱,项目侧文件保持原样。 +全部落在沙箱内。沙箱初始化时把项目配置目录拷进来 —— 任务读到的是 +**项目当前配置 + 插件参数覆盖**;账号覆盖文件在首次拷入后保留沙箱编辑。 +执行器的写入只落沙箱,项目侧文件保持原样。 -`devices.json` 是唯一例外:工具箱的 connect_game.py 把连接结果写在 +`devices.json` 是连接信息的特例:工具箱的 connect_game.py 把连接结果写在 `<项目>/configs/devices.json`(`folder=` 硬指定,不受 config_folder 影响), 而执行器需要读到同一个窗口。故启动时把它**拷进沙箱**做桥接;此后执行器对它的 写入只落沙箱,项目侧文件保持原样。 @@ -80,6 +81,7 @@ import os import shutil import sys +import tempfile import threading import time @@ -129,6 +131,36 @@ def task_key(task) -> str: # ── 配置沙箱 ────────────────────────────────────────────────────────── +def copy_account_file_once(source: str, target: str) -> None: + """以完整文件发布项目账号初值;已有沙箱编辑绝不覆盖。""" + if not os.path.isfile(source) or os.path.lexists(target): + return + fd, temporary = tempfile.mkstemp( + prefix=".account-store-", suffix=".tmp", dir=os.path.dirname(target) + ) + os.close(fd) + try: + shutil.copyfile(source, temporary) + try: + os.link(temporary, target) + except FileExistsError: + pass + except OSError: + # 不支持硬链接的文件系统退回排他创建,仍不会覆盖沙箱编辑。 + created = False + try: + with open(temporary, "rb") as input_file, open(target, "xb") as output_file: + created = True + shutil.copyfileobj(input_file, output_file) + except FileExistsError: + pass + except Exception: + if created: + os.unlink(target) + raise + finally: + os.unlink(temporary) + def apply_config_sandbox(config: dict) -> str: """把 ok 框架的配置读写改道到沙箱目录,避免污染目标项目的 configs/。 @@ -162,8 +194,9 @@ def apply_config_sandbox(config: dict) -> str: # connect_game.py 写的项目侧文件对执行器可见)。不拷的话任务读到的全是默认值, # 与插件 UI(probe 会拷项目配置读「当前值」)和项目自身 GUI 完全脱钩 —— # 实测被报告为「执行器配置跟插件的配置不相关联」。 - # copytree(dirs_exist_ok=True) 对同名文件整体覆盖:沙箱视图每次启动都从项目 - # 配置重建,上一次运行留在沙箱里的状态不参与。 + # copytree(dirs_exist_ok=True) 对普通同名文件整体覆盖:沙箱视图每次启动都从 + # 项目配置重建。账号覆盖由插件编辑、执行器读取;已有沙箱文件必须保留, + # 否则每次启动都会把用户编辑覆盖成项目里的旧文件。 # 注意必须**先**读项目原值再覆盖 config["config_folder"] —— 覆盖之后 config # 里已经是沙箱路径,再读就拷了个寂寞(首次实现就栽在这里,被测试 4 抓住)。 source_folder = str(config.get("config_folder") or "configs") @@ -179,7 +212,25 @@ def apply_config_sandbox(config: dict) -> str: if os.path.isdir(source_configs): try: - shutil.copytree(source_configs, config_folder, dirs_exist_ok=True) + account_file = "account_scoped_overrides.json" + sandbox_account_file = os.path.join(config_folder, account_file) + + def defer_account_file(source_dir, names): + if ( + os.path.normcase(os.path.abspath(source_dir)) + == os.path.normcase(os.path.abspath(source_configs)) + and account_file in names + ): + return {account_file} + return set() + + shutil.copytree( + source_configs, config_folder, dirs_exist_ok=True, + ignore=defer_account_file, + ) + copy_account_file_once( + os.path.join(source_configs, account_file), sandbox_account_file, + ) except OSError as e: # noqa: BLE001 — 拷贝失败退回「默认值」语义,绝不阻断启动 _note(f"项目 configs 拷入沙箱失败(任务将读到默认值):{e}") diff --git a/python/tests/test_probe_multi_account_path.py b/python/tests/test_probe_multi_account_path.py index cf9e901..7229e7b 100644 --- a/python/tests/test_probe_multi_account_path.py +++ b/python/tests/test_probe_multi_account_path.py @@ -135,7 +135,7 @@ def capture_chdir(path): check(".vscode" not in info["storePath"], "JetBrains 宿主的 storePath 不应再出现 .vscode") -print("\n[6] 自定义 config_folder 同时用于项目回退与沙箱 storePath") +print("\n[6] 自定义 config_folder 用于项目回退,沙箱仍使用 configs") with tempfile.TemporaryDirectory() as proj: set_run_dir(os.path.join(proj, ".idea", "ok-script-toolkit")) os.makedirs(os.path.join(proj, "src"), exist_ok=True) @@ -148,8 +148,8 @@ def capture_chdir(path): info = probe.collect_multi_account(proj, [], [], []) check(info["available"] is True, "自定义目录中的项目侧文件应使 available=True") check(info["storePath"] == os.path.join( - proj, ".idea", "ok-script-toolkit", "custom-configs", "account_scoped_overrides.json"), - f"storePath 应使用自定义配置目录,实际 {info['storePath']!r}") + proj, ".idea", "ok-script-toolkit", "configs", "account_scoped_overrides.json"), + f"storePath 应使用沙箱 configs 目录,实际 {info['storePath']!r}") check(info.get("accountCount") == 1, f"应从项目侧文件读出账号数 1,实际 {info.get('accountCount')!r}") From 4c2cd8e3fc90495cc2d6bfc1a134ce067fdd6dbe Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:20:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(submodule):=20jetbrains=20=E6=8C=87?= =?UTF-8?q?=E5=90=91=20jb-parity=20=E5=88=86=E6=94=AF=EF=BC=88jetbrains#7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指向 feat/jb-parity-task-config-accounts(651278d):showInTaskTab 过滤、全局配置编辑、多账户编辑、显式路径校验、CI 打包修复。 合并顺序:先 jetbrains#7,再本 PR。 --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index a7fa3d7..651278d 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit a7fa3d7061fe51bd12c50f81da2cbf13f782efdc +Subproject commit 651278d7532865fa3db5011b99b38e270901a80a From b362fe61452044d0380b95fcadb9f3893fb5ee73 Mon Sep 17 00:00:00 2001 From: AliceJump <149395013+AliceJump@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:44:34 +0800 Subject: [PATCH 3/3] chore(submodule): point JetBrains parity PR to follow-up fixes --- jetbrains | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jetbrains b/jetbrains index 651278d..827f6d6 160000 --- a/jetbrains +++ b/jetbrains @@ -1 +1 @@ -Subproject commit 651278d7532865fa3db5011b99b38e270901a80a +Subproject commit 827f6d638985e34248985c42aae78f6193b92678