File size: 21,474 Bytes
4879fc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
"""In-process read model over the central bucket (Β§16.1).

Two layers, per central-bucket folder:

- **Listing cache** β€” the folder's tree listing, refreshed at most once per
  ``LISTING_TTL_S`` behind a per-folder lock (single-flight): any number of
  concurrent readers costs at most one bucket listing per TTL window.
- **Content cache** β€” parsed ``{frontmatter, body}`` per file, keyed by the
  listing's ``xet_hash`` so byte-identical files (inbox copies) share one
  cached entry. Bounded by ``CONTENT_CACHE_MAX_BYTES`` with LRU eviction;
  eviction means a refetch, never an error. Cold misses are fetched in one
  **batch** download, not per file.

Coherence: the Space is the only writer to the central bucket (Β§2), so every
API write is inserted synchronously (``write_through``) β€” agents always
observe their own writes immediately, independent of TTL. Locally written
entries live in an overlay merged over bucket listings for a grace window, so
a lagging bucket listing can never make a fresh write disappear. The TTL
exists only to pick up out-of-band admin edits (verification verdicts, force
re-registrations); the per-file hash check then refreshes exactly the changed
entries, so mutable files need no special handling.

All state here is cache β€” restart-safe by loss (Β§1).
"""
from __future__ import annotations

import json
import logging
import re
import threading
import time
from collections import OrderedDict
from dataclasses import dataclass, field, replace
from typing import Any, Callable

from app.config import Settings
from app.frontmatter import parse
from app.hub import HubClient, ListedFile
from app.naming import (
    BROADCASTS_FOLDER,
    CHANNELS_FOLDER,
    VERIFICATION_STATUS_PATH,
    channel_readme_path,
)
from app.validation import NOTIFY_ALL, NOTIFY_MENTIONS, stored_notify_level


log = logging.getLogger(__name__)

_README_RE = re.compile(r"(?:^|/)README\.md$", re.IGNORECASE)

# A channel *message*: channels/{name}/{stamp}_{author}.md β€” depth exactly 2
# under channels/, stamped leaf. Excludes the README (the theme) and the
# members/ markers by shape, not by convention.
_CHANNEL_MSG_RE = re.compile(
    r"^channels/([a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)/(\d{8}-\d{6}-\d{3}_[^/]+\.md)$"
)
# A subscription marker: channels/{name}/members/{handle}.md.
_CHANNEL_MEMBER_RE = re.compile(
    r"^channels/([a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?)/members/([^/]+)\.md$"
)

# How long a write-through entry shadows the bucket before we trust the bucket
# listing to have caught up. Generous; a write normally appears immediately.
_OVERLAY_GRACE_S = 300.0


@dataclass
class Record:
    filename: str
    path: str
    frontmatter: dict[str, Any]
    body: str
    size: int
    parse_error: bool = False
    # Why this record is in the caller's unified watch stream; set only by
    # ``updates_records`` (WATCH_DESIGN.md Β§4.2) and carried through the list
    # grammar into the expanded item. Every other view leaves it None.
    reasons: list[str] | None = None


@dataclass
class _Folder:
    files: dict[str, ListedFile] = field(default_factory=dict)
    fetched_at: float = float("-inf")
    overlay: dict[str, tuple[ListedFile, float]] = field(default_factory=dict)
    lock: threading.Lock = field(default_factory=threading.Lock)


def _safe_parse(raw: bytes) -> tuple[dict[str, Any], str, bool]:
    """Parse a bucket file, never raising: a malformed historical file must
    degrade to an empty-frontmatter record, not 4xx/5xx a GET."""
    try:
        text = raw.decode("utf-8")
    except UnicodeDecodeError:
        return {}, raw.decode("utf-8", errors="replace"), True
    try:
        fm, body = parse(text)
    except Exception:
        return {}, text, True
    return fm, body, False


