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/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