filekeeper-cli 0.1.2__tar.gz → 0.1.4__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.
Files changed (36) hide show
  1. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/PKG-INFO +7 -1
  2. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/README.md +6 -0
  3. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/pyproject.toml +1 -1
  4. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/pyproject.toml.orig +1 -1
  5. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/__init__.py +1 -1
  6. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/app.py +20 -1
  7. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/config.py +22 -6
  8. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/database.py +23 -8
  9. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/service.py +49 -20
  10. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/workspace.py +166 -23
  11. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/LICENSE +0 -0
  12. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/__init__.py +0 -0
  13. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/exit_codes.py +0 -0
  14. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/cli/output.py +0 -0
  15. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/__init__.py +0 -0
  16. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/diffing.py +0 -0
  17. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/errors.py +0 -0
  18. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/__init__.py +0 -0
  19. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/blobs.py +0 -0
  20. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/journal.py +0 -0
  21. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/repository.py +0 -0
  22. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/history/undo.py +0 -0
  23. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/models.py +0 -0
  24. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/__init__.py +0 -0
  25. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/directory.py +0 -0
  26. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/edit.py +0 -0
  27. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/read.py +0 -0
  28. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/search.py +0 -0
  29. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/operations/write.py +0 -0
  30. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/paths.py +0 -0
  31. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/__init__.py +0 -0
  32. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/atomic.py +0 -0
  33. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/hashing.py +0 -0
  34. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/sensitive.py +0 -0
  35. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/textdata.py +0 -0
  36. {filekeeper_cli-0.1.2 → filekeeper_cli-0.1.4}/src/filekeeper/core/safety/vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filekeeper-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -150,6 +150,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
150
150
  per-project attribution and pruning become harder, and undo history no longer
151
151
  travels with the tree.
152
152
 
153
+ If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
154
+ at `/` — Filekeeper falls back to the global store and says so in the response.
155
+ `keeper init` writes the setting only as a commented example, so that fallback
156
+ stays available; uncomment the key to pin the choice, and a project-local choice
157
+ then fails loudly instead of being redirected.
158
+
153
159
  If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
154
160
  `.gitignore` when it creates the state directory. Outside a repository it writes
155
161
  nothing.
@@ -129,6 +129,12 @@ home directory instead. The trade-off: history survives deleting the checkout, b
129
129
  per-project attribution and pruning become harder, and undo history no longer
130
130
  travels with the tree.
131
131
 
132
+ If the workspace root cannot host `.filekeeper/` — a read-only checkout, or a root
133
+ at `/` — Filekeeper falls back to the global store and says so in the response.
134
+ `keeper init` writes the setting only as a commented example, so that fallback
135
+ stays available; uncomment the key to pin the choice, and a project-local choice
136
+ then fails loudly instead of being redirected.
137
+
132
138
  If the workspace is a git repository, Filekeeper adds `.filekeeper/` to
133
139
  `.gitignore` when it creates the state directory. Outside a repository it writes
134
140
  nothing.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "filekeeper-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Safe, predictable filesystem operations for humans and AI agents, with atomic writes and workspace confinement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.13"
@@ -21,7 +21,7 @@ from .core import (
21
21
  Workspace,
22
22
  )
23
23
 
24
- __version__ = "0.1.2"
24
+ __version__ = "0.1.4"
25
25
 
26
26
  __all__ = [
27
27
  "Config",
@@ -408,11 +408,30 @@ class _Context:
408
408
  )
409
409
  return self._service
410
410
 
411
+ def state_warnings(self) -> tuple[str, ...]:
412
+ """Warnings that explain where durable state went for this workspace."""
413
+
414
+ if self._service is None:
415
+ return ()
416
+ warning = self._service.handle.state_warning
417
+ return () if warning is None else (warning,)
418
+
411
419
  def emit(self, payload: dict[str, object], text: str) -> None:
420
+ warnings = self.state_warnings()
421
+ if warnings:
422
+ existing = payload.get("warnings")
423
+ merged = list(existing) if isinstance(existing, list) else []
424
+ for warning in warnings:
425
+ if warning not in merged:
426
+ merged.append(warning)
427
+ payload = {**payload, "warnings": merged}
412
428
  if self.as_json:
