filekeeper-cli 0.1.4__tar.gz → 0.1.7__tar.gz
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.
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/PKG-INFO +53 -7
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/README.md +52 -6
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/pyproject.toml +1 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/pyproject.toml.orig +1 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/__init__.py +5 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/output.py +13 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/__init__.py +3 -1
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/errors.py +65 -2
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/journal.py +4 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/undo.py +95 -6
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/models.py +89 -3
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/directory.py +42 -9
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/edit.py +47 -19
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/write.py +87 -14
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/service.py +15 -3
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/LICENSE +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/app.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/cli/exit_codes.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/config.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/database.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/diffing.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/blobs.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/history/repository.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/read.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/operations/search.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/paths.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/__init__.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/atomic.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/hashing.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/sensitive.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/textdata.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/safety/vcs.py +0 -0
- {filekeeper_cli-0.1.4 → filekeeper_cli-0.1.7}/src/filekeeper/core/workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: filekeeper-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement.
|
|
5
5
|
Keywords: filesystem,atomic-write,cli,agents,mcp
|
|
6
6
|
Author: sangingeum
|
|
@@ -244,6 +244,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
244
244
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
245
245
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
246
246
|
|
|
247
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
248
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
249
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
250
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
251
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
252
|
+
text is never echoed into the error.
|
|
253
|
+
|
|
247
254
|
## Commands
|
|
248
255
|
|
|
249
256
|
```
|
|
@@ -304,10 +311,11 @@ affected path with the state the operation recorded:
|
|
|
304
311
|
|
|
305
312
|
- if the path still matches the recorded **after** state, undo restores the
|
|
306
313
|
recorded **before** bytes;
|
|
307
|
-
- if it already matches the **before** state
|
|
308
|
-
operation), undo leaves it alone;
|
|
309
|
-
- if
|
|
310
|
-
`UNDO_CONFLICT` (exit code
|
|
314
|
+
- if it already matches the **before** state of an operation that never committed
|
|
315
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
316
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
317
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
318
|
+
12) and **nothing is modified**.
|
|
311
319
|
|
|
312
320
|
Undoing a create removes the file only while it still matches what was created.
|
|
313
321
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -318,6 +326,18 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
318
326
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
319
327
|
undo is the supported way to go forward again.
|
|
320
328
|
|
|
329
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
330
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
331
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
332
|
+
|
|
333
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
334
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
335
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
336
|
+
skips it. A refusal of a refusal is the one exception: undoing that
|
|
337
|
+
`undo_refused` row is refused with the same coded error, but writes no second row,
|
|
338
|
+
because the existing row already states the conflict. Every recorded path is
|
|
339
|
+
workspace-relative, never a resolved host path.
|
|
340
|
+
|
|
321
341
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
322
342
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
323
343
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -380,6 +400,23 @@ re-reads the file and writes against its new hash.
|
|
|
380
400
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
381
401
|
the paths.
|
|
382
402
|
|
|
403
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
404
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
405
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
406
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
407
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
408
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
409
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
410
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
411
|
+
`"changed": true` with its observed `after_hash`.
|
|
412
|
+
|
|
413
|
+
`changed` and `would_change` answer different questions: `would_change` asks
|
|
414
|
+
whether the bytes would differ, while `changed` records whether an operation
|
|
415
|
+
executed and was journalled — so a real write of identical content reports
|
|
416
|
+
`"changed": true` and adds a history row, which callers may rely on as proof a
|
|
417
|
+
write completed. In a preview the two cannot disagree: when `would_change` is
|
|
418
|
+
false, every item reports `"unchanged"`.
|
|
419
|
+
|
|
383
420
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
384
421
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
385
422
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -421,6 +458,13 @@ a shell predicate.
|
|
|
421
458
|
|
|
422
459
|
Codes are stable; messages are not a contract.
|
|
423
460
|
|
|
461
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
462
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
463
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
464
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
465
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
466
|
+
three surfaces.
|
|
467
|
+
|
|
424
468
|
## Safety rules
|
|
425
469
|
|
|
426
470
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -435,8 +479,10 @@ Codes are stable; messages are not a contract.
|
|
|
435
479
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
436
480
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
437
481
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
438
|
-
`--i-accept-secret-write-risk`
|
|
439
|
-
|
|
482
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
483
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
484
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
485
|
+
interface that received the request.
|
|
440
486
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
441
487
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
442
488
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -223,6 +223,13 @@ later file fails after earlier ones were written, the written files are rolled
|
|
|
223
223
|
back to their pre-patch bytes and the failure names what was restored. A crash is
|
|
224
224
|
resolved from the pending journal record, never from an all-or-nothing commit.
|
|
225
225
|
|
|
226
|
+
When a hunk's header and its body disagree — the hunk is cut short or a line
|
|
227
|
+
prefix is not ` `, `+` or `-` — the `PATCH_FAILED` error states the counts the
|
|
228
|
+
header declares and the counts the body actually supplied, in the message and in
|
|
229
|
+
`details.expected_old_lines`, `expected_new_lines`, `actual_old_lines` and
|
|
230
|
+
`actual_new_lines`. The existing `line` and `reason` fields are still there. Patch
|
|
231
|
+
text is never echoed into the error.
|
|
232
|
+
|
|
226
233
|
## Commands
|
|
227
234
|
|
|
228
235
|
```
|
|
@@ -283,10 +290,11 @@ affected path with the state the operation recorded:
|
|
|
283
290
|
|
|
284
291
|
- if the path still matches the recorded **after** state, undo restores the
|
|
285
292
|
recorded **before** bytes;
|
|
286
|
-
- if it already matches the **before** state
|
|
287
|
-
operation), undo leaves it alone;
|
|
288
|
-
- if
|
|
289
|
-
`UNDO_CONFLICT` (exit code
|
|
293
|
+
- if it already matches the **before** state of an operation that never committed
|
|
294
|
+
(a partially applied multi-file operation), undo leaves it alone;
|
|
295
|
+
- if a committed operation's path no longer matches the **after** state — edited,
|
|
296
|
+
deleted, or renamed away — the undo is refused with `UNDO_CONFLICT` (exit code
|
|
297
|
+
12) and **nothing is modified**.
|
|
290
298
|
|
|
291
299
|
Undoing a create removes the file only while it still matches what was created.
|
|
292
300
|
Undoing a delete restores the file only while the destination is still absent.
|
|
@@ -297,6 +305,18 @@ The undo is itself recorded as an operation, so `history list` remains a
|
|
|
297
305
|
truthful ledger. **Redo is deferred** — it is not half-implemented; undoing an
|
|
298
306
|
undo is the supported way to go forward again.
|
|
299
307
|
|
|
308
|
+
A copy or a move records the path it read from in the operation's `metadata`
|
|
309
|
+
under `source_path`, and its summary names both ends. `target_path` keeps its
|
|
310
|
+
meaning — the path the operation targets — so existing readers are unaffected.
|
|
311
|
+
|
|
312
|
+
A refused undo is recorded too: the ledger gains an `undo_refused` row with the
|
|
313
|
+
target path, the conflict code, and the paths that blocked the undo. It changed
|
|
314
|
+
nothing, so it carries no items, is never offered as undoable, and `undo-last`
|
|
315
|
+
skips it. A refusal of a refusal is the one exception: undoing that
|
|
316
|
+
`undo_refused` row is refused with the same coded error, but writes no second row,
|
|
317
|
+
because the existing row already states the conflict. Every recorded path is
|
|
318
|
+
workspace-relative, never a resolved host path.
|
|
319
|
+
|
|
300
320
|
`keeper file read` writes file content to standard output unchanged (line endings
|
|
301
321
|
included). Metadata goes to stderr, or into the JSON object with `--json`.
|
|
302
322
|
`--input FILE` reads replacement content from a file, avoiding shell argument
|
|
@@ -359,6 +379,23 @@ re-reads the file and writes against its new hash.
|
|
|
359
379
|
so its previous bytes were deliberately not stored; `history_skipped` then names
|
|
360
380
|
the paths.
|
|
361
381
|
|
|
382
|
+
**A dry run reports a preview, not a write.** With `"dry_run": true`, `changed` is
|
|
383
|
+
`false`, `operation_id` and `after_hash` are `null`, and each item's `after_hash`
|
|
384
|
+
and `size_bytes` are `null`: nothing was observed, so nothing is claimed. The hash
|
|
385
|
+
a real run would produce appears as `predicted_after_hash` — a prediction, never an
|
|
386
|
+
observation — and `would_change` says whether a real run would alter anything.
|
|
387
|
+
`before_hash` and `preview_diff` are kept, because they describe the state the
|
|
388
|
+
preview was computed against. The same shape applies to every mutating command and
|
|
389
|
+
to the MCP tools that mirror them. A committed operation still reports
|
|
390
|
+
`"changed": true` with its observed `after_hash`.
|
|
391
|
+
|
|
392
|
+
`changed` and `would_change` answer different questions: `would_change` asks
|
|
393
|
+
whether the bytes would differ, while `changed` records whether an operation
|
|
394
|
+
executed and was journalled — so a real write of identical content reports
|
|
395
|
+
`"changed": true` and adds a history row, which callers may rely on as proof a
|
|
396
|
+
write completed. In a preview the two cannot disagree: when `would_change` is
|
|
397
|
+
false, every item reports `"unchanged"`.
|
|
398
|
+
|
|
362
399
|
Editing results add `match_stage` and `match_count` (text replacement),
|
|
363
400
|
`preview_diff` (dry run), and, for patches, `patch_files` with per-hunk status.
|
|
364
401
|
A failure that was rolled back reports the restored paths in the error details.
|
|
@@ -400,6 +437,13 @@ a shell predicate.
|
|
|
400
437
|
|
|
401
438
|
Codes are stable; messages are not a contract.
|
|
402
439
|
|
|
440
|
+
Messages name the interface the caller is using. A refusal that offers an
|
|
441
|
+
override states that interface's own argument: Core's keyword, the CLI's flag, or
|
|
442
|
+
the MCP tool argument. The same request therefore reads correctly whether it came
|
|
443
|
+
from a library, a shell, or an agent over MCP, and an adapter never prints another
|
|
444
|
+
surface's argument. Codes, `retryable` and `details` are identical across all
|
|
445
|
+
three surfaces.
|
|
446
|
+
|
|
403
447
|
## Safety rules
|
|
404
448
|
|
|
405
449
|
- **Confinement.** Relative paths resolve inside the workspace root. Absolute paths
|
|
@@ -414,8 +458,10 @@ Codes are stable; messages are not a contract.
|
|
|
414
458
|
`*.key`, `id_rsa` and its siblings, `*.p12`, `*.pfx`, `authorized_keys`,
|
|
415
459
|
`.netrc`, and anything under `.ssh/`, `.gnupg/` or `.aws/` fail with
|
|
416
460
|
`SENSITIVE_PATH_REFUSED` (exit code 10). Override deliberately with
|
|
417
|
-
`--i-accept-secret-write-risk`
|
|
418
|
-
|
|
461
|
+
`--i-accept-secret-write-risk` on the CLI, `allow_sensitive_write=True` in Core,
|
|
462
|
+
or `i_accept_secret_write_risk` on the MCP tool. Each name is long because the
|
|
463
|
+
mistake is expensive. The error message names only the argument belonging to the
|
|
464
|
+
interface that received the request.
|
|
419
465
|
- **Version-control-ignored targets warn.** Writing a path that git ignores still
|
|
420
466
|
succeeds, but the result carries a warning that the change will not be committed.
|
|
421
467
|
- **No shell.** Filekeeper never interpolates paths into a shell command.
|
|
@@ -12,24 +12,28 @@ work; see ``CHANGELOG.md`` and ``README.md``.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
from .core import (
|
|
15
|
+
CallerSurface,
|
|
15
16
|
Config,
|
|
16
17
|
ErrorCode,
|
|
17
18
|
Filekeeper,
|
|
18
19
|
FilekeeperError,
|
|
19
20
|
FilekeeperService,
|
|
20
21
|
MutationResult,
|
|
22
|
+
OverrideHint,
|
|
21
23
|
Workspace,
|
|
22
24
|
)
|
|
23
25
|
|
|
24
|
-
__version__ = "0.1.
|
|
26
|
+
__version__ = "0.1.7"
|
|
25
27
|
|
|
26
28
|
__all__ = [
|
|
29
|
+
"CallerSurface",
|
|
27
30
|
"Config",
|
|
28
31
|
"ErrorCode",
|
|
29
32
|
"Filekeeper",
|
|
30
33
|
"FilekeeperError",
|
|
31
34
|
"FilekeeperService",
|
|
32
35
|
"MutationResult",
|
|
36
|
+
"OverrideHint",
|
|
33
37
|
"Workspace",
|
|
34
38
|
"__version__",
|
|
35
39
|
]
|
|
@@ -14,7 +14,7 @@ from enum import Enum
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
from ..core.errors import FilekeeperError
|
|
17
|
+
from ..core.errors import CallerSurface, FilekeeperError
|
|
18
18
|
from ..core.models import (
|
|
19
19
|
ContentSearchResult,
|
|
20
20
|
DirectoryListing,
|
|
@@ -53,6 +53,9 @@ def print_json(payload: dict[str, Any]) -> None:
|
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
def print_error(error: FilekeeperError, *, as_json: bool) -> None:
|
|
56
|
+
"""Render an error for this surface: its override hint names this CLI's flag."""
|
|
57
|
+
|
|
58
|
+
error = error.for_surface(CallerSurface.CLI)
|
|
56
59
|
if as_json:
|
|
57
60
|
print_json({"ok": False, "error": error.to_dict()})
|
|
58
61
|
return
|
|
@@ -210,6 +213,15 @@ def history_show(record: OperationRecord) -> str:
|
|
|
210
213
|
f" {item.item_type:<9} {item.relative_path} exists {exists} "
|
|
211
214
|
f"before {item.before_blob_hash or '-'} after {item.after_blob_hash or '-'}"
|
|
212
215
|
)
|
|
216
|
+
if record.metadata:
|
|
217
|
+
lines.append("metadata:")
|
|
218
|
+
for key in sorted(record.metadata):
|
|
219
|
+
value = record.metadata[key]
|
|
220
|
+
if isinstance(value, list):
|
|
221
|
+
rendered = ", ".join(str(entry) for entry in value)
|
|
222
|
+
else:
|
|
223
|
+
rendered = str(value)
|
|
224
|
+
lines.append(f" {key}: {rendered}")
|
|
213
225
|
if record.history_skipped:
|
|
214
226
|
lines.append("history_skipped: " + ", ".join(record.history_skipped))
|
|
215
227
|
return "\n".join(lines)
|
|
@@ -5,7 +5,7 @@ the CLI, of the MCP server, or of any filesystem protocol.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from .config import Config
|
|
8
|
-
from .errors import ErrorCode, FilekeeperError
|
|
8
|
+
from .errors import CallerSurface, ErrorCode, FilekeeperError, OverrideHint
|
|
9
9
|
from .history.blobs import BlobStore
|
|
10
10
|
from .models import (
|
|
11
11
|
ContentMatch,
|
|
@@ -27,6 +27,7 @@ from .workspace import WorkspaceHandle, WorkspaceRegistry
|
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
"BlobStore",
|
|
30
|
+
"CallerSurface",
|
|
30
31
|
"Config",
|
|
31
32
|
"ContentMatch",
|
|
32
33
|
"ContentSearchResult",
|
|
@@ -41,6 +42,7 @@ __all__ = [
|
|
|
41
42
|
"FilekeeperService",
|
|
42
43
|
"MutationResult",
|
|
43
44
|
"OperationItemResult",
|
|
45
|
+
"OverrideHint",
|
|
44
46
|
"PathInfo",
|
|
45
47
|
"PathSearchResult",
|
|
46
48
|
"ReadManyEntry",
|
|
@@ -7,6 +7,9 @@ error payloads, and callers may branch on the code without parsing prose.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import copy
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
10
13
|
from enum import StrEnum
|
|
11
14
|
from typing import Any
|
|
12
15
|
|
|
@@ -46,6 +49,38 @@ class ErrorCode(StrEnum):
|
|
|
46
49
|
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
47
50
|
|
|
48
51
|
|
|
52
|
+
class CallerSurface(StrEnum):
|
|
53
|
+
"""The interface a caller reached Core through.
|
|
54
|
+
|
|
55
|
+
A refusal that offers an override must name the argument the caller actually
|
|
56
|
+
has. Core renders the sentence for ``CORE``; each adapter re-renders the same
|
|
57
|
+
error for its own surface before returning it, so no message ever names
|
|
58
|
+
another interface's argument.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
CORE = "core"
|
|
62
|
+
CLI = "cli"
|
|
63
|
+
MCP = "mcp"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class OverrideHint:
|
|
68
|
+
"""One "pass X to override" instruction, worded for each caller surface."""
|
|
69
|
+
|
|
70
|
+
sentences: Mapping[CallerSurface, str]
|
|
71
|
+
|
|
72
|
+
def sentence(self, surface: CallerSurface) -> str:
|
|
73
|
+
return self.sentences[surface]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def override_hint(*, core: str, cli: str, mcp: str) -> OverrideHint:
|
|
77
|
+
"""Build a hint whose sentence names each surface's own argument."""
|
|
78
|
+
|
|
79
|
+
return OverrideHint(
|
|
80
|
+
{CallerSurface.CORE: core, CallerSurface.CLI: cli, CallerSurface.MCP: mcp}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
49
84
|
class FilekeeperError(Exception):
|
|
50
85
|
"""Base error carrying a stable code, a human message, and optional details."""
|
|
51
86
|
|
|
@@ -59,6 +94,7 @@ class FilekeeperError(Exception):
|
|
|
59
94
|
code: ErrorCode | None = None,
|
|
60
95
|
retryable: bool | None = None,
|
|
61
96
|
details: dict[str, Any] | None = None,
|
|
97
|
+
override_hint: OverrideHint | None = None,
|
|
62
98
|
) -> None:
|
|
63
99
|
super().__init__(message)
|
|
64
100
|
self.message = message
|
|
@@ -67,6 +103,27 @@ class FilekeeperError(Exception):
|
|
|
67
103
|
if retryable is not None:
|
|
68
104
|
self.retryable = retryable
|
|
69
105
|
self.details: dict[str, Any] = details or {}
|
|
106
|
+
# Not serialised: the hint only lets an adapter re-word the message.
|
|
107
|
+
self.override_hint = override_hint
|
|
108
|
+
|
|
109
|
+
def for_surface(self, surface: CallerSurface) -> FilekeeperError:
|
|
110
|
+
"""Return this error worded for the interface that received it.
|
|
111
|
+
|
|
112
|
+
``code``, ``retryable`` and ``details`` are carried over untouched; only
|
|
113
|
+
the message's override sentence changes. An error with no hint, or a
|
|
114
|
+
request for the surface Core already rendered, is returned as is.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
hint = self.override_hint
|
|
118
|
+
if hint is None or surface is CallerSurface.CORE:
|
|
119
|
+
return self
|
|
120
|
+
core_sentence = hint.sentence(CallerSurface.CORE)
|
|
121
|
+
if core_sentence not in self.message:
|
|
122
|
+
return self
|
|
123
|
+
adapted = copy.copy(self)
|
|
124
|
+
adapted.message = self.message.replace(core_sentence, hint.sentence(surface))
|
|
125
|
+
adapted.args = (adapted.message,)
|
|
126
|
+
return adapted
|
|
70
127
|
|
|
71
128
|
def to_dict(self) -> dict[str, Any]:
|
|
72
129
|
payload: dict[str, Any] = {
|
|
@@ -135,8 +192,14 @@ class SensitivePathRefusedError(FilekeeperError):
|
|
|
135
192
|
|
|
136
193
|
code = ErrorCode.SENSITIVE_PATH_REFUSED
|
|
137
194
|
|
|
138
|
-
def __init__(
|
|
139
|
-
|
|
195
|
+
def __init__(
|
|
196
|
+
self,
|
|
197
|
+
message: str,
|
|
198
|
+
*,
|
|
199
|
+
details: dict[str, Any] | None = None,
|
|
200
|
+
override_hint: OverrideHint | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
super().__init__(message, details=details, override_hint=override_hint)
|
|
140
203
|
|
|
141
204
|
|
|
142
205
|
class AlreadyExistsError(FilekeeperError):
|
|
@@ -38,6 +38,9 @@ COMMITTED = "committed"
|
|
|
38
38
|
UNDONE = "undone"
|
|
39
39
|
FAILED = "failed"
|
|
40
40
|
PARTIALLY_UNDONE = "partially_undone"
|
|
41
|
+
# A refused undo is recorded so the ledger shows the attempt, but it is not a
|
|
42
|
+
# mutation and is never offered as undoable.
|
|
43
|
+
UNDO_REFUSED = "undo_refused"
|
|
41
44
|
|
|
42
45
|
|
|
43
46
|
@dataclass(frozen=True, slots=True)
|
|
@@ -424,6 +427,7 @@ __all__ = [
|
|
|
424
427
|
"PARTIALLY_UNDONE",
|
|
425
428
|
"PENDING",
|
|
426
429
|
"UNDONE",
|
|
430
|
+
"UNDO_REFUSED",
|
|
427
431
|
"BeginResult",
|
|
428
432
|
"OperationJournal",
|
|
429
433
|
"PendingItem",
|
|
@@ -19,6 +19,7 @@ from dataclasses import dataclass
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
21
|
from ..errors import (
|
|
22
|
+
FilekeeperError,
|
|
22
23
|
HistoryUnavailableError,
|
|
23
24
|
StorageError,
|
|
24
25
|
UndoConflictError,
|
|
@@ -27,7 +28,14 @@ from ..errors import (
|
|
|
27
28
|
from ..models import MutationResult, OperationItemRecord, OperationItemResult, OperationRecord
|
|
28
29
|
from ..safety.atomic import atomic_write_bytes, fsync_directory
|
|
29
30
|
from ..safety.hashing import hash_file
|
|
30
|
-
from .journal import
|
|
31
|
+
from .journal import (
|
|
32
|
+
COMMITTED,
|
|
33
|
+
FAILED,
|
|
34
|
+
UNDO_REFUSED,
|
|
35
|
+
UNDONE,
|
|
36
|
+
OperationJournal,
|
|
37
|
+
PendingItem,
|
|
38
|
+
)
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
@dataclass(frozen=True, slots=True)
|
|
@@ -104,6 +112,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
104
112
|
conflicts.append(item.relative_path)
|
|
105
113
|
continue
|
|
106
114
|
if matches_before:
|
|
115
|
+
# A committed operation left every item in its post-operation state.
|
|
116
|
+
# Matching only the pre-operation state now means the path was
|
|
117
|
+
# changed or removed by someone else, not that this undo already
|
|
118
|
+
# ran. Only a failed operation can legitimately be part-way back.
|
|
119
|
+
if record.status == COMMITTED and not matches_after:
|
|
120
|
+
conflicts.append(item.relative_path)
|
|
121
|
+
continue
|
|
107
122
|
already_before += 1
|
|
108
123
|
continue
|
|
109
124
|
|
|
@@ -140,11 +155,13 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
140
155
|
conflicts.append(step.item.relative_path)
|
|
141
156
|
|
|
142
157
|
if conflicts:
|
|
158
|
+
named = ", ".join(sorted(conflicts))
|
|
143
159
|
raise UndoConflictError(
|
|
144
|
-
"cannot undo: these paths changed after the operation,
|
|
160
|
+
f"cannot undo: these paths changed after the operation ({named}), "
|
|
161
|
+
"so nothing was modified",
|
|
145
162
|
details={
|
|
146
163
|
"operation_id": record.operation_id,
|
|
147
|
-
"conflicting_paths":
|
|
164
|
+
"conflicting_paths": named,
|
|
148
165
|
},
|
|
149
166
|
)
|
|
150
167
|
if record.status == FAILED and already_before == len(record.items):
|
|
@@ -161,6 +178,16 @@ def _plan(journal: OperationJournal, record: OperationRecord) -> list[_Step]:
|
|
|
161
178
|
"operation is not committed and cannot be undone",
|
|
162
179
|
details={"operation_id": record.operation_id, "status": record.status},
|
|
163
180
|
)
|
|
181
|
+
if not steps:
|
|
182
|
+
# Every item already holds its pre-operation state, which for a committed
|
|
183
|
+
# operation means the write left the content unchanged. There is nothing
|
|
184
|
+
# to restore, and reporting success would claim a change that never
|
|
185
|
+
# happened.
|
|
186
|
+
raise UndoConflictError(
|
|
187
|
+
"cannot undo: the operation left its target in the pre-operation state, "
|
|
188
|
+
"so undoing it would change nothing",
|
|
189
|
+
details={"operation_id": record.operation_id},
|
|
190
|
+
)
|
|
164
191
|
return steps
|
|
165
192
|
|
|
166
193
|
|
|
@@ -272,6 +299,53 @@ def _result(
|
|
|
272
299
|
)
|
|
273
300
|
|
|
274
301
|
|
|
302
|
+
def _conflicting_paths(error: UndoConflictError) -> list[str]:
|
|
303
|
+
"""The refusal's paths as a list, still caller-relative as Core recorded them."""
|
|
304
|
+
|
|
305
|
+
named = error.details.get("conflicting_paths")
|
|
306
|
+
if not isinstance(named, str):
|
|
307
|
+
return []
|
|
308
|
+
return sorted({part.strip() for part in named.split(",") if part.strip()})
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _record_refusal(
|
|
312
|
+
journal: OperationJournal,
|
|
313
|
+
record: OperationRecord,
|
|
314
|
+
error: UndoConflictError,
|
|
315
|
+
*,
|
|
316
|
+
actor: str | None,
|
|
317
|
+
task_id: str | None,
|
|
318
|
+
) -> str | None:
|
|
319
|
+
"""Record a refused undo attempt so the ledger shows it was tried.
|
|
320
|
+
|
|
321
|
+
A refusal changes nothing, so the row carries no items and no before/after
|
|
322
|
+
hashes. Its status is ``failed`` — the attempt did not take effect — and its
|
|
323
|
+
type keeps it out of the undo stack (:func:`latest_undoable`). The conflict
|
|
324
|
+
code and the paths that blocked the undo travel in metadata. Recording is
|
|
325
|
+
best-effort: a storage failure here must never replace the refusal the
|
|
326
|
+
caller is owed.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
begun = journal.begin(
|
|
331
|
+
operation_type=UNDO_REFUSED,
|
|
332
|
+
target_path=record.target_path,
|
|
333
|
+
summary=f"Refused to undo operation {record.operation_id}: {error.message}",
|
|
334
|
+
items=[],
|
|
335
|
+
actor=actor,
|
|
336
|
+
task_id=task_id,
|
|
337
|
+
metadata={
|
|
338
|
+
"undone_operation": record.operation_id,
|
|
339
|
+
"conflict_code": str(error.code),
|
|
340
|
+
"conflicting_paths": _conflicting_paths(error),
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
journal.abort(begun.operation_id)
|
|
344
|
+
except FilekeeperError:
|
|
345
|
+
return None
|
|
346
|
+
return begun.operation_id
|
|
347
|
+
|
|
348
|
+
|
|
275
349
|
def undo_operation(
|
|
276
350
|
journal: OperationJournal,
|
|
277
351
|
operation_id: str,
|
|
@@ -289,7 +363,16 @@ def undo_operation(
|
|
|
289
363
|
raise OperationNotFoundError(
|
|
290
364
|
"no operation with that id in this workspace", details={"operation_id": operation_id}
|
|
291
365
|
)
|
|
292
|
-
|
|
366
|
+
try:
|
|
367
|
+
steps = _plan(journal, record)
|
|
368
|
+
except UndoConflictError as exc:
|
|
369
|
+
# A refusal of another refusal adds nothing: the first row already
|
|
370
|
+
# records that this conflict was seen and nothing changed. Journalling
|
|
371
|
+
# the repeat would be noise, not new evidence, so only a refusal of a
|
|
372
|
+
# real operation is recorded.
|
|
373
|
+
if record.operation_type != UNDO_REFUSED:
|
|
374
|
+
_record_refusal(journal, record, exc, actor=actor, task_id=task_id)
|
|
375
|
+
raise
|
|
293
376
|
undo_items = _undo_items(record)
|
|
294
377
|
|
|
295
378
|
begun = journal.begin(
|
|
@@ -316,8 +399,14 @@ def undo_operation(
|
|
|
316
399
|
|
|
317
400
|
|
|
318
401
|
def latest_undoable(journal: OperationJournal, *, actor: str | None = None) -> OperationRecord:
|
|
319
|
-
|
|
320
|
-
|
|
402
|
+
"""The most recent operation this workspace may undo.
|
|
403
|
+
|
|
404
|
+
A recorded refusal changed nothing; it is not a mutation and is never
|
|
405
|
+
offered as the next undo target, so ``undo-last`` skips those rows.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
clauses = ["status IN (?, ?)", "operation_type != ?"]
|
|
409
|
+
parameters: list[object] = [COMMITTED, FAILED, UNDO_REFUSED]
|
|
321
410
|
if actor is not None:
|
|
322
411
|
clauses.append("actor = ?")
|
|
323
412
|
parameters.append(actor)
|