class ReadModel:
    def __init__(
        self,
        hub: HubClient,
        settings: Settings,
        clock: Callable[[], float] = time.monotonic,
    ):
        self._hub = hub
        self._settings = settings
        self._clock = clock
        self._folders: dict[str, _Folder] = {}
        self._folders_lock = threading.Lock()
        # Parsed content keyed by xet_hash: (frontmatter, body, size, parse_error).
        self._content: OrderedDict[str, tuple[dict, str, int, bool]] = OrderedDict()
        self._content_bytes = 0
        # Write-through entries whose xet_hash isn't known yet, keyed by path.
        self._local: dict[str, tuple[dict, str, int, float]] = {}
        self._verification: tuple[str, dict[str, str]] | None = None
        self._content_lock = threading.Lock()

    # ───────────────────────── listings ─────────────────────────

    def _folder(self, folder: str) -> _Folder:
        with self._folders_lock:
            return self._folders.setdefault(folder, _Folder())

    def listing(self, folder: str) -> list[ListedFile]:
        """The folder's current listing: TTL-cached bucket truth merged with
        the local write-through overlay (overlay fills gaps, never overrides)."""
        f = self._folder(folder)
        with f.lock:
            now = self._clock()
            if now - f.fetched_at >= self._settings.listing_ttl_s:
                fresh = self._hub.list_central_dir(folder)
                if not fresh and f.files:
                    # The hub flattens listing errors to []; nothing is ever
                    # deleted from these folders, so an empty result for a
                    # previously non-empty folder is a transient failure.
                    log.warning(
                        "listing(%s) came back empty; keeping %d cached entries",
                        folder, len(f.files),
                    )
                else:
                    f.files = {e.rel_path: e for e in fresh}
                f.fetched_at = now
                f.overlay = {
                    p: (e, ts)
                    for p, (e, ts) in f.overlay.items()
                    if p not in f.files and now - ts < _OVERLAY_GRACE_S
                }
            merged = dict(f.files)
            for p, (e, _ts) in f.overlay.items():
                merged.setdefault(p, e)
            return list(merged.values())

    def _md_entries(self, folder: str) -> list[ListedFile]:
        return [
            e
            for e in self.listing(folder)
            if e.rel_path.endswith(".md") and not _README_RE.search(e.rel_path)
        ]

    # ───────────────────────── records ─────────────────────────

    def records(self, folder: str) -> list[Record]:
        """Parsed records for every .md file under ``folder`` (READMEs
        excluded), ascending by filename. Cold misses are batch-fetched."""
        out = self._resolve_many(self._md_entries(folder))
        return [out[p] for p in sorted(out)]

    def records_for(self, folder: str, paths: list[str]) -> dict[str, Record]:
        """Resolve specific files from ``folder``'s listing through the content
        cache, keyed by rel_path. For files ``records`` excludes by convention
        (READMEs) or selective reads over a tree listing (taskforces, Β§18).
        Unlisted paths are silently absent from the result."""
        by_path = {e.rel_path: e for e in self.listing(folder)}
        return self._resolve_many([by_path[p] for p in paths if p in by_path])

    def _resolve_many(self, entries: list[ListedFile]) -> dict[str, Record]:
        out: dict[str, Record] = {}
        misses: list[ListedFile] = []
        with self._content_lock:
            for e in entries:
                rec = self._resolve_cached(e)
                if rec is not None:
                    out[e.rel_path] = rec
                else:
                    misses.append(e)
        if misses:
            fetched = self._hub.download_many(
                self._settings.central_bucket, [e.rel_path for e in misses]
            )
            with self._content_lock:
                for e in misses:
                    raw = fetched.get(e.rel_path)
                    if raw is None:
                        continue  # transient download failure; heals next pass
                    out[e.rel_path] = self._insert(e, raw)
        return out

    def record(self, folder: str, filename: str) -> Record | None:
        """One file, resolved through the cache; None if it isn't listed."""
        path = f"{folder}/{filename}"
        entry = next((e for e in self.listing(folder) if e.rel_path == path), None)
        if entry is None:
            return None
        with self._content_lock:
            rec = self._resolve_cached(entry)
        if rec is not None:
            return rec
        raw = self._hub.download_many(self._settings.central_bucket, [path]).get(path)
        if raw is None:
            return None
        with self._content_lock:
            return self._insert(entry, raw)

    def _resolve_cached(self, e: ListedFile) -> Record | None:
        """Caller holds ``_content_lock``."""
        filename = e.rel_path.rsplit("/", 1)[-1]
        if e.xet_hash and e.xet_hash in self._content:
            self._content.move_to_end(e.xet_hash)
            fm, body, size, perr = self._content[e.xet_hash]
            return Record(filename, e.rel_path, fm, body, size, perr)
        if e.rel_path in self._local:
            fm, body, size, _ts = self._local[e.rel_path]
            return Record(filename, e.rel_path, fm, body, size, False)
        return None

    def _insert(self, e: ListedFile, raw: bytes) -> Record:
        """Caller holds ``_content_lock``."""
        fm, body, perr = _safe_parse(raw)
        if e.xet_hash:
            if e.xet_hash not in self._content:
                self._content[e.xet_hash] = (fm, body, len(raw), perr)
                self._content_bytes += len(raw)
                while (
                    self._content_bytes > self._settings.content_cache_max_bytes
                    and len(self._content) > 1
                ):
                    _, (_f, _b, sz, _p) = self._content.popitem(last=False)
                    self._content_bytes -= sz
            else:
                self._content.move_to_end(e.xet_hash)
        filename = e.rel_path.rsplit("/", 1)[-1]
        return Record(filename, e.rel_path, fm, body, len(raw), perr)

    # ───────────────────────── write-through ─────────────────────────

    def write_through(
        self, path: str, frontmatter: dict, body: str, size: int,
        folder: str | None = None,
    ) -> None:
        """Insert a just-written central-bucket file so read-after-write is
        exact regardless of listing TTL. Call right after the bucket write.

        ``folder`` pins which folder cache gets the listing overlay when it is
        not the file's immediate parent β€” taskforce files live under one shared
        ``taskforces`` tree listing whatever their subdirectory (Β§18.4)."""
        if folder is None:
            folder, _, _filename = path.rpartition("/")
        f = self._folder(folder)
        now = self._clock()
        with f.lock:
            f.overlay[path] = (ListedFile(rel_path=path, size=size, xet_hash=None), now)
        with self._content_lock:
            self._local[path] = (frontmatter, body, size, now)
            stale = [
                p for p, (_f, _b, _s, ts) in self._local.items()
                if now - ts >= _OVERLAY_GRACE_S
            ]
            for p in stale:
                del self._local[p]

    # ───────────────────────── derived views ─────────────────────────

    def registered_agents(self) -> set[str]:
        return {
            e.rel_path.rsplit("/", 1)[-1].removesuffix(".md")
            for e in self._md_entries("agents")
        }

    def inbox_records(self, handle: str) -> list[Record]:
        """The handle's inbox view: its mention/refs fan-out copies UNION every
        organizer broadcast. Broadcasts are stored once under broadcasts/ and
        merged here at read time, so a handle that never registered or joined
        after the broadcast still sees it. Deduped by filename (the same
        server-stamped name is unique), ascending by filename; callers apply
        the list grammar (order, cursor, limit)."""
        by_name: dict[str, Record] = {}
        for r in self.records(f"inbox/{handle}"):
            by_name[r.filename] = r
        for r in self.records(BROADCASTS_FOLDER):
            by_name.setdefault(r.filename, r)
        return [by_name[f] for f in sorted(by_name)]

    # ───────────────────────── channels ─────────────────────────
    # All channel reads run over the ONE recursive channels/ listing (the
    # taskforce FOLDER pattern): summaries, rosters, subscriptions, and the
    # cross-channel feed each cost at most one bucket listing per TTL window.

    def channel_exists(self, name: str) -> bool:
        """A channel exists iff its README (the theme) is listed β€” the same
        structural invariant as taskforces. Shared by the channels router and
        the POST /v1/messages channel gate (import-cycle-free)."""
        readme = channel_readme_path(name)
        return any(e.rel_path == readme for e in self.listing(CHANNELS_FOLDER))

    def channel_message_records(self, name: str) -> list[Record]:
        """One channel's messages (stamped files only β€” README and member
        markers excluded by shape), ascending by filename."""
        paths = [
            e.rel_path
            for e in self.listing(CHANNELS_FOLDER)
            if (m := _CHANNEL_MSG_RE.match(e.rel_path)) and m.group(1) == name
        ]
        recs = self.records_for(CHANNELS_FOLDER, paths)
        return [recs[p] for p in sorted(recs)]

    def channel_subscriptions(self, handle: str) -> list[str]:
        """Channel names the handle subscribes to β€” derived by filtering the
        cached listing for its member markers; zero content reads."""
        return sorted(
            {
                m.group(1)
                for e in self.listing(CHANNELS_FOLDER)
                if (m := _CHANNEL_MEMBER_RE.match(e.rel_path))
                and m.group(2) == handle
            }
        )

    def channel_notify_levels(self, handle: str) -> dict[str, str]:
        """``{channel: notify level}`` for every channel the handle is a member
        of, name-sorted. ``all`` when the marker carries ``notify: all``,
        ``mentions`` otherwise β€” an absent or unrecognised value reads as the
        quiet default, so every pre-existing (and backfilled) membership is
        correct without a migration.

        The sibling ``channel_subscriptions`` answers membership from marker
        *paths* alone at zero content reads; levels need marker *content*, so
        this costs one read per marker β€” resolved through the same
        hash-keyed content cache as every other record, so a steady state
        downloads nothing. Callers that only need membership keep the free
        path."""
        markers: dict[str, str] = {}
        for e in self.listing(CHANNELS_FOLDER):
            m = _CHANNEL_MEMBER_RE.match(e.rel_path)
            if m and m.group(2) == handle:
                markers[e.rel_path] = m.group(1)
        recs = self.records_for(CHANNELS_FOLDER, list(markers))
        levels: dict[str, str] = {}
        for path, name in markers.items():
            rec = recs.get(path)
            levels[name] = (
                stored_notify_level(rec.frontmatter) if rec else NOTIFY_MENTIONS
            )
        return dict(sorted(levels.items()))

    def updates_records(self, handle: str) -> list[Record]:
        """The handle's unified watch stream (WATCH_DESIGN.md Β§4.2): its inbox
        (mentions/refs wherever they were posted, plus organizer broadcasts)
        UNION the full traffic of only those channels it has flipped to
        ``notify: all``. Channels left at the quiet default contribute nothing
        here β€” their @mentions still arrive via the inbox side.

        Deduped by filename: a channel post that also @mentions you exists twice
        in the bucket (the channel copy and the inbox fan-out copy) and must be
        delivered exactly once, carrying BOTH reasons. Sorted by
        (filename, path) like ``channel_feed_records``, so one filename cursor
        covers the whole union β€” stamps are server-issued and per-author
        monotonic, which makes filenames globally unique and lexical order
        chronological order."""
        reasons: dict[str, list[str]] = {}
        by_name: dict[str, Record] = {}
        for r in self.inbox_records(handle):
            by_name[r.filename] = r
            # Provenance is the path: a broadcast is the one shared copy under
            # broadcasts/, everything else got here by @mention or refs.
            reasons[r.filename] = [
                "broadcast" if r.path.startswith(f"{BROADCASTS_FOLDER}/") else "mention"
            ]
        for name, level in self.channel_notify_levels(handle).items():
            if level != NOTIFY_ALL:
                continue
            for r in self.channel_message_records(name):
                by_name.setdefault(r.filename, r)
                reasons.setdefault(r.filename, []).append(f"channel:{name}")
        return sorted(
            (replace(r, reasons=reasons[fn]) for fn, r in by_name.items()),
            key=lambda r: (r.filename, r.path),
        )

    def channel_feed_records(self, handle: str) -> list[Record]:
        """The handle's cross-channel feed: the union of every subscribed
        channel's messages (CHANNELS_DESIGN.md Β§4). Records are keyed by
        rel_path β€” two channels can mint the same {stamp}_{author} filename,
        and both must survive the union β€” then sorted (filename, path) so the
        list grammar's filename cursors stay chronological."""
        subs = set(self.channel_subscriptions(handle))
        if not subs:
            return []
        paths = [
            e.rel_path
            for e in self.listing(CHANNELS_FOLDER)
            if (m := _CHANNEL_MSG_RE.match(e.rel_path)) and m.group(1) in subs
        ]
        recs = self.records_for(CHANNELS_FOLDER, paths)
        return sorted(recs.values(), key=lambda r: (r.filename, r.path))

    # ───────────────────────── delete-through ─────────────────────────

    def delete_through(self, path: str, folder: str | None = None) -> None:
        """Remove a just-deleted central-bucket file from the caches so
        read-after-delete is exact regardless of listing TTL β€” the inverse of
        ``write_through``, and like it called right after the bucket write.
        Without this, a recently written overlay entry (grace window 300s)
        would resurrect the file long after the bucket forgot it. Channel
        unsubscribe is the only caller (nothing else deletes)."""
        if folder is None:
            folder, _, _filename = path.rpartition("/")
        f = self._folder(folder)
        with f.lock:
            f.files.pop(path, None)
            f.overlay.pop(path, None)
        with self._content_lock:
            self._local.pop(path, None)

    def invalidate_verification_index(self) -> None:
        """Drop the cached verification index after the Space itself rewrites
        it (automated verdicts, Β§5.7) β€” that write is no longer an out-of-band
        admin edit, so it must not wait out the listing TTL. The next
        ``verification_index()`` call refetches the file (one download)."""
        with self._content_lock:
            self._verification = None

    def verification_index(self) -> dict[str, str]:
        """Parsed ``results/verification_status.json``, cached by its listing
        hash. Absent or unreadable β†’ {} (every result then reads as pending β€”
        the truthful default for an unreviewed result)."""
        entry = next(
            (e for e in self.listing("results") if e.rel_path == VERIFICATION_STATUS_PATH),
            None,
        )
        if entry is None:
            return {}
        with self._content_lock:
            if (
                entry.xet_hash
                and self._verification is not None
                and self._verification[0] == entry.xet_hash
            ):
                return self._verification[1]
        raw = self._hub.download_many(
            self._settings.central_bucket, [VERIFICATION_STATUS_PATH]
        ).get(VERIFICATION_STATUS_PATH)
        if raw is None:
            return {}
        try:
            data = json.loads(raw.decode("utf-8"))
        except (json.JSONDecodeError, UnicodeDecodeError) as exc:
            log.error("verification index unparseable: %s", exc)
            return {}
        if not isinstance(data, dict):
            log.error("verification index is not a JSON object")
            return {}
        index = {str(k): str(v) for k, v in data.items()}
        if entry.xet_hash:
            with self._content_lock:
                self._verification = (entry.xet_hash, index)
        return index