From c83c02a07bce026b21d34162b18b927773595d1e Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 00:12:22 +0800 Subject: [PATCH] U-003: platform runtime directory, separate from any target project --- docs/development-plan.md | 2 +- src/paths.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ src/test_paths.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/paths.py create mode 100644 src/test_paths.py diff --git a/docs/development-plan.md b/docs/development-plan.md index 8879f04..e6ae666 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -287,7 +287,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-036 [Enabler]** `supervisor/isolation/` 四模块补齐单元测试(当前从 graphloop 复制过来的 `isolated_workspace.py`/`workspace_security.py`/`change_manifest.py`/`selective_apply.py` 只做过一次手工冒烟测试,没有正式 `unittest` 套件)+ Deploy Supervisor 进程骨架(独立操作系统进程,纯 stdlib,不依赖任何 LLM CLI,PID/状态文件约定参考 graphloop `hermes_watchdog.py` 的原子写模式) - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发) - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响 - - **待定**:`supervisor/` 目录最终应归入 U-002 的目标项目树(`.claude/`、`skills/`、`src/` 那套,因为理论上任何 target project 都可能需要)还是 U-003 的"graphlemon 平台自身运行时目录"(因为 Deploy Supervisor 目前的唯一触发场景就是 self-target)——本 Unit 暂按"platform 自身工具"处理,若后续发现别的 target project 也要用同一套蓝绿回滚能力,再迁移 + - **待定(2026-07-31 订正)**:此前这里写的是"`supervisor/` 目录该归入 U-002 的目标项目树(`.claude/`、`skills/`、`src/` 那套)还是 U-003 的平台目录"——但 U-002 的范围已经订正为只创建 `.graphlemon/`(见该 Unit 2026-07-31 订正说明),`.claude/`、`skills/` 从来就不是目标项目的东西,是 graphlemon 平台自己仓库的资产。所以这个选择题已经不成立,`supervisor/` 只可能归属"platform 自身工具"(当前的实际存放位置,`supervisor/isolation/`),不存在真正的"归入目标项目"选项;保留这条记录是为了让后来者理解为什么这里曾经有过一次误判 - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库) - 依赖:U-034, U-036 diff --git a/src/paths.py b/src/paths.py new file mode 100644 index 0000000..835fccc --- /dev/null +++ b/src/paths.py @@ -0,0 +1,44 @@ +"""graphlemon's own local runtime directory (U-003). + +architecture doc §13.2 "原始执行日志的存放位置" / 01-M 1.13 execution_log_file.file_path +both give `~/.graphlemon/logs////.jsonl` +as an *example* path, not a hard spec -- this module is what makes that example +the actual convention (overridable via GRAPHLEMON_HOME, e.g. for tests). + +The default lives under the user's home directory, deliberately outside this +repo's own checkout -- that's what makes "日志目录不纳入平台自身 Git 历史" +(U-003's Done criterion) true by construction rather than by a .gitignore rule +someone could forget to add. +""" + +import os + + +def platform_home(): + """graphlemon's own local runtime directory root.""" + override = os.environ.get("GRAPHLEMON_HOME") + if override: + return os.path.abspath(override) + return os.path.join(os.path.expanduser("~"), ".graphlemon") + + +def log_dir(): + return os.path.join(platform_home(), "logs") + + +def log_file_path(project_id, task_id, run_id, node_exec_id): + """01-M 1.13 execution_log_file.file_path convention.""" + return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") + + +def is_separate_from(target_folder_path): + """True if graphlemon's platform home neither contains nor is contained by + target_folder_path -- the physical-separation property U-003's Done + criterion requires between the platform directory and any target project.""" + home = os.path.realpath(platform_home()) + target = os.path.realpath(target_folder_path) + if home == target: + return False + home_prefix = home.rstrip(os.sep) + os.sep + target_prefix = target.rstrip(os.sep) + os.sep + return not target.startswith(home_prefix) and not home.startswith(target_prefix) diff --git a/src/test_paths.py b/src/test_paths.py new file mode 100644 index 0000000..cc4a0f6 --- /dev/null +++ b/src/test_paths.py @@ -0,0 +1,87 @@ +"""U-003 tests: platform runtime directory is physically separate from any +target project directory, and stays out of this repo's own git history. + +Run: python3 -m unittest src.test_paths -v +""" + +import os +import tempfile +import unittest + +from src import paths + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +class PlatformHomeTest(unittest.TestCase): + def test_default_is_under_user_home(self): + home = paths.platform_home() + self.assertEqual(home, os.path.join(os.path.expanduser("~"), ".graphlemon")) + + def test_default_is_not_inside_this_repo_checkout(self): + home = paths.platform_home() + self.assertFalse( + os.path.realpath(home).startswith(os.path.realpath(_REPO_ROOT) + os.sep) + ) + + def test_env_var_overrides_default(self): + with tempfile.TemporaryDirectory() as tmp: + custom = os.path.join(tmp, "custom-home") + old = os.environ.get("GRAPHLEMON_HOME") + os.environ["GRAPHLEMON_HOME"] = custom + try: + self.assertEqual(paths.platform_home(), os.path.abspath(custom)) + finally: + if old is None: + os.environ.pop("GRAPHLEMON_HOME", None) + else: + os.environ["GRAPHLEMON_HOME"] = old + + +class LogFilePathTest(unittest.TestCase): + def test_matches_01_m_convention(self): + path = paths.log_file_path("proj-1", "task-1", "run-1", "node-1") + expected_suffix = os.path.join("logs", "proj-1", "task-1", "run-1", "node-1.jsonl") + self.assertTrue(path.endswith(expected_suffix), path) + self.assertTrue(path.startswith(paths.platform_home())) + + +class IsSeparateFromTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.old_home = os.environ.get("GRAPHLEMON_HOME") + self.platform_home = os.path.join(self.tmp.name, "platform-home") + os.makedirs(self.platform_home) + os.environ["GRAPHLEMON_HOME"] = self.platform_home + + def tearDown(self): + if self.old_home is None: + os.environ.pop("GRAPHLEMON_HOME", None) + else: + os.environ["GRAPHLEMON_HOME"] = self.old_home + + def test_unrelated_paths_are_separate(self): + target = os.path.join(self.tmp.name, "some-target-project") + os.makedirs(target) + self.assertTrue(paths.is_separate_from(target)) + + def test_target_equal_to_platform_home_is_not_separate(self): + self.assertFalse(paths.is_separate_from(self.platform_home)) + + def test_target_nested_inside_platform_home_is_not_separate(self): + nested = os.path.join(self.platform_home, "oops-a-target") + os.makedirs(nested) + self.assertFalse(paths.is_separate_from(nested)) + + def test_platform_home_nested_inside_target_is_not_separate(self): + # e.g. a misconfigured GRAPHLEMON_HOME pointed inside a chosen target folder + target = os.path.join(self.tmp.name, "some-target-project") + nested_home = os.path.join(target, ".graphlemon-home") + os.makedirs(nested_home) + os.environ["GRAPHLEMON_HOME"] = nested_home + self.assertFalse(paths.is_separate_from(target)) + + +if __name__ == "__main__": + unittest.main() -- libgit2 0.21.4