claude-code-session-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_code_session_sync-0.1.0.dist-info/METADATA +87 -0
- claude_code_session_sync-0.1.0.dist-info/RECORD +31 -0
- claude_code_session_sync-0.1.0.dist-info/WHEEL +5 -0
- claude_code_session_sync-0.1.0.dist-info/entry_points.txt +2 -0
- claude_code_session_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
- claude_code_session_sync-0.1.0.dist-info/top_level.txt +1 -0
- claude_session_sync/__init__.py +11 -0
- claude_session_sync/acks.py +279 -0
- claude_session_sync/anomaly.py +161 -0
- claude_session_sync/apply.py +874 -0
- claude_session_sync/atomicio.py +621 -0
- claude_session_sync/bootstrap.py +370 -0
- claude_session_sync/canonical.py +185 -0
- claude_session_sync/classify.py +133 -0
- claude_session_sync/cli.py +1065 -0
- claude_session_sync/config.py +128 -0
- claude_session_sync/doctor.py +351 -0
- claude_session_sync/fuzzy.py +136 -0
- claude_session_sync/lineset.py +143 -0
- claude_session_sync/memory.py +953 -0
- claude_session_sync/merge.py +836 -0
- claude_session_sync/pathsafe.py +91 -0
- claude_session_sync/py.typed +0 -0
- claude_session_sync/resolve.py +226 -0
- claude_session_sync/scan.py +485 -0
- claude_session_sync/session_merge.py +214 -0
- claude_session_sync/sidecar.py +238 -0
- claude_session_sync/snapshot.py +136 -0
- claude_session_sync/state.py +240 -0
- claude_session_sync/tombstone.py +330 -0
- claude_session_sync/transfer.py +462 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""pathsafe:信任根逃逸防線(reparse/symlink)——供所有「iterdir 專案夾候選」與「plan-dir 消費者」共用。
|
|
2
|
+
|
|
3
|
+
單一真相源,杜絕各自實作漂移(端到端整合審連續三輪抓到 build_plan/transfer/bootstrap/doctor/merge/resolve/
|
|
4
|
+
anomaly 各處漏檢的成因)。**leaf 模組、零專案相依**——故 anomaly 這種被 scan 依賴的 leaf 也能 import(避免循環)。
|
|
5
|
+
|
|
6
|
+
政策:專案夾(`<root>/<name>`)須非 symlink 且 resolve 後仍在 root 內(resolve-then-contain)。root 內的 junction
|
|
7
|
+
(ccdir 多帳號在同機刻意共用)透明允許(resolve 後仍在 root 內);symlink 或逃出 root 的 reparse 一律拒。與
|
|
8
|
+
`memory.reparse_kind`(memory/ 夾**跟隨** junction 的另一情境)不同層、各自合理。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import unicodedata
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def name_key(name: str) -> str:
|
|
19
|
+
"""檔名/識別名的 **caseless + Unicode 正規化** 比對鍵(e2e gate7 casefold + gate8 NFC/NFD):先 NFC(統一分解形)、
|
|
20
|
+
`casefold()`(統一大小寫)、再 NFC(casefold 可能反正規化)。使「僅大小寫、僅正規化形、或兩者皆異」的名字映到
|
|
21
|
+
同鍵。ASCII 名字僅小寫化。**全 codebase 單一正規化真相源**——放在 leaf 的 pathsafe,故 `scan`(re-export 為
|
|
22
|
+
`scan._name_key`,既有呼叫端不變)、被 scan 依賴的 `anomaly`、以及 `memory` 皆可 import(免循環、免各自實作漂移)。"""
|
|
23
|
+
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_reparse(p: str | Path) -> bool:
|
|
27
|
+
"""`p` 的**最終元件**是否為 reparse point(symlink/Windows junction 等),**不跟隨**。跨 OS:POSIX `S_ISLNK`;
|
|
28
|
+
Windows `st_file_attributes & FILE_ATTRIBUTE_REPARSE_POINT`(涵蓋 symlink+junction,因 Windows 無 `O_NOFOLLOW`、
|
|
29
|
+
`is_symlink()` 對 junction 回 False)。缺檔/lstat 失敗 → False(不存在非 reparse;呼叫端另以「缺檔」語意處理)。
|
|
30
|
+
供 leaf 檔(如 A15 `acks.json`、索引)在讀取前 fail-closed 擋掉被 redirect 的別名(與 `apply._read_index_bytes_nofollow`
|
|
31
|
+
同一套 lstat 檢查,抽成共用 leaf 予免重複實作漂移)。"""
|
|
32
|
+
try:
|
|
33
|
+
st = os.lstat(p)
|
|
34
|
+
except OSError:
|
|
35
|
+
return False
|
|
36
|
+
return bool(stat.S_ISLNK(st.st_mode)
|
|
37
|
+
or (getattr(st, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def within_root(root: str | Path, p: str | Path) -> bool:
|
|
41
|
+
"""`p` 解析(跟隨 symlink/junction)後是否落在 `root` 內。擋 reparse 把路徑導出信任根(讀/寫到 root 外)。
|
|
42
|
+
不存在的 `p`(如 push --map 待建夾)以非嚴格 resolve 判其字面路徑仍在 root 內。resolve 失敗 → fail-closed False。"""
|
|
43
|
+
try:
|
|
44
|
+
rr, rp = Path(root).resolve(), Path(p).resolve()
|
|
45
|
+
except OSError:
|
|
46
|
+
return False
|
|
47
|
+
return rp == rr or rr in rp.parents
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def safe_project_dir(root: str | Path, d: str | Path) -> bool:
|
|
51
|
+
"""專案夾(hub/local/remote 側)是否安全在 `root` 內:**非 symlink** 且解析後(跟隨 junction)落在 root 內。
|
|
52
|
+
擋兩類逃逸——① symlink 專案夾(可跨裝置/特殊檔);② junction/reparse 指向 root **外**(resolve-then-contain)。
|
|
53
|
+
root **內**的 junction 透明允許(resolve 後仍在 root 內)。"""
|
|
54
|
+
if Path(d).is_symlink():
|
|
55
|
+
return False
|
|
56
|
+
return within_root(root, d)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def dir_scannable(d: str | Path | None) -> bool:
|
|
60
|
+
"""`d` 能否列舉(`iterdir` 不 raise)。供 fail-closed 判定:`glob`/`Path.glob` 對**存在但不可讀**(POSIX
|
|
61
|
+
read-denied)的目錄會 **fail-open**(吞 PermissionError → 回空)→ 上層可能把「不可讀」誤當「空/全刪」而寫
|
|
62
|
+
抑制 tombstone/錯基線/復活已刪(違反 A3)。呼叫端據此在寫入或信任「無 tombstone/無檔」前擋掉不可列舉夾
|
|
63
|
+
(e2e gate9/10/11 的 read-denied-dir class)。不存在(含 None)→ True(真的沒有、非不可讀,由既有「空」語意處理);
|
|
64
|
+
存在但 `iterdir`/`stat` raise → False(fail-closed)。**leaf**(僅 pathlib),故 scan/tombstone/transfer 共用免循環。"""
|
|
65
|
+
if d is None:
|
|
66
|
+
return True
|
|
67
|
+
try:
|
|
68
|
+
p = Path(d)
|
|
69
|
+
if not p.exists():
|
|
70
|
+
return True
|
|
71
|
+
for _ in p.iterdir():
|
|
72
|
+
break
|
|
73
|
+
return True
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_project_dirs(root: str | Path) -> tuple[list[Path], list[Path]]:
|
|
79
|
+
"""列 `root` 下的專案子目錄,依 `safe_project_dir` 分 (safe, unsafe):safe=在 root 內的普通夾/root 內 junction;
|
|
80
|
+
unsafe=symlink 或逃出 root 的 reparse。**所有**「iterdir 專案夾候選清單」都該經此過濾。root 不存在 → ([], [])。
|
|
81
|
+
unsafe 夾**不讀其內容**(連 sidecar/cwd 都不碰)→ 一併堵住界外讀。"""
|
|
82
|
+
root = Path(root)
|
|
83
|
+
if not root.exists():
|
|
84
|
+
return [], []
|
|
85
|
+
safe: list[Path] = []
|
|
86
|
+
unsafe: list[Path] = []
|
|
87
|
+
for d in sorted(root.iterdir()):
|
|
88
|
+
if not d.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
(safe if safe_project_dir(root, d) else unsafe).append(d)
|
|
91
|
+
return safe, unsafe
|
|
File without changes
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""resolve:互動式解決 fork / superset-branch(union 或 keep-both)。DESIGN §6.6 的「停下來問」。
|
|
2
|
+
|
|
3
|
+
兩種使用者選擇(皆**無損、C3-safe**,只寫 keep-both 新檔、絕不覆蓋任何既有檔):
|
|
4
|
+
- **UNION**:呼叫 `session_merge` 把兩枝行級併成一檔(標 chosen tip),寫成 local 端 keep-both 新檔。
|
|
5
|
+
- **KEEP_BOTH**:把**對側(hub)分枝**以 keep-both 新檔名帶進 local(原本只在 hub,現在 local 也能 resume)。
|
|
6
|
+
- **SKIP**:不動。
|
|
7
|
+
|
|
8
|
+
原 fork 兩檔(兩側同 sid)一律**保留**——本工具永不自動刪;要收斂成單一 session 需使用者自行刪除原檔
|
|
9
|
+
(刪除→tombstone 是另一塊)。故 union/keep-both 後該 sid 仍會被視為 fork,直到原檔被處理。
|
|
10
|
+
|
|
11
|
+
互動只在使用者明確要求時跑(`--interactive`);非互動 apply 仍只回報 needs-decision(既有行為不變)。
|
|
12
|
+
決策由可注入的 `Decider` 回呼提供(CLI 用 stdin、測試用 stub),故核心邏輯可測、不綁 TUI。
|
|
13
|
+
`conflict-delete-vs-update` 不在此處理(屬刪除衝突,非分枝 fork)。
|
|
14
|
+
|
|
15
|
+
安全紀律:每 session 取 hub 側路徑鎖 → 持鎖**重新分類**確認仍是 fork/superset(tombstone 期間出現→不 union)
|
|
16
|
+
→ 讀兩側、merge、寫 local keep-both(O_EXCL)。新檔不覆蓋任何東西,故不需 C4 覆蓋快照;鎖足以序列化。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
from . import anomaly, atomicio, scan, state as state_mod, tombstone
|
|
26
|
+
from .lineset import analyze
|
|
27
|
+
from .session_merge import LeafCandidate, MergeOutcome, merge_sessions, render_jsonl
|
|
28
|
+
|
|
29
|
+
# 互動可解的分枝分類(其餘類別不在此處理)。
|
|
30
|
+
RESOLVABLE = frozenset({"fork", "superset-branch"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Choice(str, Enum):
|
|
34
|
+
UNION = "union"
|
|
35
|
+
KEEP_BOTH = "keep-both"
|
|
36
|
+
SKIP = "skip"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Decision:
|
|
41
|
+
choice: Choice
|
|
42
|
+
chosen_tip: str | None = None # UNION 用;merge 自動選不出(缺 ts/並列)時必須由此指定
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ResolveContext:
|
|
47
|
+
"""交給 decider 的資訊:sid、分類、預先嘗試的 union 結果(含可選 tip 候選)。"""
|
|
48
|
+
session_id: str
|
|
49
|
+
action: str
|
|
50
|
+
union_outcome: MergeOutcome # MERGED / NEEDS_DECISION(要 tip)/ FALLBACK(不能 union)
|
|
51
|
+
union_reason: str
|
|
52
|
+
leaves: list[LeafCandidate] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ResolveOutcome:
|
|
57
|
+
session_id: str
|
|
58
|
+
result: str # union-merged / kept-both / skipped / union-unavailable / skipped-changed /
|
|
59
|
+
# skipped-locked / skipped-stale / error
|
|
60
|
+
detail: str
|
|
61
|
+
path: str | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ResolveReport:
|
|
66
|
+
outcomes: list[ResolveOutcome] = field(default_factory=list)
|
|
67
|
+
halted: bool = False
|
|
68
|
+
halt_reason: str | None = None
|
|
69
|
+
|
|
70
|
+
def counts(self) -> dict[str, int]:
|
|
71
|
+
c: dict[str, int] = {}
|
|
72
|
+
for o in self.outcomes:
|
|
73
|
+
c[o.result] = c.get(o.result, 0) + 1
|
|
74
|
+
return c
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def had_error(self) -> bool:
|
|
78
|
+
"""互動解決中有寫入錯誤(disk full/權限/keep-both 名用罄)→ CLI 須非零退出(codex r23)。"""
|
|
79
|
+
return any(o.result == "error" for o in self.outcomes)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
Decider = Callable[[ResolveContext], Decision]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _safe_analyze(path: Path):
|
|
86
|
+
"""analyze 但容忍 race(檔在 exists 檢查後被刪/chmod/截斷):OSError → None(呼叫端視為 skipped)。"""
|
|
87
|
+
try:
|
|
88
|
+
return analyze(str(path))
|
|
89
|
+
except OSError:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _resolve_one(
|
|
94
|
+
sid: str, action: str, *, local_dir: Path, hub_dir: Path, project_key: str,
|
|
95
|
+
decider: Decider, machine: str | None, lock_timeout_s: float, state_path,
|
|
96
|
+
) -> ResolveOutcome:
|
|
97
|
+
local_file = local_dir / f"{sid}.jsonl"
|
|
98
|
+
hub_file = hub_dir / f"{sid}.jsonl"
|
|
99
|
+
# leaf symlink 防線(e2e gate2 #2):兩側 .jsonl 若為 symlink(可能指夾外)→ 不讀/寫(既有分類已略過 symlink
|
|
100
|
+
# session;此為 resolve 獨立讀寫路徑的防線,含 plan→resolve TOCTOU)。
|
|
101
|
+
if local_file.is_symlink() or hub_file.is_symlink():
|
|
102
|
+
return ResolveOutcome(sid, "skipped-changed", "session 檔為 symlink(疑逃逸),略過")
|
|
103
|
+
|
|
104
|
+
# ── Phase A(唯讀、**不持鎖**):算 union preview 交 decider。
|
|
105
|
+
# 刻意不在持鎖時等待人類輸入(A6/A10:網路 FS 上長時間持鎖會擋住他機)。
|
|
106
|
+
ls, hs = _safe_analyze(local_file), _safe_analyze(hub_file)
|
|
107
|
+
if ls is None or hs is None:
|
|
108
|
+
return ResolveOutcome(sid, "skipped-changed", "session 已不在/讀不到兩側(race),略過")
|
|
109
|
+
preview = merge_sessions(ls, hs)
|
|
110
|
+
decision = decider(ResolveContext(sid, action, preview.outcome, preview.reason, list(preview.leaves)))
|
|
111
|
+
|
|
112
|
+
# decider 契約驗證(外部回呼,須防呆,codex r23):choice 必須是 Choice、chosen_tip 必須 None|str。
|
|
113
|
+
if not isinstance(decision.choice, Choice):
|
|
114
|
+
return ResolveOutcome(sid, "skipped", f"decider 回傳未知選擇 {decision.choice!r},未處理")
|
|
115
|
+
if decision.chosen_tip is not None and not isinstance(decision.chosen_tip, str):
|
|
116
|
+
return ResolveOutcome(sid, "skipped",
|
|
117
|
+
f"decider chosen_tip 型別不合(須 str|None):{decision.chosen_tip!r}")
|
|
118
|
+
if decision.choice == Choice.SKIP:
|
|
119
|
+
return ResolveOutcome(sid, "skipped", "使用者選擇暫不處理")
|
|
120
|
+
if decision.choice == Choice.UNION:
|
|
121
|
+
if preview.outcome == MergeOutcome.FALLBACK:
|
|
122
|
+
return ResolveOutcome(sid, "union-unavailable", f"無法 union(退回挑選):{preview.reason}")
|
|
123
|
+
if preview.outcome == MergeOutcome.NEEDS_DECISION and not decision.chosen_tip:
|
|
124
|
+
return ResolveOutcome(sid, "union-unavailable", f"union 需指定 tip 但未提供:{preview.reason}")
|
|
125
|
+
|
|
126
|
+
# ── Phase B(持鎖):鎖內重讀 coverage/tombstone、重新分類確認仍 fork → 由**鎖內現況**重讀寫。
|
|
127
|
+
try:
|
|
128
|
+
lock = atomicio.FileLock(hub_file).acquire_blocking(timeout_s=lock_timeout_s)
|
|
129
|
+
except atomicio.StaleLock as e:
|
|
130
|
+
return ResolveOutcome(sid, "skipped-stale", f"鎖疑似陳舊,交人工:{e}")
|
|
131
|
+
except atomicio.LockError as e:
|
|
132
|
+
return ResolveOutcome(sid, "skipped-locked", f"取鎖逾時,略過:{e}")
|
|
133
|
+
try:
|
|
134
|
+
# 信任邊界:decider 暫停期間 coverage 可能被移除/損壞 → 鎖內**重讀**,已非 initialized 則不寫(codex r23)。
|
|
135
|
+
cov = tombstone.is_initialized(hub_dir)
|
|
136
|
+
if not cov:
|
|
137
|
+
return ResolveOutcome(sid, "skipped-changed", "專案已非 initialized(coverage 消失),不寫")
|
|
138
|
+
if local_file.is_symlink() or hub_file.is_symlink(): # 鎖內 leaf symlink 重驗(TOCTOU,e2e gate2 #2)
|
|
139
|
+
return ResolveOutcome(sid, "skipped-changed", "session 檔為 symlink(疑逃逸/TOCTOU),略過")
|
|
140
|
+
cur_lf = local_file if local_file.exists() else None
|
|
141
|
+
cur_hf = hub_file if hub_file.exists() else None
|
|
142
|
+
tombs = tombstone.read_tombstones(hub_dir)
|
|
143
|
+
corrupt = tombstone.corrupt_tombstone_targets(hub_dir)
|
|
144
|
+
coll = scan.casefold_collisions_for(local_dir, hub_dir)
|
|
145
|
+
cur_state = state_mod.load_or_none(state_path)
|
|
146
|
+
known = cur_state.known_sessions.get(project_key) if cur_state else None
|
|
147
|
+
has_baseline = bool(cur_state and project_key in cur_state.known_sessions)
|
|
148
|
+
cur = scan.classify_session(
|
|
149
|
+
sid, cur_lf, cur_hf, both=True, coverage_initialized=cov,
|
|
150
|
+
tombs=tombs, corrupt=corrupt, known=known, has_baseline=has_baseline,
|
|
151
|
+
is_collision=sid.casefold() in coll,
|
|
152
|
+
)
|
|
153
|
+
if cur.action not in RESOLVABLE:
|
|
154
|
+
return ResolveOutcome(sid, "skipped-changed",
|
|
155
|
+
f"重新分類已非可解 fork(現為 {cur.action}),請重跑")
|
|
156
|
+
|
|
157
|
+
if decision.choice == Choice.KEEP_BOTH:
|
|
158
|
+
dest = atomicio.write_keep_both(local_file, hub_file.read_bytes(), machine=machine)
|
|
159
|
+
return ResolveOutcome(sid, "kept-both", "hub 分枝已另存 local keep-both(可 resume)", str(dest))
|
|
160
|
+
if decision.choice != Choice.UNION: # 防呆:未知選擇不落到 UNION 寫入路徑(已於 Phase A 擋下,雙保險)
|
|
161
|
+
return ResolveOutcome(sid, "skipped", f"未知選擇 {decision.choice},未處理")
|
|
162
|
+
|
|
163
|
+
# UNION:鎖內**重讀重 merge**(反映現況;期間 source 變得無法 union → 安全退為 union-unavailable)。
|
|
164
|
+
ls2, hs2 = _safe_analyze(local_file), _safe_analyze(hub_file)
|
|
165
|
+
if ls2 is None or hs2 is None:
|
|
166
|
+
return ResolveOutcome(sid, "skipped-changed", "鎖內讀取失敗(race),略過")
|
|
167
|
+
merged = merge_sessions(ls2, hs2, chosen_tip=decision.chosen_tip)
|
|
168
|
+
if merged.outcome != MergeOutcome.MERGED:
|
|
169
|
+
why = "需指定 tip" if merged.outcome == MergeOutcome.NEEDS_DECISION else "退回挑選"
|
|
170
|
+
return ResolveOutcome(sid, "union-unavailable", f"無法 union({why}):{merged.reason}")
|
|
171
|
+
dest = atomicio.write_keep_both(local_file, render_jsonl(merged.objs), machine=machine)
|
|
172
|
+
return ResolveOutcome(sid, "union-merged",
|
|
173
|
+
f"兩枝已 union(tip={merged.chosen_tip[:8]}),另存 local keep-both", str(dest))
|
|
174
|
+
except (atomicio.AtomicWriteError, OSError) as e:
|
|
175
|
+
return ResolveOutcome(sid, "error", f"寫入失敗(已中止該檔,未污染目標):{e}")
|
|
176
|
+
finally:
|
|
177
|
+
lock.release()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve_plan(
|
|
181
|
+
plan: scan.SyncPlan, *, hub_root, state, state_path,
|
|
182
|
+
decider: Decider, machine: str | None = None, lock_timeout_s: float = 5.0,
|
|
183
|
+
) -> ResolveReport:
|
|
184
|
+
"""對 plan 內所有 fork/superset-branch session 跑互動解決。回 ResolveReport。"""
|
|
185
|
+
report = ResolveReport()
|
|
186
|
+
halts = [f"{a.code}: {a.message}" for a in anomaly.check(state, Path(hub_root)) if a.severity == "halt"]
|
|
187
|
+
if halts:
|
|
188
|
+
report.halted = True
|
|
189
|
+
report.halt_reason = "; ".join(halts)
|
|
190
|
+
return report
|
|
191
|
+
|
|
192
|
+
for pp in plan.projects:
|
|
193
|
+
if not pp.hub_dir or not pp.local_dir or not pp.coverage_initialized:
|
|
194
|
+
continue
|
|
195
|
+
hub_dir, local_dir = Path(pp.hub_dir), Path(pp.local_dir)
|
|
196
|
+
# 逃逸重驗(TOCTOU:plan 後專案夾被換成 symlink/junction 逃出 root)→ 不讀/寫界外(e2e gate2 #1)。
|
|
197
|
+
# apply_plan 已擋,但互動 resolve 是**獨立**寫入路徑(Phase A 讀兩側、Phase B 寫 local keep-both),須各自守。
|
|
198
|
+
if not scan._safe_project_dir(Path(hub_root), hub_dir) or \
|
|
199
|
+
not scan._safe_project_dir(local_dir.parent, local_dir):
|
|
200
|
+
for sp in pp.sessions:
|
|
201
|
+
if sp.action in RESOLVABLE:
|
|
202
|
+
report.outcomes.append(ResolveOutcome(
|
|
203
|
+
sp.session_id, "skipped", "專案夾是 symlink 或逃逸信任根 → 不互動處理(不讀/寫界外)"))
|
|
204
|
+
continue
|
|
205
|
+
for sp in pp.sessions:
|
|
206
|
+
if sp.action not in RESOLVABLE:
|
|
207
|
+
continue
|
|
208
|
+
report.outcomes.append(_resolve_one(
|
|
209
|
+
sp.session_id, sp.action, local_dir=local_dir, hub_dir=hub_dir, project_key=hub_dir.name,
|
|
210
|
+
decider=decider, machine=machine, lock_timeout_s=lock_timeout_s, state_path=state_path,
|
|
211
|
+
))
|
|
212
|
+
return report
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def format_report(report: ResolveReport) -> str:
|
|
216
|
+
lines: list[str] = []
|
|
217
|
+
if report.halted:
|
|
218
|
+
lines.append(f"[HALT] {report.halt_reason}")
|
|
219
|
+
return "\n".join(lines)
|
|
220
|
+
for o in report.outcomes:
|
|
221
|
+
lines.append(f" - {o.session_id[:8]}: [{o.result}] {o.detail}"
|
|
222
|
+
+ (f" → {o.path}" if o.path else ""))
|
|
223
|
+
c = report.counts()
|
|
224
|
+
if c:
|
|
225
|
+
lines.append("\n互動解決摘要:" + ";".join(f"{k}={v}" for k, v in sorted(c.items())))
|
|
226
|
+
return "\n".join(lines) if lines else "(無 fork/superset 需互動解決)"
|