413
429
  output.print_json(payload)
414
- elif text:
430
+ return
431
+ if text:
415
432
  sys.stdout.write(text if text.endswith("\n") else text + "\n")
433
+ for warning in warnings:
434
+ sys.stderr.write(f"warning: {warning}\n")
416
435
 
417
436
 
418
437
  def _content_from_args(args: argparse.Namespace) -> str:
@@ -11,7 +11,9 @@ Two kinds of state exist, and they live in different places on purpose:
11
11
 
12
12
  ``config.toml`` in the home directory holds tunable limits. It is read on every
13
13
  invocation; writing it is left to the user (``keeper init`` creates it with the
14
- defaults spelled out).
14
+ defaults spelled out). ``state_location`` is written only as a commented example:
15
+ a generated file must not turn a default into an explicit operator decision,
16
+ because an explicit decision refuses the global fallback.
15
17
  """
16
18
 
17
19
  from __future__ import annotations
@@ -40,6 +42,11 @@ class Config:
40
42
  max_history_blob_bytes: int = 4 * 1024 * 1024
41
43
  fsync: bool = True
42
44
  state_location: str = "project"
45
+ # True only when the configuration file named ``state_location`` explicitly.
46
+ # A workspace whose root cannot host its state directory may fall back to the
47
+ # global store when the setting was left at the default, but an explicit
48
+ # operator choice is honoured and fails loudly instead of being redirected.
49
+ state_location_explicit: bool = False
43
50
 
44
51
  def __post_init__(self) -> None:
45
52
  if self.state_location not in {"project", "global"}:
@@ -93,13 +100,14 @@ def load_config(home: Path) -> Config:
93
100
  if not isinstance(section, dict):
94
101
  raise StorageError("configuration file must contain a table", details={"path": str(path)})
95
102
 
96
- known = {field_info.name for field_info in fields(Config)}
103
+ known = {field_info.name for field_info in fields(Config)} - {"state_location_explicit"}
97
104
  values: dict[str, object] = {}
98
105
  for key, value in section.items():
99
106
  if key in known:
100
107
  values[key] = value
101
108
  try:
102
- return Config(**values) # type: ignore[arg-type]
109
+ explicit = "state_location" in section
110
+ return Config(**values, state_location_explicit=explicit) # type: ignore[arg-type]
103
111
  except TypeError as exc:
104
112
  raise StorageError(
105
113
  f"invalid configuration value: {exc}", details={"path": str(path)}
@@ -127,7 +135,15 @@ max_history_blob_bytes = 4194304
127
135
  # parent directory before reporting success.
128
136
  fsync = true
129
137
 
130
- # "project" keeps per-workspace state in <workspace>/.filekeeper/ (default).
131
- # "global" keeps one shared store under this home directory instead.
132
- state_location = "project"
138
+ # Per-workspace state lives in <workspace>/.filekeeper/ by default. When the
139
+ # workspace root cannot host that directory (a read-only checkout, or a root at
140
+ # /), Filekeeper falls back to the global store under this home directory and
141
+ # says so in the response.
142
+ #
143
+ # Uncommenting the next line pins the choice instead of leaving it at the
144
+ # built-in default. "project" then fails loudly when the root cannot host the
145
+ # directory instead of being redirected; "global" always uses the shared store.
146
+ # An uncommented key is an operator decision, so keep it commented unless you
147
+ # mean it.
148
+ # state_location = "project"
133
149
  """
@@ -135,10 +135,21 @@ def uuid_token() -> str:
135
135
  return uuid.uuid4().hex[:16]
136
136
 
137
137
 
138
- def connect(path: Path) -> sqlite3.Connection:
139
- """Open a SQLite database with durable, foreign-key-enforcing settings."""
138
+ def connect(path: Path, *, display: str = "the Filekeeper state store") -> sqlite3.Connection:
139
+ """Open a SQLite database with durable, foreign-key-enforcing settings.
140
140
 
141
- path.parent.mkdir(parents=True, exist_ok=True)
141
+ ``display`` is the caller-facing name of the directory holding the database.
142
+ A public error envelope never carries the resolved host path; the reason
143
+ phrase is all the message needs.
144
+ """
145
+
146
+ try:
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ except OSError as exc:
149
+ raise StorageError(
150
+ f"cannot create state directory: {exc.strerror or type(exc).__name__}",
151
+ details={"state_dir": display},
152
+ ) from exc
142
153
  try:
