filekeeper-cli 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.
@@ -0,0 +1,226 @@
1
+ """SQLite connection setup and forward-only migrations.
2
+
3
+ There are two migration chains because there are two stores:
4
+
5
+ * the **registry** database (``<home>/metadata.sqlite3``) holds ``workspaces``
6
+ and ``settings`` — data that spans workspaces and cannot live inside one;
7
+ * the **workspace** database (``<workspace>/.filekeeper/state.sqlite3``, or the
8
+ shared location in global mode) holds ``operations``, ``operation_items``,
9
+ ``blobs`` and ``locks`` — per-workspace state that travels with the tree.
10
+
11
+ Both chains are ordered, numbered, and applied at most once. A database whose
12
+ recorded version is ahead of this code is refused rather than guessed at.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sqlite3
19
+ import stat
20
+ import uuid
21
+ from collections.abc import Sequence
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path
24
+
25
+ from .errors import MigrationError, StorageError
26
+
27
+ REGISTRY_SCHEMA_VERSION = 1
28
+ WORKSPACE_SCHEMA_VERSION = 1
29
+
30
+ _REGISTRY_MIGRATIONS: tuple[tuple[int, tuple[str, ...]], ...] = (
31
+ (
32
+ 1,
33
+ (
34
+ """
35
+ CREATE TABLE workspaces (
36
+ id TEXT PRIMARY KEY,
37
+ name TEXT NOT NULL UNIQUE,
38
+ root_path TEXT NOT NULL,
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL
41
+ )
42
+ """,
43
+ "CREATE UNIQUE INDEX idx_workspaces_root_path ON workspaces(root_path)",
44
+ """
45
+ CREATE TABLE settings (
46
+ key TEXT PRIMARY KEY,
47
+ value TEXT NOT NULL
48
+ )
49
+ """,
50
+ ),
51
+ ),
52
+ )
53
+
54
+ _WORKSPACE_MIGRATIONS: tuple[tuple[int, tuple[str, ...]], ...] = (
55
+ (
56
+ 1,
57
+ (
58
+ """
59
+ CREATE TABLE operations (
60
+ id TEXT PRIMARY KEY,
61
+ workspace_id TEXT NOT NULL,
62
+ operation_type TEXT NOT NULL,
63
+ target_path TEXT,
64
+ status TEXT NOT NULL CHECK (
65
+ status IN ('pending', 'committed', 'undone', 'failed', 'partially_undone')
66
+ ),
67
+ created_at TEXT NOT NULL,
68
+ actor TEXT,
69
+ task_id TEXT,
70
+ summary TEXT,
71
+ before_hash TEXT,
72
+ after_hash TEXT,
73
+ metadata_json TEXT
74
+ )
75
+ """,
76
+ "CREATE INDEX idx_operations_workspace_time ON operations(workspace_id, created_at)",
77
+ "CREATE INDEX idx_operations_target_path ON operations(target_path)",
78
+ "CREATE INDEX idx_operations_task_id ON operations(task_id)",
79
+ """
80
+ CREATE TABLE operation_items (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ operation_id TEXT NOT NULL REFERENCES operations(id) ON DELETE CASCADE,
83
+ relative_path TEXT NOT NULL,
84
+ item_type TEXT NOT NULL,
85
+ before_blob_hash TEXT,
86
+ after_blob_hash TEXT,
87
+ before_exists INTEGER NOT NULL DEFAULT 0,
88
+ after_exists INTEGER NOT NULL DEFAULT 0,
89
+ before_mode INTEGER,
90
+ after_mode INTEGER
91
+ )
92
+ """,
93
+ "CREATE INDEX idx_operation_items_operation ON operation_items(operation_id)",
94
+ "CREATE INDEX idx_operation_items_path ON operation_items(relative_path)",
95
+ """
96
+ CREATE TABLE blobs (
97
+ hash TEXT PRIMARY KEY,
98
+ size INTEGER NOT NULL,
99
+ created_at TEXT NOT NULL
100
+ )
101
+ """,
102
+ """
103
+ CREATE TABLE locks (
104
+ workspace_id TEXT NOT NULL,
105
+ relative_path TEXT NOT NULL,
106
+ owner TEXT NOT NULL,
107
+ token TEXT NOT NULL,
108
+ expires_at TEXT NOT NULL,
109
+ created_at TEXT NOT NULL,
110
+ PRIMARY KEY (workspace_id, relative_path)
111
+ )
112
+ """,
113
+ "CREATE INDEX idx_locks_expires_at ON locks(expires_at)",
114
+ ),
115
+ ),
116
+ )
117
+
118
+ _MIGRATION_TABLE_SQL = """
119
+ CREATE TABLE IF NOT EXISTS schema_migrations (
120
+ version INTEGER PRIMARY KEY,
121
+ applied_at TEXT NOT NULL
122
+ )
123
+ """
124
+
125
+
126
+ def utc_now() -> str:
127
+ """Current UTC time as an ISO-8601 string with second precision."""
128
+
129
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
130
+
131
+
132
+ def uuid_token() -> str:
133
+ """A short, URL-safe, collision-resistant identifier fragment."""
134
+
135
+ return uuid.uuid4().hex[:16]
136
+
137
+
138
+ def connect(path: Path) -> sqlite3.Connection:
139
+ """Open a SQLite database with durable, foreign-key-enforcing settings."""
140
+
141
+ path.parent.mkdir(parents=True, exist_ok=True)
142
+ try:
143
+ os.chmod(path.parent, stat.S_IRWXU)
144
+ except OSError: # pragma: no cover - restricted filesystems
145
+ pass
146
+ try:
147
+ connection = sqlite3.connect(path, timeout=10.0)
148
+ except sqlite3.Error as exc:
149
+ raise StorageError(f"cannot open database: {exc}", details={"path": str(path)}) from exc
150
+ connection.row_factory = sqlite3.Row
151
+ connection.execute("PRAGMA journal_mode = WAL")
152
+ # Full synchronous mode: a committed record survives an OS crash, which the
153
+ # recovery story depends on.
154
+ connection.execute("PRAGMA synchronous = FULL")
155
+ connection.execute("PRAGMA foreign_keys = ON")
156
+ connection.execute("PRAGMA busy_timeout = 5000")
157
+ return connection
158
+
159
+
160
+ def _current_version(connection: sqlite3.Connection) -> int:
161
+ connection.execute(_MIGRATION_TABLE_SQL)
162
+ row = connection.execute("SELECT MAX(version) AS version FROM schema_migrations").fetchone()
163
+ if row is None or row["version"] is None:
164
+ return 0
165
+ return int(row["version"])
166
+
167
+
168
+ def migrate(
169
+ connection: sqlite3.Connection,
170
+ migrations: Sequence[tuple[int, tuple[str, ...]]],
171
+ *,
172
+ label: str,
173
+ ) -> int:
174
+ """Apply every pending migration in order; return the resulting version."""
175
+
176
+ current = _current_version(connection)
177
+ target = max((version for version, _ in migrations), default=0)
178
+ if current > target:
179
+ raise MigrationError(
180
+ f"{label} database schema version {current} is newer than this release "
181
+ f"supports ({target}); upgrade Filekeeper",
182
+ details={"current": current, "supported": target},
183
+ )
184
+
185
+ for version, statements in migrations:
186
+ if version <= current:
187
+ continue
188
+ try:
189
+ with connection:
190
+ for statement in statements:
191
+ connection.execute(statement)
192
+ connection.execute(
193
+ "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
194
+ (version, utc_now()),
195
+ )
196
+ except sqlite3.Error as exc:
197
+ raise MigrationError(
198
+ f"cannot migrate {label} database to version {version}: {exc}",
199
+ details={"label": label, "version": version},
200
+ ) from exc
201
+ current = version
202
+ return current
203
+
204
+
205
+ def migrate_registry(connection: sqlite3.Connection) -> int:
206
+ return migrate(connection, _REGISTRY_MIGRATIONS, label="registry")
207
+
208
+
209
+ def migrate_workspace(connection: sqlite3.Connection) -> int:
210
+ return migrate(connection, _WORKSPACE_MIGRATIONS, label="workspace")
211
+
212
+
213
+ def open_registry_database(path: Path) -> sqlite3.Connection:
214
+ connection = connect(path)
215
+ migrate_registry(connection)
216
+ return connection
217
+
218
+
219
+ def open_workspace_database(path: Path) -> sqlite3.Connection:
220
+ connection = connect(path)
221
+ migrate_workspace(connection)
222
+ try:
223
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
224
+ except OSError: # pragma: no cover - restricted filesystems
225
+ pass
226
+ return connection
@@ -0,0 +1,127 @@
1
+ """Unified diff rendering that never concatenates two lines.
2
+
3
+ ``difflib.unified_diff`` renders each input line exactly as it received it, so a
4
+ final line without a newline carries no terminator and runs straight into the
5
+ next rendered line. That produces output no patch consumer can read.
6
+
7
+ This module normalises every rendered content line to end with a newline and
8
+ states a missing final newline explicitly with the conventional
9
+ ``\`` marker. Every line of the result, including the
10
+ last, ends with ``\\n``.
11
+
12
+ The same function feeds the human view, the JSON payload and the dry-run
13
+ preview, so all three describe a change identically.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import difflib
19
+ from dataclasses import dataclass
20
+
21
+ NO_NEWLINE_MARKER = "\"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class DiffLine:
26
+ """One line of text, split from its terminator.
27
+
28
+ ``text`` keeps every character except the terminator, including unusual
29
+ separators such as a form feed, so joining ``raw`` values reproduces the
30
+ input exactly.
31
+ """
32
+
33
+ text: str
34
+ ending: str
35
+
36
+ @property
37
+ def raw(self) -> str:
38
+ return self.text + self.ending
39
+
40
+
41
+ def split_lines(text: str) -> list[DiffLine]:
42
+ """Split text into lines without discarding each line's own terminator."""
43
+
44
+ lines: list[DiffLine] = []
45
+ for piece in text.splitlines(keepends=True):
46
+ if piece.endswith("\r\n"):
47
+ lines.append(DiffLine(piece[:-2], "\r\n"))
48
+ elif piece.endswith(("\n", "\r")):
49
+ lines.append(DiffLine(piece[:-1], piece[-1]))
50
+ else:
51
+ lines.append(DiffLine(piece, ""))
52
+ return lines
53
+
54
+
55
+ def join_lines(lines: list[DiffLine]) -> str:
56
+ return "".join(line.raw for line in lines)
57
+
58
+
59
+ def _unterminated_last_line(text: str) -> bool:
60
+ if not text:
61
+ return False
62
+ return not text.endswith(("\n", "\r"))
63
+
64
+
65
+ def unified_diff_text(
66
+ before: str,
67
+ after: str,
68
+ *,
69
+ fromfile: str,
70
+ tofile: str,
71
+ ) -> str:
72
+ """Render a unified diff in which every line is newline-terminated.
73
+
74
+ A side that does not end with a newline gets an explicit marker after the
75
+ rendered line that represents its final line. The marker is emitted once
76
+ when both sides share the same final context line.
77
+ """
78
+
79
+ before_lines = split_lines(before)
80
+ after_lines = split_lines(after)
81
+ before_texts = [line.text for line in before_lines]
82
+ after_texts = [line.text for line in after_lines]
83
+
84
+ rendered = list(
85
+ difflib.unified_diff(
86
+ [text + "\n" for text in before_texts],
87
+ [text + "\n" for text in after_texts],
88
+ fromfile=fromfile,
89
+ tofile=tofile,
90
+ lineterm="\n",
91
+ )
92
+ )
93
+ if not rendered:
94
+ return ""
95
+
96
+ inserts: dict[int, bool] = {}
97
+
98
+ if before_lines and not before_lines[-1].ending:
99
+ target = before_texts[-1] + "\n"
100
+ for index in range(len(rendered) - 1, 1, -1):
101
+ if rendered[index][:1] in {"-", " "} and rendered[index][1:] == target:
102
+ inserts[index] = True
103
+ break
104
+
105
+ if after_lines and not after_lines[-1].ending:
106
+ target = after_texts[-1] + "\n"
107
+ for index in range(len(rendered) - 1, 1, -1):
108
+ if rendered[index][:1] in {"+", " "} and rendered[index][1:] == target:
109
+ inserts[index] = True
110
+ break
111
+
112
+ output: list[str] = []
113
+ marker = NO_NEWLINE_MARKER + "\n"
114
+ for index, line in enumerate(rendered):
115
+ output.append(line)
116
+ if index in inserts:
117
+ output.append(marker)
118
+ return "".join(output)
119
+
120
+
121
+ __all__ = [
122
+ "NO_NEWLINE_MARKER",
123
+ "DiffLine",
124
+ "join_lines",
125
+ "split_lines",
126
+ "unified_diff_text",
127
+ ]
@@ -0,0 +1,278 @@
1
+ """Structured errors with stable, documented codes.
2
+
3
+ Every failure surfaced by Core is a :class:`FilekeeperError`. The code is the
4
+ stable part of the contract: adapters (CLI, MCP) map codes to exit codes and to
5
+ error payloads, and callers may branch on the code without parsing prose.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+ from typing import Any
12
+
13
+
14
+ class ErrorCode(StrEnum):
15
+ """Stable error codes. Adding a code is a compatible change; renaming one is not."""
16
+
17
+ INVALID_ARGUMENT = "INVALID_ARGUMENT"
18
+ PATH_NOT_FOUND = "PATH_NOT_FOUND"
19
+ NOT_A_FILE = "NOT_A_FILE"
20
+ NOT_A_DIRECTORY = "NOT_A_DIRECTORY"
21
+ PATH_OUTSIDE_WORKSPACE = "PATH_OUTSIDE_WORKSPACE"
22
+ ABSOLUTE_PATH_OUTSIDE_WORKSPACE = "ABSOLUTE_PATH_OUTSIDE_WORKSPACE"
23
+ SYMLINK_ESCAPE_REFUSED = "SYMLINK_ESCAPE_REFUSED"
24
+ PATH_IS_STATE_PATH = "PATH_IS_STATE_PATH"
25
+ SYMLINK_WRITE_REFUSED = "SYMLINK_WRITE_REFUSED"
26
+ SPECIAL_FILE_REFUSED = "SPECIAL_FILE_REFUSED"
27
+ SENSITIVE_PATH_REFUSED = "SENSITIVE_PATH_REFUSED"
28
+ ALREADY_EXISTS = "ALREADY_EXISTS"
29
+ CONTENT_CONFLICT = "CONTENT_CONFLICT"
30
+ NO_MATCH = "NO_MATCH"
31
+ MATCH_COUNT_MISMATCH = "MATCH_COUNT_MISMATCH"
32
+ PATCH_FAILED = "PATCH_FAILED"
33
+ PARTIAL_RECOVERY = "PARTIAL_RECOVERY"
34
+ BINARY_CONTENT_REFUSED = "BINARY_CONTENT_REFUSED"
35
+ LIMIT_EXCEEDED = "LIMIT_EXCEEDED"
36
+ INVALID_PATTERN = "INVALID_PATTERN"
37
+ WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND"
38
+ WORKSPACE_EXISTS = "WORKSPACE_EXISTS"
39
+ NO_WORKSPACE_SELECTED = "NO_WORKSPACE_SELECTED"
40
+ STORAGE_ERROR = "STORAGE_ERROR"
41
+ MIGRATION_ERROR = "MIGRATION_ERROR"
42
+ OPERATION_NOT_FOUND = "OPERATION_NOT_FOUND"
43
+ UNDO_CONFLICT = "UNDO_CONFLICT"
44
+ HISTORY_UNAVAILABLE = "HISTORY_UNAVAILABLE"
45
+ NOT_IMPLEMENTED_YET = "NOT_IMPLEMENTED_YET"
46
+ INTERNAL_ERROR = "INTERNAL_ERROR"
47
+
48
+
49
+ class FilekeeperError(Exception):
50
+ """Base error carrying a stable code, a human message, and optional details."""
51
+
52
+ code: ErrorCode = ErrorCode.INTERNAL_ERROR
53
+ retryable: bool = False
54
+
55
+ def __init__(
56
+ self,
57
+ message: str,
58
+ *,
59
+ code: ErrorCode | None = None,
60
+ retryable: bool | None = None,
61
+ details: dict[str, Any] | None = None,
62
+ ) -> None:
63
+ super().__init__(message)
64
+ self.message = message
65
+ if code is not None:
66
+ self.code = code
67
+ if retryable is not None:
68
+ self.retryable = retryable
69
+ self.details: dict[str, Any] = details or {}
70
+
71
+ def to_dict(self) -> dict[str, Any]:
72
+ payload: dict[str, Any] = {
73
+ "code": str(self.code),
74
+ "message": self.message,
75
+ "retryable": self.retryable,
76
+ }
77
+ if self.details:
78
+ payload["details"] = dict(self.details)
79
+ return payload
80
+
81
+ def __str__(self) -> str:
82
+ return f"{self.code}: {self.message}"
83
+
84
+
85
+ class ValidationError(FilekeeperError):
86
+ code = ErrorCode.INVALID_ARGUMENT
87
+
88
+
89
+ class PathNotFoundError(FilekeeperError):
90
+ code = ErrorCode.PATH_NOT_FOUND
91
+
92
+
93
+ class NotAFileError(FilekeeperError):
94
+ code = ErrorCode.NOT_A_FILE
95
+
96
+
97
+ class NotADirectoryError(FilekeeperError):
98
+ code = ErrorCode.NOT_A_DIRECTORY
99
+
100
+
101
+ class PathOutsideWorkspaceError(FilekeeperError):
102
+ """A relative path used ``..`` traversal."""
103
+
104
+ code = ErrorCode.PATH_OUTSIDE_WORKSPACE
105
+
106
+
107
+ class AbsolutePathOutsideWorkspaceError(PathOutsideWorkspaceError):
108
+ """An absolute path resolved outside the selected workspace."""
109
+
110
+ code = ErrorCode.ABSOLUTE_PATH_OUTSIDE_WORKSPACE
111
+
112
+
113
+ class SymlinkEscapeRefusedError(PathOutsideWorkspaceError):
114
+ """A symlink inside the workspace resolved to a target outside it."""
115
+
116
+ code = ErrorCode.SYMLINK_ESCAPE_REFUSED
117
+
118
+
119
+ class StatePathError(FilekeeperError):
120
+ """A path points inside Filekeeper's own state directory."""
121
+
122
+ code = ErrorCode.PATH_IS_STATE_PATH
123
+
124
+
125
+ class SymlinkWriteRefusedError(FilekeeperError):
126
+ code = ErrorCode.SYMLINK_WRITE_REFUSED
127
+
128
+
129
+ class SpecialFileRefusedError(FilekeeperError):
130
+ code = ErrorCode.SPECIAL_FILE_REFUSED
131
+
132
+
133
+ class SensitivePathRefusedError(FilekeeperError):
134
+ """The target looks like credential or key material and was refused."""
135
+
136
+ code = ErrorCode.SENSITIVE_PATH_REFUSED
137
+
138
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
139
+ super().__init__(message, details=details)
140
+
141
+
142
+ class AlreadyExistsError(FilekeeperError):
143
+ code = ErrorCode.ALREADY_EXISTS
144
+
145
+
146
+ class ContentConflictError(FilekeeperError):
147
+ """The guarded write's expected hash or content no longer matches.
148
+
149
+ The identical request cannot succeed, so ``retryable`` is ``False``. The
150
+ recovery path is stated in the payload: read the current state again and
151
+ issue the write against the hash that read returned.
152
+ """
153
+
154
+ code = ErrorCode.CONTENT_CONFLICT
155
+ retryable = False
156
+
157
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
158
+ payload: dict[str, Any] = {
159
+ "recovery": "re-read the target, then retry against its current hash"
160
+ }
161
+ if details:
162
+ payload.update(details)
163
+ super().__init__(message, details=payload)
164
+
165
+
166
+ class NoMatchError(FilekeeperError):
167
+ """No occurrence of the requested text was found; nothing was modified."""
168
+
169
+ code = ErrorCode.NO_MATCH
170
+
171
+
172
+ class MatchCountMismatchError(FilekeeperError):
173
+ """The requested text matched a different number of times than expected."""
174
+
175
+ code = ErrorCode.MATCH_COUNT_MISMATCH
176
+
177
+
178
+ class PatchFailedError(FilekeeperError):
179
+ """A patch was rejected or could not be applied; the pre-patch state is intact."""
180
+
181
+ code = ErrorCode.PATCH_FAILED
182
+
183
+
184
+ class PartialRecoveryError(FilekeeperError):
185
+ """A failed multi-file application could not be fully rolled back."""
186
+
187
+ code = ErrorCode.PARTIAL_RECOVERY
188
+
189
+
190
+ class BinaryContentRefusedError(FilekeeperError):
191
+ code = ErrorCode.BINARY_CONTENT_REFUSED
192
+
193
+
194
+ class LimitExceededError(FilekeeperError):
195
+ code = ErrorCode.LIMIT_EXCEEDED
196
+
197
+ def __init__(
198
+ self, message: str, *, limit: int, actual: int, details: dict[str, Any] | None = None
199
+ ) -> None:
200
+ payload: dict[str, Any] = {"limit": limit, "actual": actual}
201
+ if details:
202
+ payload.update(details)
203
+ super().__init__(message, details=payload)
204
+
205
+
206
+ class InvalidPatternError(FilekeeperError):
207
+ code = ErrorCode.INVALID_PATTERN
208
+
209
+
210
+ class WorkspaceNotFoundError(FilekeeperError):
211
+ code = ErrorCode.WORKSPACE_NOT_FOUND
212
+
213
+
214
+ class WorkspaceExistsError(FilekeeperError):
215
+ code = ErrorCode.WORKSPACE_EXISTS
216
+
217
+
218
+ class NoWorkspaceSelectedError(FilekeeperError):
219
+ code = ErrorCode.NO_WORKSPACE_SELECTED
220
+
221
+
222
+ class StorageError(FilekeeperError):
223
+ code = ErrorCode.STORAGE_ERROR
224
+
225
+
226
+ class MigrationError(FilekeeperError):
227
+ code = ErrorCode.MIGRATION_ERROR
228
+
229
+
230
+ class OperationNotFoundError(FilekeeperError):
231
+ """No operation with that id is recorded in this workspace."""
232
+
233
+ code = ErrorCode.OPERATION_NOT_FOUND
234
+
235
+
236
+ class UndoConflictError(FilekeeperError):
237
+ """Undo would destroy a later change; nothing was modified."""
238
+
239
+ code = ErrorCode.UNDO_CONFLICT
240
+
241
+
242
+ class HistoryUnavailableError(FilekeeperError):
243
+ """The bytes needed to undo are missing, corrupt or were never stored."""
244
+
245
+ code = ErrorCode.HISTORY_UNAVAILABLE
246
+
247
+
248
+ class NotImplementedYetError(FilekeeperError, NotImplementedError):
249
+ """A published API surface whose implementation is deliberately a later release.
250
+
251
+ It subclasses :class:`NotImplementedError` as well, so callers that guard the
252
+ standard exception also catch it. It is never a silent no-op.
253
+ """
254
+
255
+ code = ErrorCode.NOT_IMPLEMENTED_YET
256
+
257
+
258
+ def storage_failure(action: str, path: str, exc: OSError) -> StorageError:
259
+ """Build the :class:`StorageError` for a failed filesystem call on ``path``.
260
+
261
+ ``path`` is the caller-facing path: the workspace-relative form for anything
262
+ inside a workspace. An ``OSError``'s own message embeds the resolved host
263
+ path, so only its standard reason phrase is carried into the message.
264
+
265
+ Guaranteed: a workspace file or directory is never named by its resolved
266
+ host path in a :class:`StorageError`, neither in the message nor in
267
+ ``details``. Every construction site that has a workspace path in hand
268
+ passes the relative form, and :func:`filekeeper.core.safety.hashing.hash_file`
269
+ never falls back to the resolved path.
270
+
271
+ Not covered: locations the operator chooses rather than workspace content —
272
+ the state directory and its database, and the configuration file. Those
273
+ errors report the location as the operator supplied it, and their message
274
+ still carries only the reason phrase.
275
+ """
276
+
277
+ reason = exc.strerror or type(exc).__name__
278
+ return StorageError(f"cannot {action} {path}: {reason}", details={"path": path})
@@ -0,0 +1,20 @@
1
+ """Durable operation history: blob store, journal, read surfaces, and undo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .blobs import BlobStore
6
+ from .journal import BeginResult, OperationJournal, PendingItem
7
+ from .repository import diff_operation, get_operation, list_history
8
+ from .undo import undo_last, undo_operation
9
+
10
+ __all__ = [
11
+ "BeginResult",
12
+ "BlobStore",
13
+ "OperationJournal",
14
+ "PendingItem",
15
+ "diff_operation",
16
+ "get_operation",
17
+ "list_history",
18
+ "undo_last",
19
+ "undo_operation",
20
+ ]