Commit c83c02a07bce026b21d34162b18b927773595d1e
1 parent
5a490603
U-003: platform runtime directory, separate from any target project
U-003's coverage in unit_matrix.json is empty (no M/V/C/P/REQ) -- its Done criterion is a property, not a contract: platform dir and any target project dir stay physically separate, and the raw log directory never enters graphlemon's own git history. src/paths.py makes the `~/.graphlemon/ logs/...` example path from 01-M 1.13 / architecture doc §13.2 into the actual convention (GRAPHLEMON_HOME-overridable), defaulting to the user's home directory so it's outside this repo's checkout by construction rather than by a .gitignore rule. is_separate_from() is the property U-003 has to guarantee, tested from both directions (target nested in platform home, platform home nested in target). Also fixes a second stale reference to the pre-correction U-002 scope, found while scoping this Unit: U-036's "supervisor/ directory ownership" open question in development-plan.md still described U-002 as owning a `.claude/`/`skills/`/`src/` target-project tree (already corrected out of U-002 itself last commit). Notes that this makes the open question moot -- supervisor/ was never a real target-project candidate. 8 new stdlib unittest cases (src/test_paths.py); 31 total across U-001/U-002/U-003, all passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
132 additions
and
1 deletions
docs/development-plan.md
| ... | ... | @@ -287,7 +287,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 287 | 287 | - **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` 的原子写模式) |
| 288 | 288 | - 依赖:无(代码已存在,是补测试和补骨架,不是从零开发) |
| 289 | 289 | - Done:四模块各自有对应 `test_*.py`(可直接照搬/改造 graphloop 的 `test_change_manifest.py`/`test_selective_apply.py`/`test_workspace_security.py` 等,逻辑未改动,测试也基本不用改);Deploy Supervisor 骨架能独立启停,不受 Live/Candidate 进程崩溃影响 |
| 290 | - - **待定**:`supervisor/` 目录最终应归入 U-002 的目标项目树(`.claude/`、`skills/`、`src/` 那套,因为理论上任何 target project 都可能需要)还是 U-003 的"graphlemon 平台自身运行时目录"(因为 Deploy Supervisor 目前的唯一触发场景就是 self-target)——本 Unit 暂按"platform 自身工具"处理,若后续发现别的 target project 也要用同一套蓝绿回滚能力,再迁移 | |
| 290 | + - **待定(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/`),不存在真正的"归入目标项目"选项;保留这条记录是为了让后来者理解为什么这里曾经有过一次误判 | |
| 291 | 291 | |
| 292 | 292 | - **U-037 [Risk Gate]** `c_deploy_supervisor_stage`:候选实例拉起(全新目录 checkout,不 `git pull` 原地改)+ 一次性 DB 副本机制(候选实例验证期间绝不接触生产库) |
| 293 | 293 | - 依赖:U-034, U-036 | ... | ... |
src/paths.py
0 → 100644
| 1 | +"""graphlemon's own local runtime directory (U-003). | |
| 2 | + | |
| 3 | +architecture doc §13.2 "原始执行日志的存放位置" / 01-M 1.13 execution_log_file.file_path | |
| 4 | +both give `~/.graphlemon/logs/<project_id>/<task_id>/<run_id>/<node_exec_id>.jsonl` | |
| 5 | +as an *example* path, not a hard spec -- this module is what makes that example | |
| 6 | +the actual convention (overridable via GRAPHLEMON_HOME, e.g. for tests). | |
| 7 | + | |
| 8 | +The default lives under the user's home directory, deliberately outside this | |
| 9 | +repo's own checkout -- that's what makes "日志目录不纳入平台自身 Git 历史" | |
| 10 | +(U-003's Done criterion) true by construction rather than by a .gitignore rule | |
| 11 | +someone could forget to add. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +import os | |
| 15 | + | |
| 16 | + | |
| 17 | +def platform_home(): | |
| 18 | + """graphlemon's own local runtime directory root.""" | |
| 19 | + override = os.environ.get("GRAPHLEMON_HOME") | |
| 20 | + if override: | |
| 21 | + return os.path.abspath(override) | |
| 22 | + return os.path.join(os.path.expanduser("~"), ".graphlemon") | |
| 23 | + | |
| 24 | + | |
| 25 | +def log_dir(): | |
| 26 | + return os.path.join(platform_home(), "logs") | |
| 27 | + | |
| 28 | + | |
| 29 | +def log_file_path(project_id, task_id, run_id, node_exec_id): | |
| 30 | + """01-M 1.13 execution_log_file.file_path convention.""" | |
| 31 | + return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") | |
| 32 | + | |
| 33 | + | |
| 34 | +def is_separate_from(target_folder_path): | |
| 35 | + """True if graphlemon's platform home neither contains nor is contained by | |
| 36 | + target_folder_path -- the physical-separation property U-003's Done | |
| 37 | + criterion requires between the platform directory and any target project.""" | |
| 38 | + home = os.path.realpath(platform_home()) | |
| 39 | + target = os.path.realpath(target_folder_path) | |
| 40 | + if home == target: | |
| 41 | + return False | |
| 42 | + home_prefix = home.rstrip(os.sep) + os.sep | |
| 43 | + target_prefix = target.rstrip(os.sep) + os.sep | |
| 44 | + return not target.startswith(home_prefix) and not home.startswith(target_prefix) | ... | ... |
src/test_paths.py
0 → 100644
| 1 | +"""U-003 tests: platform runtime directory is physically separate from any | |
| 2 | +target project directory, and stays out of this repo's own git history. | |
| 3 | + | |
| 4 | +Run: python3 -m unittest src.test_paths -v | |
| 5 | +""" | |
| 6 | + | |
| 7 | +import os | |
| 8 | +import tempfile | |
| 9 | +import unittest | |
| 10 | + | |
| 11 | +from src import paths | |
| 12 | + | |
| 13 | +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 14 | + | |
| 15 | + | |
| 16 | +class PlatformHomeTest(unittest.TestCase): | |
| 17 | + def test_default_is_under_user_home(self): | |
| 18 | + home = paths.platform_home() | |
| 19 | + self.assertEqual(home, os.path.join(os.path.expanduser("~"), ".graphlemon")) | |
| 20 | + | |
| 21 | + def test_default_is_not_inside_this_repo_checkout(self): | |
| 22 | + home = paths.platform_home() | |
| 23 | + self.assertFalse( | |
| 24 | + os.path.realpath(home).startswith(os.path.realpath(_REPO_ROOT) + os.sep) | |
| 25 | + ) | |
| 26 | + | |
| 27 | + def test_env_var_overrides_default(self): | |
| 28 | + with tempfile.TemporaryDirectory() as tmp: | |
| 29 | + custom = os.path.join(tmp, "custom-home") | |
| 30 | + old = os.environ.get("GRAPHLEMON_HOME") | |
| 31 | + os.environ["GRAPHLEMON_HOME"] = custom | |
| 32 | + try: | |
| 33 | + self.assertEqual(paths.platform_home(), os.path.abspath(custom)) | |
| 34 | + finally: | |
| 35 | + if old is None: | |
| 36 | + os.environ.pop("GRAPHLEMON_HOME", None) | |
| 37 | + else: | |
| 38 | + os.environ["GRAPHLEMON_HOME"] = old | |
| 39 | + | |
| 40 | + | |
| 41 | +class LogFilePathTest(unittest.TestCase): | |
| 42 | + def test_matches_01_m_convention(self): | |
| 43 | + path = paths.log_file_path("proj-1", "task-1", "run-1", "node-1") | |
| 44 | + expected_suffix = os.path.join("logs", "proj-1", "task-1", "run-1", "node-1.jsonl") | |
| 45 | + self.assertTrue(path.endswith(expected_suffix), path) | |
| 46 | + self.assertTrue(path.startswith(paths.platform_home())) | |
| 47 | + | |
| 48 | + | |
| 49 | +class IsSeparateFromTest(unittest.TestCase): | |
| 50 | + def setUp(self): | |
| 51 | + self.tmp = tempfile.TemporaryDirectory() | |
| 52 | + self.addCleanup(self.tmp.cleanup) | |
| 53 | + self.old_home = os.environ.get("GRAPHLEMON_HOME") | |
| 54 | + self.platform_home = os.path.join(self.tmp.name, "platform-home") | |
| 55 | + os.makedirs(self.platform_home) | |
| 56 | + os.environ["GRAPHLEMON_HOME"] = self.platform_home | |
| 57 | + | |
| 58 | + def tearDown(self): | |
| 59 | + if self.old_home is None: | |
| 60 | + os.environ.pop("GRAPHLEMON_HOME", None) | |
| 61 | + else: | |
| 62 | + os.environ["GRAPHLEMON_HOME"] = self.old_home | |
| 63 | + | |
| 64 | + def test_unrelated_paths_are_separate(self): | |
| 65 | + target = os.path.join(self.tmp.name, "some-target-project") | |
| 66 | + os.makedirs(target) | |
| 67 | + self.assertTrue(paths.is_separate_from(target)) | |
| 68 | + | |
| 69 | + def test_target_equal_to_platform_home_is_not_separate(self): | |
| 70 | + self.assertFalse(paths.is_separate_from(self.platform_home)) | |
| 71 | + | |
| 72 | + def test_target_nested_inside_platform_home_is_not_separate(self): | |
| 73 | + nested = os.path.join(self.platform_home, "oops-a-target") | |
| 74 | + os.makedirs(nested) | |
| 75 | + self.assertFalse(paths.is_separate_from(nested)) | |
| 76 | + | |
| 77 | + def test_platform_home_nested_inside_target_is_not_separate(self): | |
| 78 | + # e.g. a misconfigured GRAPHLEMON_HOME pointed inside a chosen target folder | |
| 79 | + target = os.path.join(self.tmp.name, "some-target-project") | |
| 80 | + nested_home = os.path.join(target, ".graphlemon-home") | |
| 81 | + os.makedirs(nested_home) | |
| 82 | + os.environ["GRAPHLEMON_HOME"] = nested_home | |
| 83 | + self.assertFalse(paths.is_separate_from(target)) | |
| 84 | + | |
| 85 | + | |
| 86 | +if __name__ == "__main__": | |
| 87 | + unittest.main() | ... | ... |