agent-memory-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.
- agent_memory/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
agent_memory/org.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Scaffold a memory home from the bundled templates: ``agent-memory init`` and ``org init``.
|
|
4
|
+
|
|
5
|
+
``init`` lays the home out (its directories and the canonical ``.gitignore``,
|
|
6
|
+
through :func:`layout.scaffold_home`), writes the org tier's files from the
|
|
7
|
+
templates shipped inside this package, keeps ``events/`` and ``debriefs/``
|
|
8
|
+
alive with a ``.gitkeep``, and, given a project, writes that project's tier
|
|
9
|
+
the same way. Given a repository, it records a binding there last, so a
|
|
10
|
+
later session started inside that repository finds this home and project.
|
|
11
|
+
``org init`` is the org tier alone, for a home that already exists.
|
|
12
|
+
|
|
13
|
+
Three rules, in the order they run:
|
|
14
|
+
|
|
15
|
+
1. Every template is read through the public ``importlib.resources`` API
|
|
16
|
+
before anything is written. A template missing from the installed package
|
|
17
|
+
is an error and the home is untouched; there is no silent fallback.
|
|
18
|
+
2. Nothing that exists is overwritten, so a rerun changes no byte and reports
|
|
19
|
+
what it kept. A binding that would collide with one already recorded is
|
|
20
|
+
refused before the first write.
|
|
21
|
+
3. No git, no network, no credentials: the verbs touch the filesystem only.
|
|
22
|
+
Making the home a sync repository is ``enable``, a separate step.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from importlib import resources
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
from . import layout
|
|
35
|
+
from .home import BINDING_FILE, BINDING_SCHEMA_VERSION, HomeError, load_binding
|
|
36
|
+
|
|
37
|
+
TEMPLATES_DIR = "templates"
|
|
38
|
+
#: Template directory per tier; the file names are the tier's own file list.
|
|
39
|
+
TEMPLATE_DIRS = {layout.ORG.name: "org-memory", layout.PROJECT.name: "project-memory"}
|
|
40
|
+
#: Keeps an otherwise empty org-tier directory present in a git-synced home.
|
|
41
|
+
KEEP_FILE = ".gitkeep"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ScaffoldError(Exception):
|
|
45
|
+
"""A precondition failed, or a template is missing; nothing was changed."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Report:
|
|
50
|
+
"""What the verb created, what it found already in place, and the binding it recorded."""
|
|
51
|
+
|
|
52
|
+
home: Path
|
|
53
|
+
created: Tuple[str, ...]
|
|
54
|
+
kept: Tuple[str, ...]
|
|
55
|
+
project: Optional[str] = None
|
|
56
|
+
binding: Optional[Path] = None
|
|
57
|
+
#: ``created``, ``unchanged`` or ``""`` when no repository was given.
|
|
58
|
+
binding_action: str = ""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def changed(self) -> bool:
|
|
62
|
+
return bool(self.created) or self.binding_action == "created"
|
|
63
|
+
|
|
64
|
+
def lines(self) -> List[str]:
|
|
65
|
+
head = "Initialized memory home" if self.created else "Memory home already complete"
|
|
66
|
+
lines = [f"{head}: {self.home}"]
|
|
67
|
+
lines.extend(f" + {path}" for path in self.created)
|
|
68
|
+
lines.extend(f" (exists) {path}" for path in self.kept)
|
|
69
|
+
if self.project:
|
|
70
|
+
lines.append(f"project: {self.project}")
|
|
71
|
+
if self.binding is not None:
|
|
72
|
+
verb = "recorded" if self.binding_action == "created" else "already recorded"
|
|
73
|
+
lines.append(f"binding {verb}: {self.binding} -> {self.home}")
|
|
74
|
+
if self.binding_action == "created":
|
|
75
|
+
lines.append(f" keep {BINDING_FILE} out of the repository's history; it holds a machine-local path")
|
|
76
|
+
return lines
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --- the verbs --------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def init(home: Path, *, project: Optional[str] = None, repo: Optional[Path] = None) -> Report:
|
|
83
|
+
"""Create or complete ``home``; with ``project`` its project tier too; with ``repo`` a binding in that repository."""
|
|
84
|
+
home = _absolute(home)
|
|
85
|
+
templates = load_templates()
|
|
86
|
+
if repo is not None:
|
|
87
|
+
repo = _absolute(repo)
|
|
88
|
+
if project is None:
|
|
89
|
+
project = repo.name
|
|
90
|
+
try:
|
|
91
|
+
layout.validate_project_name(project)
|
|
92
|
+
except ValueError as exc:
|
|
93
|
+
raise ScaffoldError(f"cannot derive a project name from {repo}: {exc}; pass --project") from exc
|
|
94
|
+
if project is not None:
|
|
95
|
+
try:
|
|
96
|
+
layout.validate_project_name(project)
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise ScaffoldError(f"project {project!r}: {exc}") from exc
|
|
99
|
+
binding: Optional[Tuple[Path, str]] = None
|
|
100
|
+
if repo is not None:
|
|
101
|
+
binding = _plan_binding(repo, home, project)
|
|
102
|
+
created, kept = _scaffold(home, project, templates)
|
|
103
|
+
action = ""
|
|
104
|
+
if binding is not None:
|
|
105
|
+
path, action = binding
|
|
106
|
+
if action == "created":
|
|
107
|
+
_write_binding(path, home, project)
|
|
108
|
+
return Report(
|
|
109
|
+
home,
|
|
110
|
+
tuple(created),
|
|
111
|
+
tuple(kept),
|
|
112
|
+
project=project,
|
|
113
|
+
binding=binding[0] if binding is not None else None,
|
|
114
|
+
binding_action=action,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def org_init(home: Path) -> Report:
|
|
119
|
+
"""The org tier alone, for a home that already exists."""
|
|
120
|
+
home = _absolute(home)
|
|
121
|
+
if not home.is_dir():
|
|
122
|
+
raise ScaffoldError(f"{home} is not a directory; `agent-memory init` creates a home")
|
|
123
|
+
return init(home)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- templates --------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _templates_root():
|
|
130
|
+
"""The bundled ``templates/`` directory as a ``Traversable``; tests point this elsewhere."""
|
|
131
|
+
return resources.files(__package__) / TEMPLATES_DIR
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def template_bytes(tier: layout.Tier, name: str) -> bytes:
|
|
135
|
+
"""The bytes of one bundled template, or :class:`ScaffoldError` when the package does not carry it."""
|
|
136
|
+
relative = f"{TEMPLATES_DIR}/{TEMPLATE_DIRS[tier.name]}/{name}"
|
|
137
|
+
try:
|
|
138
|
+
with resources.as_file(_templates_root() / TEMPLATE_DIRS[tier.name] / name) as path:
|
|
139
|
+
return path.read_bytes()
|
|
140
|
+
except FileNotFoundError as exc:
|
|
141
|
+
raise ScaffoldError(f"template {relative} is missing from the installed package") from exc
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
raise ScaffoldError(f"template {relative} cannot be read: {exc.strerror or exc}") from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_templates() -> Dict[Tuple[str, str], bytes]:
|
|
147
|
+
"""Every tier file's template, read up front so a missing one fails before the first write."""
|
|
148
|
+
return {(tier.name, name): template_bytes(tier, name) for tier in layout.TIERS for name in tier.files}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# --- the writes -------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _scaffold(home: Path, project: Optional[str], templates: Dict[Tuple[str, str], bytes]) -> Tuple[List[str], List[str]]:
|
|
155
|
+
created: List[str] = []
|
|
156
|
+
kept: List[str] = []
|
|
157
|
+
|
|
158
|
+
def rel(path: Path) -> str:
|
|
159
|
+
text = path.relative_to(home).as_posix()
|
|
160
|
+
return f"{text}/" if path.is_dir() else text
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
created.extend(rel(path) for path in layout.scaffold_home(home, project) if path != home)
|
|
164
|
+
except OSError as exc:
|
|
165
|
+
raise ScaffoldError(f"cannot lay out {home}: {exc.strerror or exc}: {exc.filename}") from exc
|
|
166
|
+
|
|
167
|
+
def place(path: Path, data: bytes) -> None:
|
|
168
|
+
try:
|
|
169
|
+
fresh = layout.write_if_absent(path, data)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
raise ScaffoldError(f"cannot write {path}: {exc.strerror or exc}") from exc
|
|
172
|
+
(created if fresh else kept).append(rel(path))
|
|
173
|
+
|
|
174
|
+
org = layout.org_memory_dir(home)
|
|
175
|
+
for name in layout.ORG.files:
|
|
176
|
+
place(org / name, templates[(layout.ORG.name, name)])
|
|
177
|
+
for sub in layout.ORG.dirs:
|
|
178
|
+
place(org / sub / KEEP_FILE, b"")
|
|
179
|
+
if project is not None:
|
|
180
|
+
memory = layout.project_memory_dir(home, project)
|
|
181
|
+
for name in layout.PROJECT.files:
|
|
182
|
+
place(memory / name, templates[(layout.PROJECT.name, name)])
|
|
183
|
+
return created, kept
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _plan_binding(repo: Path, home: Path, project: str) -> Tuple[Path, str]:
|
|
187
|
+
"""Where the binding goes and whether it needs writing; a collision is refused here, before any write."""
|
|
188
|
+
if not repo.is_dir():
|
|
189
|
+
raise ScaffoldError(f"{repo} is not a directory")
|
|
190
|
+
path = repo / BINDING_FILE
|
|
191
|
+
if not os.path.lexists(path):
|
|
192
|
+
return path, "created"
|
|
193
|
+
try:
|
|
194
|
+
existing = load_binding(path)
|
|
195
|
+
except HomeError as exc:
|
|
196
|
+
raise ScaffoldError(f"collision: {exc}; not overwriting") from exc
|
|
197
|
+
if existing.path.resolve() == home.resolve() and existing.project == project:
|
|
198
|
+
return path, "unchanged"
|
|
199
|
+
raise ScaffoldError(
|
|
200
|
+
f"collision: {path} already binds this repository to home {existing.path} "
|
|
201
|
+
f"(project {existing.project!r}); not overwriting"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _write_binding(path: Path, home: Path, project: str) -> None:
|
|
206
|
+
data = {"schema_version": BINDING_SCHEMA_VERSION, "project": project, "home": str(home)}
|
|
207
|
+
try:
|
|
208
|
+
with open(path, "x", encoding="utf-8") as handle:
|
|
209
|
+
handle.write(json.dumps(data, indent=2) + "\n")
|
|
210
|
+
except OSError as exc:
|
|
211
|
+
raise ScaffoldError(f"cannot write {path}: {exc.strerror or exc}") from exc
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _absolute(value: Path) -> Path:
|
|
215
|
+
try:
|
|
216
|
+
return Path(value).expanduser().absolute()
|
|
217
|
+
except RuntimeError as exc:
|
|
218
|
+
raise ScaffoldError(f"cannot expand {value}: {exc}") from exc
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Kiloloop
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Failure-atomic publication of one immutable record: stage, verify, link, read back.
|
|
4
|
+
|
|
5
|
+
The one implementation of the writer commit contract in the package; the
|
|
6
|
+
debrief writer uses it today and the event writer reuses it next. The
|
|
7
|
+
canonical path only ever holds a complete, verified record, never partial
|
|
8
|
+
bytes (one qualification, on platforms without a descriptor-bound link, is
|
|
9
|
+
stated below): the record is staged in a writer-owned private file, verified through
|
|
10
|
+
the descriptor that created it, then published with an atomic no-replace
|
|
11
|
+
primitive (``os.link``). A failure before publication leaves the canonical
|
|
12
|
+
namespace clean.
|
|
13
|
+
|
|
14
|
+
Staging ownership is a single invariant: **this writer only ever publishes an
|
|
15
|
+
inode it created itself.** The staging nonce is unpredictable, the staging
|
|
16
|
+
file is created with ``O_CREAT|O_EXCL|O_NOFOLLOW``, its bytes are verified
|
|
17
|
+
through that same descriptor, and the published name is confirmed to resolve
|
|
18
|
+
to that same ``(st_dev, st_ino)``. A pre-existing path is never read, adopted,
|
|
19
|
+
or linked. Stale staging artifacts are swept *after* the record is published,
|
|
20
|
+
when no writer of it can still need one.
|
|
21
|
+
|
|
22
|
+
What a record must satisfy internally is the caller's business: ``publish``
|
|
23
|
+
takes a ``verify`` callable that raises :class:`WriterError` when the bytes it
|
|
24
|
+
is handed are not a consistent record, and runs it over the staged bytes and
|
|
25
|
+
over the read-back.
|
|
26
|
+
|
|
27
|
+
Publication is bound to the verified descriptor where the platform can name
|
|
28
|
+
one: on Linux the link source is ``/proc/self/fd/<fd>``, and a staging name
|
|
29
|
+
swapped for a link to some other file meanwhile has orphaned the verified
|
|
30
|
+
inode, which the kernel then refuses to link (ENOENT): nothing foreign is ever
|
|
31
|
+
visible. Elsewhere (macOS, a Linux without ``/proc``) the link source is the
|
|
32
|
+
staging name and the contract is narrower: a swap in the window between
|
|
33
|
+
verification and the link makes the foreign file visible under the canonical
|
|
34
|
+
name from the link until the identity check that follows takes that name back
|
|
35
|
+
down, and a writer stopped in that interval leaves it there (the next writer
|
|
36
|
+
of the record reports it as a collision). What holds on such platforms is the
|
|
37
|
+
post-call state: the call returns with the verified record published, or
|
|
38
|
+
raises with the canonical path absent. That contract rests on the store
|
|
39
|
+
directory not being writable by other users, its default mode, so the swap
|
|
40
|
+
needs the owner's own uid; it is an accepted, documented platform limitation,
|
|
41
|
+
and the structural close is a private staging directory with the link bound
|
|
42
|
+
to that directory's descriptor. Either way the writer treats the swap as a
|
|
43
|
+
vanished stage, restages under a fresh nonce and retries; a swap that
|
|
44
|
+
persists exhausts the attempts and is reported, with the canonical path
|
|
45
|
+
absent.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import errno
|
|
51
|
+
import os
|
|
52
|
+
import stat as stat_mod
|
|
53
|
+
import sys
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Callable, Optional, Tuple
|
|
56
|
+
|
|
57
|
+
# The leading dot keeps staging files outside the canonical namespace; the
|
|
58
|
+
# prefix is scoped to one canonical record, so every file matching it belongs
|
|
59
|
+
# to a writer of that exact record.
|
|
60
|
+
STAGE_PREFIX = ".stage."
|
|
61
|
+
|
|
62
|
+
# A staging file vanishes before publication when a concurrent writer of this
|
|
63
|
+
# record published and swept it (the sweep runs only after a record is
|
|
64
|
+
# published), or when its name was swapped under the writer (see the module
|
|
65
|
+
# docstring). A vanished stage is resolved against the landed record first:
|
|
66
|
+
# identical is idempotent, different is a collision, nothing landed is a
|
|
67
|
+
# restage. The vanish shows up at three points -- the fstat after the O_EXCL
|
|
68
|
+
# create reports 0 links, the link reports the stage missing, or the published
|
|
69
|
+
# name fails to resolve to the verified inode -- and all three raise
|
|
70
|
+
# _StageVanished. The bound covers the window where a sweep beat the publish
|
|
71
|
+
# into visibility, and turns a persistent swap into a reported failure.
|
|
72
|
+
PUBLISH_ATTEMPTS = 3
|
|
73
|
+
|
|
74
|
+
# Linux names an open descriptor's inode under /proc/self/fd, and linkat with
|
|
75
|
+
# AT_SYMLINK_FOLLOW publishes from that name: the link source is then the
|
|
76
|
+
# verified inode itself, not the staging name. Elsewhere the source is the
|
|
77
|
+
# name (see the module docstring).
|
|
78
|
+
LINK_VIA_FD = sys.platform.startswith("linux") and os.path.isdir("/proc/self/fd")
|
|
79
|
+
|
|
80
|
+
Verifier = Callable[[bytes], None]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class WriterError(Exception):
|
|
84
|
+
"""A validation or publication failure. Never leaves partial bytes.
|
|
85
|
+
|
|
86
|
+
``code`` is the process exit code the failure maps to: 1 for a usage or
|
|
87
|
+
validation error, 2 for a publication failure.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, message: str, code: int = 2) -> None:
|
|
91
|
+
super().__init__(message)
|
|
92
|
+
self.code = code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _StageVanished(Exception):
|
|
96
|
+
"""Internal: the staging file disappeared before it could be published."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def staging_path(target: Path) -> Path:
|
|
100
|
+
"""Writer-unique private staging name for ``target``.
|
|
101
|
+
|
|
102
|
+
The nonce is unpredictable, which is what makes the ownership invariant
|
|
103
|
+
hold: no other process can pre-create the path this writer is about to
|
|
104
|
+
claim, so the ``O_EXCL`` create below always produces a fresh inode that
|
|
105
|
+
this writer alone has ever written to.
|
|
106
|
+
"""
|
|
107
|
+
nonce = f"{os.getpid():x}{os.urandom(8).hex()}"
|
|
108
|
+
return target.with_name(f"{STAGE_PREFIX}{target.name}.{nonce}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pread_all(fd: int, size: int) -> bytes:
|
|
112
|
+
chunks = []
|
|
113
|
+
offset = 0
|
|
114
|
+
while offset < size:
|
|
115
|
+
chunk = os.pread(fd, size - offset, offset)
|
|
116
|
+
if not chunk:
|
|
117
|
+
break
|
|
118
|
+
chunks.append(chunk)
|
|
119
|
+
offset += len(chunk)
|
|
120
|
+
return b"".join(chunks)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _identity(path: Path) -> Tuple[int, int]:
|
|
124
|
+
"""The (device, inode) pair naming one filesystem object, without following."""
|
|
125
|
+
try:
|
|
126
|
+
st = os.lstat(path)
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise WriterError(f"cannot inspect {path}: {exc}") from exc
|
|
129
|
+
return (st.st_dev, st.st_ino)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _unlink_quietly(path: Path) -> None:
|
|
133
|
+
"""Best-effort removal of a staging name. Never fatal, by design.
|
|
134
|
+
|
|
135
|
+
Every call site is cleanup: an error path that is already raising the real
|
|
136
|
+
failure, the ``finally`` that releases the staging name after publication,
|
|
137
|
+
or the post-publication sweep. Re-raising from any of them would replace an
|
|
138
|
+
accurate outcome with an incidental one. A stage that survives the
|
|
139
|
+
``finally`` leaves the published inode with a second name, and the caller
|
|
140
|
+
asserts ``st_nlink == 1`` immediately after, turning exactly that case into
|
|
141
|
+
a reported failure.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
os.unlink(path)
|
|
145
|
+
except OSError:
|
|
146
|
+
# Deliberate: see the docstring.
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def read_publishable_target(target: Path) -> Optional[bytes]:
|
|
151
|
+
"""Return the existing canonical bytes, or None when the path is free.
|
|
152
|
+
|
|
153
|
+
Never follows symlinks: a symlink or any non-regular file at the canonical
|
|
154
|
+
path is a hard failure, not something to read through. The read is bound
|
|
155
|
+
to the inode that was inspected -- a file swapped in between the inspection
|
|
156
|
+
and the open is reported rather than silently accepted.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
lst = os.lstat(target)
|
|
160
|
+
except FileNotFoundError:
|
|
161
|
+
return None
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
raise WriterError(f"cannot inspect canonical path {target}: {exc}") from exc
|
|
164
|
+
|
|
165
|
+
if stat_mod.S_ISLNK(lst.st_mode):
|
|
166
|
+
raise WriterError(
|
|
167
|
+
f"canonical path {target} is a symlink; the store holds regular files only and the writer must not follow links"
|
|
168
|
+
)
|
|
169
|
+
if not stat_mod.S_ISREG(lst.st_mode):
|
|
170
|
+
raise WriterError(f"canonical path {target} exists and is not a regular file")
|
|
171
|
+
|
|
172
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
173
|
+
try:
|
|
174
|
+
fd = os.open(target, flags)
|
|
175
|
+
except FileNotFoundError:
|
|
176
|
+
return None
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
if getattr(exc, "errno", None) == errno.ELOOP:
|
|
179
|
+
raise WriterError(f"canonical path {target} became a symlink while it was being read") from exc
|
|
180
|
+
raise WriterError(f"cannot open canonical path {target}: {exc}") from exc
|
|
181
|
+
try:
|
|
182
|
+
st = os.fstat(fd)
|
|
183
|
+
if not stat_mod.S_ISREG(st.st_mode):
|
|
184
|
+
raise WriterError(f"canonical path {target} exists and is not a regular file")
|
|
185
|
+
if (st.st_dev, st.st_ino) != (lst.st_dev, lst.st_ino):
|
|
186
|
+
raise WriterError(f"canonical path {target} was replaced while it was being read")
|
|
187
|
+
return _pread_all(fd, st.st_size)
|
|
188
|
+
finally:
|
|
189
|
+
os.close(fd)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _sweep_own_stages(target: Path, canonical: Optional[Tuple[int, int]] = None) -> None:
|
|
193
|
+
"""Remove staging artifacts for ``target`` left by writers of this record.
|
|
194
|
+
|
|
195
|
+
Called only once the canonical record is published and verified. Two kinds
|
|
196
|
+
of match are removable, and only those two: a single-link regular file
|
|
197
|
+
owned by this euid (a stale or partial stage), and a regular file owned by
|
|
198
|
+
this euid that shares ``canonical``, the record's own inode (an alias left
|
|
199
|
+
behind when the ``finally`` unlink failed, which keeps the immutable record
|
|
200
|
+
writable under a second name). Anything else -- a symlink, a directory, a
|
|
201
|
+
multi-link file that is not the record, another user's file -- is left in
|
|
202
|
+
place. It was never this writer's to delete.
|
|
203
|
+
"""
|
|
204
|
+
prefix = f"{STAGE_PREFIX}{target.name}."
|
|
205
|
+
euid = os.geteuid()
|
|
206
|
+
try:
|
|
207
|
+
entries = os.listdir(target.parent)
|
|
208
|
+
except OSError:
|
|
209
|
+
# The record is already published and verified; the sweep is tidying only.
|
|
210
|
+
return
|
|
211
|
+
for name in entries:
|
|
212
|
+
if not name.startswith(prefix):
|
|
213
|
+
continue
|
|
214
|
+
candidate = target.parent / name
|
|
215
|
+
try:
|
|
216
|
+
st = os.lstat(candidate)
|
|
217
|
+
except OSError:
|
|
218
|
+
continue
|
|
219
|
+
if not stat_mod.S_ISREG(st.st_mode) or st.st_uid != euid:
|
|
220
|
+
continue
|
|
221
|
+
is_record_alias = canonical is not None and (st.st_dev, st.st_ino) == canonical
|
|
222
|
+
if st.st_nlink != 1 and not is_record_alias:
|
|
223
|
+
continue
|
|
224
|
+
_unlink_quietly(candidate)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _link_verified(stage: Path, fd: int, target: Path) -> None:
|
|
228
|
+
"""Give the verified inode its canonical name, never replacing anything.
|
|
229
|
+
|
|
230
|
+
Where the platform can name the descriptor, the link source is the
|
|
231
|
+
descriptor and the staging name is irrelevant; elsewhere it is the name.
|
|
232
|
+
Either way the call fails with FileExistsError when the canonical name is
|
|
233
|
+
taken, which is the collision guard the contract wants.
|
|
234
|
+
"""
|
|
235
|
+
if not LINK_VIA_FD:
|
|
236
|
+
os.link(stage, target)
|
|
237
|
+
return
|
|
238
|
+
dir_fd = os.open(target.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
239
|
+
try:
|
|
240
|
+
# A destination dir_fd selects linkat(); follow_symlinks=True gives it
|
|
241
|
+
# AT_SYMLINK_FOLLOW, which is what resolves the /proc name to the inode.
|
|
242
|
+
os.link(f"/proc/self/fd/{fd}", target.name, dst_dir_fd=dir_fd, follow_symlinks=True)
|
|
243
|
+
finally:
|
|
244
|
+
os.close(dir_fd)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _stage(target: Path, record: bytes, verify: Verifier) -> Tuple[Path, Tuple[int, int], int]:
|
|
248
|
+
"""Create, write and verify a staging file this writer owns outright.
|
|
249
|
+
|
|
250
|
+
Returns ``(path, (st_dev, st_ino), fd)``. The identity pair is what binds
|
|
251
|
+
verification to publication: the caller confirms the canonical name lands
|
|
252
|
+
on this exact inode, so the bytes that were checked here are provably the
|
|
253
|
+
bytes that became the record. The descriptor is returned open so the
|
|
254
|
+
publication link can be bound to it; the caller closes it.
|
|
255
|
+
"""
|
|
256
|
+
stage = staging_path(target)
|
|
257
|
+
# O_RDWR, not O_WRONLY: the staged bytes are read back through this same
|
|
258
|
+
# descriptor, which is what binds the verification to the published inode.
|
|
259
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
260
|
+
try:
|
|
261
|
+
fd = os.open(stage, flags, 0o644)
|
|
262
|
+
except OSError as exc:
|
|
263
|
+
raise WriterError(f"cannot create staging file {stage}: {exc}") from exc
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
written = os.write(fd, record)
|
|
267
|
+
if written != len(record):
|
|
268
|
+
raise WriterError(f"short write staging {stage}: {written} of {len(record)} bytes")
|
|
269
|
+
os.fsync(fd)
|
|
270
|
+
|
|
271
|
+
st = os.fstat(fd)
|
|
272
|
+
if not stat_mod.S_ISREG(st.st_mode):
|
|
273
|
+
raise WriterError(f"staging file {stage} is not a regular file")
|
|
274
|
+
if st.st_nlink == 0:
|
|
275
|
+
# Unlinked between the O_EXCL create and this fstat: a concurrent
|
|
276
|
+
# writer of this record published and swept the directory. The
|
|
277
|
+
# caller resolves against the landed record.
|
|
278
|
+
raise _StageVanished()
|
|
279
|
+
if st.st_nlink != 1:
|
|
280
|
+
raise WriterError(
|
|
281
|
+
f"staging file {stage} has {st.st_nlink} links; it must be the only "
|
|
282
|
+
"name for its inode or publication would share it with another path"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Verify through the descriptor that created the file, never by
|
|
286
|
+
# reopening the path: the bytes checked and the inode published are
|
|
287
|
+
# then provably the same object.
|
|
288
|
+
staged = _pread_all(fd, st.st_size)
|
|
289
|
+
if len(staged) != len(record) or staged != record:
|
|
290
|
+
raise WriterError(f"staged bytes at {stage} do not match the composed record")
|
|
291
|
+
try:
|
|
292
|
+
verify(staged)
|
|
293
|
+
except WriterError as exc:
|
|
294
|
+
raise WriterError(f"staged record at {stage} is inconsistent: {exc}") from exc
|
|
295
|
+
ident = (st.st_dev, st.st_ino)
|
|
296
|
+
except BaseException:
|
|
297
|
+
os.close(fd)
|
|
298
|
+
_unlink_quietly(stage)
|
|
299
|
+
raise
|
|
300
|
+
return stage, ident, fd
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _collision_error(target: Path) -> WriterError:
|
|
304
|
+
return WriterError(
|
|
305
|
+
f"canonical path {target} already holds a different record; "
|
|
306
|
+
"never replace a published record -- re-publish under a new "
|
|
307
|
+
"session identifier instead"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _attempt_publish(target: Path, record: bytes, verify: Verifier) -> str:
|
|
312
|
+
stage, ident, fd = _stage(target, record, verify)
|
|
313
|
+
try:
|
|
314
|
+
try:
|
|
315
|
+
# Atomic no-replace publication; a replacing rename would be
|
|
316
|
+
# forbidden here.
|
|
317
|
+
_link_verified(stage, fd, target)
|
|
318
|
+
except FileExistsError:
|
|
319
|
+
landed = read_publishable_target(target)
|
|
320
|
+
if landed == record:
|
|
321
|
+
return "idempotent"
|
|
322
|
+
raise _collision_error(target)
|
|
323
|
+
except FileNotFoundError:
|
|
324
|
+
# A concurrent writer published and swept this stage; the caller
|
|
325
|
+
# resolves against the landed record.
|
|
326
|
+
raise _StageVanished()
|
|
327
|
+
finally:
|
|
328
|
+
# The descriptor has done its work, and the staging name is always
|
|
329
|
+
# released: on success the canonical path is the surviving link, on
|
|
330
|
+
# failure the namespace is left clean.
|
|
331
|
+
os.close(fd)
|
|
332
|
+
_unlink_quietly(stage)
|
|
333
|
+
|
|
334
|
+
# The canonical name must resolve to the inode that was verified above --
|
|
335
|
+
# not to some other file that appeared at that name in the meantime.
|
|
336
|
+
try:
|
|
337
|
+
landed_st = os.lstat(target)
|
|
338
|
+
except OSError as exc:
|
|
339
|
+
raise WriterError(f"cannot inspect published record {target}: {exc}") from exc
|
|
340
|
+
if (landed_st.st_dev, landed_st.st_ino) != ident:
|
|
341
|
+
# The staging name was turned into a link to some other file between
|
|
342
|
+
# verification and the link call (possible only where the link source
|
|
343
|
+
# is the name, not the descriptor). The canonical name is the one this
|
|
344
|
+
# call created -- the link would have failed had it existed -- so
|
|
345
|
+
# taking it back down restores the namespace; the foreign file was
|
|
346
|
+
# visible under that name from the link until here, and a writer
|
|
347
|
+
# stopped in between leaves it (the name-fallback contract, module
|
|
348
|
+
# docstring). The stage is then a vanished one, and the caller restages.
|
|
349
|
+
_unlink_quietly(target)
|
|
350
|
+
if os.path.lexists(target):
|
|
351
|
+
raise WriterError(
|
|
352
|
+
f"published record {target} does not resolve to the staged inode: the staging entry was "
|
|
353
|
+
"replaced before publication, and the foreign name the link created could not be removed"
|
|
354
|
+
)
|
|
355
|
+
raise _StageVanished()
|
|
356
|
+
# Link count and read-back are proved in _finalize, which every successful
|
|
357
|
+
# return -- published and idempotent alike -- passes through.
|
|
358
|
+
return "published"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _finalize(target: Path, record: bytes, verify: Verifier) -> None:
|
|
362
|
+
"""Sweep staging artifacts, then prove the landed record stands alone.
|
|
363
|
+
|
|
364
|
+
Every successful return from :func:`publish` passes through here, published
|
|
365
|
+
and idempotent alike. A previous run can have landed the record and then
|
|
366
|
+
failed to release its staging name, which leaves a second, writable name
|
|
367
|
+
for the canonical inode; finding the bytes already correct says nothing
|
|
368
|
+
about that. If the alias cannot be removed, this raises: a retained
|
|
369
|
+
publication failure is the honest outcome.
|
|
370
|
+
"""
|
|
371
|
+
canonical = _identity(target)
|
|
372
|
+
_sweep_own_stages(target, canonical)
|
|
373
|
+
|
|
374
|
+
st = os.lstat(target)
|
|
375
|
+
if (st.st_dev, st.st_ino) != canonical:
|
|
376
|
+
raise WriterError(f"published record {target} was replaced during cleanup")
|
|
377
|
+
if st.st_nlink != 1:
|
|
378
|
+
raise WriterError(
|
|
379
|
+
f"published record {target} still has {st.st_nlink} links; the staging "
|
|
380
|
+
"name could not be released and the record is reachable -- and "
|
|
381
|
+
"writable -- under another path"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
landed = read_publishable_target(target)
|
|
385
|
+
if landed is None:
|
|
386
|
+
raise WriterError(f"published record {target} disappeared before read-back")
|
|
387
|
+
try:
|
|
388
|
+
verify(landed)
|
|
389
|
+
except WriterError as exc:
|
|
390
|
+
raise WriterError(f"read-back mismatch at {target}: {exc}") from exc
|
|
391
|
+
if landed != record:
|
|
392
|
+
raise WriterError(f"read-back mismatch at {target}: bytes differ from the record")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def publish(target: Path, record: bytes, *, verify: Verifier) -> str:
|
|
396
|
+
"""Publish ``record`` at ``target``. Returns 'published' or 'idempotent'.
|
|
397
|
+
|
|
398
|
+
Failure-atomic: on any error before publication the canonical path is left
|
|
399
|
+
absent and the staging file is removed. ``verify`` is run over the staged
|
|
400
|
+
bytes and over the read-back, and raises :class:`WriterError` when the
|
|
401
|
+
bytes are not a consistent record.
|
|
402
|
+
"""
|
|
403
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
404
|
+
|
|
405
|
+
existing = read_publishable_target(target)
|
|
406
|
+
if existing is not None:
|
|
407
|
+
if existing != record:
|
|
408
|
+
raise _collision_error(target)
|
|
409
|
+
# Idempotent retry after a success: nothing to write, but the previous
|
|
410
|
+
# run's invariants still have to hold before this one calls it success.
|
|
411
|
+
_finalize(target, record, verify)
|
|
412
|
+
return "idempotent"
|
|
413
|
+
|
|
414
|
+
for attempt in range(PUBLISH_ATTEMPTS):
|
|
415
|
+
try:
|
|
416
|
+
status = _attempt_publish(target, record, verify)
|
|
417
|
+
except _StageVanished:
|
|
418
|
+
# The sweep that removed the stage ran after a record was published:
|
|
419
|
+
# ours byte-for-byte when the writers were identical.
|
|
420
|
+
landed = read_publishable_target(target)
|
|
421
|
+
if landed is not None:
|
|
422
|
+
if landed != record:
|
|
423
|
+
raise _collision_error(target) from None
|
|
424
|
+
_finalize(target, record, verify)
|
|
425
|
+
return "idempotent"
|
|
426
|
+
if attempt == PUBLISH_ATTEMPTS - 1:
|
|
427
|
+
raise WriterError(
|
|
428
|
+
f"staging file for {target} was removed before publication on {PUBLISH_ATTEMPTS} consecutive attempts"
|
|
429
|
+
) from None
|
|
430
|
+
continue
|
|
431
|
+
_finalize(target, record, verify)
|
|
432
|
+
return status
|
|
433
|
+
raise AssertionError("unreachable") # pragma: no cover
|