oc_chat
Loading...
Searching...
No Matches
event_bootstrap.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/event_bootstrap.py
4
5"""
6Per-process auto-attach of the M2.2 ``EventWriter`` to the default bus — M2.3.
7
8Purpose
9-------
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.
17
18Responsibilities
19----------------
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.
27
28Diagnostics
29-----------
30Domain: OC_CHAT.MAIN
31Levels:
32 L2 — writer attach / detach lifecycle.
33 L3 — base-dir resolution detail (env override vs default).
34 L4 — module-load coverage marker.
35
36Contracts
37---------
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).
43
44Dependencies
45------------
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)
51"""
52from __future__ import annotations
53
54import os
55import re
56from pathlib import Path
57from typing import TYPE_CHECKING, Optional
58
59from oc_diagnostics import _dbg
60
61from oc_chat.core.event_bus import default_bus
62from oc_chat.core.event_writer import EventWriter, PreparedEventWriter
63
64if TYPE_CHECKING:
65 from oc_chat.core.session import ChatSession
66
67_dbg("OC_CHAT.MAIN", "event_bootstrap", "module", 4)
68
69# Module-level singleton guarding re-attach attempts (ADR-2 idempotency).
70_active_writer: Optional[EventWriter] = None
71
72
73def _slug(name: str) -> str:
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"
77
78
79def _event_root() -> Path:
80 """Return the ROOT under which per-session event dirs live (plan 152 D8).
81
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`).
87 """
88 env_dir = os.environ.get("OC_CHAT_EVENT_DIR")
89 if env_dir:
90 return Path(env_dir)
91 return Path.cwd() / ".oc_chat" / "events"
92
93
94def _session_dir_key(session: "ChatSession") -> str:
95 """The directory name for *session*: its canonical id, else the name slug.
96
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
101 no canonical id.
102 """
103 from oc_chat.core.external_content_gate import is_canonical_session_id
104 sid = getattr(session, "session_id", None)
105 if is_canonical_session_id(sid):
106 return str(sid)
107 return _slug(getattr(session, "name", "") or "")
108
109
110def _resolve_base_dir(session: "ChatSession") -> Path:
111 """Resolve the writer base dir: ``<event root>/<session_id>`` (plan 152 D8).
112
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.
117 """
118 func = "_resolve_base_dir"
119 base = _event_root() / _session_dir_key(session)
120 _dbg("OC_CHAT.MAIN", func, f"base={base}", 3)
121 return base
122
123
124def _valid_alias(value: object) -> bool:
125 """True iff *value* is EXACTLY a canonical :func:`_slug` output (R11-08).
126
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
133 to something else.
134 """
135 if not isinstance(value, str) or not value:
136 return False
137 return value == _slug(value)
138
139
140def _contained_alias_dir(root: Path, alias: str) -> Optional[Path]:
141 """Resolve *alias* under *root* and prove containment AFTER resolution.
142
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).
147 """
148 func = "_contained_alias_dir"
149 if not _valid_alias(alias):
150 _dbg("OC_CHAT.MAIN", func, "rejected alias: not a canonical slug", 3)
151 return None
152 try:
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)
157 return None
158 if not candidate.is_dir():
159 return None
160 return candidate
161 except Exception as exc:
162 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: alias resolution failed: {type(exc).__name__}", 3)
163 return None
164
165
166def event_read_dirs(session: "ChatSession") -> tuple[Path, ...]:
167 """Return the CANDIDATE event-log read roots for *session*, in MERGE ORDER.
168
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.
172
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.
179
180 Discovery is READ-ONLY: this never creates, mutates or dirties anything.
181 Only existing directories are returned, de-duplicated by resolved path.
182 """
183 func = "event_read_dirs"
184 root = _event_root()
185 out: list[Path] = []
186 seen: set[str] = set()
187
188 def _add(path: Optional[Path]) -> None:
189 if path is None:
190 return
191 try:
192 if not path.is_dir():
193 return
194 key = str(path.resolve(strict=False)).casefold() # Windows-safe identity
195 except Exception:
196 return
197 if key in seen:
198 return
199 seen.add(key)
200 out.append(path)
201
202 id_key = _session_dir_key(session)
203 name_slug = _slug(getattr(session, "name", "") or "")
204
205 # 1. legacy: the pre-migration name-slug dir (skip when it IS the id dir).
206 if name_slug != id_key:
207 _add(root / name_slug)
208 # 2. legacy: persisted rename aliases, re-validated + containment-proved.
209 for alias in getattr(session, "legacy_event_slugs", None) or ():
210 _add(_contained_alias_dir(root, alias))
211 # 3. legacy: the OLD direct override root (events written before it became a root).
212 if os.environ.get("OC_CHAT_EVENT_DIR"):
213 _add(root)
214 # 4. the current identity dir, LAST.
215 _add(root / id_key)
216
217 _dbg("OC_CHAT.MAIN", func, f"{len(out)} candidate root(s)", 3)
218 return tuple(out)
219
220
222 session: "ChatSession",
223 *,
224 base_dir: Optional[Path] = None,
225) -> Optional[EventWriter]:
226 """Attach a singleton ``EventWriter`` to ``default_bus()`` (idempotent).
227
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.
230 """
231 func = "attach_default_writer"
232 global _active_writer
233 if _active_writer is not None:
234 return _active_writer
235 base = base_dir or _resolve_base_dir(session)
236 writer = EventWriter(base)
237 writer.attach(default_bus())
238 _active_writer = writer
239 _dbg("OC_CHAT.MAIN", func, f"attached writer at {base}", 2)
240 return writer
241
242
243def active_event_dir(session: "ChatSession") -> Path:
244 """Return the session's event-log base dir (read-only; M2.6 consumers).
245
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).
249 """
250 if _active_writer is not None:
251 return _active_writer._base_dir
252 return _resolve_base_dir(session)
253
254
255def install_prepared_writer(prepared: "PreparedEventWriter") -> EventWriter:
256 """Swap the singleton writer for a PREPARE-opened one — COMMIT. No-fail.
257
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.
264
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.
270
271 Linearization boundary: pre-swap events are in the OLD dir, post-swap events
272 in the NEW dir.
273 """
274 func = "install_prepared_writer"
275 global _active_writer
276 old = _active_writer
277 old_sub = getattr(old, "_subscription", None) if old is not None else None
278 bus = default_bus()
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
282 if old is not None:
283 try:
284 old.close()
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
289
290
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)
299
300
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)