143
154
  os.chmod(path.parent, stat.S_IRWXU)
144
155
  except OSError: # pragma: no cover - restricted filesystems
@@ -146,7 +157,7 @@ def connect(path: Path) -> sqlite3.Connection:
146
157
  try:
147
158
  connection = sqlite3.connect(path, timeout=10.0)
148
159
  except sqlite3.Error as exc:
149
- raise StorageError(f"cannot open database: {exc}", details={"path": str(path)}) from exc
160
+ raise StorageError(f"cannot open database: {exc}", details={"path": display}) from exc
150
161
  connection.row_factory = sqlite3.Row
151
162
  connection.execute("PRAGMA journal_mode = WAL")
152
163
  # Full synchronous mode: a committed record survives an OS crash, which the
@@ -210,14 +221,18 @@ def migrate_workspace(connection: sqlite3.Connection) -> int:
210
221
  return migrate(connection, _WORKSPACE_MIGRATIONS, label="workspace")
211
222
 
212
223
 
213
- def open_registry_database(path: Path) -> sqlite3.Connection:
214
- connection = connect(path)
224
+ def open_registry_database(
225
+ path: Path, *, display: str = "the Filekeeper home directory"
226
+ ) -> sqlite3.Connection:
227
+ connection = connect(path, display=display)
215
228
  migrate_registry(connection)
216
229
  return connection
217
230
 
218
231
 
219
- def open_workspace_database(path: Path) -> sqlite3.Connection:
220
- connection = connect(path)
232
+ def open_workspace_database(
233
+ path: Path, *, display: str = "the workspace state directory"
234
+ ) -> sqlite3.Connection:
235
+ connection = connect(path, display=display)
221
236
  migrate_workspace(connection)
222
237
  try:
223
238
  os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
@@ -14,6 +14,7 @@ values inside results, never their fields.
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
+ import hashlib
17
18
  from pathlib import Path
18
19
 
19
20
  from . import database, operations
@@ -50,9 +51,26 @@ from .workspace import (
50
51
  STATE_DATABASE_NAME,
51
52
  WorkspaceHandle,
52
53
  WorkspaceRegistry,
54
+ ensure_state_directory,
53
55
  handle_for,
54
56
  )
55
57
 
58
+ TRANSIENT_ID_PREFIX = "ws-transient-"
59
+ TRANSIENT_ID_LENGTH = 16
60
+
61
+
62
+ def transient_workspace_id(root: str | Path) -> str:
63
+ """A stable id for the transient workspace rooted at ``root``.
64
+
65
+ Durable state is keyed by workspace id, so the id must survive a process
66
+ restart: it is derived from the canonical absolute root rather than drawn
67
+ at random.
68
+ """
69
+
70
+ canonical = str(Path(root).expanduser().resolve())
71
+ digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
72
+ return f"{TRANSIENT_ID_PREFIX}{digest[:TRANSIENT_ID_LENGTH]}"
73
+
56
74
 
57
75
  def init_home(home: Path | None = None) -> Path:
58
76
  """Create the home directory, default configuration and registry database."""
@@ -135,7 +153,7 @@ class Filekeeper:
135
153
  if registered is not None:
136
154
  return FilekeeperService(handle_for(registered, self.config, self.home), self)
