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,462 @@
|
|
|
1
|
+
"""transfer:跨群明確 `pull`/`push`(DESIGN §8.1 git-remote 心智模型)。
|
|
2
|
+
|
|
3
|
+
與例行 `sync` 的差別(決定:stateless 明確選擇式傳輸):
|
|
4
|
+
- **單向**:pull = remote hub → local;push = local → remote hub(另一條方向只回報、不寫)。
|
|
5
|
+
- **跨群**:對象是**別群組的 hub**(config `[remotes]` 具名),不是 own_hub。
|
|
6
|
+
- **可挑選**:`--session <id>` 點名單一 session;不給則該方向全部待傳(dry-run 預設先看)。
|
|
7
|
+
- **無 per-remote 基線**:不為 remote 維護 known/local_sessions/coverage——使用者的**明確選擇**取代了
|
|
8
|
+
例行 sync 用來分辨「新檔 vs 已刪」的基線推論。故無 has_baseline/bulk/local-deleted 那套。
|
|
9
|
+
|
|
10
|
+
**保留的安全性質(與 sync 同)**:classify(§4.1) 全套(identical/ff/fork/damaged/collision)+ **C3**(pull 寫
|
|
11
|
+
local 一律 O_EXCL 新建或 keep-both、絕不覆蓋 local 既有檔)+ **A3**(respect **remote** hub 的 session
|
|
12
|
+
tombstone:條件式 suppress/conflict,不跨群復活已刪)+ sidecar 同一性(git 指紋 / `--map`,判不出拒落地)
|
|
13
|
+
+ 鎖 remote 檔(跨機共用資源序列化)+ 來源 stable-read(擋 active session 半截)+ dry-run 預設。
|
|
14
|
+
|
|
15
|
+
**本版範圍**:兩側都須**身分可解析**(git 指紋或 `--map`)。單邊存在 → needs-map(回報、不傳)。
|
|
16
|
+
跨群**新建專案**(含 sidecar/`_project.json` 與跨 OS 夾名)留後續;`--map` 指定的目標夾不存在時 push 會
|
|
17
|
+
建(僅放 session,不寫 sidecar)。互動 fork union/keep-both(resolve.py 為 own-hub)跨群版亦留後續。
|
|
18
|
+
|
|
19
|
+
**威脅模型 / 同一性(誠實聲明,codex r-transfer-2)**:remote 是**使用者自己的別群組 hub**(家/公司),
|
|
20
|
+
非對抗性第三方;且 CLI 的 plan→apply 為**單次 in-process 呼叫**(相隔數秒)。專案同一性目前靠
|
|
21
|
+
**夾名配對**(`--map` / git 指紋)+ `_safe_remote_dir`(解析後須在 root 內、非 symlink,擋逃逸——這是真正
|
|
22
|
+
的安全性質)。`_project.json` sidecar digest 有捕捉就重驗(forward-compat),但工具**目前尚未寫** sidecar,
|
|
23
|
+
故多半為 'absent':在此前提下「remote 夾被同名抽換且無 sidecar」之殘留風險**有界**——sid 是 UUID(跨專案
|
|
24
|
+
不碰撞)+ C3(pull 不覆蓋 local)⇒ 至多「錯置一個新 session(copy)」,**絕不覆蓋/丟失**既有資料、可逆。
|
|
25
|
+
更強的跨群同一性(push 時寫 `_project.json`、per-remote 指紋)留後續。
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
from . import anomaly, atomicio, scan, tombstone
|
|
35
|
+
from .anomaly import Anomaly
|
|
36
|
+
from .classify import classify
|
|
37
|
+
from .lineset import SessionShape, analyze, analyze_bytes
|
|
38
|
+
|
|
39
|
+
PULL = "pull"
|
|
40
|
+
PUSH = "push"
|
|
41
|
+
|
|
42
|
+
# 會自動落地的傳輸動作(其餘只回報)。
|
|
43
|
+
AUTO_TRANSFER = frozenset({"transfer-copy", "transfer-ff"})
|
|
44
|
+
_WROTE_RESULTS = frozenset({"copied-to-local", "kept-both-local", "copied-to-remote", "applied-ff-remote"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TransferItem:
|
|
49
|
+
session_id: str
|
|
50
|
+
action: str # transfer-copy/transfer-ff/identical/dest-newer/source-absent/needs-decision/
|
|
51
|
+
# suppressed-deleted/conflict-delete-vs-update/blocked-*/
|
|
52
|
+
reason: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class TransferProject:
|
|
57
|
+
local_dir: str | None
|
|
58
|
+
remote_dir: str | None
|
|
59
|
+
identity: str # match / needs-map / ambiguous / blocked-multi-cwd / skipped-bad-map /
|
|
60
|
+
# skipped-unsafe / remote-only
|
|
61
|
+
items: list[TransferItem] = field(default_factory=list)
|
|
62
|
+
notes: list[str] = field(default_factory=list)
|
|
63
|
+
remote_sidecar: str = "absent" # plan 時 remote `_project.json` 的 digest(apply 鎖內重驗,擋夾被抽換)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class TransferPlan:
|
|
68
|
+
direction: str # pull | push
|
|
69
|
+
remote: str # remote 名(顯示用)
|
|
70
|
+
anomalies: list[Anomaly]
|
|
71
|
+
projects: list[TransferProject]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def halt(self) -> bool:
|
|
75
|
+
return any(a.severity == "halt" for a in self.anomalies)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class TransferOutcome:
|
|
80
|
+
session_id: str
|
|
81
|
+
action: str
|
|
82
|
+
result: str # copied-to-local/kept-both-local/copied-to-remote/applied-ff-remote/identical/
|
|
83
|
+
# skipped-changed/skipped-locked/skipped-stale/reported/error/halt
|
|
84
|
+
detail: str
|
|
85
|
+
path: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class TransferReport:
|
|
90
|
+
outcomes: list[TransferOutcome] = field(default_factory=list)
|
|
91
|
+
halted: bool = False
|
|
92
|
+
halt_reason: str | None = None
|
|
93
|
+
warnings: list[str] = field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
def counts(self) -> dict[str, int]:
|
|
96
|
+
c: dict[str, int] = {}
|
|
97
|
+
for o in self.outcomes:
|
|
98
|
+
c[o.result] = c.get(o.result, 0) + 1
|
|
99
|
+
return c
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def wrote_anything(self) -> bool:
|
|
103
|
+
return any(o.result in _WROTE_RESULTS for o in self.outcomes)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def had_error(self) -> bool:
|
|
107
|
+
return any(o.result == "error" for o in self.outcomes)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _safe_name(name: str) -> bool:
|
|
111
|
+
"""`--map` 目標夾名須是 remote_root 底下單一安全夾名(擋逃出信任根,比照 bootstrap)。"""
|
|
112
|
+
return bool(name) and name == Path(name).name and name not in (".", "..")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sidecar_digest(remote_dir: Path) -> str:
|
|
116
|
+
"""remote 專案 `_project.json` 的 raw digest(無檔 → 'absent')。供 apply 鎖內重驗夾未被抽換成別專案。"""
|
|
117
|
+
sc = remote_dir / "_project.json"
|
|
118
|
+
if sc.is_symlink(): # symlink _project.json → 視為 absent(不跟隨讀界外 digest,e2e gate4 #2)
|
|
119
|
+
return "absent"
|
|
120
|
+
return tombstone.raw_file_digest(sc) or "absent"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _resolve_pair(
|
|
124
|
+
ld: Path, remote_dirs: list[Path], mappings: dict[str, str], remote_root: Path,
|
|
125
|
+
resolve: Callable[[Path, list[Path]], tuple[str, Path | None]],
|
|
126
|
+
) -> tuple[str, Path | None]:
|
|
127
|
+
"""local 夾 → remote 夾:`--map`(local夾名=remote夾名)優先,否則 git 指紋。判不出 → needs-map。
|
|
128
|
+
解析到的 remote 夾須通過 `_safe_remote_dir`(擋 symlink 逃逸)→ 否則 skipped-unsafe。"""
|
|
129
|
+
if ld.name in mappings:
|
|
130
|
+
tgt = mappings[ld.name]
|
|
131
|
+
if not _safe_name(tgt):
|
|
132
|
+
return ("skipped-bad-map", None)
|
|
133
|
+
rd: Path | None = remote_root / tgt # 夾可能尚不存在(push 建 / pull 視為空)
|
|
134
|
+
else:
|
|
135
|
+
status, rd = resolve(ld, remote_dirs)
|
|
136
|
+
if status != "match":
|
|
137
|
+
return (status, rd)
|
|
138
|
+
if rd is not None and not scan._safe_project_dir(remote_root, rd):
|
|
139
|
+
return ("skipped-unsafe", None)
|
|
140
|
+
return ("match", rd)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _classify_transfer(
|
|
144
|
+
sid: str, lf: Path | None, rf: Path | None, *,
|
|
145
|
+
direction: str, tombs: dict, corrupt: set | None = None, collision: bool = False,
|
|
146
|
+
src_shape: SessionShape | None = None,
|
|
147
|
+
) -> TransferItem:
|
|
148
|
+
"""單一 session 的傳輸分類(plan 與 apply-鎖內重分類共用 → 不漂移)。
|
|
149
|
+
|
|
150
|
+
tombs/corrupt = **remote** hub 的 tombstone(A3:跨群也不復活已刪)。direction 決定 source/dest 與
|
|
151
|
+
哪個 ff 方向可寫。`src_shape`(apply 提供)= 已讀進的**來源 bytes** 算出的 shape:用它做分類使「寫出的
|
|
152
|
+
bytes」與「分類決策」綁定同一份(push 來源未持鎖,避免寫出未經分類/半截的內容,codex r-transfer-1)。"""
|
|
153
|
+
if collision:
|
|
154
|
+
return TransferItem(sid, "blocked-casefold-collision",
|
|
155
|
+
"casefold 撞名 sessionId(跨 OS 碰撞風險,A9)")
|
|
156
|
+
# A3 tombstone 閘**先於**配對:remote 已刪此 session → 不跨群復活(條件式 suppress / 交人)。
|
|
157
|
+
if ("session", sid) in tombs:
|
|
158
|
+
sp = scan._suppress_or_conflict(sid, lf, rf, tombs[("session", sid)])
|
|
159
|
+
return TransferItem(sid, sp.action, "remote " + sp.reason)
|
|
160
|
+
if corrupt and ("session", sid) in corrupt:
|
|
161
|
+
return TransferItem(sid, "blocked-tombstone-corrupt",
|
|
162
|
+
"remote tombstone 損壞、無法確認是否已刪 → 阻擋(fail-closed)")
|
|
163
|
+
# 來源側用 src_shape(綁定 bytes)若有;否則由檔分析。目的側一律由檔分析。
|
|
164
|
+
if direction == PULL:
|
|
165
|
+
remote_shape = src_shape if src_shape is not None else (analyze(str(rf)) if rf else None)
|
|
166
|
+
local_shape = analyze(str(lf)) if lf else None
|
|
167
|
+
else:
|
|
168
|
+
local_shape = src_shape if src_shape is not None else (analyze(str(lf)) if lf else None)
|
|
169
|
+
remote_shape = analyze(str(rf)) if rf else None
|
|
170
|
+
src_eff = remote_shape if direction == PULL else local_shape
|
|
171
|
+
dst_eff = local_shape if direction == PULL else remote_shape
|
|
172
|
+
if src_eff is None:
|
|
173
|
+
return TransferItem(sid, "source-absent", "來源側無此 session,無可傳")
|
|
174
|
+
if dst_eff is None:
|
|
175
|
+
# 目的側無 → 新檔複製。來源損壞/無身分不可散播(同 sync 單邊 copy 閘)。
|
|
176
|
+
if src_eff.is_damaged or not src_eff.uuids:
|
|
177
|
+
return TransferItem(sid, "blocked-damaged-source",
|
|
178
|
+
"來源檔損壞/無對話身分(0-byte/壞行/空/可能正在寫),不複製")
|
|
179
|
+
return TransferItem(sid, "transfer-copy", f"{direction}:來源單邊新檔")
|
|
180
|
+
# 兩側都有 → classify(local, remote);c.direction 'local->hub'=local 較新、'hub->local'=remote 較新。
|
|
181
|
+
c = classify(local_shape, remote_shape)
|
|
182
|
+
k = c.klass.value
|
|
183
|
+
if k == "identical":
|
|
184
|
+
return TransferItem(sid, "identical", "兩側相同")
|
|
185
|
+
if k == "fast-forward":
|
|
186
|
+
local_newer = c.direction == "local->hub"
|
|
187
|
+
if direction == PULL:
|
|
188
|
+
return (TransferItem(sid, "transfer-ff", "remote 較新 → 帶進 local(keep-both,不覆蓋)")
|
|
189
|
+
if not local_newer else
|
|
190
|
+
TransferItem(sid, "dest-newer", "local 已較新,無可 pull"))
|
|
191
|
+
return (TransferItem(sid, "transfer-ff", "local 較新 → 寫入 remote")
|
|
192
|
+
if local_newer else
|
|
193
|
+
TransferItem(sid, "dest-newer", "remote 已較新,無可 push"))
|
|
194
|
+
if k in ("fork", "superset-branch", "needs-decision"):
|
|
195
|
+
return TransferItem(sid, "needs-decision", c.reason)
|
|
196
|
+
return TransferItem(sid, f"blocked-{k}", c.reason) # damaged / identity-collision
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _plan_pair(local_dir: Path | None, remote_dir: Path | None, direction: str,
|
|
200
|
+
session: str | None) -> list[TransferItem]:
|
|
201
|
+
local = scan._session_files(local_dir)
|
|
202
|
+
remote = scan._session_files(remote_dir)
|
|
203
|
+
collisions = scan._collision_casefolds(local.keys(), remote.keys())
|
|
204
|
+
tombs = tombstone.read_tombstones(remote_dir) if remote_dir else {}
|
|
205
|
+
corrupt = tombstone.corrupt_tombstone_targets(remote_dir) if remote_dir else set()
|
|
206
|
+
sids = set(local) | set(remote)
|
|
207
|
+
if session is not None:
|
|
208
|
+
sids &= {session}
|
|
209
|
+
items: list[TransferItem] = []
|
|
210
|
+
for sid in sorted(sids):
|
|
211
|
+
items.append(_classify_transfer(
|
|
212
|
+
sid, local.get(sid), remote.get(sid), direction=direction,
|
|
213
|
+
tombs=tombs, corrupt=corrupt, collision=sid.casefold() in collisions))
|
|
214
|
+
return items
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def plan_transfer(
|
|
218
|
+
direction: str, local_root, remote_root, *,
|
|
219
|
+
remote_name: str = "", session: str | None = None,
|
|
220
|
+
mappings: dict[str, str] | None = None,
|
|
221
|
+
identity_fn: Callable[[Path, list[Path]], tuple[str, Path | None]] | None = None,
|
|
222
|
+
) -> TransferPlan:
|
|
223
|
+
"""跨群傳輸的 dry-run 計畫(不寫任何檔)。identity_fn 可注入(測試);預設 git 指紋。"""
|
|
224
|
+
assert direction in (PULL, PUSH)
|
|
225
|
+
local_root, remote_root = Path(local_root), Path(remote_root)
|
|
226
|
+
mappings = mappings or {}
|
|
227
|
+
resolve = identity_fn or scan._git_identity
|
|
228
|
+
anomalies = anomaly.check(None, remote_root) # remote 掛載存在性(無 state → 不查指紋/消失)
|
|
229
|
+
if any(a.severity == "halt" for a in anomalies):
|
|
230
|
+
return TransferPlan(direction, remote_name, anomalies, [])
|
|
231
|
+
|
|
232
|
+
# 逃逸專案夾過濾(e2e gate G-High/G-Low + xgrp #3):symlink/逃出 root 的 reparse 夾不讀/不寫、不跟隨(連
|
|
233
|
+
# 候選 remote 的 sidecar 都不碰);root 內 junction resolve 後仍在 root 內 → 允許。與 build_plan/bootstrap 一致。
|
|
234
|
+
remote_dirs, _remote_unsafe = scan._list_project_dirs(remote_root)
|
|
235
|
+
local_dirs, local_unsafe = scan._list_project_dirs(local_root)
|
|
236
|
+
|
|
237
|
+
projects: list[TransferProject] = []
|
|
238
|
+
for ld in local_unsafe: # 逃逸 local 夾 → 可見 skipped-unsafe(push 不洩漏界外、pull 不寫界外)
|
|
239
|
+
projects.append(TransferProject(str(ld), None, "skipped-unsafe", [],
|
|
240
|
+
["local 專案夾是 symlink 或逃逸 local_root(拒絕跟隨,避免讀/寫到信任根外)"]))
|
|
241
|
+
matched_remote: set[Path] = set()
|
|
242
|
+
for ld in local_dirs:
|
|
243
|
+
status, rd = _resolve_pair(ld, remote_dirs, mappings, remote_root, resolve)
|
|
244
|
+
if rd:
|
|
245
|
+
matched_remote.add(rd)
|
|
246
|
+
if status != "match":
|
|
247
|
+
projects.append(TransferProject(str(ld), str(rd) if rd else None, status, [],
|
|
248
|
+
[f"未對應 remote 專案({status});需 --map <local夾>=<remote夾>"]))
|
|
249
|
+
continue
|
|
250
|
+
projects.append(TransferProject(str(ld), str(rd), "match", _plan_pair(ld, rd, direction, session),
|
|
251
|
+
remote_sidecar=_sidecar_digest(rd)))
|
|
252
|
+
|
|
253
|
+
# 多個 local 夾對到**同一** remote 夾(--map 撞 target 或 git 多對一)→ 全數跳過,否則 push 會把不同
|
|
254
|
+
# 專案的 session 合併進一個 remote 夾(比照 bootstrap dup-key guard,codex r-transfer-3)。
|
|
255
|
+
# **比對 resolve 後的實體路徑**(非字串):junction 別名(`--map a=real` + `--map b=alias`,alias 為指向 real
|
|
256
|
+
# 的 junction)字串不同但實體同夾,只比字串會漏 → 兩專案仍合進同一實體夾(e2e xgrp #5)。
|
|
257
|
+
matched = [p for p in projects if p.identity == "match"]
|
|
258
|
+
|
|
259
|
+
def _rkey(rd: str | None) -> str:
|
|
260
|
+
try:
|
|
261
|
+
return str(Path(rd).resolve()) if rd else ""
|
|
262
|
+
except OSError:
|
|
263
|
+
return rd or "" # 解析失敗 → 退回字串(該夾另會被 `_safe_project_dir` 擋下)
|
|
264
|
+
|
|
265
|
+
keyed = [(p, _rkey(p.remote_dir)) for p in matched]
|
|
266
|
+
dup_targets = {k for k, n in Counter(k for _, k in keyed).items() if n > 1}
|
|
267
|
+
for p, k in keyed:
|
|
268
|
+
if k in dup_targets:
|
|
269
|
+
p.identity, p.items = "skipped-dup-target", []
|
|
270
|
+
p.notes = ["多個 local 夾對到同一 remote 夾(含 junction 別名指向同實體)→ 全數跳過(避免把不同專案合併進一個 remote 夾)"]
|
|
271
|
+
|
|
272
|
+
# pull:remote 有、無 local 對應 → 無法落地(不建死夾),列待 --map。
|
|
273
|
+
if direction == PULL:
|
|
274
|
+
for rd in remote_dirs:
|
|
275
|
+
if rd in matched_remote:
|
|
276
|
+
continue
|
|
277
|
+
projects.append(TransferProject(None, str(rd), "remote-only", [],
|
|
278
|
+
["remote 有、local 無對應專案;需 --map 才能 pull"]))
|
|
279
|
+
return TransferPlan(direction, remote_name, anomalies, projects)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def format_plan(plan: TransferPlan) -> str:
|
|
283
|
+
arrow = "remote→local" if plan.direction == PULL else "local→remote"
|
|
284
|
+
lines = [f"{plan.direction} ({arrow}) remote={plan.remote or '?'}"]
|
|
285
|
+
for a in plan.anomalies:
|
|
286
|
+
lines.append(f"[{a.severity.upper()}] {a.code}: {a.message}")
|
|
287
|
+
if plan.halt:
|
|
288
|
+
lines.append("→ halt 級異常,停止。")
|
|
289
|
+
return "\n".join(lines)
|
|
290
|
+
for pp in plan.projects:
|
|
291
|
+
head = pp.local_dir or pp.remote_dir
|
|
292
|
+
lines.append(f"\n專案 {head} [{pp.identity}]")
|
|
293
|
+
for n in pp.notes:
|
|
294
|
+
lines.append(f" · {n}")
|
|
295
|
+
for it in pp.items:
|
|
296
|
+
lines.append(f" - {it.session_id[:8]}: {it.action} — {it.reason}")
|
|
297
|
+
if pp.identity == "match" and not pp.items:
|
|
298
|
+
lines.append(" (無可傳項)")
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _stable_read(path: Path) -> bytes | None:
|
|
303
|
+
"""讀兩次比對:不一致(來源正被 append,如 active session)或讀不到 → None(呼叫端略過)。
|
|
304
|
+
pull 的 remote 來源已持鎖穩定;push 的 local 來源未持鎖,靠此擋半截檔(DESIGN §6.8)。"""
|
|
305
|
+
try:
|
|
306
|
+
a = path.read_bytes()
|
|
307
|
+
b = path.read_bytes()
|
|
308
|
+
except OSError:
|
|
309
|
+
return None
|
|
310
|
+
return a if a == b else None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _apply_one(
|
|
314
|
+
item: TransferItem, *, local_dir: Path, remote_dir: Path, remote_root: Path, local_root: Path,
|
|
315
|
+
remote_sidecar: str, direction: str, machine: str | None, lock_timeout_s: float,
|
|
316
|
+
) -> TransferOutcome:
|
|
317
|
+
sid, action = item.session_id, item.action
|
|
318
|
+
local_file = local_dir / f"{sid}.jsonl"
|
|
319
|
+
remote_file = remote_dir / f"{sid}.jsonl"
|
|
320
|
+
|
|
321
|
+
# 取鎖(會 mkdir 父夾)前先擋 symlink/junction 逃逸(plan 後夾可能被換成 reparse,codex r-transfer-3 + e2e)。
|
|
322
|
+
# **remote 與 local 兩側都檢**:local 側逃逸會令 push 讀 root 外真檔洩漏 / pull 寫穿到 root 外(e2e xgrp #3)。
|
|
323
|
+
if not scan._safe_project_dir(remote_root, remote_dir) or not scan._safe_project_dir(local_root, local_dir):
|
|
324
|
+
return TransferOutcome(sid, action, "skipped-changed", "remote/local 專案夾不安全(symlink/逃出信任根),中止")
|
|
325
|
+
|
|
326
|
+
# 鎖 remote 檔(跨機共用資源;其 .lock 父夾 acquire 時會建 → push 到新 --map 夾亦可)。
|
|
327
|
+
try:
|
|
328
|
+
lock = atomicio.FileLock(remote_file).acquire_blocking(timeout_s=lock_timeout_s)
|
|
329
|
+
except atomicio.StaleLock as e:
|
|
330
|
+
return TransferOutcome(sid, action, "skipped-stale", f"鎖疑似陳舊,交人工:{e}")
|
|
331
|
+
except atomicio.LockError as e:
|
|
332
|
+
return TransferOutcome(sid, action, "skipped-locked", f"取鎖逾時,略過:{e}")
|
|
333
|
+
try:
|
|
334
|
+
# 鎖內**再驗** symlink/逃逸(TOCTOU:pre-lock 檢查後夾/檔可能被換成 reparse)+ 拒 symlink session 檔
|
|
335
|
+
# ——確保 lock 後實際讀/寫的路徑解析後仍在 root 內,不沿 symlink 讀/寫到信任根外(codex r-transfer-1/3 + e2e)。
|
|
336
|
+
# **remote 與 local 兩側 + 各自 session 檔**都驗(local 側補端到端整合審抓到的缺口,e2e xgrp #3)。
|
|
337
|
+
if (not scan._safe_project_dir(remote_root, remote_dir) or remote_file.is_symlink()
|
|
338
|
+
or not scan._within_root(remote_root, remote_file)
|
|
339
|
+
or not scan._safe_project_dir(local_root, local_dir) or local_file.is_symlink()
|
|
340
|
+
or not scan._within_root(local_root, local_file)):
|
|
341
|
+
return TransferOutcome(sid, action, "skipped-changed", "remote/local 路徑不安全(symlink/逃出信任根),中止")
|
|
342
|
+
# 不可信 tombstone/夾 fail-stop(e2e gate10/11/12,對稱主 sync;transfer 不 gate on coverage 故此處自檢):
|
|
343
|
+
# ① remote/local 專案夾不可列舉(POSIX read-denied)→ `_session_files`/`_symlink_name_keys` fail-open 漏 alias;
|
|
344
|
+
# ② remote `.tombstones/` **不安全(symlink/逃逸)或不可列舉** → `read_tombstones` 回 {}(拒讀界外/glob
|
|
345
|
+
# fail-open)=漏刪除標記 → transfer-copy 復活已刪 session(A3)。`tombstones_enumerable` 同時涵蓋兩者。
|
|
346
|
+
# 先擋(不存在的 dest 夾=pull 待建 → `_dir_scannable` 回 True,不誤擋)。
|
|
347
|
+
if (not scan._dir_scannable(remote_dir) or not scan._dir_scannable(local_dir)
|
|
348
|
+
or not tombstone.tombstones_enumerable(remote_dir)):
|
|
349
|
+
return TransferOutcome(sid, action, "skipped-changed",
|
|
350
|
+
"remote/local 專案夾不可列舉、或 remote .tombstones/ 不安全/不可列舉 → 不自動處理(tombstone/alias 偵測失效,fail-closed)")
|
|
351
|
+
# symlink-alias 防線(e2e gate9 finding1,對稱主 sync apply 的 `_leaf_symlink`):dest/來源夾若有 **casefold
|
|
352
|
+
# 或 normalization-alias** 的 symlink leaf(大寫 UUID `ABC.jsonl`、NFD 名),`_session_files` 略過 → 看似
|
|
353
|
+
# absent → transfer-copy/ff 會把不可信 alias symlink 當 absent 寫入/覆蓋。上面 exact `is_symlink()` 只擋原
|
|
354
|
+
# 字面;此處以 `scan._name_key`(NFC+casefold)比對兩側夾的 symlink leaf 名(夾已驗 `_safe_project_dir`)。
|
|
355
|
+
name_key = scan._name_key(f"{sid}.jsonl")
|
|
356
|
+
if (name_key in scan._symlink_name_keys(remote_dir)
|
|
357
|
+
or name_key in scan._symlink_name_keys(local_dir)):
|
|
358
|
+
return TransferOutcome(sid, action, "skipped-changed",
|
|
359
|
+
"remote/local 有 symlink-alias 同名 session 檔(不可信),中止")
|
|
360
|
+
# remote 專案同一性:`_project.json` digest 自 plan 後變(夾被抽換成別專案)→ 中止(codex r-transfer-2,
|
|
361
|
+
# forward-compat;工具目前未寫 sidecar 故多半 'absent',殘留風險見模組 docstring 威脅模型)。
|
|
362
|
+
if _sidecar_digest(remote_dir) != remote_sidecar:
|
|
363
|
+
return TransferOutcome(sid, action, "skipped-changed",
|
|
364
|
+
"remote 專案 _project.json 自 plan 後已變(疑夾被抽換),中止")
|
|
365
|
+
# 讀來源 bytes **一次**:pull 源=remote(已持鎖穩定)、push 源=local(未鎖→stable-read 擋 active 半截)。
|
|
366
|
+
cur_lf = local_file if local_file.exists() else None
|
|
367
|
+
cur_rf = remote_file if remote_file.exists() else None
|
|
368
|
+
src_file = cur_rf if direction == PULL else cur_lf
|
|
369
|
+
data = _stable_read(src_file) if src_file else None
|
|
370
|
+
if data is None:
|
|
371
|
+
return TransferOutcome(sid, action, "skipped-changed", "來源內容讀不到/不穩定(active?),中止")
|
|
372
|
+
|
|
373
|
+
# 鎖內重新分類**用同一份 bytes** 算來源 shape → 寫出的 bytes 與決策綁定(codex r-transfer-1)。
|
|
374
|
+
tombs = tombstone.read_tombstones(remote_dir)
|
|
375
|
+
corrupt = tombstone.corrupt_tombstone_targets(remote_dir)
|
|
376
|
+
coll = scan.casefold_collisions_for(local_dir, remote_dir)
|
|
377
|
+
cur = _classify_transfer(sid, cur_lf, cur_rf, direction=direction, tombs=tombs,
|
|
378
|
+
corrupt=corrupt, collision=sid.casefold() in coll,
|
|
379
|
+
src_shape=analyze_bytes(data))
|
|
380
|
+
if cur.action != action:
|
|
381
|
+
return TransferOutcome(sid, cur.action, "skipped-changed",
|
|
382
|
+
f"重新分類已變({action}→{cur.action}),請重跑")
|
|
383
|
+
|
|
384
|
+
if direction == PULL:
|
|
385
|
+
# C3:絕不覆蓋 local 既有檔。copy→O_EXCL 新建;ff(local 已有、remote 較新)→ keep-both。
|
|
386
|
+
if action == "transfer-copy":
|
|
387
|
+
try:
|
|
388
|
+
atomicio.atomic_create_bytes(local_file, data)
|
|
389
|
+
return TransferOutcome(sid, action, "copied-to-local", "remote 單邊新檔複製到 local",
|
|
390
|
+
str(local_file))
|
|
391
|
+
except FileExistsError:
|
|
392
|
+
dest = atomicio.write_keep_both(local_file, data, machine=machine)
|
|
393
|
+
return TransferOutcome(sid, action, "kept-both-local",
|
|
394
|
+
"local 期間冒出同名檔 → keep-both 不覆蓋", str(dest))
|
|
395
|
+
dest = atomicio.write_keep_both(local_file, data, machine=machine) # transfer-ff
|
|
396
|
+
return TransferOutcome(sid, action, "kept-both-local",
|
|
397
|
+
"remote 較新但不覆蓋 local,另存 keep-both(resume 即可接續)", str(dest))
|
|
398
|
+
# push:寫 remote(允許覆蓋;ff 為 local 較新的純延伸,classify 已擋會丟標題的情形)。
|
|
399
|
+
atomicio.atomic_write_bytes(remote_file, data)
|
|
400
|
+
result = "copied-to-remote" if action == "transfer-copy" else "applied-ff-remote"
|
|
401
|
+
return TransferOutcome(sid, action, result, "已寫入 remote hub", str(remote_file))
|
|
402
|
+
except (atomicio.AtomicWriteError, OSError) as e:
|
|
403
|
+
return TransferOutcome(sid, action, "error", f"寫入失敗(已中止該檔,未污染目標):{e}")
|
|
404
|
+
finally:
|
|
405
|
+
lock.release()
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def apply_transfer(
|
|
409
|
+
plan: TransferPlan, *, local_root, remote_root,
|
|
410
|
+
machine: str | None = None, lock_timeout_s: float = 5.0,
|
|
411
|
+
) -> TransferReport:
|
|
412
|
+
"""落地跨群傳輸。只自動套用 transfer-copy/transfer-ff;其餘只回報。"""
|
|
413
|
+
local_root, remote_root = Path(local_root), Path(remote_root)
|
|
414
|
+
report = TransferReport()
|
|
415
|
+
|
|
416
|
+
halts = [f"{a.code}: {a.message}" for a in anomaly.check(None, remote_root) if a.severity == "halt"]
|
|
417
|
+
if not local_root.is_dir():
|
|
418
|
+
halts.append(f"local-mount-missing: local 根不存在或非目錄:{local_root}")
|
|
419
|
+
if halts:
|
|
420
|
+
report.halted = True
|
|
421
|
+
report.halt_reason = "; ".join(halts)
|
|
422
|
+
return report
|
|
423
|
+
|
|
424
|
+
for label, d in (("remote", remote_root), ("local", local_root)):
|
|
425
|
+
a = atomicio.assess_fs(d)
|
|
426
|
+
if not a.can_write:
|
|
427
|
+
report.warnings.append(f"{label} 目標不可寫:{a.reason}")
|
|
428
|
+
elif not a.reliable:
|
|
429
|
+
report.warnings.append(f"{label} FS 不可靠(best-effort + 已保留 rvw+lock):{a.reason}")
|
|
430
|
+
|
|
431
|
+
# 不用整體 hub-fingerprint 比對(與 push 合法建夾相衝,codex r-transfer-4);改靠 _apply_one 鎖內
|
|
432
|
+
# 逐專案 `_project.json` digest + symlink 重驗,精準擋「夾被抽換」而不擋自己建的新夾。
|
|
433
|
+
for pp in plan.projects:
|
|
434
|
+
if pp.identity != "match" or pp.remote_dir is None or pp.local_dir is None:
|
|
435
|
+
for it in pp.items:
|
|
436
|
+
report.outcomes.append(TransferOutcome(it.session_id, it.action, "reported", it.reason))
|
|
437
|
+
continue
|
|
438
|
+
local_dir, remote_dir = Path(pp.local_dir), Path(pp.remote_dir)
|
|
439
|
+
for it in pp.items:
|
|
440
|
+
if it.action not in AUTO_TRANSFER:
|
|
441
|
+
report.outcomes.append(TransferOutcome(it.session_id, it.action, "reported", it.reason))
|
|
442
|
+
continue
|
|
443
|
+
report.outcomes.append(_apply_one(
|
|
444
|
+
it, local_dir=local_dir, remote_dir=remote_dir, remote_root=remote_root,
|
|
445
|
+
local_root=local_root, remote_sidecar=pp.remote_sidecar, direction=plan.direction,
|
|
446
|
+
machine=machine, lock_timeout_s=lock_timeout_s))
|
|
447
|
+
return report
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def format_report(report: TransferReport) -> str:
|
|
451
|
+
lines: list[str] = []
|
|
452
|
+
for w in report.warnings:
|
|
453
|
+
lines.append(f"⚠ {w}")
|
|
454
|
+
if report.halted:
|
|
455
|
+
lines.append(f"[HALT] {report.halt_reason}")
|
|
456
|
+
for o in report.outcomes:
|
|
457
|
+
lines.append(f" - {o.session_id[:8]}: [{o.result}] {o.action} — {o.detail}"
|
|
458
|
+
+ (f" → {o.path}" if o.path else ""))
|
|
459
|
+
c = report.counts()
|
|
460
|
+
if c:
|
|
461
|
+
lines.append("\n摘要:" + ";".join(f"{k}={v}" for k, v in sorted(c.items())))
|
|
462
|
+
return "\n".join(lines) if lines else "(無可傳輸項)"
|