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,240 @@
|
|
|
1
|
+
"""state.json:已知 session/project 指紋、hub fingerprint、跨路徑綁定(A17.4),含完整性校驗。
|
|
2
|
+
|
|
3
|
+
依據 DESIGN §4(state)/§8.5 + 附錄 A17.4 + PLAN v0.5 §2.6:
|
|
4
|
+
- schema version + checksum;**present-but-corrupt 與 missing 嚴格分開**——壞檔保守要求確認,
|
|
5
|
+
不退化成「首次同步」(§8.5)。
|
|
6
|
+
- 跨路徑 local-cwd ↔ hub-project 綁定持久化(A17.4)。
|
|
7
|
+
|
|
8
|
+
P1b:save 改走 atomicio(fsync+讀回驗);新增 **per-session 加鎖 read-modify-write(CAS)**——
|
|
9
|
+
逐 session commit、絕不批次覆蓋;持鎖期間重讀最新 state 再加上本次 delta,故並發 commit 不互蓋。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import atomicio, config
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StateCorruptError(Exception):
|
|
26
|
+
"""state.json 存在但損壞(schema/checksum 不符)。呼叫端須保守處理、不可當首次同步。"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def default_state_path() -> Path:
|
|
30
|
+
return config.default_config_path().with_name("state.json")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _checksum(payload: dict) -> str:
|
|
34
|
+
return hashlib.sha256(
|
|
35
|
+
json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
36
|
+
).hexdigest()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class State:
|
|
41
|
+
hub_fingerprint: str | None = None
|
|
42
|
+
known_sessions: dict[str, set[str]] = field(default_factory=dict) # project_key -> **hub** 端 sessionId 集
|
|
43
|
+
local_sessions: dict[str, set[str]] = field(default_factory=dict) # project_key -> **local** 端 sessionId 集(對稱刪除追蹤,P1c)
|
|
44
|
+
known_memory: dict[str, set[str]] = field(default_factory=dict) # project_key -> **hub** 端 memory 檔名集
|
|
45
|
+
local_memory: dict[str, set[str]] = field(default_factory=dict) # project_key -> **local** 端 memory 檔名集(對稱刪除追蹤,P1d)
|
|
46
|
+
bindings: dict[str, str] = field(default_factory=dict) # local_cwd -> hub_project_key (A17.4)
|
|
47
|
+
local_dir_bindings: dict[str, str] = field(default_factory=dict) # local 夾名 -> hub_project_key(供 session 全刪、無 cwd 可解析的空夾仍能配對,P1c)
|
|
48
|
+
schema_version: int = SCHEMA_VERSION
|
|
49
|
+
path: str | None = None
|
|
50
|
+
|
|
51
|
+
def _payload(self) -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"schema_version": self.schema_version,
|
|
54
|
+
"hub_fingerprint": self.hub_fingerprint,
|
|
55
|
+
"known_sessions": {k: sorted(v) for k, v in self.known_sessions.items()},
|
|
56
|
+
"local_sessions": {k: sorted(v) for k, v in self.local_sessions.items()},
|
|
57
|
+
"known_memory": {k: sorted(v) for k, v in self.known_memory.items()},
|
|
58
|
+
"local_memory": {k: sorted(v) for k, v in self.local_memory.items()},
|
|
59
|
+
"bindings": dict(self.bindings),
|
|
60
|
+
"local_dir_bindings": dict(self.local_dir_bindings),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_or_none(path: str | os.PathLike | None = None) -> State | None:
|
|
65
|
+
"""檔不存在 → None(真首次同步)。檔在但壞 → raise StateCorruptError(不可當首次)。"""
|
|
66
|
+
p = Path(path) if path is not None else default_state_path()
|
|
67
|
+
if not p.exists():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
|
71
|
+
except Exception as e: # noqa: BLE001
|
|
72
|
+
raise StateCorruptError(f"state.json 無法解析:{e}") from e
|
|
73
|
+
if not isinstance(raw, dict) or "_checksum" not in raw:
|
|
74
|
+
raise StateCorruptError("state.json 結構不符或缺 checksum")
|
|
75
|
+
payload = {k: v for k, v in raw.items() if k != "_checksum"}
|
|
76
|
+
if _checksum(payload) != raw["_checksum"]:
|
|
77
|
+
raise StateCorruptError("state.json checksum 不符(可能損壞/被竄改)")
|
|
78
|
+
try:
|
|
79
|
+
return State(
|
|
80
|
+
hub_fingerprint=payload.get("hub_fingerprint"),
|
|
81
|
+
known_sessions={k: set(v) for k, v in payload.get("known_sessions", {}).items()},
|
|
82
|
+
# 舊 state 缺 local_sessions → .get(...,{}) 給空(clean migration,首次 apply 後由 re-glob 填)。
|
|
83
|
+
local_sessions={k: set(v) for k, v in payload.get("local_sessions", {}).items()},
|
|
84
|
+
known_memory={k: set(v) for k, v in payload.get("known_memory", {}).items()},
|
|
85
|
+
# 舊 state 缺 local_memory → 空(migration);has_local_memory_baseline 以「pk 是否在此 dict」判(空集≠缺欄位)。
|
|
86
|
+
local_memory={k: set(v) for k, v in payload.get("local_memory", {}).items()},
|
|
87
|
+
bindings=dict(payload.get("bindings", {})),
|
|
88
|
+
local_dir_bindings=dict(payload.get("local_dir_bindings", {})),
|
|
89
|
+
schema_version=int(payload.get("schema_version", SCHEMA_VERSION)),
|
|
90
|
+
path=str(p),
|
|
91
|
+
)
|
|
92
|
+
except (TypeError, ValueError, AttributeError) as e:
|
|
93
|
+
raise StateCorruptError(f"state.json 欄位型別不符:{e}") from e
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def save(state: State, path: str | os.PathLike | None = None) -> str:
|
|
97
|
+
"""原子寫(atomicio:同目錄 temp + fsync + rename + 讀回驗)。回寫出的路徑。
|
|
98
|
+
|
|
99
|
+
注意:本函式**不加鎖**——並發場景請走 `commit_session` / `update_under_lock`(持鎖 RMW),
|
|
100
|
+
否則「load→改→save」之間另一 process 的 commit 會被覆蓋掉。
|
|
101
|
+
"""
|
|
102
|
+
p = Path(path) if path is not None else Path(state.path or default_state_path())
|
|
103
|
+
payload = state._payload()
|
|
104
|
+
doc = {**payload, "_checksum": _checksum(payload)}
|
|
105
|
+
atomicio.atomic_write_text(p, json.dumps(doc, ensure_ascii=False, indent=2))
|
|
106
|
+
state.path = str(p)
|
|
107
|
+
return str(p)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_path(path: str | os.PathLike | None) -> Path:
|
|
111
|
+
return Path(path) if path is not None else default_state_path()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def update_under_lock(
|
|
115
|
+
mutate: Callable[[State], None],
|
|
116
|
+
path: str | os.PathLike | None = None,
|
|
117
|
+
*,
|
|
118
|
+
lock_timeout_s: float = 5.0,
|
|
119
|
+
) -> State:
|
|
120
|
+
"""加鎖 read-modify-write:取 state 鎖 → 重讀**最新** state → 套用 mutate(delta) → 原子寫。
|
|
121
|
+
|
|
122
|
+
並發安全的關鍵在「持鎖期間重讀」:即便呼叫端手上的 State 已過期,這裡仍以磁碟最新內容為基底,
|
|
123
|
+
故兩個 process 各加一個 session 不會互蓋(CAS 等價,PLAN §2.6)。state 壞檔 → 拋 StateCorruptError,
|
|
124
|
+
不靜默當首次同步覆蓋。鎖取不到(逾時/stale)→ 拋 atomicio.LockError/StaleLock,不靜默 proceed。
|
|
125
|
+
"""
|
|
126
|
+
p = _resolve_path(path)
|
|
127
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
lock = atomicio.FileLock(p).acquire_blocking(timeout_s=lock_timeout_s)
|
|
129
|
+
try:
|
|
130
|
+
st = load_or_none(p) or State(path=str(p))
|
|
131
|
+
mutate(st)
|
|
132
|
+
save(st, p)
|
|
133
|
+
return st
|
|
134
|
+
finally:
|
|
135
|
+
lock.release()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def commit_session(
|
|
139
|
+
project_key: str,
|
|
140
|
+
sid: str,
|
|
141
|
+
path: str | os.PathLike | None = None,
|
|
142
|
+
*,
|
|
143
|
+
cwd: str | None = None,
|
|
144
|
+
hub_fingerprint: str | None = None,
|
|
145
|
+
lock_timeout_s: float = 5.0,
|
|
146
|
+
) -> State:
|
|
147
|
+
"""逐 session 提交(加鎖 RMW):把 sid 記入 known_sessions[project_key];可選同時記跨路徑綁定
|
|
148
|
+
(cwd→project_key,A17.4)與 hub_fingerprint。回提交後的 State。"""
|
|
149
|
+
def _mutate(st: State) -> None:
|
|
150
|
+
st.known_sessions.setdefault(project_key, set()).add(sid)
|
|
151
|
+
if cwd is not None:
|
|
152
|
+
st.bindings[cwd] = project_key
|
|
153
|
+
if hub_fingerprint is not None:
|
|
154
|
+
st.hub_fingerprint = hub_fingerprint
|
|
155
|
+
|
|
156
|
+
return update_under_lock(_mutate, path, lock_timeout_s=lock_timeout_s)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def commit_memory(
|
|
160
|
+
project_key: str,
|
|
161
|
+
name: str,
|
|
162
|
+
path: str | os.PathLike | None = None,
|
|
163
|
+
*,
|
|
164
|
+
lock_timeout_s: float = 5.0,
|
|
165
|
+
) -> State:
|
|
166
|
+
"""逐 memory 提交(加鎖 RMW、additive):把檔名記入 known_memory[project_key](hub 基線已知,P1d)。
|
|
167
|
+
對稱 `commit_session`;不動 binding/fingerprint(那些是 session/專案層級)。回提交後的 State。"""
|
|
168
|
+
def _mutate(st: State) -> None:
|
|
169
|
+
st.known_memory.setdefault(project_key, set()).add(name)
|
|
170
|
+
|
|
171
|
+
return update_under_lock(_mutate, path, lock_timeout_s=lock_timeout_s)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def reconcile_local_memory_presence(
|
|
175
|
+
project_key: str,
|
|
176
|
+
present_names,
|
|
177
|
+
tombstoned,
|
|
178
|
+
path: str | os.PathLike | None = None,
|
|
179
|
+
*,
|
|
180
|
+
lock_timeout_s: float = 5.0,
|
|
181
|
+
require_baseline: bool = False,
|
|
182
|
+
) -> State:
|
|
183
|
+
"""更新 local_memory[project_key](memory apply 末由 re-glob 結果呼叫,對稱 `reconcile_local_presence`,P1d)。
|
|
184
|
+
新值 = present_names(寫入後 local memory 現況)∪ pending;pending = **鎖內最新** baseline 中「已不在 local、
|
|
185
|
+
且尚無 memory tombstone」者(未落地的本機刪除,保留以免下次當新檔復活)。已成功落地 tombstone 的檔名(∈
|
|
186
|
+
tombstoned)才從 baseline 移除(往後由 tombstone 閘保護)。加鎖 RMW,只動 local_memory[pk]。
|
|
187
|
+
|
|
188
|
+
`require_baseline`(apply 傳 True):鎖內若**最新** state 已無此 pk 的 local memory 基線(並發 doctor
|
|
189
|
+
--rebuild-state 或 migration 移除)→ **不重建**。否則會把 has_local_baseline 守衛(取自呼叫時可能 stale 的
|
|
190
|
+
state)失效後仍憑當前磁碟建空基線 → 下次 hub-only memory 當新檔復活(e2e Pass2 Medium)。"""
|
|
191
|
+
present = set(present_names)
|
|
192
|
+
tombset = set(tombstoned)
|
|
193
|
+
|
|
194
|
+
def _mutate(st: State) -> None:
|
|
195
|
+
if require_baseline and project_key not in st.local_memory:
|
|
196
|
+
return # 鎖內最新 state 已無此專案 local memory 基線 → fail-closed 不重建(避免復活)
|
|
197
|
+
prev = st.local_memory.get(project_key, set())
|
|
198
|
+
pending = {n for n in prev if n not in present and n not in tombset}
|
|
199
|
+
st.local_memory[project_key] = present | pending
|
|
200
|
+
|
|
201
|
+
return update_under_lock(_mutate, path, lock_timeout_s=lock_timeout_s)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def reconcile_local_presence(
|
|
205
|
+
project_key: str,
|
|
206
|
+
present_stems,
|
|
207
|
+
tombstoned,
|
|
208
|
+
path: str | os.PathLike | None = None,
|
|
209
|
+
*,
|
|
210
|
+
lock_timeout_s: float = 5.0,
|
|
211
|
+
require_baseline: bool = False,
|
|
212
|
+
) -> State:
|
|
213
|
+
"""更新 local_sessions[project_key](apply 末由 re-glob 結果呼叫,P1c)。新值 =
|
|
214
|
+
present_stems(寫入後 local 現況)∪ pending,
|
|
215
|
+
pending = **鎖內最新** baseline 中「已不在 local、且尚無 tombstone」者 = 未落地的本機刪除。
|
|
216
|
+
|
|
217
|
+
pending 為何由**鎖內 disk baseline** 算(非呼叫端傳入的 stale 集):並發另一 sync 可能已在 disk
|
|
218
|
+
baseline 保留了某個 tombstone 寫失敗的 pending 刪除;若本 process 以自己的 stale 快照盲覆寫,會把它
|
|
219
|
+
抹掉 → 下次當「新 hub 檔」復活(codex r24-4)。故在鎖內以最新 `prev` 計算、合併。
|
|
220
|
+
|
|
221
|
+
pending **不以 hub 是否在場為條件**:local-deleted 的 tombstone 若寫失敗(error/skip),即便 hub 檔
|
|
222
|
+
此刻恰好不在,也須保留該 sid 於 baseline,否則 hub 檔稍後復現(無 tombstone)會被當新檔復活(codex r24-3)。
|
|
223
|
+
已成功落地 tombstone 的 sid(∈tombstoned)才從 baseline 移除(往後由 tombstone 閘保護)。
|
|
224
|
+
與 known_sessions 的 additive commit 不同,這是 baseline 取代式更新;加鎖 RMW,只動 local_sessions[pk],
|
|
225
|
+
其餘(known_sessions / 別 pk)一律保留。dry-run **不**呼叫(apply 專用)。
|
|
226
|
+
|
|
227
|
+
`require_baseline`(apply 傳 True):鎖內若**最新** state 已無此 pk 的 local 基線(並發 doctor --rebuild-state
|
|
228
|
+
或 migration 移除)→ **不重建**。否則會令 apply 的 has_local_baseline 守衛(取自呼叫時 stale state)失效後仍憑
|
|
229
|
+
當前磁碟建空基線 → 下次 present=hub 走 copy-to-local 而非 blocked-no-local-baseline = 復活(e2e Pass2 Medium)。"""
|
|
230
|
+
present = set(present_stems)
|
|
231
|
+
tombset = set(tombstoned)
|
|
232
|
+
|
|
233
|
+
def _mutate(st: State) -> None:
|
|
234
|
+
if require_baseline and project_key not in st.local_sessions:
|
|
235
|
+
return # 鎖內最新 state 已無此專案 local 基線 → fail-closed 不重建(避免復活)
|
|
236
|
+
prev = st.local_sessions.get(project_key, set())
|
|
237
|
+
pending = {sid for sid in prev if sid not in present and sid not in tombset}
|
|
238
|
+
st.local_sessions[project_key] = present | pending
|
|
239
|
+
|
|
240
|
+
return update_under_lock(_mutate, path, lock_timeout_s=lock_timeout_s)
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Tombstone(刪除標記)+ coverage epoch。hub `<proj>/.tombstones/`,append-only。
|
|
2
|
+
|
|
3
|
+
依據 DESIGN 附錄 A3/A17.1/A17.3 + PLAN v0.5 §2.7(codex r3 bootstrap/coverage、r3 digest 含 coverage):
|
|
4
|
+
- conditional **suppress**:只抑制復活,**永不自動刪 local**;預設**永不自動 GC**(A17.3)。
|
|
5
|
+
- 偵測一律查 **hub tombstone**,不依賴 per-machine state(A17.1)。
|
|
6
|
+
- 未 initialized(無 `_coverage.json`)的 project → 上層應 blocked,除非 `--bootstrap`(codex r3)。
|
|
7
|
+
- `tombstone_dir_digest` **含 `_coverage.json`**、排除 temp/lock(供決策快照偵測 epoch 變動,codex r3)。
|
|
8
|
+
|
|
9
|
+
P1a:讀 + digest(+ 提供 bootstrap/標記用的簡單 atomic write 原語);conditional suppress 套用是 P1b。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import atomicio
|
|
22
|
+
from .pathsafe import dir_scannable, safe_project_dir
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = 1
|
|
25
|
+
TOMB_DIR = ".tombstones"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnsafeTombstonesDir(OSError):
|
|
29
|
+
"""`<proj>/.tombstones` 是 symlink 或逃逸專案夾(指向界外)。拒絕跟隨——否則讀界外 coverage/tombstone 當
|
|
30
|
+
決策輸入、或寫 coverage/tombstone 到界外(e2e gate3 #3)。子類 OSError → 寫入端 raise、呼叫端既有 except
|
|
31
|
+
OSError 捕捉;讀取端 fail-closed(coverage → None ⇒ 專案視為未初始化 ⇒ 上層 blocked,不復活/不自動套用)。"""
|
|
32
|
+
COVERAGE_FILE = "_coverage.json"
|
|
33
|
+
_TOMB_SUFFIX = ".deleted.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def now_iso() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def raw_file_digest(path: str | os.PathLike) -> str | None:
|
|
41
|
+
"""檔 bytes 的 sha256(**原始位元組**,非語意 content hash)。對任何可讀檔都可算(含 JSON 壞行/
|
|
42
|
+
空白/0-byte);讀不到 → None。
|
|
43
|
+
|
|
44
|
+
base_hash 與條件式 suppress 用**同一基準**(raw bytes):bootstrap 記 base_hash 用它(codex r9-4),
|
|
45
|
+
P1c suppress 比對現存側也用它(同 hash 空間才可比)。保守取捨——純編碼往返(CRLF/BOM)會讓 raw 不等
|
|
46
|
+
→ 轉 conflict 交人,**寧可多問、不靜默復活也不靜默丟更新**(A3)。"""
|
|
47
|
+
try:
|
|
48
|
+
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
|
49
|
+
except OSError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def local_machine_id() -> str:
|
|
54
|
+
return socket.gethostname() or "unknown"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def tombstones_dir(project_dir: str | os.PathLike) -> Path:
|
|
58
|
+
return Path(project_dir) / TOMB_DIR
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tombstones_ok(project_dir: str | os.PathLike) -> bool:
|
|
62
|
+
"""`.tombstones` 是否安全在 project_dir 內(非 symlink、resolve 後不逃逸)。不存在(首寫前)→ 安全(字面在內)。"""
|
|
63
|
+
return safe_project_dir(project_dir, tombstones_dir(project_dir))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def tombstones_enumerable(project_dir: str | os.PathLike) -> bool:
|
|
67
|
+
"""`.tombstones/` 是否**安全且可完整列舉**——供**不 gate on coverage** 的消費者(transfer)在信任
|
|
68
|
+
「`read_tombstones` 回傳的集合是完整的」之前檢查。False 有兩因,皆須 fail-closed(否則漏刪除標記 → 復活已刪,A3):
|
|
69
|
+
① `_tombstones_ok` False(`.tombstones` 是 symlink/逃逸)→ read_tombstones 回 {},但那是**拒讀界外**、非「真的沒有」;
|
|
70
|
+
② 不可列舉(POSIX read-denied)→ read_tombstones 的 glob **fail-open** 漏標記。
|
|
71
|
+
主 sync/resolve 由 `read_coverage`(內含本檢查)→ `is_initialized` 擋;transfer 無 coverage gate,直接用本函式(e2e gate12)。"""
|
|
72
|
+
return _tombstones_ok(project_dir) and dir_scannable(tombstones_dir(project_dir))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _atomic_write_json(path: Path, obj: dict) -> None:
|
|
76
|
+
# `.tombstones` 逃逸專案夾(symlink/junction 指界外)→ **拒寫界外**(e2e gate3 #3);path.parent=.tombstones、
|
|
77
|
+
# path.parent.parent=專案夾。走 atomicio(fsync + 讀回驗),確保不寫出半截/未落地的 tombstone/coverage(codex r11-3)。
|
|
78
|
+
tdir = Path(path).parent
|
|
79
|
+
if not safe_project_dir(tdir.parent, tdir):
|
|
80
|
+
raise UnsafeTombstonesDir(f".tombstones 為 symlink 或逃逸專案夾,拒絕寫入:{tdir}")
|
|
81
|
+
atomicio.atomic_write_text(path, json.dumps(obj, ensure_ascii=False, indent=2))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── coverage ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class Coverage:
|
|
88
|
+
initialized: bool
|
|
89
|
+
epoch: int
|
|
90
|
+
bootstrap_time: str | None
|
|
91
|
+
machine: str | None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_coverage(project_dir: str | os.PathLike) -> Coverage | None:
|
|
95
|
+
# `.tombstones/` symlink/逃逸(e2e gate3 #3,不讀界外假 coverage)或**不可列舉**(e2e gate11 finding1,glob
|
|
96
|
+
# fail-open 漏刪除標記 → 復活)→ 回 None(→ is_initialized False → 專案 blocked,覆蓋 build_plan/apply/resolve
|
|
97
|
+
# 等所有 coverage-gated 路徑)。transfer 不 gate on coverage,另在 _apply_one 直接用 `tombstones_enumerable`。
|
|
98
|
+
if not tombstones_enumerable(project_dir):
|
|
99
|
+
return None
|
|
100
|
+
p = tombstones_dir(project_dir) / COVERAGE_FILE
|
|
101
|
+
if not p.exists() or p.is_symlink(): # leaf:_coverage.json 為 symlink → 不信界外 coverage(e2e gate4 #1)
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
105
|
+
except Exception: # noqa: BLE001
|
|
106
|
+
return None
|
|
107
|
+
# 嚴格驗型別、fail-closed:壞/竄改的 coverage 不可被當成「已 bootstrap」而放行單邊複製(codex r13-2,
|
|
108
|
+
# 同 config force_unsafe_lock="false" 的陷阱——bool("false")=True)。任何不符 → 回 None(視為未初始化)。
|
|
109
|
+
if not isinstance(d, dict):
|
|
110
|
+
return None
|
|
111
|
+
init, epoch = d.get("initialized"), d.get("epoch")
|
|
112
|
+
if not isinstance(init, bool):
|
|
113
|
+
return None
|
|
114
|
+
if isinstance(epoch, bool) or not isinstance(epoch, int) or epoch < 0:
|
|
115
|
+
return None
|
|
116
|
+
bt, mc = d.get("bootstrap_time"), d.get("machine")
|
|
117
|
+
if not (bt is None or isinstance(bt, str)) or not (mc is None or isinstance(mc, str)):
|
|
118
|
+
return None
|
|
119
|
+
return Coverage(initialized=init, epoch=epoch, bootstrap_time=bt, machine=mc)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_initialized(project_dir: str | os.PathLike) -> bool:
|
|
123
|
+
cov = read_coverage(project_dir)
|
|
124
|
+
return bool(cov and cov.initialized)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_coverage(project_dir: str | os.PathLike, epoch: int = 1,
|
|
128
|
+
machine: str | None = None, when: str | None = None) -> None:
|
|
129
|
+
_atomic_write_json(
|
|
130
|
+
tombstones_dir(project_dir) / COVERAGE_FILE,
|
|
131
|
+
{
|
|
132
|
+
"schema_version": SCHEMA_VERSION,
|
|
133
|
+
"initialized": True,
|
|
134
|
+
"epoch": epoch,
|
|
135
|
+
"bootstrap_time": when or now_iso(),
|
|
136
|
+
"machine": machine or local_machine_id(),
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── tombstones ───────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
@dataclass
|
|
144
|
+
class Tombstone:
|
|
145
|
+
kind: str # "session" | "memory"
|
|
146
|
+
target: str # sessionId 或 memory 檔名
|
|
147
|
+
base_hash: str | None
|
|
148
|
+
machine: str | None
|
|
149
|
+
time: str | None
|
|
150
|
+
identity: str | None = None # **memory 專屬**:刪除時的 frontmatter `name`(跨檔身分,A14/§7.2.3)。
|
|
151
|
+
# 讓「已刪事實換檔名復活」可被偵測(present 檔的 frontmatter name 命中此值、即使檔名不同 → 不復活,
|
|
152
|
+
# P1d Block 2b duty b)。session 恆 None(無 frontmatter 身分)。schema 末端 + default → 向後相容。
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _mem_file(name: str) -> str:
|
|
156
|
+
safe = name.replace("/", "_").replace("\\", "_")
|
|
157
|
+
return f"memory-{safe}.deleted.json"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_tombstone_safe_name(name: str) -> bool:
|
|
161
|
+
"""memory 檔名能否與其 tombstone 檔名**無損 round-trip**。`_mem_file` 對斜線/反斜線有損 sanitize(兩者在
|
|
162
|
+
不同 OS 皆可能是路徑分隔)→ 含這些字元的名稱無法由 tombstone 檔名還原身分:read 端 `target == ftarget`
|
|
163
|
+
會把合法刪除標記判成「corrupt 於錯誤身分」,而真實檔仍無 tombstone → 可能復活(codex P1d gate)。
|
|
164
|
+
真實 memory 檔名為 slug(無分隔字元);含者由上層 blocked(不複製、不寫 tombstone),徹底解=可逆檔名
|
|
165
|
+
編碼(留後續)。判據刻意綁定 `_mem_file` 的 sanitize 字元集,避免兩處漂移。"""
|
|
166
|
+
return "/" not in name and "\\" not in name
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _sess_file(sid: str) -> str:
|
|
170
|
+
return f"{sid}.deleted.json"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _filename_identity(base: str) -> tuple[str, str] | None:
|
|
174
|
+
"""由 tombstone 檔名(去 .deleted.json 後的 base)推身分。`<sid>`→session;`memory-<safe>`→memory。"""
|
|
175
|
+
if base.startswith("memory-"):
|
|
176
|
+
return ("memory", base[len("memory-"):])
|
|
177
|
+
return ("session", base) if base else None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _valid_tombstone(path: Path) -> Tombstone | None:
|
|
181
|
+
"""嚴格解析一個 tombstone 檔。回有效 Tombstone 或 None(壞/語意不符)。
|
|
182
|
+
|
|
183
|
+
**檔名命名空間為準**(檔名=身分,spike-3):`<sid>.deleted.json`=session、`memory-*`=memory。
|
|
184
|
+
內容 `kind` 必須**符合檔名命名空間**(否則 `secret.deleted.json` 內容寫 `{"kind":"memory"}` 會繞過
|
|
185
|
+
session 比對、讓 secret 復活,codex r13 fail-closed)。**且內容 `target` 必須精確 == 檔名身分 `ftarget`**
|
|
186
|
+
(session=檔名 sid、memory=`memory-` 與 `.deleted.json` 之間那段)——session 與 memory 一律如此(統一規則)。
|
|
187
|
+
否則 `memory-secret.md.deleted.json` 寫 `{"target":"other.md"}` 會被當 `other.md` 的有效 tombstone,而
|
|
188
|
+
`secret.md` 既無有效 tombstone 也不進 corrupt → 單邊 `secret.md` 復活(P1d Block 2 起 memory tombstone 進
|
|
189
|
+
classify,須與 session 對稱 fail-closed,codex P1d-r1)。
|
|
190
|
+
用 `== ftarget`(非 `_mem_file(target) == 檔名`)才**完整**:後者會放行 sanitize 撞名的非扁平 target(如
|
|
191
|
+
`target="a/b.md"` 映射回 `memory-a_b.md...` → 卻記成 `("memory","a/b.md")`,真扁平檔 `a_b.md` 仍無 tombstone,
|
|
192
|
+
codex P1d-r2)。真實 memory 檔名恆扁平(檔名不含路徑分隔),故 `target == ftarget` 不會誤殺合法 tombstone;
|
|
193
|
+
含斜線或反斜線的 target 一律落 corrupt。可逆檔名編碼留後續。
|
|
194
|
+
"""
|
|
195
|
+
if Path(path).is_symlink():
|
|
196
|
+
return None # leaf:symlink tombstone → 不跟隨讀界外(→ 落 corrupt_tombstone_targets/blocked,fail-closed,e2e gate4 #1)
|
|
197
|
+
fid = _filename_identity(path.name[: -len(_TOMB_SUFFIX)])
|
|
198
|
+
if fid is None:
|
|
199
|
+
return None
|
|
200
|
+
fkind, ftarget = fid
|
|
201
|
+
try:
|
|
202
|
+
o = json.loads(path.read_text(encoding="utf-8"))
|
|
203
|
+
except Exception: # noqa: BLE001
|
|
204
|
+
return None
|
|
205
|
+
if not isinstance(o, dict):
|
|
206
|
+
return None
|
|
207
|
+
kind, target = o.get("kind"), o.get("target")
|
|
208
|
+
if kind != fkind or not isinstance(target, str) or not target:
|
|
209
|
+
return None # 內容 kind 必須符合檔名命名空間;target 須非空字串
|
|
210
|
+
if target != ftarget:
|
|
211
|
+
return None # 內容 target 與檔名身分不符(竄改/半寫/sanitize 撞名)→ 損壞、fail-closed(session r13 + memory P1d-r2)
|
|
212
|
+
# base_hash/machine/time 須 **None 或字串**(fail-closed,codex gate7 F2):下游(memory-merge `_short`/`_disp`、
|
|
213
|
+
# session suppress 比對)皆假設字串;`"base_hash": 123` 等型別錯的 tombstone 會讓 `_short` 切片整數而崩 dry-run/
|
|
214
|
+
# 提示詞。型別不符 → 回 None(落 corrupt_tombstone_targets → 上層 blocked-tombstone-corrupt,不放行給 merge)。
|
|
215
|
+
for v in (o.get("base_hash"), o.get("machine"), o.get("time")):
|
|
216
|
+
if not (v is None or isinstance(v, str)):
|
|
217
|
+
return None
|
|
218
|
+
# identity(**memory 專屬**跨檔身分,A14):僅 memory kind 接受非空字串、否則 None(與 MemoryDoc.name 對稱——
|
|
219
|
+
# 空/缺/型別錯一律當「無身分」,不 fail-closed〔tombstone 仍是合法的檔名鍵刪除標記、只是不參與 identity 配對〕;
|
|
220
|
+
# session kind 一律 None〔無 frontmatter 身分,codex P1d gate2〕)。**slug 形驗證在 memory.py 消費端**(非 slug
|
|
221
|
+
# → 當不可判),此處只做型別/命名空間把關。Block 3 寫 memory tombstone 時恆填刪除檔的 name slug。
|
|
222
|
+
ident = o.get("identity")
|
|
223
|
+
identity = (ident if isinstance(ident, str) and ident.strip() else None) if fkind == "memory" else None
|
|
224
|
+
return Tombstone(kind=fkind, target=target, base_hash=o.get("base_hash"),
|
|
225
|
+
machine=o.get("machine"), time=o.get("time"), identity=identity)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def read_tombstones(project_dir: str | os.PathLike) -> dict[tuple[str, str], Tombstone]:
|
|
229
|
+
"""回 {(kind, target): Tombstone}。只收**嚴格有效**者;壞/語意不符者個別略過(由 corrupt_… 阻擋)。"""
|
|
230
|
+
if not _tombstones_ok(project_dir):
|
|
231
|
+
return {} # .tombstones 逃逸 → 不讀界外(專案已由 coverage gate blocked;e2e gate3 #3)
|
|
232
|
+
d = tombstones_dir(project_dir)
|
|
233
|
+
out: dict[tuple[str, str], Tombstone] = {}
|
|
234
|
+
if not d.exists():
|
|
235
|
+
return out
|
|
236
|
+
for p in sorted(d.glob("*" + _TOMB_SUFFIX)):
|
|
237
|
+
t = _valid_tombstone(p)
|
|
238
|
+
if t is not None:
|
|
239
|
+
out[(t.kind, t.target)] = t
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def corrupt_tombstone_targets(project_dir: str | os.PathLike) -> set[tuple[str, str]]:
|
|
244
|
+
"""回「存在但非嚴格有效」的 tombstone 之**檔名推定身分**(內容壞/型別錯/session 身分不符)。
|
|
245
|
+
|
|
246
|
+
供上層把「壞掉的刪除標記」當**阻擋**而非「沒有標記」——否則半截/竄改/檔名內容不符的
|
|
247
|
+
`<sid>.deleted.json` 會被當作「無 tombstone」而讓單邊檔復活(codex r11-3 / r12,fail-closed)。
|
|
248
|
+
"""
|
|
249
|
+
if not _tombstones_ok(project_dir):
|
|
250
|
+
return set() # .tombstones 逃逸 → 不讀界外(e2e gate3 #3)
|
|
251
|
+
d = tombstones_dir(project_dir)
|
|
252
|
+
out: set[tuple[str, str]] = set()
|
|
253
|
+
if not d.exists():
|
|
254
|
+
return out
|
|
255
|
+
for p in sorted(d.glob("*" + _TOMB_SUFFIX)):
|
|
256
|
+
if _valid_tombstone(p) is not None:
|
|
257
|
+
continue
|
|
258
|
+
fid = _filename_identity(p.name[: -len(_TOMB_SUFFIX)])
|
|
259
|
+
if fid is not None:
|
|
260
|
+
out.add(fid)
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def session_tombstone_path(project_dir, sid: str) -> Path:
|
|
265
|
+
"""該 session tombstone 的完整路徑(命名與 write_session_tombstone 一致)。供回報/定位用。"""
|
|
266
|
+
return tombstones_dir(project_dir) / _sess_file(sid)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def memory_tombstone_path(project_dir, name: str) -> Path:
|
|
270
|
+
"""該 memory tombstone 的完整路徑(命名與 write_memory_tombstone 一致)。供回報/定位用。"""
|
|
271
|
+
return tombstones_dir(project_dir) / _mem_file(name)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def find_session_tombstone(project_dir, sid: str) -> Tombstone | None:
|
|
275
|
+
return read_tombstones(project_dir).get(("session", sid))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def find_memory_tombstone(project_dir, name: str) -> Tombstone | None:
|
|
279
|
+
return read_tombstones(project_dir).get(("memory", name))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def write_session_tombstone(project_dir, sid: str, base_hash: str | None,
|
|
283
|
+
machine: str | None = None, when: str | None = None) -> None:
|
|
284
|
+
_atomic_write_json(
|
|
285
|
+
tombstones_dir(project_dir) / _sess_file(sid),
|
|
286
|
+
{
|
|
287
|
+
"schema_version": SCHEMA_VERSION, "kind": "session", "target": sid,
|
|
288
|
+
"base_hash": base_hash, "machine": machine or local_machine_id(),
|
|
289
|
+
"time": when or now_iso(),
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def write_memory_tombstone(project_dir, name: str, base_hash: str | None,
|
|
295
|
+
machine: str | None = None, when: str | None = None,
|
|
296
|
+
identity: str | None = None) -> None:
|
|
297
|
+
"""寫 memory tombstone。`identity` = 刪除檔的 frontmatter `name`(跨檔身分,A14/§7.2.3)——供偵測
|
|
298
|
+
「換檔名復活」(present 檔 name 命中此值即使檔名不同 → 不復活)。Block 3 由刪除 doc 的 `.name` 帶入;
|
|
299
|
+
無 name 的 memory(fm 壞/無 name)→ identity=None,僅靠檔名鍵 tombstone 追蹤(退回 Block 2 行為)。"""
|
|
300
|
+
_atomic_write_json(
|
|
301
|
+
tombstones_dir(project_dir) / _mem_file(name),
|
|
302
|
+
{
|
|
303
|
+
"schema_version": SCHEMA_VERSION, "kind": "memory", "target": name,
|
|
304
|
+
"base_hash": base_hash, "machine": machine or local_machine_id(),
|
|
305
|
+
"time": when or now_iso(), "identity": identity,
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# A15 ack 帳本檔名(放 `.tombstones/` 內、但**呈現層**、非決策相關)→ 必須排除於決策 digest 之外(見下)。
|
|
311
|
+
# 字面常量(非 import acks,避免 acks→tombstone 反向循環);`test_tombstone` 有漂移守衛測試釘住此名。
|
|
312
|
+
_ACKS_FILE = "acks.json"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def tombstone_dir_digest(project_dir: str | os.PathLike) -> str:
|
|
316
|
+
"""整個 .tombstones/ 的 canonical digest(含 `_coverage.json`、排除 temp/lock/acks.json)。
|
|
317
|
+
供決策快照偵測 tombstone/epoch 在交易中被改(codex r3/C4)。"""
|
|
318
|
+
d = tombstones_dir(project_dir)
|
|
319
|
+
items: list[list[str]] = []
|
|
320
|
+
if _tombstones_ok(project_dir) and d.exists(): # .tombstones 逃逸 → 不 iterdir 界外、視為空 digest(e2e gate3 #3)
|
|
321
|
+
for p in sorted(d.iterdir()):
|
|
322
|
+
if p.is_symlink() or not p.is_file() or p.name.endswith((".tmp", ".lock")):
|
|
323
|
+
continue # symlink leaf → 不 hash 界外目標(e2e gate4 #1;已由 _valid_tombstone 落 corrupt)
|
|
324
|
+
if p.name == _ACKS_FILE:
|
|
325
|
+
continue # A15 ack 帳本=**純呈現層**,不得進決策快照(否則並發 `doctor --ack` 令 apply 對無關
|
|
326
|
+
# session 誤判 skipped-changed=ack 改了 apply 行為,違反呈現層不變量,fresh gate g1 Medium)
|
|
327
|
+
items.append([p.name, hashlib.sha256(p.read_bytes()).hexdigest()])
|
|
328
|
+
return hashlib.sha256(
|
|
329
|
+
json.dumps(items, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
330
|
+
).hexdigest()
|