6Per-process auto-attach of the M2.2 ``EventWriter`` to the default bus — M2.3.
10The React loop (M2.3) publishes ``TurnEvent``s on ``default_bus()`` but does
11not persist them. This module wires the M2.2 ``EventWriter`` to that bus once
12per process at session bootstrap (plan 101 ADR-2): not at import time (would
13touch the filesystem on import) and not lazily inside ``run_turn`` (would
14couple the loop to disk state and complicate unit fixtures). The single
15bootstrap call site is ``oc_chat/main.py``, immediately after the active
16``ChatSession`` is bound.
20- :func:`attach_default_writer` — idempotently construct one ``EventWriter``
21 rooted at the resolved base dir and attach it to ``default_bus()``.
22- :func:`detach_default_writer` — close the active writer and clear the
23 module singleton (test teardown + clean process exit).
24- :func:`_resolve_base_dir` — honour ``OC_CHAT_EVENT_DIR`` else default to
25 ``<cwd>/.oc_chat/events/<session-slug>``.
26- :func:`_slug` — sanitise a session name into a path-safe slug.
32 L2 — writer attach / detach lifecycle.
33 L3 — base-dir resolution detail (env override vs default).
34 L4 — module-load coverage marker.
38- ``attach_default_writer`` is idempotent: repeated calls return the same
39 ``EventWriter`` and never double-subscribe the bus.
40- The writer is attached to the process-wide ``default_bus()`` singleton, so
41 every event the React loop publishes is persisted.
42- ``detach_default_writer`` is safe to call when no writer is attached (no-op).
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.core.event_bus (default_bus — the process-wide event bus)
48- oc_chat.core.event_writer (EventWriter — the rotating NDJSON sink)
49- oc_chat.core.external_content_gate (lazy; canonical session id check for the event directory name)
50- oc_chat.core.session (lazy; ChatSession type of the session whose events are written)
52from __future__
import annotations
56from pathlib
import Path
57from typing
import TYPE_CHECKING, Optional
59from oc_diagnostics
import _dbg
67_dbg(
"OC_CHAT.MAIN",
"event_bootstrap",
"module", 4)
70_active_writer: Optional[EventWriter] =
None
74 """Return a path-safe slug for *name* (lowercase, non ``[a-z0-9_-]`` → ``-``)."""
75 slug = re.sub(
r"[^a-z0-9_-]+",
"-", (name
or "").lower()).strip(
"-")
76 return slug
or "session"
80 """Return the ROOT under which per-session event dirs live (plan 152 D8).
82 ``OC_CHAT_EVENT_DIR`` is a ROOT, not an exact per-process directory. It used
83 to be returned verbatim, which meant every session in a process collided
84 under one directory — incompatible with deriving the dir from the session
85 identity. Callers append the session subdirectory themselves; the OLD direct
86 root stays READABLE as a legacy candidate (see :func:`event_read_dirs`).
88 env_dir = os.environ.get(
"OC_CHAT_EVENT_DIR")
91 return Path.cwd() /
".oc_chat" /
"events"
95 """The directory name for *session*: its canonical id, else the name slug.
97 The canonical ``session_id`` is IMMUTABLE, so deriving the event dir from it
98 makes rename, restart and same-name reuse all stable — a name slug does not
99 (it splits one session's history across a rename and collides when two
100 sessions share a name). The slug remains the fallback for doubles that carry
104 sid = getattr(session,
"session_id",
None)
105 if is_canonical_session_id(sid):
107 return _slug(getattr(session,
"name",
"")
or "")
111 """Resolve the writer base dir: ``<event root>/<session_id>`` (plan 152 D8).
113 The WRITE dir is derived from the immutable canonical ``session_id``, not the
114 mutable name slug. ``OC_CHAT_EVENT_DIR`` selects the root; the session
115 subdirectory is appended under it either way, so the override can no longer
116 collide across sessions.
118 func =
"_resolve_base_dir"
120 _dbg(
"OC_CHAT.MAIN", func, f
"base={base}", 3)
125 """True iff *value* is EXACTLY a canonical :func:`_slug` output (R11-08).
127 The persisted ``legacy_event_slugs`` collection is untrusted path input:
128 ``load_session_metadata`` returns the RAW dict when model validation fails,
129 so model-level validation alone is bypassable and the grammar must be
130 re-checked AT USE. Requiring ``value == _slug(value)`` and non-empty rejects
131 separators, ``.``/``..``, absolute / drive / UNC forms, and every
132 non-canonical spelling in one predicate, because ``_slug`` maps all of them
135 if not isinstance(value, str)
or not value:
137 return value ==
_slug(value)
141 """Resolve *alias* under *root* and prove containment AFTER resolution.
143 Returns the resolved directory, or ``None`` when the alias is not a valid
144 slug, does not exist, or resolves OUTSIDE the event root (the pinned
145 symlink / reparse-point policy: comparison is on RESOLVED real paths, so a
146 link escaping the root is rejected rather than followed).
148 func =
"_contained_alias_dir"
150 _dbg(
"OC_CHAT.MAIN", func,
"rejected alias: not a canonical slug", 3)
153 root_resolved = root.resolve(strict=
False)
154 candidate = (root / alias).resolve(strict=
False)
155 if not candidate.is_relative_to(root_resolved):
156 _dbg(
"OC_CHAT.MAIN", func,
"rejected alias: resolves outside the event root", 2)
158 if not candidate.is_dir():
161 except Exception
as exc:
162 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: alias resolution failed: {type(exc).__name__}", 3)
167 """Return the CANDIDATE event-log read roots for *session*, in MERGE ORDER.
169 A directory is a candidate SOURCE, never an identity — the reader must still
170 filter every line by payload ``session_id`` (real slug dirs are measurably
171 multi-session). This function only says WHERE to look.
173 Order is the pinned total order (plan 152 ADR-8): ALL legacy roots precede
174 the ``session_id`` dir, because post-cutover writes go only to the id dir —
175 a migration-epoch fact, not a timestamp guess. Across legacy roots the
176 precedence is: the default name-slug dir, then the persisted
177 ``legacy_event_slugs`` aliases in their recorded order, then the OLD direct
178 ``OC_CHAT_EVENT_DIR`` root.
180 Discovery is READ-ONLY: this never creates, mutates or dirties anything.
181 Only existing directories are returned, de-duplicated by resolved path.
183 func =
"event_read_dirs"
186 seen: set[str] = set()
188 def _add(path: Optional[Path]) ->
None:
192 if not path.is_dir():
194 key = str(path.resolve(strict=
False)).casefold()
203 name_slug =
_slug(getattr(session,
"name",
"")
or "")
206 if name_slug != id_key:
207 _add(root / name_slug)
209 for alias
in getattr(session,
"legacy_event_slugs",
None)
or ():
212 if os.environ.get(
"OC_CHAT_EVENT_DIR"):
217 _dbg(
"OC_CHAT.MAIN", func, f
"{len(out)} candidate root(s)", 3)
222 session:
"ChatSession",
224 base_dir: Optional[Path] =
None,
225) -> Optional[EventWriter]:
226 """Attach a singleton ``EventWriter`` to ``default_bus()`` (idempotent).
228 Returns the active writer. A second call returns the same instance without
229 re-subscribing the bus, so the wildcard subscriber count stays at one.
231 func =
"attach_default_writer"
232 global _active_writer
233 if _active_writer
is not None:
234 return _active_writer
237 writer.attach(default_bus())
238 _active_writer = writer
239 _dbg(
"OC_CHAT.MAIN", func, f
"attached writer at {base}", 2)
244 """Return the session's event-log base dir (read-only; M2.6 consumers).
246 Prefers the attached writer's actual base dir so ``/trace`` and ``/packet``
247 read exactly where events are being written; falls back to
248 :func:`_resolve_base_dir` when no writer is attached (e.g. tests).
250 if _active_writer
is not None:
251 return _active_writer._base_dir
256 """Swap the singleton writer for a PREPARE-opened one — COMMIT. No-fail.
258 The bus mutation is a single :meth:`EventBus.replace_subscription` under ONE
259 lock acquisition, so a ``publish`` racing this swap observes EXACTLY one
260 wildcard writer — never zero, never two. All the fallible work (mkdir,
261 resume scan, open) already happened in
262 :meth:`EventWriter.open_unsubscribed`; what remains is a lock-protected list
263 mutation and two assignments, none of which can raise.
265 The OLD writer is closed immediately after the swap. There is no in-flight
266 drain: production publishers are main-thread and between-turns quiescent
267 (see the ``event_bus`` module Contracts note), and the transition's entry
268 lease forecloses a pre-swap snapshot. Closing is best-effort — the old
269 writer is already unsubscribed, so it cannot route later events either way.
271 Linearization boundary: pre-swap events are in the OLD dir, post-swap events
274 func =
"install_prepared_writer"
275 global _active_writer
277 old_sub = getattr(old,
"_subscription",
None)
if old
is not None else None
279 new_sub = bus.replace_subscription(old_sub,
"*", prepared.writer._on_event)
280 prepared.writer.adopt_subscription(bus, new_sub)
281 _active_writer = prepared.writer
285 except Exception
as exc:
286 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: old writer close failed: {exc}", 2)
287 _dbg(
"OC_CHAT.MAIN", func, f
"installed writer at {prepared.writer._base_dir}", 2)
288 return prepared.writer
292 """Close the active writer and clear the singleton. No-op when unattached."""
293 func =
"detach_default_writer"
294 global _active_writer
295 if _active_writer
is not None:
296 _active_writer.close()
297 _active_writer =
None
298 _dbg(
"OC_CHAT.MAIN", func,
"detached writer", 2)
301__all__ = [
"attach_default_writer",
"detach_default_writer",
"active_event_dir"]
bool _valid_alias(object value)
tuple[Path,...] event_read_dirs("ChatSession" session)
str _session_dir_key("ChatSession" session)
Path active_event_dir("ChatSession" session)
Optional[Path] _contained_alias_dir(Path root, str alias)
Path _resolve_base_dir("ChatSession" session)
Optional[EventWriter] attach_default_writer("ChatSession" session, *, Optional[Path] base_dir=None)
EventWriter install_prepared_writer("PreparedEventWriter" prepared)
None detach_default_writer()