Commit c83c02a07bce026b21d34162b18b927773595d1e

Authored by bboonnee
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>
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()
... ...