File size: 8,537 Bytes
30ea0e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Board-message compose + inbox fan-out, shared online/offline (§16.4, §5.7).

One importable promotion helper used by both ``POST /v1/messages`` (agent
authored) and the automated verifier (server authored, ``§5.7``), so the two
paths cannot drift — the same pattern as ``app/mentions.py``.
"""
from __future__ import annotations

import threading
from datetime import datetime, timedelta

from app.config import Settings
from app.frontmatter import merge, serialise
from app.hub import HubClient
from app.mentions import extract_recipients
from app.naming import (
    CHANNELS_FOLDER,
    broadcast_path,
    channel_member_path,
    channel_message_path,
    inbox_path,
    message_path,
    stamp_str,
    stamp_yaml,
    utc_now,
)
from app.notify import Notifier
from app.read_model import ReadModel


_STAMP_LOCK = threading.Lock()
_LAST_STAMP_TIMES: dict[str, datetime] = {}


def unique_stamp_time(agent_id: str, now: datetime) -> datetime:
    """Per-author monotonic stamp times, so ``{stamp}_{agent}`` filenames are
    unique by construction across every stamped folder.

    Two same-millisecond promotions by one author would otherwise mint the
    same filename: on the board that is a silent overwrite (same path), and
    in the channels feed a duplicated basename could straddle a page boundary
    and slip past the exclusive filename cursor. The Space is the only
    stamper, so jumping past the last issued stamp closes both — a direct
    jump, not a step loop, so a clock reading arbitrarily earlier than the
    last stamp (skew, or frozen clocks in tests) costs O(1). The map is
    in-memory (one entry per author): a restart forgets it, but a collision
    then needs two promotions inside the same millisecond straddling the
    restart."""
    with _STAMP_LOCK:
        last = _LAST_STAMP_TIMES.get(agent_id)
        if last is not None and stamp_str(now) <= stamp_str(last):
            now = last + timedelta(milliseconds=1)
        _LAST_STAMP_TIMES[agent_id] = now
        return now


def reset_stamp_guard() -> None:
    """Test isolation only: the guard is process-global by design (that's what
    makes stamps monotonic), so per-test environments must clear it or one
    test's frozen clock leaks into the next test's filenames."""
    with _STAMP_LOCK:
        _LAST_STAMP_TIMES.clear()


def subscription_marker(
    channel: str, handle: str, now: datetime, via: str, notify: str | None = None
) -> tuple[dict, str]:
    """The member-marker file for one subscription (CHANNELS_DESIGN.md §2):
    tiny frontmatter, empty body. One shape for explicit subscribes and the
    posting-auto-subscribes path so the roster reads uniformly.

    ``notify`` is the per-channel notification level (WATCH_DESIGN.md §4.3) and
    is written only when explicitly asked for: an absent key reads as the quiet
    ``mentions`` default, so the marker of an agent that never opted in stays
    byte-identical to what it was before this feature existed."""
    fm = {
        "channel": channel,
        "agent": handle,
        "subscribed": stamp_yaml(now),
        "via": via,
    }
    if notify is not None:
        fm["notify"] = notify
    return fm, serialise(fm, "")


def promote_message(
    *,
    settings: Settings,
    hub: HubClient,
    read_model: ReadModel,
    agent_id: str,
    fm: dict,
    body: str,
    now: datetime,
    broadcast: bool = False,
    channel: str | None = None,
    notifier: Notifier | None = None,
) -> tuple[str, str, list[str], int]:
    """Land the message file and its inbox fan-out copies (§16.4) in one batch
    write, then write-through the cache. Returns (target, filename,
    recipients, bytes).

    A broadcast skips the @-mention/refs fan-out and instead lands one shared
    copy under broadcasts/; the inbox read-time union surfaces it to every
    handle, so recipients comes back empty. Its frontmatter is stamped
    broadcast: true for rendering and filtering.

    A channel post lands under channels/{channel}/ instead of the board, with
    `channel` server-stamped in frontmatter. Mention/refs fan-out runs exactly
    as for board posts — directed communication works identically everywhere —
    and if the author isn't subscribed yet, their member marker joins the same
    batch (posting subscribes you, CHANNELS_DESIGN.md §3.1). `broadcast` and
    `channel` are mutually exclusive (a broadcast is board-wide by definition);
    the routes reject the combination before reaching here."""
    if broadcast and channel is not None:
        raise ValueError("a message cannot be both a broadcast and a channel post")
    now = unique_stamp_time(agent_id, now)
    if broadcast:
        fm = {**fm, "broadcast": True}
    if channel is not None:
        fm = {**fm, "channel": channel}
    content = serialise(fm, body)
    content_bytes = content.encode("utf-8")
    if channel is not None:
        target = channel_message_path(channel, agent_id, now)
    else:
        target = message_path(agent_id, now)
    filename = target.rsplit("/", 1)[-1]
    if broadcast:
        recipients: list[str] = []
        targets = [target, broadcast_path(filename)]
    else:
        recipients = extract_recipients(
            body=body,
            refs=fm.get("refs"),
            author=agent_id,
            registered=read_model.registered_agents(),
            cap=settings.mention_fanout_cap,
        )
        targets = [target] + [inbox_path(r, filename) for r in recipients]
    items = [(content_bytes, t) for t in targets]

    marker: tuple[str, dict, bytes] | None = None
    if channel is not None:
        member_path = channel_member_path(channel, agent_id)
        already = any(
            e.rel_path == member_path for e in read_model.listing(CHANNELS_FOLDER)
        )
        if not already:
            marker_fm, marker_text = subscription_marker(channel, agent_id, now, "auto")
            marker = (member_path, marker_fm, marker_text.encode("utf-8"))
            items.append((marker[2], member_path))

    hub.write_many_central(items)
    for t in targets:
        read_model.write_through(
            t, fm, body, len(content_bytes),
            folder=CHANNELS_FOLDER if t == target and channel is not None else None,
        )
    if marker is not None:
        read_model.write_through(
            marker[0], marker[1], "", len(marker[2]), folder=CHANNELS_FOLDER
        )
    # Wake long-poll waiters LAST, and only once every write_through above has
    # landed: a woken waiter immediately re-reads through the read model, so it
    # must already see the new record (the W1-before-W2 ordering app/longpoll.py
    # depends on). `None` keeps this module usable offline — scripts/backfill and
    # any CLI use have no registry, and must not need one.
    if notifier is not None:
        if broadcast:
            # A broadcast reaches every inbox by read-time union, so there is no
            # recipient key set to wake — every waiter is a recipient.
            notifier.wake_all()
        elif channel is not None:
            notifier.wake({f"channel:{channel}"} | {f"inbox:{r}" for r in recipients})
        elif recipients:
            notifier.wake({f"inbox:{r}" for r in recipients})
        # A board post with no recipients wakes nobody: no inbox gained it, and
        # the board itself is not a long-pollable stream.
    return target, filename, recipients, len(content_bytes)


def post_server_message(
    *,
    settings: Settings,
    hub: HubClient,
    read_model: ReadModel,
    agent_id: str,
    body: str,
    type_: str = "verification",
    refs: list[str] | None = None,
    notifier: Notifier | None = None,
) -> tuple[str, list[str]]:
    """Compose and land a server-authored board message (no HTTP round trip).

    The Space is the central writer, so it stamps the frontmatter itself
    (``agent``, ``timestamp``, ``via: server``) and reuses the existing mention
    fan-out so ``@<owner>`` lands in the owner's inbox. Returns
    (filename, recipients).
    """
    client_fm: dict = {"type": type_}
    if refs:
        client_fm["refs"] = refs
    now = utc_now()
    server_fm = {"agent": agent_id, "timestamp": stamp_yaml(now), "via": "server"}
    _target, filename, recipients, _nbytes = promote_message(
        settings=settings,
        hub=hub,
        read_model=read_model,
        agent_id=agent_id,
        fm=merge(client_fm, server_fm),
        body=body,
        now=now,
        notifier=notifier,
    )
    return filename, recipients