fix(webui): assign readable session handles
This commit is contained in:
@@ -28,7 +28,7 @@ from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import (
|
||||
SessionHandleResolver,
|
||||
normalize_session_handle,
|
||||
session_handle_for_key,
|
||||
session_handle_for_name,
|
||||
)
|
||||
from nanobot.session.session_messages import (
|
||||
SESSION_MESSAGE_METADATA_KEY,
|
||||
@@ -52,6 +52,7 @@ class _CancelHandle(Protocol):
|
||||
@dataclass(slots=True)
|
||||
class _PendingReply:
|
||||
timeout_seconds: int
|
||||
target_handle: str
|
||||
request: SessionMessageEnvelope
|
||||
timer: _CancelHandle | None = None
|
||||
|
||||
@@ -81,10 +82,6 @@ class ListSessionsTool(Tool):
|
||||
def description(self) -> str:
|
||||
return "List other persisted sessions by @handle."
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return True
|
||||
|
||||
async def execute(self, **kwargs: Any) -> str:
|
||||
request = current_request_context()
|
||||
if request is None or not request.session_key:
|
||||
@@ -167,7 +164,10 @@ class SendSessionMessageTool(Tool):
|
||||
envelope = session_message_envelope(request.metadata)
|
||||
if envelope is None:
|
||||
return None
|
||||
source = session_handle_for_key(envelope["source_session_key"])
|
||||
source = session_handle_for_name(
|
||||
envelope["source_session_key"],
|
||||
envelope["source_handle"],
|
||||
)
|
||||
content = f"Message from @{source.name}."
|
||||
if envelope["expect_reply"]:
|
||||
content += " Reply with send_session_message."
|
||||
@@ -221,11 +221,17 @@ class SendSessionMessageTool(Tool):
|
||||
if target is None:
|
||||
raise SessionMessageError(f"session @{target_name} was not found")
|
||||
|
||||
source = session_handle_for_key(source_session_key)
|
||||
source = await asyncio.to_thread(
|
||||
self._handles.handle_for_session,
|
||||
source_session_key,
|
||||
)
|
||||
if source is None:
|
||||
raise SessionMessageError("source session was not found")
|
||||
envelope: SessionMessageEnvelope = {
|
||||
"message_id": uuid4().hex,
|
||||
"created_at_ms": int(time.time() * 1000),
|
||||
"expect_reply": expect_reply,
|
||||
"source_handle": source.name,
|
||||
"source_session_key": source.session_key,
|
||||
"target_session_key": target.session_key,
|
||||
}
|
||||
@@ -256,7 +262,12 @@ class SendSessionMessageTool(Tool):
|
||||
self._cancel_pending_reply(reverse_wait_key)
|
||||
if timeout_seconds is not None:
|
||||
self._cancel_pending_reply(wait_key)
|
||||
self._schedule_pending_reply(wait_key, timeout_seconds, envelope)
|
||||
self._schedule_pending_reply(
|
||||
wait_key,
|
||||
timeout_seconds,
|
||||
target.name,
|
||||
envelope,
|
||||
)
|
||||
|
||||
return f"@{target.name}"
|
||||
|
||||
@@ -288,9 +299,14 @@ class SendSessionMessageTool(Tool):
|
||||
self,
|
||||
key: tuple[str, str],
|
||||
timeout_seconds: int,
|
||||
target_handle: str,
|
||||
request: SessionMessageEnvelope,
|
||||
) -> None:
|
||||
pending = _PendingReply(timeout_seconds=timeout_seconds, request=request)
|
||||
pending = _PendingReply(
|
||||
timeout_seconds=timeout_seconds,
|
||||
target_handle=target_handle,
|
||||
request=request,
|
||||
)
|
||||
self._pending_replies[key] = pending
|
||||
|
||||
def expire() -> None:
|
||||
@@ -311,13 +327,12 @@ class SendSessionMessageTool(Tool):
|
||||
return
|
||||
self._pending_replies.pop(key, None)
|
||||
source_session_key = expected.request["source_session_key"]
|
||||
target = session_handle_for_key(expected.request["target_session_key"])
|
||||
await self._bus.publish_inbound(InboundMessage(
|
||||
channel="system",
|
||||
sender_id="session_timeout",
|
||||
chat_id=source_session_key,
|
||||
content=(
|
||||
f"No reply from @{target.name} after "
|
||||
f"No reply from @{expected.target_handle} after "
|
||||
f"{expected.timeout_seconds} seconds."
|
||||
),
|
||||
session_key_override=source_session_key,
|
||||
|
||||
@@ -48,7 +48,7 @@ from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_handles import session_handle_for_name
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
from nanobot.webui.http_utils import (
|
||||
http_error as _http_error,
|
||||
@@ -2027,7 +2027,7 @@ async def test_send_projects_external_user_input_to_existing_wire_event() -> Non
|
||||
event=UserInputEvent(
|
||||
content="hello from another session",
|
||||
created_at_ms=1234,
|
||||
provenance={"name": "mira-deadbeef00"},
|
||||
provenance={"name": "luma"},
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -2039,7 +2039,7 @@ async def test_send_projects_external_user_input_to_existing_wire_event() -> Non
|
||||
"text": "hello from another session",
|
||||
"created_at_ms": 1234,
|
||||
"starts_turn": False,
|
||||
"provenance": {"name": "mira-deadbeef00"},
|
||||
"provenance": {"name": "luma"},
|
||||
}
|
||||
|
||||
|
||||
@@ -4982,6 +4982,14 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(ws_http_module, "list_webui_sessions", lambda _session_manager: sessions)
|
||||
handle = session_handle_for_name("websocket:chat-1", "luma")
|
||||
monkeypatch.setattr(
|
||||
ws_http_module,
|
||||
"SessionHandleResolver",
|
||||
lambda _session_manager: SimpleNamespace(
|
||||
list_all_by_key=lambda: {handle.session_key: handle}
|
||||
),
|
||||
)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
@@ -5011,7 +5019,7 @@ def test_sessions_list_includes_active_run_started_at(monkeypatch) -> None:
|
||||
"preview": "work",
|
||||
"model_preset": "fast",
|
||||
"run_started_at": 1_700_000_000.0,
|
||||
"handle": session_handle_for_key("websocket:chat-1").public_payload(),
|
||||
"handle": handle.public_payload(),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from nanobot.channels.websocket.runtime import (
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_handles import SessionHandleResolver
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
|
||||
|
||||
@@ -258,8 +258,10 @@ async def test_webui_message_forwards_verified_session_mentions(tmp_path) -> Non
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
metadata = channel._handle_message.call_args.kwargs["metadata"]
|
||||
handle = SessionHandleResolver(manager).handle_for_session("websocket:pricing")
|
||||
assert handle is not None
|
||||
assert metadata["session_mentions"] == [{
|
||||
**session_handle_for_key("websocket:pricing").public_payload(),
|
||||
**handle.public_payload(),
|
||||
"session_key": "websocket:pricing",
|
||||
"title": "Pricing",
|
||||
}]
|
||||
|
||||
@@ -23,7 +23,7 @@ from nanobot.optional_features import InstallResult
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_handles import SessionHandleResolver
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
|
||||
@@ -2213,10 +2213,6 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
}
|
||||
sm.save(scoped)
|
||||
|
||||
def fail_metadata_read(_key: str) -> None:
|
||||
raise AssertionError("the session list must use its own index metadata")
|
||||
|
||||
monkeypatch.setattr(sm, "read_session_metadata", fail_metadata_read)
|
||||
channel = _ch(bus, session_manager=sm, workspace_path=tmp_path, port=29906)
|
||||
server_task = asyncio.create_task(channel.start())
|
||||
try:
|
||||
@@ -2233,12 +2229,16 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
||||
# Slack / Lark rows would be non-resumable from the browser.
|
||||
assert keys == {"websocket:alpha", "websocket:beta"}
|
||||
rows = {row["key"]: row for row in sessions}
|
||||
assert rows["websocket:alpha"]["handle"] == session_handle_for_key(
|
||||
handles = {
|
||||
handle.session_key: handle
|
||||
for handle in SessionHandleResolver(sm).list_all()
|
||||
}
|
||||
assert rows["websocket:alpha"]["handle"] == handles[
|
||||
"websocket:alpha"
|
||||
).public_payload()
|
||||
assert rows["websocket:beta"]["handle"] == session_handle_for_key(
|
||||
].public_payload()
|
||||
assert rows["websocket:beta"]["handle"] == handles[
|
||||
"websocket:beta"
|
||||
).public_payload()
|
||||
].public_payload()
|
||||
assert rows["websocket:beta"]["workspace_scope"]["project_path"] == str(
|
||||
project.resolve()
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager, suppress
|
||||
@@ -58,6 +59,7 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
||||
"goal_state",
|
||||
"pending_user_turn",
|
||||
"runtime_checkpoint",
|
||||
"session_handle",
|
||||
"thread_goal",
|
||||
"title",
|
||||
"title_user_edited",
|
||||
@@ -557,6 +559,14 @@ class SessionStore(Protocol):
|
||||
|
||||
def read_metadata(self, key: str) -> SessionMetadataPayload | None: ...
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
key: str,
|
||||
updates: dict[str, Any],
|
||||
*,
|
||||
fsync: bool = False,
|
||||
) -> bool: ...
|
||||
|
||||
def list_sessions(self) -> list[SessionInfo]: ...
|
||||
|
||||
|
||||
@@ -1268,6 +1278,49 @@ class JsonlSessionStore:
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def update_metadata(
|
||||
self,
|
||||
key: str,
|
||||
updates: dict[str, Any],
|
||||
*,
|
||||
fsync: bool = False,
|
||||
) -> bool:
|
||||
"""Atomically replace only a session file's metadata record."""
|
||||
with self._session_files_lock:
|
||||
path = self.get_session_path(key)
|
||||
if not path.exists():
|
||||
return False
|
||||
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as source:
|
||||
first_line = source.readline()
|
||||
data = _json_object(json.loads(first_line))
|
||||
if data.get("_type") != "metadata":
|
||||
return False
|
||||
raw_metadata = cast(object, data.get("metadata", {}))
|
||||
metadata = (
|
||||
dict(cast(dict[str, Any], raw_metadata))
|
||||
if isinstance(raw_metadata, dict)
|
||||
else {}
|
||||
)
|
||||
metadata.update(deepcopy(updates))
|
||||
data["metadata"] = metadata
|
||||
with open(tmp_path, "x", encoding="utf-8") as target:
|
||||
target.write(json.dumps(data, ensure_ascii=False) + "\n")
|
||||
shutil.copyfileobj(source, target)
|
||||
if fsync:
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
os.replace(tmp_path, path)
|
||||
if fsync:
|
||||
self._fsync_directory(path.parent)
|
||||
return True
|
||||
except _SESSION_DATA_ERRORS as exc:
|
||||
logger.warning("Failed to update session metadata {}: {}", key, exc)
|
||||
return False
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
with self._session_files_lock:
|
||||
return self._delete_unlocked(key)
|
||||
@@ -1808,5 +1861,18 @@ class SessionManager:
|
||||
"""Read session metadata without loading the transcript."""
|
||||
return cast(dict[str, Any] | None, self._store.read_metadata(key))
|
||||
|
||||
def update_session_metadata(
|
||||
self,
|
||||
key: str,
|
||||
updates: dict[str, Any],
|
||||
*,
|
||||
fsync: bool = False,
|
||||
) -> bool:
|
||||
"""Atomically update metadata without replacing session history."""
|
||||
updated = self._store.update_metadata(key, updates, fsync=fsync)
|
||||
if updated and (session := self.get_cached(key)) is not None:
|
||||
session.metadata.update(deepcopy(updates))
|
||||
return updated
|
||||
|
||||
def list_sessions(self) -> list[dict[str, Any]]:
|
||||
return cast(list[dict[str, Any]], self._store.list_sessions())
|
||||
|
||||
@@ -1,32 +1,56 @@
|
||||
"""Stable public handles derived from persisted session keys."""
|
||||
"""Short, pronounceable public handles for persisted sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
_MAX_SESSION_KEY_CHARS = 512
|
||||
_HANDLE_RE = re.compile(r"^[a-z]{2,16}-[0-9a-f]{10}$")
|
||||
SESSION_HANDLE_METADATA_KEY = "session_handle"
|
||||
|
||||
_HANDLE_NAMES = tuple(
|
||||
"""
|
||||
ada abel adil aiko alba alex alia alma amir amos anil anja arlo asha ava bea
|
||||
ben blair bruno cara carl cato celia chen chloe clara cleo cora dahlia daisy
|
||||
dana dante dara dario dev dina drew eden eira eli elio ella elsa emil emma
|
||||
enzo eric esme eva farah felix finn flora freya gabe gia gwen hana harper
|
||||
hazel heidi hugo ida ila iman ines iris ivan jade jamie joel jona jude jules
|
||||
juno kai ken kira lana lara leif lena leo lia liam lila lina liv lois lola
|
||||
luca lucy mabel mae malik mara marco maya mila mina mira nadia nate neve nico
|
||||
nina noah nora omar oren orla otto owen pablo piper priya quinn rafi remy ren
|
||||
rhea rio robin rosa ruby sage sami sara sena shay silas sofia sol sora tariq
|
||||
tavi tess theo timo uma val vera vida wes will wren xena yara yasmin yuki zara
|
||||
zeno zoe
|
||||
""".split()
|
||||
_MAX_SESSION_KEY_CHARS = 512
|
||||
_MAX_HANDLE_CHARS = 16
|
||||
_HANDLE_RE = re.compile(rf"^[a-z]{{4,{_MAX_HANDLE_CHARS}}}$")
|
||||
_ALPHABET = "abcdefghijklmnopqrstuvwxyz"
|
||||
_SYLLABLES = (
|
||||
"ba", "be", "bi", "bo",
|
||||
"da", "de", "di", "do",
|
||||
"fa", "fe", "fi", "fo",
|
||||
"ga", "ge", "gi", "go",
|
||||
"ha", "he", "hi", "ho",
|
||||
"ja", "je", "ji", "jo",
|
||||
"ka", "ke", "ki", "ko", "ku",
|
||||
"la", "le", "li", "lo", "lu",
|
||||
"ma", "me", "mi", "mo", "mu",
|
||||
"na", "ne", "ni", "no", "nu",
|
||||
"pa", "pe", "pi", "po",
|
||||
"ra", "re", "ri", "ro", "ru",
|
||||
"sa", "se", "si", "so", "su",
|
||||
"ta", "te", "ti", "to", "tu",
|
||||
"va",
|
||||
)
|
||||
_END_SYLLABLES = (
|
||||
"la", "le", "li", "lo", "lu",
|
||||
"ma", "me", "mi", "mo", "mu",
|
||||
"na", "ne", "ni", "no", "nu",
|
||||
"ra", "re", "ri", "ro", "ru",
|
||||
"sa", "se", "si", "so", "su",
|
||||
"ta", "te", "ti", "to", "tu",
|
||||
"va", "ve", "vi", "vo", "vu",
|
||||
"ya", "ye", "yi", "yo", "yu",
|
||||
)
|
||||
_SYLLABLE_COUNTS = (2, 3, 4)
|
||||
_BLOCKED_NAMES = frozenset({"dago", "homo", "kike", "pedo", "rape"})
|
||||
|
||||
assert len(_SYLLABLES) == 64
|
||||
assert len(set(_SYLLABLES)) == len(_SYLLABLES)
|
||||
assert len(_END_SYLLABLES) == 40
|
||||
assert len(set(_END_SYLLABLES)) == len(_END_SYLLABLES)
|
||||
|
||||
|
||||
class SessionHandlePayload(TypedDict):
|
||||
@@ -54,42 +78,150 @@ def normalize_session_handle(value: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def session_handle_for_key(session_key: str) -> SessionHandle:
|
||||
"""Derive a stable handle without creating a second persistence lifecycle."""
|
||||
key = session_key.strip()
|
||||
if not key or len(key) > _MAX_SESSION_KEY_CHARS:
|
||||
raise ValueError("session key is invalid")
|
||||
def session_handle_for_name(session_key: str, name: str) -> SessionHandle:
|
||||
"""Build a trusted handle from a persisted name and its private session key."""
|
||||
key = _clean_session_key(session_key)
|
||||
normalized = normalize_session_handle(name)
|
||||
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
||||
word = _HANDLE_NAMES[int(digest[:8], 16) % len(_HANDLE_NAMES)]
|
||||
return SessionHandle(
|
||||
id=f"handle_{digest[:32]}",
|
||||
name=f"{word}-{digest[32:42]}",
|
||||
name=normalized,
|
||||
session_key=key,
|
||||
)
|
||||
|
||||
|
||||
def _clean_session_key(value: str) -> str:
|
||||
key = value.strip()
|
||||
if not key or len(key) > _MAX_SESSION_KEY_CHARS:
|
||||
raise ValueError("session key is invalid")
|
||||
return key
|
||||
|
||||
|
||||
def _tier_size(syllable_count: int) -> int:
|
||||
return len(_SYLLABLES) ** (syllable_count - 1) * len(_END_SYLLABLES)
|
||||
|
||||
|
||||
def _name_parts_at(syllable_count: int, index: int) -> tuple[str, ...]:
|
||||
"""Decode one permutation index without materializing the candidate space."""
|
||||
size = _tier_size(syllable_count)
|
||||
if not 0 <= index < size:
|
||||
raise ValueError("session handle candidate index is invalid")
|
||||
choices: list[str] = []
|
||||
index, ending = divmod(index, len(_END_SYLLABLES))
|
||||
choices.append(_END_SYLLABLES[ending])
|
||||
for _ in range(syllable_count - 1):
|
||||
index, syllable = divmod(index, len(_SYLLABLES))
|
||||
choices.append(_SYLLABLES[syllable])
|
||||
choices.reverse()
|
||||
return tuple(choices)
|
||||
|
||||
|
||||
def _candidate_indexes(syllable_count: int):
|
||||
"""Visit every candidate once in a stable, non-alphabetical order."""
|
||||
size = _tier_size(syllable_count)
|
||||
seed = hashlib.sha256(f"nanobot-handle-v1:{syllable_count}".encode()).digest()
|
||||
start = int.from_bytes(seed[:8], "big") % size
|
||||
step = int.from_bytes(seed[8:16], "big") % size or 1
|
||||
while math.gcd(step, size) != 1:
|
||||
step += 1
|
||||
for offset in range(size):
|
||||
yield (start + offset * step) % size
|
||||
|
||||
|
||||
def _allocate_name(used: set[str]) -> str:
|
||||
for syllable_count in _SYLLABLE_COUNTS:
|
||||
for index in _candidate_indexes(syllable_count):
|
||||
parts = _name_parts_at(syllable_count, index)
|
||||
if len(set(parts)) != len(parts):
|
||||
continue
|
||||
name = "".join(parts)
|
||||
if name not in used and name not in _BLOCKED_NAMES:
|
||||
return name
|
||||
while True:
|
||||
name = "".join(secrets.choice(_ALPHABET) for _ in range(12))
|
||||
if name not in used and name not in _BLOCKED_NAMES:
|
||||
return name
|
||||
|
||||
|
||||
class SessionHandleResolver:
|
||||
"""Resolve derived handles against the current persisted-session list."""
|
||||
"""Allocate and resolve handles stored in canonical session metadata."""
|
||||
|
||||
def __init__(self, sessions: SessionManager) -> None:
|
||||
self._sessions = sessions
|
||||
|
||||
def _ensure_all(self) -> dict[str, SessionHandle]:
|
||||
with self._sessions.locked_session_files():
|
||||
rows = sorted(
|
||||
self._sessions.list_sessions(),
|
||||
key=lambda row: (
|
||||
str(row.get("created_at", "")),
|
||||
str(row.get("key", "")),
|
||||
),
|
||||
)
|
||||
used: set[str] = set()
|
||||
names: dict[str, str] = {}
|
||||
pending: list[str] = []
|
||||
for row in rows:
|
||||
raw_key: Any = row.get("key")
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
payload = self._sessions.read_session_metadata(raw_key)
|
||||
raw_metadata = payload.get("metadata") if payload is not None else None
|
||||
metadata = (
|
||||
cast(dict[str, Any], raw_metadata)
|
||||
if isinstance(raw_metadata, dict)
|
||||
else {}
|
||||
)
|
||||
raw_name = metadata.get(SESSION_HANDLE_METADATA_KEY)
|
||||
try:
|
||||
name = normalize_session_handle(raw_name) if isinstance(raw_name, str) else ""
|
||||
except ValueError:
|
||||
name = ""
|
||||
if not name or name in used:
|
||||
pending.append(raw_key)
|
||||
continue
|
||||
names[raw_key] = name
|
||||
used.add(name)
|
||||
|
||||
for key in pending:
|
||||
name = _allocate_name(used)
|
||||
if not self._sessions.update_session_metadata(
|
||||
key,
|
||||
{SESSION_HANDLE_METADATA_KEY: name},
|
||||
fsync=True,
|
||||
):
|
||||
continue
|
||||
names[key] = name
|
||||
used.add(name)
|
||||
|
||||
return {
|
||||
key: session_handle_for_name(key, name)
|
||||
for key, name in names.items()
|
||||
}
|
||||
|
||||
def handle_for_session(self, session_key: str) -> SessionHandle | None:
|
||||
try:
|
||||
key = _clean_session_key(session_key)
|
||||
except ValueError:
|
||||
return None
|
||||
return self._ensure_all().get(key)
|
||||
|
||||
def list_all(self) -> list[SessionHandle]:
|
||||
handles: list[SessionHandle] = []
|
||||
for row in self._sessions.list_sessions():
|
||||
raw_key: Any = row.get("key")
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
try:
|
||||
handles.append(session_handle_for_key(raw_key))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(handles, key=lambda handle: handle.name)
|
||||
return sorted(self._ensure_all().values(), key=lambda handle: handle.name)
|
||||
|
||||
def list_all_by_key(self) -> dict[str, SessionHandle]:
|
||||
return self._ensure_all()
|
||||
|
||||
def resolve(self, name: str) -> SessionHandle | None:
|
||||
try:
|
||||
normalized = normalize_session_handle(name)
|
||||
except ValueError:
|
||||
return None
|
||||
matches = [handle for handle in self.list_all() if handle.name == normalized]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
return next(
|
||||
(
|
||||
handle
|
||||
for handle in self._ensure_all().values()
|
||||
if handle.name == normalized
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from nanobot.session.session_handles import normalize_session_handle
|
||||
|
||||
SESSION_MESSAGE_METADATA_KEY = "_session_message"
|
||||
|
||||
_MAX_SESSION_KEY_CHARS = 512
|
||||
@@ -16,6 +18,7 @@ class SessionMessageEnvelope(TypedDict):
|
||||
message_id: str
|
||||
created_at_ms: int
|
||||
expect_reply: bool
|
||||
source_handle: str
|
||||
source_session_key: str
|
||||
target_session_key: str
|
||||
|
||||
@@ -33,8 +36,17 @@ def session_message_envelope(
|
||||
message_id = data.get("message_id")
|
||||
created_at_ms = data.get("created_at_ms")
|
||||
expect_reply = data.get("expect_reply")
|
||||
source_handle_value = data.get("source_handle")
|
||||
source_session_key = _session_key(data.get("source_session_key"))
|
||||
target_session_key = _session_key(data.get("target_session_key"))
|
||||
try:
|
||||
source_handle = (
|
||||
normalize_session_handle(source_handle_value)
|
||||
if isinstance(source_handle_value, str)
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
source_handle = None
|
||||
if (
|
||||
not isinstance(message_id, str)
|
||||
or _MESSAGE_ID_RE.fullmatch(message_id) is None
|
||||
@@ -42,6 +54,7 @@ def session_message_envelope(
|
||||
or isinstance(created_at_ms, bool)
|
||||
or created_at_ms < 0
|
||||
or not isinstance(expect_reply, bool)
|
||||
or source_handle is None
|
||||
or source_session_key is None
|
||||
or target_session_key is None
|
||||
):
|
||||
@@ -50,6 +63,7 @@ def session_message_envelope(
|
||||
"message_id": message_id,
|
||||
"created_at_ms": created_at_ms,
|
||||
"expect_reply": expect_reply,
|
||||
"source_handle": source_handle,
|
||||
"source_session_key": source_session_key,
|
||||
"target_session_key": target_session_key,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ from nanobot.runtime_context import public_history_message
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_handles import session_handle_for_name
|
||||
from nanobot.session.session_messages import (
|
||||
SessionMessageEnvelope,
|
||||
session_message_envelope,
|
||||
@@ -85,7 +85,10 @@ _WEBSOCKET_ACTIVE_TURNS: dict[str, dict[str, _WebsocketTurn]] = {}
|
||||
def _session_message_public_metadata(
|
||||
envelope: SessionMessageEnvelope,
|
||||
) -> dict[str, Any]:
|
||||
source = session_handle_for_key(envelope["source_session_key"])
|
||||
source = session_handle_for_name(
|
||||
envelope["source_session_key"],
|
||||
envelope["source_handle"],
|
||||
)
|
||||
return {
|
||||
"message_id": envelope["message_id"],
|
||||
"session": source.public_payload(),
|
||||
|
||||
@@ -14,7 +14,7 @@ from nanobot.runtime_context import (
|
||||
)
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.session_handles import SessionHandleResolver
|
||||
from nanobot.webui.session_list_index import list_webui_sessions
|
||||
from nanobot.webui.transcript import (
|
||||
build_webui_thread_response,
|
||||
@@ -105,6 +105,7 @@ class WebuiSessionAccess:
|
||||
|
||||
def __init__(self, sessions: SessionManager) -> None:
|
||||
self._sessions = sessions
|
||||
self._handles = SessionHandleResolver(sessions)
|
||||
|
||||
def _metadata(
|
||||
self,
|
||||
@@ -233,7 +234,9 @@ class WebuiSessionAccess:
|
||||
payload = self._metadata(key, exclude_session_key=exclude_session_key)
|
||||
if payload is None or key in seen_keys:
|
||||
continue
|
||||
handle = session_handle_for_key(key)
|
||||
handle = self._handles.handle_for_session(key)
|
||||
if handle is None:
|
||||
continue
|
||||
folded_name = handle.name.casefold()
|
||||
if folded_name in seen_names:
|
||||
continue
|
||||
|
||||
@@ -28,6 +28,10 @@ from nanobot.command.builtin import builtin_command_palette
|
||||
from nanobot.cron.session_turns import is_bound_cron_job
|
||||
from nanobot.cron.types import CronJob, CronSchedule
|
||||
from nanobot.security.workspace_access import WorkspaceScope
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.session_handles import (
|
||||
SessionHandleResolver,
|
||||
)
|
||||
from nanobot.triggers.local_types import LocalTrigger
|
||||
from nanobot.webui.file_preview import (
|
||||
WebUIFilePreviewError,
|
||||
@@ -216,7 +220,6 @@ if TYPE_CHECKING:
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.websocket.runtime import WebSocketConfig
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.triggers.local_store import LocalTriggerStore
|
||||
from nanobot.webui.settings_services import WebUISettingsServices
|
||||
|
||||
@@ -728,10 +731,10 @@ class GatewayHTTPHandler:
|
||||
|
||||
def _sessions_list_payload(self) -> dict[str, Any]:
|
||||
assert self.session_manager is not None
|
||||
from nanobot.session.session_handles import session_handle_for_key
|
||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||
|
||||
sessions = list_webui_sessions(self.session_manager)
|
||||
handles = SessionHandleResolver(self.session_manager).list_all_by_key()
|
||||
cleaned: list[dict[str, Any]] = []
|
||||
default_scope: WorkspaceScope | None = None
|
||||
for s in sessions:
|
||||
@@ -756,7 +759,9 @@ class GatewayHTTPHandler:
|
||||
default_scope=default_scope,
|
||||
)
|
||||
row["workspace_scope"] = scope.payload()
|
||||
row["handle"] = session_handle_for_key(key).public_payload()
|
||||
handle = handles.get(key)
|
||||
if handle is not None:
|
||||
row["handle"] = handle.public_payload()
|
||||
cleaned.append(row)
|
||||
return {"sessions": cleaned}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user