137
155
  transient = Workspace(
138
- id=f"ws-transient-{database.uuid_token()}",
156
+ id=transient_workspace_id(candidate),
139
157
  name=candidate.name or "workspace",
140
158
  root_path=str(candidate),
141
159
  created_at=database.utc_now(),
@@ -201,16 +219,27 @@ class FilekeeperService:
201
219
  if self.registered and self.handle.workspace_id is not None:
202
220
  workspace = self.owner.registry.get(self.handle.name)
203
221
  return self.owner.registry.ensure_state_dir(workspace)
204
- state_dir = self.handle.state_dir
205
- state_dir.mkdir(parents=True, exist_ok=True)
206
- target = state_dir / STATE_DATABASE_NAME
207
- connection = database.open_workspace_database(target)
208
- connection.close()
209
- return target
222
+ return ensure_state_directory(
223
+ self.handle.state_dir, root=self.root, home=self.owner.home
224
+ )
210
225
 
211
226
  def blob_store(self) -> BlobStore:
212
227
  return BlobStore(self.state_database_path().parent / "blobs", fsync=self.config.fsync)
213
228
 
229
+ def _journal_for(self, dry_run: bool) -> OperationJournal | None:
230
+ """The journal a mutation will record into, or ``None`` for a dry run.
231
+
232
+ A dry run has nothing to journal, so it touches no state at all: it does
233
+ not create the state directory, open the database or write a blob. That
234
+ makes a preview readable even where the real operation would be refused —
235
+ which is the point of a preview, and why a preview that failed on state
236
+ creation was not a preview.
237
+ """
238
+
239
+ if dry_run:
240
+ return None
241
+ return self.history
242
+
214
243
  # -- history ------------------------------------------------------------
215
244
  def _open_journal(self, *, create: bool) -> OperationJournal | None:
216
245
  """Return the journal, reconciling pending work once per service instance.
@@ -413,7 +442,7 @@ class FilekeeperService:
413
442
  encoding=encoding,
414
443
  allow_sensitive_write=allow_sensitive_write,
415
444
  dry_run=dry_run,
416
- journal=None if dry_run else self.history,
445
+ journal=self._journal_for(dry_run),
417
446
  actor=actor,
418
447
  task_id=task_id,
419
448
  )
@@ -438,7 +467,7 @@ class FilekeeperService:
438
467
  encoding=encoding,
439
468
  allow_sensitive_write=allow_sensitive_write,
440
469
  dry_run=dry_run,
441
- journal=None if dry_run else self.history,
470
+ journal=self._journal_for(dry_run),
442
471
  actor=actor,
443
472
  task_id=task_id,
444
473
  )
@@ -461,7 +490,7 @@ class FilekeeperService:
461
490
  expected_destination_hash=expected_destination_hash,
462
491
  allow_sensitive_write=allow_sensitive_write,
463
492
  dry_run=dry_run,
464
- journal=None if dry_run else self.history,
493
+ journal=self._journal_for(dry_run),
465
494
  actor=actor,
466
495
  task_id=task_id,
467
496
  )
@@ -484,7 +513,7 @@ class FilekeeperService:
484
513
  overwrite=overwrite,
485
514
  allow_sensitive_write=allow_sensitive_write,
486
515
  dry_run=dry_run,
487
- journal=None if dry_run else self.history,
516
+ journal=self._journal_for(dry_run),
488
517
  actor=actor,
489
518
  task_id=task_id,
490
519
  )
@@ -503,7 +532,7 @@ class FilekeeperService:
503
532
  path,
504
533
  expected_hash=expected_hash,
505
534
  dry_run=dry_run,
506
- journal=None if dry_run else self.history,
535
+ journal=self._journal_for(dry_run),
507
536
  actor=actor,
508
537
  task_id=task_id,
509
538
  )
@@ -535,7 +564,7 @@ class FilekeeperService:
535
564
  expected_match_count=expected_match_count,
536
565
  allow_sensitive_write=allow_sensitive_write,
537
566
  dry_run=dry_run,
538
- journal=None if dry_run else self.history,
567
+ journal=self._journal_for(dry_run),
539
568
  actor=actor,
540
569
  task_id=task_id,
541
570
  )
@@ -562,7 +591,7 @@ class FilekeeperService:
562
591
  expected_content=expected_content,
563
592
  expected_hash=expected_hash,
564
593
  dry_run=dry_run,
565
- journal=None if dry_run else self.history,
594
+ journal=self._journal_for(dry_run),
566
595
  actor=actor,
567
596
  task_id=task_id,
568
597
  )
@@ -589,7 +618,7 @@ class FilekeeperService:
589
618
  expected_content=expected_content,
590
619
  expected_hash=expected_hash,
591
620
  dry_run=dry_run,
592
- journal=None if dry_run else self.history,
621
+ journal=self._journal_for(dry_run),
593
622
  actor=actor,
594
623
  task_id=task_id,
595
624
  )
@@ -614,7 +643,7 @@ class FilekeeperService:
614
643
  expected_content=expected_content,
615
644
  expected_hash=expected_hash,
616
645
  dry_run=dry_run,
617
- journal=None if dry_run else self.history,
646
+ journal=self._journal_for(dry_run),
618
647
  actor=actor,
619
648
  task_id=task_id,
620
649
  )
@@ -633,7 +662,7 @@ class FilekeeperService:
633
662
  patch_text,
634
663
  allow_sensitive_write=allow_sensitive_write,
635
664
  dry_run=dry_run,
636
- journal=None if dry_run else self.history,
665
+ journal=self._journal_for(dry_run),
637
666
  actor=actor,
638
667
  task_id=task_id,
639
668
  )
@@ -652,7 +681,7 @@ class FilekeeperService:
652
681
  path,
653
682
  parents=parents,
654
683
  dry_run=dry_run,
655
- journal=None if dry_run else self.history,
684
+ journal=self._journal_for(dry_run),
656
685
  actor=actor,
657
686
  task_id=task_id,
658
687
  )
@@ -671,7 +700,7 @@ class FilekeeperService:
671
700
  source,
672
701
  destination,
673
702
  dry_run=dry_run,
674
- journal=None if dry_run else self.history,
703
+ journal=self._journal_for(dry_run),
675
704
  actor=actor,
676
705
  task_id=task_id,
677
706
  )
@@ -692,7 +721,7 @@ class FilekeeperService:
692
721
  recursive=recursive,
693
722
  confirm_recursive=confirm_recursive,
694
723
  dry_run=dry_run,
695
- journal=None if dry_run else self.history,
724
+ journal=self._journal_for(dry_run),
696
725
  actor=actor,
697
726
  task_id=task_id,
698
727
  )
@@ -3,10 +3,16 @@
3
3
  A workspace is a registered directory that bounds every operation. Registration
4
4
  lives in the home database; the workspace's own state directory lives in the tree
5
5
  by default so that history and blobs travel with the checkout.
6
+
7
+ When the tree cannot host that directory — a read-only checkout, or a workspace
8
+ root at ``/`` — the deployment still works: the state directory falls back to the
9
+ global store and the response says so. An operator who named ``state_location``
10
+ explicitly gets the failure instead, because that choice is theirs to make.
6
11
  """
7
12
 
8
13
  from __future__ import annotations
9
14
 
15
+ import os
10
16
  import re
11
17
  import sqlite3
12
18
  import stat
@@ -29,6 +35,13 @@ DEFAULT_WORKSPACE_KEY: Final = "default_workspace"
29
35
  STATE_DATABASE_NAME: Final = "state.sqlite3"
30
36
  GITIGNORE_ENTRY: Final = f"{STATE_DIR_NAME}/"
31
37
 
38
+ # Names the fallback without naming a host path: the message is rendered to
39
+ # callers who are confined to the workspace.
40
+ GLOBAL_FALLBACK_WARNING: Final = (
41
+ "the workspace root cannot host a state directory, so history is kept in "
42
+ "the global Filekeeper state store instead of a project-local directory"
43
+ )
44
+
32
45
 
33
46
  @dataclass(frozen=True, slots=True)
34
47
  class WorkspaceHandle:
@@ -39,31 +52,164 @@ class WorkspaceHandle:
39
52
  workspace_id: str | None
40
53
  config: Config
41
54
  state_dir: Path
55
+ state_warning: str | None = None
42
56
 
43
57
 
44
- def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
45
- """Where this workspace's journal and blobs live.
58
+ @dataclass(frozen=True, slots=True)
59
+ class StateLocation:
60
+ """A resolved state directory plus the warning that explains a fallback."""
61
+
62
+ directory: Path
63
+ warning: str | None = None
64
+
65
+
66
+ def global_workspace_state_dir(workspace_id: str, home: Path) -> Path:
67
+ """The global store's directory for one workspace id."""
68
+
69
+ return Path(home) / "workspaces" / workspace_id
70
+
71
+
72
+ def display_state_location(
73
+ state_dir: Path, *, root: Path | None, home: Path | None
74
+ ) -> str:
75
+ """Render a state directory without naming an absolute host path.
76
+
77
+ A public error envelope is read by a caller confined to the workspace, so a
78
+ resolved host path there tells them nothing they can use and describes the
79
+ host tree. The workspace-relative form is preferred; a global store outside
80
+ the workspace is rendered relative to the home directory; anything else falls
81
+ back to prose. The value stays meaningful, which is what the operator needs
82
+ in order to fix the deployment.
83
+ """
84
+
85
+ for base in (root, home):
86
+ if base is None:
87
+ continue
88
+ try:
89
+ relative = Path(state_dir).relative_to(Path(base)).as_posix()
90
+ except ValueError:
91
+ continue
92
+ return relative or "."
93
+ return "the global Filekeeper state store"
94
+
95
+
96
+ def _directory_usable(directory: Path) -> bool:
97
+ """Whether ``directory`` exists and is writable, or can be created."""
98
+
99
+ try:
100
+ if directory.exists():
101
+ return directory.is_dir() and os.access(directory, os.W_OK | os.X_OK)
102
+ parent = directory.parent
103
+ return parent.is_dir() and os.access(parent, os.W_OK | os.X_OK)
104
+ except OSError: # pragma: no cover - unreadable ancestors
105
+ return False
106
+
107
+
108
+ def resolve_state_location(workspace: Workspace, config: Config, home: Path) -> StateLocation:
109
+ """Where this workspace's journal and blobs live, after checking usability.
46
110
 
47
111
  Project-local is the default: ``<root>/.filekeeper``. Global mode keeps one
48
112
  directory per workspace id under the home store, so projects can still be
49
- attributed and pruned individually.
113
+ attributed and pruned individually. When the root cannot host the project
114
+ directory and the operator did not choose a location explicitly, the global
115
+ store is used and the returned warning says so.
50
116
  """
51
117
 
52
118
  if config.state_location == "global":
53
- return home / "workspaces" / workspace.id
54
- return Path(workspace.root_path) / STATE_DIR_NAME
119
+ return StateLocation(global_workspace_state_dir(workspace.id, home))
120
+ project_local = Path(workspace.root_path) / STATE_DIR_NAME
121
+ if _directory_usable(project_local):
122
+ return StateLocation(project_local)
123
+ if config.state_location_explicit:
124
+ # The operator asked for project-local state; do not redirect it.
125
+ return StateLocation(project_local)
126
+ return StateLocation(
127
+ global_workspace_state_dir(workspace.id, home), GLOBAL_FALLBACK_WARNING
128
+ )
129
+
130
+
131
+ def workspace_state_dir(workspace: Workspace, config: Config, home: Path) -> Path:
132
+ """The state directory only, without the fallback explanation."""
133
+
134
+ return resolve_state_location(workspace, config, home).directory
135
+
136
+
137
+ def _unattributed_history_note(workspace_id: str, state_dir: Path) -> str | None:
138
+ """Name earlier per-process transient journals that cannot be adopted.
139
+
140
+ Before this release a transient workspace drew a random id per process, so
141
+ every process wrote its own directory. Those directories carry no record of
142
+ their root, which makes silent adoption unsafe; they are left untouched and
143
+ their existence is reported instead of being hidden.
144
+ """
145
+
146
+ if not workspace_id.startswith("ws-transient-"):
147
+ return None
148
+ try:
149
+ siblings = [
150
+ entry
151
+ for entry in state_dir.parent.iterdir()
152
+ if entry.is_dir()
153
+ and entry != state_dir
154
+ and entry.name.startswith("ws-transient-")
155
+ and (entry / STATE_DATABASE_NAME).is_file()
156
+ ]
157
+ except OSError: # pragma: no cover - unreadable store
158
+ return None
159
+ if not siblings:
160
+ return None
161
+ noun = "directory" if len(siblings) == 1 else "directories"
162
+ return (
163
+ f"{len(siblings)} earlier per-process state {noun} remain in the global store; "
164
+ "their history is not attributed to this workspace"
165
+ )
55
166
 
56
167
 
57
168
  def handle_for(workspace: Workspace, config: Config, home: Path) -> WorkspaceHandle:
169
+ location = resolve_state_location(workspace, config, home)
170
+ warning = location.warning
171
+ legacy = _unattributed_history_note(workspace.id, location.directory)
172
+ if legacy is not None:
173
+ warning = legacy if warning is None else f"{warning}; {legacy}"
58
174
  return WorkspaceHandle(
59
175
  name=workspace.name,
60
176
  root=Path(workspace.root_path),
61
177
  workspace_id=workspace.id,
62
178
  config=config,
63
- state_dir=workspace_state_dir(workspace, config, home),
179
+ state_dir=location.directory,
180
+ state_warning=warning,
64
181
  )
65
182
 
66
183
 
184
+ def ensure_state_directory(
185
+ state_dir: Path, *, root: Path | None = None, home: Path | None = None
186
+ ) -> Path:
187
+ """Create the state directory and its migrated database; return the DB path.
188
+
189
+ Every filesystem failure here is a :class:`StorageError` naming the state
190
+ directory the operator chose. It is never an ``INTERNAL_ERROR``, and neither
191
+ the message nor the details carry a resolved host path: the location is
192
+ rendered workspace-relative (or home-relative for the global store).
193
+ """
194
+
195
+ display = display_state_location(state_dir, root=root, home=home)
196
+ try:
197
+ state_dir.mkdir(parents=True, exist_ok=True)
198
+ state_dir.chmod(stat.S_IRWXU)
199
+ except OSError as exc:
200
+ raise StorageError(
201
+ f"cannot create state directory: {exc.strerror or type(exc).__name__}",
202
+ details={"state_dir": display},
203
+ ) from exc
204
+ if root is not None and state_dir.parent == root:
205
+ # Only a project-local directory belongs in the tree's ignore rules.
206
+ ensure_gitignore_entry(root)
207
+ database_path = state_dir / STATE_DATABASE_NAME
208
+ connection = database.open_workspace_database(database_path, display=display)
209
+ connection.close()
210
+ return database_path
211
+
212
+
67
213
  def _inside_git_repository(root: Path) -> bool:
68
214
  return any((candidate / ".git").exists() for candidate in (root, *root.parents))
69
215
 
@@ -104,7 +250,14 @@ class WorkspaceRegistry:
104
250
  self.database_path = self.home / "metadata.sqlite3"
105
251
 
106
252
  def _connect(self) -> sqlite3.Connection:
107
- self.home.mkdir(parents=True, exist_ok=True)
253
+ try:
254
+ self.home.mkdir(parents=True, exist_ok=True)
255
+ except OSError as exc:
256
+ raise StorageError(
257
+ f"cannot create the Filekeeper home directory: "
258
+ f"{exc.strerror or type(exc).__name__}",
259
+ details={"state_dir": "the Filekeeper home directory"},
260
+ ) from exc
108
261
  try:
109
262
  self.home.chmod(stat.S_IRWXU)
110
263
  except OSError: # pragma: no cover - restricted filesystems
@@ -270,24 +423,14 @@ class WorkspaceRegistry:
270
423
  """Create the state directory and its migrated database; return the DB path.
271
424
 
272
425
  Called before the first mutation that will need durable state. It also
273
- makes sure a git repository will not track the directory.
426
+ makes sure a git repository will not track a project-local directory.
274
427
  """
275
428
 
276
- state_dir = self.state_dir_for(workspace)
277
- try:
278
- state_dir.mkdir(parents=True, exist_ok=True)
279
- state_dir.chmod(stat.S_IRWXU)
280
- except OSError as exc:
281
- raise StorageError(
282
- f"cannot create state directory: "
283
- f"{exc.strerror or type(exc).__name__}",
284
- details={"state_dir": str(state_dir)},
285
- ) from exc
286
- ensure_gitignore_entry(Path(workspace.root_path))
287
- database_path = state_dir / STATE_DATABASE_NAME
288
- connection = database.open_workspace_database(database_path)
289
- connection.close()
290
- return database_path
429
+ return ensure_state_directory(
430
+ self.state_dir_for(workspace),
431
+ root=Path(workspace.root_path),
432
+ home=self.home,
433
+ )
291
434
 
292
435
 
293
436
  def _row_to_workspace(row: sqlite3.Row) -> Workspace:
File without changes