410 lines
17 KiB
Python
410 lines
17 KiB
Python
"""In-process index of un-consumed web-composer user messages.
|
|
|
|
Backs the optimistic "queued message" bubble for native-terminal
|
|
sessions (claude-native / codex-native) so it survives a client
|
|
re-bind. On those sessions the Omnigent server does NOT persist a web-typed
|
|
user message at POST time — the message is forwarded into the vendor
|
|
TUI and the transcript forwarder later mirrors it back as the single
|
|
durable writer (see ``_dispatch_session_event_to_runner``). Until that
|
|
round-trip completes the message lives nowhere on the server, so a
|
|
client that navigates away and back (or whose SSE pump rebinds
|
|
mid-flight via ``ensureBoundSession``) loses the optimistic bubble it
|
|
rendered locally — it reappears only once the transcript persists it.
|
|
|
|
This index closes that window with the same shape the codebase already
|
|
uses for transient recovery state (:mod:`pending_elicitations`,
|
|
:mod:`inflight_text`):
|
|
|
|
* populated by the route layer on a native web message POST (via
|
|
:func:`record`, before the runner forward), so the message is known
|
|
server-side immediately;
|
|
* replayed into the cold-load snapshot (``GET /v1/sessions/{id}``) via
|
|
:func:`snapshot_for`, so a (re)connecting client re-hydrates the
|
|
bubble instead of showing nothing;
|
|
* drained when the transcript forwarder persists the matching user
|
|
message (via :func:`resolve_oldest`), so the now-committed item
|
|
doesn't double-render alongside a stale pending entry.
|
|
|
|
Unlike :mod:`pending_elicitations` / :mod:`inflight_text`, this index
|
|
is NOT populated through the :func:`session_stream.publish` chokepoint:
|
|
recording needs to return the new entry id to the POST handler (so the
|
|
sender can adopt it and dedupe cleanly), and draining needs to run at
|
|
the persist site so the ``session.input.consumed`` event can carry the
|
|
cleared id. Both are caller-driven, so the access is explicit.
|
|
|
|
Draining is by FIFO order (oldest first), NOT by text. Native gives no
|
|
id channel back through the TUI to correlate the forwarded POST with the
|
|
mirrored transcript item, and the transcript freely reformats the text
|
|
(reply-quote ``>`` blockquotes, ``[Attached:]`` markers, whitespace), so
|
|
matching on text is unreliable — it would leave a reformatted message
|
|
stuck pending and double-rendered. Per-session SSE ordering guarantees
|
|
the i-th persisted user message corresponds to the i-th queued one, so
|
|
each persisted native user message drains the oldest pending entry.
|
|
|
|
The one imperfect case is interleaving a web-composer message with a
|
|
message typed directly in the TUI: the TUI message (which has no pending
|
|
entry) drains the oldest web entry, so that web bubble briefly
|
|
disappears and reappears once it persists. It self-heals; the committed
|
|
bubble always renders the just-persisted content regardless.
|
|
|
|
Limitations (identical to :mod:`pending_elicitations`):
|
|
|
|
* In-memory only; multi-replica Omnigent deploys would each see their own
|
|
slice. Session events are already process-affine (``session_stream``
|
|
is in-process with no replay, and a session's runner relay + SSE
|
|
subscribers live on one process), so this rides the same affinity.
|
|
* Entries do not survive an AP-server restart — acceptable, the loss
|
|
is one in-flight message, same as every other AP-side transient.
|
|
|
|
A forwarded message the vendor TUI never accepts (runner crash, dropped
|
|
keystrokes) is never persisted, so :func:`resolve_oldest` never
|
|
drains its entry. :data:`_TTL_S` bounds that ghost: stale entries are
|
|
evicted lazily on the next :func:`record` / :func:`snapshot_for` /
|
|
:func:`resolve_oldest` for the same conversation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
# A pending entry is evicted this many seconds after it was recorded
|
|
# if it was never drained by a matching persisted message. Covers the
|
|
# vendor-TUI-never-accepted-the-message ghost; long enough that a slow
|
|
# transcript round-trip on a busy session still drains normally.
|
|
_TTL_S: float = 600.0
|
|
|
|
|
|
def _now() -> float:
|
|
"""
|
|
Return the current monotonic clock reading for TTL bookkeeping.
|
|
|
|
Indirection point (not ``time.monotonic`` directly) so tests can
|
|
advance the clock to exercise stale-entry eviction without a real
|
|
sleep, and so the :class:`_Entry` default factory resolves the
|
|
patched function at call time rather than binding the original.
|
|
|
|
:returns: ``time.monotonic()`` seconds.
|
|
"""
|
|
return time.monotonic()
|
|
|
|
|
|
@dataclass
|
|
class DrainedInput:
|
|
"""
|
|
The pending entry drained by :func:`resolve_oldest`.
|
|
|
|
:param pending_id: The drained entry's id, e.g. ``"pending_a1b2c3"``
|
|
— echoed to clients as ``cleared_pending_id`` so they drop the
|
|
matching optimistic bubble by id.
|
|
:param content: The drained entry's message content blocks, e.g.
|
|
``[{"type": "input_image", "file_id": "file_x", "filename":
|
|
"a.png"}, {"type": "input_text", "text": "hi"}]``. The caller
|
|
merges the file blocks into the durably-persisted item, since
|
|
the native transcript round-trip is text-only and would
|
|
otherwise drop the image from history.
|
|
:param created_by: Authenticated identity of the user who posted
|
|
the message, e.g. ``"alice@example.com"``. ``None`` when the
|
|
entry was recorded before this field was introduced or when
|
|
the posting actor was unknown. Applied to the persisted item
|
|
so ``session.input.consumed`` carries the correct author on
|
|
all clients (including collaborators who never saw the
|
|
optimistic bubble).
|
|
"""
|
|
|
|
pending_id: str
|
|
content: list[dict[str, Any]]
|
|
created_by: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class MatchedDrain:
|
|
"""Result from draining pending inputs up to a text-matched entry."""
|
|
|
|
matched: DrainedInput | None
|
|
skipped: list[DrainedInput]
|
|
|
|
|
|
@dataclass
|
|
class _Entry:
|
|
"""
|
|
One un-consumed web-composer user message.
|
|
|
|
:param pending_id: Index-assigned id for this entry, e.g.
|
|
``"pending_a1b2c3"``. Returned by :func:`record`, surfaced in
|
|
:func:`snapshot_for`, and echoed back as the cleared id by
|
|
:func:`resolve_oldest` so the client can drop the bubble by
|
|
id.
|
|
:param content: The message content blocks exactly as POSTed, e.g.
|
|
``[{"type": "input_text", "text": "hi"}]`` (file blocks carry
|
|
real ``file_id``s, since the client uploads before POSTing).
|
|
Replayed verbatim into the snapshot.
|
|
:param created_by: Authenticated identity of the posting actor,
|
|
e.g. ``"alice@example.com"``. ``None`` when unknown. Persisted
|
|
through to the committed item so ``session.input.consumed``
|
|
carries the correct author on all clients.
|
|
:param created_at: ``time.monotonic()`` timestamp at record time,
|
|
used only for TTL eviction.
|
|
"""
|
|
|
|
pending_id: str
|
|
content: list[dict[str, Any]]
|
|
created_by: str | None = None
|
|
# Lambda (not ``_now`` directly) so a monkeypatched ``_now`` is
|
|
# resolved at construction time rather than bound at class def.
|
|
created_at: float = field(default_factory=lambda: _now())
|
|
|
|
|
|
# Per-conversation mapping conversation_id → {pending_id: entry}. The
|
|
# inner dict is insertion-ordered (FIFO), which :func:`resolve_oldest`
|
|
# relies on to drain the oldest matching message first. Empty inner
|
|
# dicts are popped eagerly so the index doesn't accrete stale keys.
|
|
_pending: dict[str, dict[str, _Entry]] = {}
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def _evict_stale_locked(conversation_id: str, now: float) -> None:
|
|
"""
|
|
Drop entries older than :data:`_TTL_S` for one conversation.
|
|
|
|
Caller must hold :data:`_lock`. Pops the conversation key entirely
|
|
once its last entry is evicted so :func:`snapshot_for` returns an
|
|
empty list cleanly.
|
|
|
|
:param conversation_id: Conversation/session id to sweep,
|
|
e.g. ``"conv_abc123"``.
|
|
:param now: Current ``time.monotonic()`` value to compare against.
|
|
"""
|
|
entries = _pending.get(conversation_id)
|
|
if entries is None:
|
|
return
|
|
stale = [pid for pid, entry in entries.items() if now - entry.created_at > _TTL_S]
|
|
for pid in stale:
|
|
entries.pop(pid, None)
|
|
if not entries:
|
|
_pending.pop(conversation_id, None)
|
|
|
|
|
|
def record(
|
|
conversation_id: str,
|
|
content: list[dict[str, Any]],
|
|
created_by: str | None = None,
|
|
) -> str:
|
|
"""
|
|
Record an un-consumed web-composer user message.
|
|
|
|
Called by the route layer for a native-terminal session's web
|
|
message POST, before forwarding to the runner, so the message is
|
|
known server-side immediately and a (re)connecting client can
|
|
replay it via :func:`snapshot_for`. Roll back with :func:`resolve`
|
|
if the forward fails.
|
|
|
|
:param conversation_id: Conversation/session id the message was
|
|
posted to, e.g. ``"conv_abc123"``.
|
|
:param content: Message content blocks as POSTed, e.g.
|
|
``[{"type": "input_text", "text": "hi"}]``.
|
|
:param created_by: Authenticated identity of the posting actor,
|
|
e.g. ``"alice@example.com"``. ``None`` when unknown. Stored
|
|
so :func:`resolve_oldest` can apply it to the persisted item
|
|
and broadcast it via ``session.input.consumed``.
|
|
:returns: The index-assigned pending id, e.g. ``"pending_a1b2c3"``.
|
|
"""
|
|
pending_id = f"pending_{uuid.uuid4().hex}"
|
|
entry = _Entry(pending_id=pending_id, content=content, created_by=created_by)
|
|
with _lock:
|
|
_evict_stale_locked(conversation_id, entry.created_at)
|
|
_pending.setdefault(conversation_id, {})[pending_id] = entry
|
|
return pending_id
|
|
|
|
|
|
def resolve(conversation_id: str, pending_id: str) -> None:
|
|
"""
|
|
Drop a pending entry by id.
|
|
|
|
Called to roll back a :func:`record` whose runner forward failed
|
|
(so a never-delivered message doesn't replay as a ghost bubble).
|
|
Idempotent: dropping an unknown id is a no-op.
|
|
|
|
:param conversation_id: Conversation/session id, e.g.
|
|
``"conv_abc123"``.
|
|
:param pending_id: The id returned by :func:`record`, e.g.
|
|
``"pending_a1b2c3"``.
|
|
"""
|
|
with _lock:
|
|
entries = _pending.get(conversation_id)
|
|
if entries is None:
|
|
return
|
|
entries.pop(pending_id, None)
|
|
if not entries:
|
|
_pending.pop(conversation_id, None)
|
|
|
|
|
|
def resolve_oldest(conversation_id: str) -> DrainedInput | None:
|
|
"""
|
|
Drain the oldest pending entry (FIFO) and return it.
|
|
|
|
Called at the persist site when a native user message is mirrored
|
|
back from the transcript, so the now-committed item doesn't
|
|
double-render alongside its stale pending entry. Draining is by
|
|
insertion order, NOT text: per-session SSE ordering guarantees the
|
|
i-th persisted user message is the i-th queued one, and the
|
|
transcript reformats text (reply-quote blockquotes, ``[Attached:]``
|
|
markers) in ways a text match can't survive.
|
|
|
|
Returns the drained entry (id + content) so the caller can echo the
|
|
id to clients AND merge its file blocks into the durable item — the
|
|
transcript is text-only, so the image would otherwise vanish from
|
|
history. Returns ``None`` when nothing is pending — e.g. a message
|
|
typed directly in the TUI on a session with no queued web messages;
|
|
the caller then renders it as a plain committed item.
|
|
|
|
:param conversation_id: Conversation/session id the message was
|
|
persisted on, e.g. ``"conv_abc123"``.
|
|
:returns: The drained :class:`DrainedInput`, or ``None`` when no
|
|
entry was pending.
|
|
"""
|
|
with _lock:
|
|
_evict_stale_locked(conversation_id, _now())
|
|
entries = _pending.get(conversation_id)
|
|
if entries is None:
|
|
return None
|
|
# Insertion order = FIFO; the first key is the oldest entry.
|
|
oldest_id = next(iter(entries))
|
|
entry = entries.pop(oldest_id)
|
|
if not entries:
|
|
_pending.pop(conversation_id, None)
|
|
return DrainedInput(
|
|
pending_id=entry.pending_id,
|
|
content=copy.deepcopy(entry.content),
|
|
created_by=entry.created_by,
|
|
)
|
|
|
|
|
|
def resolve_matching_text(conversation_id: str, text: str) -> MatchedDrain:
|
|
"""
|
|
Drain through the first pending entry whose text matches ``text``.
|
|
|
|
Kiro persists accepted web prompts as structured ``Prompt`` records. If an
|
|
earlier injected web message errors before Kiro records a prompt, FIFO
|
|
draining would consume that failed entry when the next successful prompt is
|
|
mirrored, leaving the successful prompt stuck pending. This resolver lets
|
|
Kiro match the accepted prompt text and returns any older skipped entries so
|
|
the caller can surface them as failed web injections.
|
|
|
|
:param conversation_id: Conversation/session id, e.g. ``"conv_abc123"``.
|
|
:param text: Accepted prompt text mirrored from Kiro's structured JSONL.
|
|
:returns: Matched entry plus older skipped entries, or no match with an
|
|
empty skipped list when the text was typed directly in the TUI.
|
|
"""
|
|
needle = _normalize_text(text)
|
|
if not needle:
|
|
return MatchedDrain(matched=None, skipped=[])
|
|
with _lock:
|
|
_evict_stale_locked(conversation_id, _now())
|
|
entries = _pending.get(conversation_id)
|
|
if entries is None:
|
|
return MatchedDrain(matched=None, skipped=[])
|
|
ordered = list(entries.items())
|
|
match_index: int | None = None
|
|
for index, (_pending_id, entry) in enumerate(ordered):
|
|
entry_text = _normalize_text(_content_text(entry.content))
|
|
if entry_text and (needle == entry_text or needle.endswith(entry_text)):
|
|
match_index = index
|
|
break
|
|
if match_index is None:
|
|
return MatchedDrain(matched=None, skipped=[])
|
|
skipped_entries = ordered[:match_index]
|
|
_matched_id, matched_entry = ordered[match_index]
|
|
for pending_id, _entry in ordered[: match_index + 1]:
|
|
entries.pop(pending_id, None)
|
|
if not entries:
|
|
_pending.pop(conversation_id, None)
|
|
return MatchedDrain(
|
|
matched=_drained_input(matched_entry),
|
|
skipped=[_drained_input(entry) for _pending_id, entry in skipped_entries],
|
|
)
|
|
|
|
|
|
def snapshot_for(conversation_id: str) -> list[dict[str, Any]]:
|
|
"""
|
|
Return un-consumed messages for one session, for snapshot replay.
|
|
|
|
Read by ``GET /v1/sessions/{id}`` so a client that (re)connects
|
|
after posting a native web message (or after navigating away and
|
|
back) re-hydrates the optimistic bubble. The live SSE stream has no
|
|
replay buffer, so without this the bubble would show nothing until
|
|
the transcript round-trip persists the message.
|
|
|
|
Returns deep copies of the stored content so a caller mutating the
|
|
replayed entry cannot poison the index.
|
|
|
|
:param conversation_id: Conversation/session id to query, e.g.
|
|
``"conv_abc123"``.
|
|
:returns: Insertion-ordered list of dicts, each with ``"pending_id"``
|
|
and ``"content"`` keys, plus an optional ``"created_by"`` key
|
|
when the sender identity was recorded at
|
|
:func:`record` time, e.g. ``[{"pending_id": "pending_a1b2c3",
|
|
"content": [{"type": "input_text", "text": "hi"}],
|
|
"created_by": "alice@example.com"}]``. ``"created_by"`` is
|
|
omitted (not ``null``) when unknown, keeping the wire shape
|
|
backward-compatible with older clients. Empty list when the
|
|
session has no un-consumed messages.
|
|
"""
|
|
with _lock:
|
|
_evict_stale_locked(conversation_id, _now())
|
|
entries = _pending.get(conversation_id)
|
|
if entries is None:
|
|
return []
|
|
return [
|
|
{
|
|
"pending_id": entry.pending_id,
|
|
"content": copy.deepcopy(entry.content),
|
|
**({"created_by": entry.created_by} if entry.created_by is not None else {}),
|
|
}
|
|
for entry in entries.values()
|
|
]
|
|
|
|
|
|
def _drained_input(entry: _Entry) -> DrainedInput:
|
|
"""Copy a pending entry into the public drained shape."""
|
|
return DrainedInput(
|
|
pending_id=entry.pending_id,
|
|
content=copy.deepcopy(entry.content),
|
|
created_by=entry.created_by,
|
|
)
|
|
|
|
|
|
def _content_text(content: list[dict[str, Any]]) -> str:
|
|
"""Extract text blocks from a pending-input content list."""
|
|
parts: list[str] = []
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
continue
|
|
block_type = block.get("type")
|
|
if block_type in {"input_text", "text", "output_text"}:
|
|
text = block.get("text")
|
|
if isinstance(text, str):
|
|
parts.append(text)
|
|
return "\n".join(parts)
|
|
|
|
|
|
def _normalize_text(text: str) -> str:
|
|
"""Normalize text enough to compare pending input with Kiro Prompt text."""
|
|
return " ".join(text.split())
|
|
|
|
|
|
def reset_for_tests() -> None:
|
|
"""
|
|
Clear the entire index. For test isolation only.
|
|
|
|
The index is process-global; a leaked entry would change the replay
|
|
behavior of a later test. Not for production callers — there is no
|
|
legitimate runtime use case for wiping it.
|
|
"""
|
|
with _lock:
|
|
_pending.clear()
|