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,214 @@
|
|
|
1
|
+
"""session_merge:fork/superset 兩枝的**決定性、無損** union(H2/H3 + DESIGN A1/§6.6)。
|
|
2
|
+
|
|
3
|
+
語意(PLAN v0.8 §2.4,N1:行級 union 非語意合併):
|
|
4
|
+
1. 共同前綴 = 兩枝**依檔序逐行比 line-identity 到第一個 divergence**(uuid 行比 (uuid,hash)、
|
|
5
|
+
內容 no-uuid 比 content-hash);揮發 meta(last-prompt/mode/title…)先濾掉、不參與也不保留。
|
|
6
|
+
2. 共同前綴輸出「一次」。
|
|
7
|
+
3. 各 branch 分歧段接在共同前綴後、依原序整段輸出:
|
|
8
|
+
- uuid 行:(uuid,hash) **去重**(跨整檔;同 uuid 異 hash 應已被 classify 擋成 damaged);
|
|
9
|
+
- no-uuid 內容行(summary 等):**不去重**——同段保留 multiplicity、跨段(A vs B)即使同 hash 也各留
|
|
10
|
+
(屬不同枝的摘要)。
|
|
11
|
+
4. chosen tip:使用者選或預設「**唯一**最新 timestamp 的 genuine leaf」;結尾 **append 一條新
|
|
12
|
+
`last-prompt{leafUuid=chosen}`**,不沿用任一輸入尾端(防裸 rewind 落後的 stale tip 被寫死,B2)。
|
|
13
|
+
chosen 必須是合併後存在的 genuine leaf;自動選只在「唯一最新」時成立,缺 ts / 並列最新 → needs-decision
|
|
14
|
+
(A11:不以裸/並列 timestamp 自動拍板),交人帶 chosen_tip 重呼。
|
|
15
|
+
5. 安全條件不成立(damaged / 零共同 uuid=collision / 多個非-system 根 / parent 環 / 無 leaf)
|
|
16
|
+
→ **退回挑選**(FALLBACK;上層改走 keep-local/keep-hub/keep-both)。
|
|
17
|
+
|
|
18
|
+
決定性 / commutative:兩枝分歧段以「分歧段 line-identity 序列」做 stable sort,故輸出**與 local/hub 標籤無關**;
|
|
19
|
+
每行用 `canonical.canon_dumps` 序列化,故同一 line-identity 在任何機器都產生相同 bytes(跨機 union 收斂)。
|
|
20
|
+
keep-both = 複製/落地時改檔名(檔名即身分,B6),不重寫內文 sessionId——由上層 atomicio 負責。
|
|
21
|
+
|
|
22
|
+
純標準庫、無 IO。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from enum import Enum
|
|
28
|
+
|
|
29
|
+
from .canonical import Line, canon_dumps
|
|
30
|
+
from .lineset import VOLATILE_META_TYPES, SessionShape
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MergeOutcome(str, Enum):
|
|
34
|
+
MERGED = "merged" # union 成功,objs 為合併後 JSONL 物件序列
|
|
35
|
+
FALLBACK = "fallback" # 安全條件不成立 → 退回挑選(keep-local/keep-hub/keep-both)
|
|
36
|
+
NEEDS_DECISION = "needs-decision" # chosen tip 非合併後 genuine leaf → 交人
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LeafCandidate:
|
|
41
|
+
"""合併後可當 tip 的 genuine leaf(供互動呈現:時間/身分)。"""
|
|
42
|
+
uuid: str
|
|
43
|
+
ts: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MergeResult:
|
|
48
|
+
outcome: MergeOutcome
|
|
49
|
+
reason: str
|
|
50
|
+
objs: list[dict] | None = None # MERGED 才有;含結尾新 last-prompt
|
|
51
|
+
chosen_tip: str | None = None
|
|
52
|
+
leaves: list[LeafCandidate] = field(default_factory=list) # 可當 tip 的 genuine leaf(NEEDS_DECISION 時供互動挑選)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_content_line(ln: Line) -> bool:
|
|
56
|
+
"""納入 union 的內容行:uuid 行一律算;no-uuid 只算**非揮發** meta(summary 等內容行)。
|
|
57
|
+
|
|
58
|
+
與 lineset._counts_for_compare 同準則——揮發 meta(last-prompt/mode/title…)不進 union,
|
|
59
|
+
結尾另 append 一條新 last-prompt。"""
|
|
60
|
+
return bool(ln.uuid) or (ln.type not in VOLATILE_META_TYPES)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _idkey(ln: Line) -> tuple[str, str]:
|
|
64
|
+
"""可比較的 line-identity 鍵(避免 None 與 str 比較)。uuid 行=(uuid,hash);no-uuid=("",hash)。"""
|
|
65
|
+
return (ln.uuid or "", ln.canon_hash or "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _common_prefix_len(a: list[Line], b: list[Line]) -> int:
|
|
69
|
+
k = 0
|
|
70
|
+
while k < len(a) and k < len(b) and a[k].identity == b[k].identity:
|
|
71
|
+
k += 1
|
|
72
|
+
return k
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _has_cycle(parent_map: dict[str, str | None]) -> bool:
|
|
76
|
+
"""parent 鏈是否含環(沿 parentUuid 往上走重複造訪即環)。"""
|
|
77
|
+
for start in parent_map:
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
cur: str | None = start
|
|
80
|
+
while cur is not None and cur in parent_map:
|
|
81
|
+
if cur in seen:
|
|
82
|
+
return True
|
|
83
|
+
seen.add(cur)
|
|
84
|
+
cur = parent_map[cur]
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def merge_sessions(
|
|
89
|
+
local: SessionShape, hub: SessionShape, *, chosen_tip: str | None = None
|
|
90
|
+
) -> MergeResult:
|
|
91
|
+
"""union 兩個 SessionShape。回 MergeResult;不寫檔。"""
|
|
92
|
+
# ── (5) 安全前置:damaged / 零共同 uuid → 退回挑選 ──────────────────────────
|
|
93
|
+
if local.is_damaged or hub.is_damaged:
|
|
94
|
+
return MergeResult(MergeOutcome.FALLBACK, "至少一側 damaged,不可 union(退回挑選)")
|
|
95
|
+
if not local.uuids or not hub.uuids:
|
|
96
|
+
return MergeResult(MergeOutcome.FALLBACK, "至少一側無對話 uuid,無法 union(退回挑選)")
|
|
97
|
+
if not (local.uuids & hub.uuids):
|
|
98
|
+
return MergeResult(MergeOutcome.FALLBACK, "零共同 uuid(無共同祖先 / collision),不可 union(退回挑選)")
|
|
99
|
+
|
|
100
|
+
a = [ln for ln in local.lines if _is_content_line(ln)]
|
|
101
|
+
b = [ln for ln in hub.lines if _is_content_line(ln)]
|
|
102
|
+
k = _common_prefix_len(a, b)
|
|
103
|
+
|
|
104
|
+
# 兩枝分歧段以「分歧段 line-identity 序列」做 stable sort → 輸出與 local/hub 標籤無關(commutative)。
|
|
105
|
+
seg_a, seg_b = a[k:], b[k:]
|
|
106
|
+
first, first_seg, second_seg = (
|
|
107
|
+
(a, seg_a, seg_b)
|
|
108
|
+
if [_idkey(x) for x in seg_a] <= [_idkey(x) for x in seg_b]
|
|
109
|
+
else (b, seg_b, seg_a)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# ── (1)(2)(3) 共同前綴一次 + 兩分歧段保序;uuid 去重、no-uuid 不去重 ──────────
|
|
113
|
+
out: list[Line] = []
|
|
114
|
+
emitted_uuid_hash: dict[str, str | None] = {} # uuid -> 已輸出的 hash(偵測衝突)
|
|
115
|
+
|
|
116
|
+
def _emit(ln: Line) -> bool:
|
|
117
|
+
if ln.uuid:
|
|
118
|
+
prev = emitted_uuid_hash.get(ln.uuid)
|
|
119
|
+
if prev is not None:
|
|
120
|
+
# 同 uuid 重複:同 hash → 安全去重(略過);異 hash → 歷史行被改寫(damaged,本不該到這)
|
|
121
|
+
return prev == ln.canon_hash
|
|
122
|
+
emitted_uuid_hash[ln.uuid] = ln.canon_hash
|
|
123
|
+
out.append(ln)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
for ln in first[:k]: # 共同前綴(取 canonically-first 那側,bytes 一致)
|
|
127
|
+
_emit(ln)
|
|
128
|
+
for ln in first_seg: # 先輸出的分歧段
|
|
129
|
+
if not _emit(ln):
|
|
130
|
+
return MergeResult(MergeOutcome.FALLBACK, f"同 uuid 異 hash({ln.uuid[:8]} 歷史行被改寫),不可 union")
|
|
131
|
+
for ln in second_seg: # 後輸出的分歧段
|
|
132
|
+
if not _emit(ln):
|
|
133
|
+
return MergeResult(MergeOutcome.FALLBACK, f"同 uuid 異 hash({ln.uuid[:8]} 歷史行被改寫),不可 union")
|
|
134
|
+
|
|
135
|
+
# ── (5) 合併後結構檢查:多非-system 根 / parent 環 → 退回挑選 ───────────────
|
|
136
|
+
merged_uuids = {ln.uuid for ln in out if ln.uuid}
|
|
137
|
+
parent_map = {ln.uuid: ln.parent for ln in out if ln.uuid}
|
|
138
|
+
if _has_cycle(parent_map):
|
|
139
|
+
return MergeResult(MergeOutcome.FALLBACK, "合併後 parent 鏈成環(不可解),退回挑選")
|
|
140
|
+
nonsystem_roots = [
|
|
141
|
+
ln for ln in out
|
|
142
|
+
if ln.uuid and (ln.parent is None or ln.parent not in merged_uuids) and ln.type != "system"
|
|
143
|
+
]
|
|
144
|
+
if len(nonsystem_roots) > 1:
|
|
145
|
+
return MergeResult(
|
|
146
|
+
MergeOutcome.FALLBACK,
|
|
147
|
+
"合併後出現多個非-system 對話根(疑似併入不相關對話),退回挑選",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# genuine leaves = 未被任何行當 parent、非 sidechain、非工具 fan-out 的 uuid 行。
|
|
151
|
+
used_as_parent = {ln.parent for ln in out if ln.parent}
|
|
152
|
+
leaf_lines = [
|
|
153
|
+
ln for ln in out
|
|
154
|
+
if ln.uuid and ln.uuid not in used_as_parent and not ln.is_sidechain and not ln.is_tool_fanout
|
|
155
|
+
]
|
|
156
|
+
if not leaf_lines:
|
|
157
|
+
return MergeResult(MergeOutcome.FALLBACK, "合併後無 genuine leaf,退回挑選")
|
|
158
|
+
leaves = [LeafCandidate(ln.uuid, ln.ts) for ln in leaf_lines]
|
|
159
|
+
leaf_uuids = {ln.uuid for ln in leaf_lines}
|
|
160
|
+
|
|
161
|
+
# ── (4) chosen tip:使用者選或預設最新;append 新 last-prompt ─────────────────
|
|
162
|
+
# 自動只在「唯一最新」時拍板;缺 ts / 並列最新 → needs-decision(A11:不以裸/並列 timestamp 自動選;
|
|
163
|
+
# 防裸 rewind 落後或無法比較時被寫死錯 tip,B2)。上層互動再帶 chosen_tip 重呼。
|
|
164
|
+
if chosen_tip is not None:
|
|
165
|
+
if chosen_tip not in leaf_uuids:
|
|
166
|
+
return MergeResult(
|
|
167
|
+
MergeOutcome.NEEDS_DECISION,
|
|
168
|
+
f"指定 tip {chosen_tip[:8]} 非合併後 genuine leaf(交人決策)",
|
|
169
|
+
leaves=leaves,
|
|
170
|
+
)
|
|
171
|
+
chosen = chosen_tip
|
|
172
|
+
else:
|
|
173
|
+
if any(ln.ts is None for ln in leaf_lines):
|
|
174
|
+
return MergeResult(
|
|
175
|
+
MergeOutcome.NEEDS_DECISION,
|
|
176
|
+
"部分 genuine leaf 缺 timestamp,無法自動選 tip(交人挑選)",
|
|
177
|
+
leaves=leaves,
|
|
178
|
+
)
|
|
179
|
+
newest_ts = max(ln.ts for ln in leaf_lines)
|
|
180
|
+
newest = [ln for ln in leaf_lines if ln.ts == newest_ts]
|
|
181
|
+
if len(newest) != 1:
|
|
182
|
+
return MergeResult(
|
|
183
|
+
MergeOutcome.NEEDS_DECISION,
|
|
184
|
+
"多個 genuine leaf 並列最新 timestamp,無法自動選 tip(交人挑選)",
|
|
185
|
+
leaves=leaves,
|
|
186
|
+
)
|
|
187
|
+
chosen = newest[0].uuid
|
|
188
|
+
|
|
189
|
+
objs = [ln.obj for ln in out if ln.obj is not None]
|
|
190
|
+
sid = None
|
|
191
|
+
for ln in leaf_lines:
|
|
192
|
+
if ln.uuid == chosen and isinstance(ln.obj, dict):
|
|
193
|
+
sid = ln.obj.get("sessionId")
|
|
194
|
+
break
|
|
195
|
+
tip_obj: dict = {"type": "last-prompt", "leafUuid": chosen}
|
|
196
|
+
if sid is not None:
|
|
197
|
+
tip_obj["sessionId"] = sid
|
|
198
|
+
objs.append(tip_obj)
|
|
199
|
+
|
|
200
|
+
return MergeResult(
|
|
201
|
+
MergeOutcome.MERGED,
|
|
202
|
+
"union 成功(共同前綴去重一次 + 兩分歧段保序 + 新 last-prompt 標 tip)",
|
|
203
|
+
objs=objs,
|
|
204
|
+
chosen_tip=chosen,
|
|
205
|
+
leaves=leaves,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def render_jsonl(objs: list[dict]) -> bytes:
|
|
210
|
+
"""把 union 後物件序列序列化成 canonical JSONL bytes(每行 canon_dumps + LF)。
|
|
211
|
+
|
|
212
|
+
canonical 序列化 → 同一 line-identity 跨機 bytes 一致;落地用 atomicio.atomic_create_bytes
|
|
213
|
+
寫到 keep_both_path(不覆蓋既有 local JSONL,C3)。"""
|
|
214
|
+
return ("".join(canon_dumps(o) + "\n" for o in objs)).encode("utf-8")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Sidecar:專案同一性(git 指紋優先)與 session meta(A4 hashes)。
|
|
2
|
+
|
|
3
|
+
依據 DESIGN §8.2/§8.3 + 附錄 A4 + PLAN v0.4 §2.5:
|
|
4
|
+
- 跨機同一性**優先 git remote / repo fingerprint**;cwd 字串永不單獨自動落地(決定 #7)。
|
|
5
|
+
- 「無法判斷」與「同一 repo」嚴格分開:判不出一律 NEEDS_MAP,不猜。
|
|
6
|
+
- 降級:no-git → NEEDS_MAP;同 first-commit 不同 remote(fork/rename)→ AMBIGUOUS(要人確認)。
|
|
7
|
+
|
|
8
|
+
P1a 只做**讀 + 比對 + 計算**(不寫 sidecar;寫入是 P1b)。純標準庫(git 走 subprocess)。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .lineset import analyze
|
|
22
|
+
|
|
23
|
+
SCHEMA_VERSION = 1
|
|
24
|
+
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
|
25
|
+
_DEFAULT_PORTS = {"ssh": 22, "git": 9418, "http": 80, "https": 443}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ── git remote 正規化 ────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
def normalize_remote(url: str | None) -> str | None:
|
|
31
|
+
"""把各種 remote URL 形式正規化成 `host[:port]/path`(小寫、去 .git、去認證/scheme)。
|
|
32
|
+
**保留非預設 port**(同 host/path 不同 port = 不同伺服器,不可當同專案)。IPv6/query 走 urllib。"""
|
|
33
|
+
u = (url or "").strip()
|
|
34
|
+
if not u:
|
|
35
|
+
return None
|
|
36
|
+
u = re.sub(r"\.git/?$", "", u)
|
|
37
|
+
if "://" not in u:
|
|
38
|
+
# scp-like: user@host:owner/repo(無 scheme)
|
|
39
|
+
m = re.match(r"^[\w.+-]+@([^:/]+):(.+)$", u)
|
|
40
|
+
if m:
|
|
41
|
+
return f"{m.group(1).lower()}/{m.group(2).strip('/').lower()}"
|
|
42
|
+
return u.lower() # 本地路徑/未知形式
|
|
43
|
+
try:
|
|
44
|
+
parsed = urllib.parse.urlparse(u)
|
|
45
|
+
host = (parsed.hostname or "").lower()
|
|
46
|
+
port = parsed.port
|
|
47
|
+
except ValueError:
|
|
48
|
+
return u.lower()
|
|
49
|
+
if not host:
|
|
50
|
+
return u.lower()
|
|
51
|
+
if port is not None and port != _DEFAULT_PORTS.get(parsed.scheme):
|
|
52
|
+
host = f"{host}:{port}"
|
|
53
|
+
path = parsed.path.strip("/").lower()
|
|
54
|
+
return f"{host}/{path}" if path else host
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _git(cwd: str | Path, *args: str) -> str | None:
|
|
58
|
+
try:
|
|
59
|
+
r = subprocess.run(
|
|
60
|
+
["git", "-C", str(cwd), *args],
|
|
61
|
+
capture_output=True, text=True, timeout=10,
|
|
62
|
+
)
|
|
63
|
+
except Exception: # noqa: BLE001 - git 不存在/逾時都當無 git
|
|
64
|
+
return None
|
|
65
|
+
if r.returncode != 0:
|
|
66
|
+
return None
|
|
67
|
+
return r.stdout.strip()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── 專案指紋 ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ProjectFingerprint:
|
|
74
|
+
cwd: str
|
|
75
|
+
repo_root: str | None
|
|
76
|
+
remotes: dict[str, str] # name -> normalized remote
|
|
77
|
+
first_commit: str | None
|
|
78
|
+
has_git: bool
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def remote_set(self) -> set[str]:
|
|
82
|
+
return set(self.remotes.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def local_fingerprint(cwd: str | Path) -> ProjectFingerprint:
|
|
86
|
+
root = _git(cwd, "rev-parse", "--show-toplevel")
|
|
87
|
+
remotes: dict[str, str] = {}
|
|
88
|
+
first: str | None = None
|
|
89
|
+
if root:
|
|
90
|
+
for name in (_git(cwd, "remote") or "").split():
|
|
91
|
+
norm = normalize_remote(_git(cwd, "remote", "get-url", name))
|
|
92
|
+
if norm:
|
|
93
|
+
remotes[name] = norm
|
|
94
|
+
roots = _git(cwd, "rev-list", "--max-parents=0", "--all") or ""
|
|
95
|
+
first = sorted(roots.split())[0] if roots.split() else None
|
|
96
|
+
return ProjectFingerprint(str(cwd), root, remotes, first, has_git=bool(root))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── _project.json(hub 端專案 sidecar)────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ProjectSidecar:
|
|
103
|
+
git_remote: str | None = None # 正規化(primary)
|
|
104
|
+
first_commit: str | None = None
|
|
105
|
+
repo_root: str | None = None
|
|
106
|
+
observed_cwds: list[str] = field(default_factory=list)
|
|
107
|
+
schema_version: int = SCHEMA_VERSION
|
|
108
|
+
|
|
109
|
+
def to_dict(self) -> dict:
|
|
110
|
+
return {
|
|
111
|
+
"schema_version": self.schema_version,
|
|
112
|
+
"git_remote": self.git_remote,
|
|
113
|
+
"first_commit": self.first_commit,
|
|
114
|
+
"repo_root": self.repo_root,
|
|
115
|
+
"observed_cwds": self.observed_cwds,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_project_sidecar(project_dir: str | Path) -> ProjectSidecar | None:
|
|
120
|
+
p = Path(project_dir) / "_project.json"
|
|
121
|
+
if not p.exists() or p.is_symlink(): # leaf 防線:symlink _project.json → 不跟隨讀界外身分(treat as absent,e2e)
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
125
|
+
except Exception: # noqa: BLE001
|
|
126
|
+
return None
|
|
127
|
+
if not isinstance(d, dict):
|
|
128
|
+
return None
|
|
129
|
+
gr, fc, rr = d.get("git_remote"), d.get("first_commit"), d.get("repo_root")
|
|
130
|
+
if not all(x is None or isinstance(x, str) for x in (gr, fc, rr)):
|
|
131
|
+
return None # 壞 sidecar → blocked,不可扭曲身分
|
|
132
|
+
cwds = d.get("observed_cwds", [])
|
|
133
|
+
if not isinstance(cwds, list) or not all(isinstance(x, str) for x in cwds):
|
|
134
|
+
return None
|
|
135
|
+
sv = d.get("schema_version", SCHEMA_VERSION)
|
|
136
|
+
if not isinstance(sv, int) or isinstance(sv, bool):
|
|
137
|
+
return None
|
|
138
|
+
return ProjectSidecar(git_remote=gr, first_commit=fc, repo_root=rr,
|
|
139
|
+
observed_cwds=list(cwds), schema_version=sv)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── 同一性比對 ───────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
class MatchStatus(str, Enum):
|
|
145
|
+
MATCH = "match"
|
|
146
|
+
NO_MATCH = "no-match"
|
|
147
|
+
AMBIGUOUS = "ambiguous" # 同 lineage 不同 remote(fork/rename)→ 要人確認
|
|
148
|
+
NEEDS_MAP = "needs-map" # 缺 git 身分 → 不可自動,要 --map
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class MatchResult:
|
|
153
|
+
status: MatchStatus
|
|
154
|
+
reason: str
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def match(local: ProjectFingerprint, sc: ProjectSidecar) -> MatchResult:
|
|
158
|
+
"""本機專案指紋 vs hub _project.json。git 指紋優先;判不出一律 NEEDS_MAP,不靠 cwd 猜。"""
|
|
159
|
+
if not local.has_git or not sc.git_remote:
|
|
160
|
+
return MatchResult(MatchStatus.NEEDS_MAP, "缺 git remote 身分,需 --map 人工對應")
|
|
161
|
+
if sc.git_remote in local.remote_set:
|
|
162
|
+
return MatchResult(MatchStatus.MATCH, f"git remote 相符:{sc.git_remote}")
|
|
163
|
+
if sc.first_commit and local.first_commit and sc.first_commit == local.first_commit:
|
|
164
|
+
return MatchResult(MatchStatus.AMBIGUOUS, "first-commit 相同但 remote 不同(fork/rename)→ 要確認")
|
|
165
|
+
return MatchResult(MatchStatus.NO_MATCH, "remote 與 first-commit 皆不符")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── session meta(A4 hashes;P1a 計算 + 讀)──────────────────────────────
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class SessionMeta:
|
|
172
|
+
content_hash: str # 全檔 ordered canonical content digest
|
|
173
|
+
tail_hash: str # 末段 digest(快速偵測截斷/延伸)
|
|
174
|
+
line_count: int
|
|
175
|
+
uuid_count: int
|
|
176
|
+
non_uuid_count: int
|
|
177
|
+
schema_version: int = SCHEMA_VERSION
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> dict:
|
|
180
|
+
return {
|
|
181
|
+
"schema_version": self.schema_version,
|
|
182
|
+
"content_hash": self.content_hash,
|
|
183
|
+
"tail_hash": self.tail_hash,
|
|
184
|
+
"line_count": self.line_count,
|
|
185
|
+
"uuid_count": self.uuid_count,
|
|
186
|
+
"non_uuid_count": self.non_uuid_count,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _sha(parts: list[str]) -> str:
|
|
191
|
+
h = hashlib.sha256()
|
|
192
|
+
for p in parts:
|
|
193
|
+
h.update(p.encode("utf-8"))
|
|
194
|
+
h.update(b"\n")
|
|
195
|
+
return h.hexdigest()
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def compute_session_meta(path: str | Path, tail: int = 20) -> SessionMeta | None:
|
|
199
|
+
"""從一個 jsonl 算 A4 meta。**任何 damaged 都回 None**:檔級(zero/blank/decode)、壞 JSON 行、
|
|
200
|
+
或同檔同 uuid 異 hash —— meta 不得替 damaged 檔背書(codex r5 critical)。"""
|
|
201
|
+
shape = analyze(str(path))
|
|
202
|
+
if shape.is_damaged:
|
|
203
|
+
return None
|
|
204
|
+
ok = shape.lines
|
|
205
|
+
hashes = [ln.canon_hash or "" for ln in ok]
|
|
206
|
+
uuid_count = sum(1 for ln in ok if ln.uuid)
|
|
207
|
+
return SessionMeta(
|
|
208
|
+
content_hash=_sha(hashes),
|
|
209
|
+
tail_hash=_sha(hashes[-tail:]),
|
|
210
|
+
line_count=len(ok),
|
|
211
|
+
uuid_count=uuid_count,
|
|
212
|
+
non_uuid_count=len(ok) - uuid_count,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def read_session_meta(meta_path: str | Path) -> SessionMeta | None:
|
|
217
|
+
p = Path(meta_path)
|
|
218
|
+
if not p.exists():
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
222
|
+
except Exception: # noqa: BLE001
|
|
223
|
+
return None
|
|
224
|
+
if not isinstance(d, dict):
|
|
225
|
+
return None
|
|
226
|
+
ch, th = d.get("content_hash"), d.get("tail_hash")
|
|
227
|
+
if not (isinstance(ch, str) and _HEX64.match(ch) and isinstance(th, str) and _HEX64.match(th)):
|
|
228
|
+
return None
|
|
229
|
+
counts: dict[str, int] = {}
|
|
230
|
+
for key in ("line_count", "uuid_count", "non_uuid_count"):
|
|
231
|
+
v = d.get(key)
|
|
232
|
+
if not isinstance(v, int) or isinstance(v, bool) or v < 0: # bool 是 int 子類,須排除
|
|
233
|
+
return None
|
|
234
|
+
counts[key] = v
|
|
235
|
+
sv = d.get("schema_version", SCHEMA_VERSION)
|
|
236
|
+
if not isinstance(sv, int) or isinstance(sv, bool):
|
|
237
|
+
return None
|
|
238
|
+
return SessionMeta(content_hash=ch, tail_hash=th, schema_version=sv, **counts)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""決策快照子集(C4):plan 時擷取、apply 取鎖後重算比對;不一致即「決策已過期」→ 中止。
|
|
2
|
+
|
|
3
|
+
依據 PLAN v0.8 §2.8(codex r3/r4 C4)+ §3 資料流第 6/7 步。一個 per-session 子集,涵蓋這個分類決策
|
|
4
|
+
**所依賴的全部輸入**:兩側資料檔 + 各自 meta sidecar + 該 hub 專案 `_project.json` + config(own_hub/
|
|
5
|
+
remotes/force_unsafe_lock)+ 該 hub 專案 **tombstone 目錄 digest(含 `_coverage.json`)** + **該 session
|
|
6
|
+
在 state 的單一條目 hash**(不是整個 state,避免 per-session commit 自我失效)。
|
|
7
|
+
|
|
8
|
+
「hub/project fingerprint + known-session set + coverage」屬 **anomaly 重跑**(見 anomaly.py),與本
|
|
9
|
+
per-session 子集分開:本子集放整個 known-set 會被自己的逐 session commit 連動而誤判過期。
|
|
10
|
+
|
|
11
|
+
codex r8 兩個 critical 防護:
|
|
12
|
+
① 接 **專案夾 + sid**(非可為 None 的具體路徑)自行推導 `<sid>.jsonl`/`<sid>.meta.json`——否則 plan
|
|
13
|
+
時 None 代表「這側沒檔」,apply 時還是 None,即使期間檔被建出來也偵測不到 → 覆蓋掉新檔。
|
|
14
|
+
② `_file_digest` **永不回 None**:以 absent/sha/err/nonreg 區分「不存在」與「存在但讀不到/非一般檔」,
|
|
15
|
+
否則兩者都 None 會被當「沒變」而覆蓋未檢視過的資料。
|
|
16
|
+
|
|
17
|
+
純標準庫;不寫檔。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import stat as statmod
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import tombstone
|
|
29
|
+
from .config import Config
|
|
30
|
+
from .state import State
|
|
31
|
+
|
|
32
|
+
META_SUFFIX = ".meta.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _file_digest(path: Path | None) -> str:
|
|
36
|
+
"""檔狀態指紋,永不回 None。先 stat(不開 FIFO 以免阻塞),只對一般檔讀內容算 sha256:
|
|
37
|
+
- absent :不存在
|
|
38
|
+
- sha:<hex> :一般檔內容
|
|
39
|
+
- nonreg:<ifmt> :存在但非一般檔(目錄/FIFO/symlink-to-dir…)
|
|
40
|
+
- err:<errno> :存在但讀不到(權限/IO)
|
|
41
|
+
四類彼此可辨,確保「plan 時沒檔、apply 時冒出檔(或變不可讀)」一定改變快照。
|
|
42
|
+
"""
|
|
43
|
+
if path is None:
|
|
44
|
+
return "absent"
|
|
45
|
+
try:
|
|
46
|
+
st = os.lstat(path) # **no-follow**(e2e gate4 #2):symlink leaf → S_ISLNK → 下方 nonreg(不 open 讀界外目標);
|
|
47
|
+
# 一般檔 lstat==stat、行為不變。變動偵測仍有效(symlink swap → nonreg vs sha 改變 → 快照失效)。
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return "absent"
|
|
50
|
+
except OSError as e:
|
|
51
|
+
return f"err:{e.errno}"
|
|
52
|
+
if not statmod.S_ISREG(st.st_mode):
|
|
53
|
+
return f"nonreg:{statmod.S_IFMT(st.st_mode)}" # symlink → S_ISLNK → 非 S_ISREG → 不 open 讀界外(e2e gate4 #2)
|
|
54
|
+
try:
|
|
55
|
+
with open(path, "rb") as f:
|
|
56
|
+
data = f.read()
|
|
57
|
+
except OSError as e:
|
|
58
|
+
return f"err:{e.errno}"
|
|
59
|
+
return "sha:" + hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def config_hash(config: Config) -> str:
|
|
63
|
+
"""只納入會左右落地決策的欄位:own_hub / remotes / force_unsafe_lock(map=bindings 在 state 條目)。"""
|
|
64
|
+
payload = {
|
|
65
|
+
"own_hub": config.own_hub,
|
|
66
|
+
"remotes": dict(sorted(config.remotes.items())),
|
|
67
|
+
"force_unsafe_lock": config.force_unsafe_lock,
|
|
68
|
+
}
|
|
69
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def state_entry_hash(state: State | None, project_key: str | None, sid: str, cwd: str | None,
|
|
73
|
+
local_dir_name: str | None = None) -> str:
|
|
74
|
+
"""**單一 session** 在 state 的條目指紋:是否已知(hub / local 基線)+ 該 cwd 的跨路徑綁定。
|
|
75
|
+
|
|
76
|
+
刻意只取**本 sid 的成員性**、不納入整個集合——否則 apply 迴圈中對**其他** session 的 per-session
|
|
77
|
+
commit 會改動集合、使本 session 的快照誤判過期(PLAN §2.8 明列的自我失效陷阱)。新增其他 sid 不改變
|
|
78
|
+
「本 sid 是否在集合內」,故此 hash 在我方 additive commit 下穩定。`local_known` 與 `known` 對稱納入
|
|
79
|
+
(供 local-deleted/copy-to-local 決策;local_sessions[pk] 的 baseline 取代只在專案末發生、不影響本 sid
|
|
80
|
+
成員性於迴圈內的穩定性,P1c)。"""
|
|
81
|
+
known = bool(state and project_key is not None and sid in state.known_sessions.get(project_key, set()))
|
|
82
|
+
local_known = bool(state and project_key is not None and sid in state.local_sessions.get(project_key, set()))
|
|
83
|
+
binding = state.bindings.get(cwd) if (state and cwd is not None) else None
|
|
84
|
+
# 夾名綁定也納入:空夾(cwd=None)的身分解析改靠 local_dir_bindings,故其變動(並發 remap)須能令快照失效,
|
|
85
|
+
# 與 cwd-binding 對稱(codex r26-2)。
|
|
86
|
+
dir_binding = state.local_dir_bindings.get(local_dir_name) if (state and local_dir_name is not None) else None
|
|
87
|
+
payload = {"pk": project_key, "sid": sid, "known": known, "local_known": local_known,
|
|
88
|
+
"binding": binding, "dir_binding": dir_binding}
|
|
89
|
+
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class DecisionSnapshot:
|
|
94
|
+
session_id: str
|
|
95
|
+
local_data: str
|
|
96
|
+
hub_data: str
|
|
97
|
+
local_meta: str
|
|
98
|
+
hub_meta: str
|
|
99
|
+
project_sidecar: str
|
|
100
|
+
config_hash: str
|
|
101
|
+
tomb_dir_digest: str
|
|
102
|
+
state_entry: str
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def compute_decision_snapshot(
|
|
106
|
+
*,
|
|
107
|
+
session_id: str,
|
|
108
|
+
local_project_dir: Path | None,
|
|
109
|
+
hub_project_dir: Path | None,
|
|
110
|
+
config: Config,
|
|
111
|
+
state: State | None,
|
|
112
|
+
project_key: str | None,
|
|
113
|
+
cwd: str | None,
|
|
114
|
+
) -> DecisionSnapshot:
|
|
115
|
+
"""擷取/重算一個 session 的決策快照子集。plan 與 apply 各呼叫一次(傳同樣的專案夾),相等才可落地。
|
|
116
|
+
|
|
117
|
+
傳**專案夾**而非具體檔路徑:本函式自行推導 `<sid>.jsonl`/`<sid>.meta.json`,使「期間冒出/消失/變不可讀」
|
|
118
|
+
都能在 apply 重算時被偵測(codex r8 critical①)。某側無對應專案夾(未綁定)→ 該側恆 absent。
|
|
119
|
+
"""
|
|
120
|
+
def in_dir(d: Path | None, name: str) -> Path | None:
|
|
121
|
+
return (d / name) if d is not None else None
|
|
122
|
+
|
|
123
|
+
sidecar_path = in_dir(hub_project_dir, "_project.json")
|
|
124
|
+
tomb_digest = tombstone.tombstone_dir_digest(hub_project_dir) if hub_project_dir else ""
|
|
125
|
+
return DecisionSnapshot(
|
|
126
|
+
session_id=session_id,
|
|
127
|
+
local_data=_file_digest(in_dir(local_project_dir, f"{session_id}.jsonl")),
|
|
128
|
+
hub_data=_file_digest(in_dir(hub_project_dir, f"{session_id}.jsonl")),
|
|
129
|
+
local_meta=_file_digest(in_dir(local_project_dir, f"{session_id}{META_SUFFIX}")),
|
|
130
|
+
hub_meta=_file_digest(in_dir(hub_project_dir, f"{session_id}{META_SUFFIX}")),
|
|
131
|
+
project_sidecar=_file_digest(sidecar_path),
|
|
132
|
+
config_hash=config_hash(config),
|
|
133
|
+
tomb_dir_digest=tomb_digest,
|
|
134
|
+
state_entry=state_entry_hash(state, project_key, session_id, cwd,
|
|
135
|
+
local_project_dir.name if local_project_dir is not None else None),
|
|
136
|
+
)
|