diff --git a/navi/api/routes/sessions.py b/navi/api/routes/sessions.py index 4a28b44..d54e80a 100644 --- a/navi/api/routes/sessions.py +++ b/navi/api/routes/sessions.py @@ -142,11 +142,16 @@ offset: Annotated[int | None, Query(ge=0)] = None, profile_id: str | None = None, search: str | None = Query(None), + special: bool | None = Query(None), ) -> dict | list[dict]: + # special=None and special=False both mean the regular list; special=True + # exposes the service sessions (e.g. Synapse reactions), which the sidebar + # hides behind its toggle. + special_filter = False if special is None else special is_admin = _session_admin(user) if limit is None and offset is None: - sessions = await store.list_all(user_id=_session_owner(user), is_admin=is_admin) + sessions = await store.list_all(user_id=_session_owner(user), is_admin=is_admin, special=special_filter) if profile_id and not search: sessions = [s for s in sessions if s.profile_id == profile_id] pending_ids = await scheduler.get_pending_session_ids([s.id for s in sessions]) @@ -161,6 +166,7 @@ user_id=_session_owner(user), is_admin=is_admin, search=search, + special=special_filter, ) else: sessions = await store.list_page( @@ -169,6 +175,7 @@ profile_id=profile_id, user_id=_session_owner(user), is_admin=is_admin, + special=special_filter, ) items = sessions[:page_limit] pending_ids = await scheduler.get_pending_session_ids([s.id for s in items]) @@ -189,6 +196,7 @@ "message_count": len(session.messages), "preview": _preview(session), "pinned": session.pinned, + "special": session.special, "has_pending_recall": has_pending_recall, "created_at": session.created_at.isoformat(), "last_active": session.last_active.isoformat(), diff --git a/navi/api/routes/synapse.py b/navi/api/routes/synapse.py index 530eb4a..0c388c8 100644 --- a/navi/api/routes/synapse.py +++ b/navi/api/routes/synapse.py @@ -12,22 +12,28 @@ """ from collections import OrderedDict -from typing import Annotated +from typing import Annotated, Literal import structlog -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request from gnexus_synapse import ack, verify_webhook from gnexus_synapse.exceptions import SynapseWebhookError from pydantic import BaseModel from navi.api.deps import require_user from navi.auth import User +from navi.synapse.settings_store import ( + CompletionNotify, + PushTarget, + SynapseSettingsStore, +) from navi.synapse.store import SynapseTargetStore log = structlog.get_logger() webhook_router = APIRouter(prefix="/webhooks", tags=["synapse"]) targets_router = APIRouter(prefix="/synapse-targets", tags=["synapse"]) +settings_router = APIRouter(prefix="/synapse-settings", tags=["synapse"]) # Idempotency: Synapse retries until a 2xx, so the same delivery may arrive # more than once. An insertion-ordered map of event ids, hard-capped, is @@ -161,4 +167,51 @@ store = SynapseTargetStore(await _pool()) if not await store.revoke(target_id, user.id): raise HTTPException(status_code=404, detail="Target not found") - log.info("synapse.target_revoked", user_id=user.id, target_id=target_id) \ No newline at end of file + log.info("synapse.target_revoked", user_id=user.id, target_id=target_id) + + +class UpdateSynapseSettingsRequest(BaseModel): + reactions_enabled: bool + push_target: PushTarget + completion_notify: CompletionNotify + instructions: str + + +@settings_router.get("") +async def get_synapse_settings( + user: Annotated[User, Depends(require_user)], +) -> dict: + store = SynapseSettingsStore(await _pool()) + settings_row = await store.get(user.id) + return settings_row.model_dump(mode="json", exclude={"user_id"}) + + +@settings_router.put("") +async def update_synapse_settings( + payload: UpdateSynapseSettingsRequest, + user: Annotated[User, Depends(require_user)], +) -> dict: + store = SynapseSettingsStore(await _pool()) + current = await store.get(user.id) + current.reactions_enabled = payload.reactions_enabled + current.push_target = payload.push_target + current.completion_notify = payload.completion_notify + if payload.instructions != current.instructions: + await store.save_instructions( + user.id, payload.instructions, edited_by="user" + ) + current.instructions = payload.instructions + await store.save(current) + return current.model_dump(mode="json", exclude={"user_id"}) + + +@settings_router.get("/versions") +async def list_instruction_versions( + user: Annotated[User, Depends(require_user)], + limit: Annotated[int, Query(ge=1, le=100)] = 20, +) -> dict: + store = SynapseSettingsStore(await _pool()) + versions = await store.list_versions(user.id, limit=limit) + return { + "items": [v.model_dump(mode="json") for v in versions] + } \ No newline at end of file diff --git a/navi/config.py b/navi/config.py index c33c90c..88e7143 100644 --- a/navi/config.py +++ b/navi/config.py @@ -181,6 +181,14 @@ # runs can overlap on one session; also dedups rapid-fire turns). navi_push_cooldown_sec: int = 30 + # Synapse ingest — navi as an event SOURCE (outbound reaction notifications). + # Empty api key = navi cannot emit events to Synapse: the settings UI + # disables the synapse push-target options and the notify tool silently + # skips that leg of delivery. + synapse_source_url: str = "" + synapse_source_api_key: str = "" + synapse_source_name: str = "navi" + # LLM call timeouts # complete() is non-streaming (compression) — blocked until full response llm_complete_timeout: int = 120 diff --git a/navi/core/pg_session_store.py b/navi/core/pg_session_store.py index ec6d4ab..11c1631 100644 --- a/navi/core/pg_session_store.py +++ b/navi/core/pg_session_store.py @@ -32,6 +32,7 @@ messages TEXT NOT NULL DEFAULT '[]', context TEXT NOT NULL DEFAULT '', pinned BOOLEAN NOT NULL DEFAULT FALSE, + special BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL, last_active TIMESTAMPTZ NOT NULL, context_token_count INTEGER NOT NULL DEFAULT 0, @@ -46,7 +47,8 @@ ALTER TABLE sessions ADD COLUMN IF NOT EXISTS user_id TEXT REFERENCES navi_users(id) ON DELETE SET NULL; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS next_sequence INTEGER NOT NULL DEFAULT 0; ALTER TABLE sessions ADD COLUMN IF NOT EXISTS archive_threshold INTEGER NOT NULL DEFAULT 0; -ALTER TABLE sessions ADD COLUMN IF NOT EXISTS session_metadata TEXT NOT NULL DEFAULT '{}' +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS session_metadata TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS special BOOLEAN NOT NULL DEFAULT FALSE """ _SESSION_MESSAGES_DDL = """ @@ -333,6 +335,7 @@ messages=messages, context=context, pinned=bool(row["pinned"]), + special=bool(row.get("special", False)), name=row["name"], created_at=row["created_at"], last_active=row["last_active"], @@ -348,6 +351,23 @@ return sessions +def _special_frag(special: bool | None) -> str: + """SQL fragment for the special filter; '' when unfiltered.""" + if special is None: + return "" + return f"special = {str(bool(special)).upper()}" + + +def _special_and(special: bool | None) -> str: + frag = _special_frag(special) + return f" AND {frag}" if frag else "" + + +def _special_where(special: bool | None) -> str: + frag = _special_frag(special) + return f" WHERE {frag}" if frag else "" + + class PgSessionStore(SessionStore): def __init__(self, pool: asyncpg.Pool) -> None: self._pool = pool @@ -391,7 +411,7 @@ pool = await self._get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " "FROM sessions WHERE id = $1", session_id, ) @@ -429,6 +449,7 @@ messages=messages, context=context, pinned=bool(row["pinned"]), + special=bool(row.get("special", False)), name=row["name"], created_at=row["created_at"], last_active=row["last_active"], @@ -452,9 +473,9 @@ # set on insert and not overwritten on conflict (managed elsewhere). await conn.execute( "INSERT INTO sessions " - "(id, profile_id, user_id, pinned, created_at, last_active, " + "(id, profile_id, user_id, pinned, special, created_at, last_active, " " context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata) " - "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) " "ON CONFLICT (id) DO UPDATE SET " " profile_id = EXCLUDED.profile_id, " " user_id = EXCLUDED.user_id, " @@ -463,7 +484,8 @@ " planning_logs = EXCLUDED.planning_logs, " " session_metadata = EXCLUDED.session_metadata", session.id, session.profile_id, session.user_id, session.pinned, - session.created_at, session.last_active, session.context_token_count, + session.special, session.created_at, session.last_active, + session.context_token_count, session.name, json.dumps(session.planning_logs, ensure_ascii=False), session.db_next_sequence, session.archive_threshold, @@ -651,7 +673,7 @@ pool = await self._get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " "FROM sessions WHERE id = $1", session_id, ) @@ -671,6 +693,7 @@ messages=[], context=[], pinned=bool(row["pinned"]), + special=bool(row.get("special", False)), name=row["name"], created_at=row["created_at"], last_active=row["last_active"], @@ -763,30 +786,33 @@ ) return result == "UPDATE 1" - async def list_all(self, user_id: str | None = None, is_admin: bool = False) -> list[Session]: + async def list_all( + self, user_id: str | None = None, is_admin: bool = False, special: bool | None = None + ) -> list[Session]: """Sessions visible to the caller. Scoping: admins see everything; a named owner sees their own rows; ``user_id=None`` (local mode, the anonymous local user) sees only NULL-owner sessions — never other users' chats. + ``special``: None — no filter, False — regular only, True — special only. """ pool = await self._get_pool() async with pool.acquire() as conn: if is_admin: rows = await conn.fetch( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " - "FROM sessions ORDER BY pinned DESC, last_active DESC" + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + f"FROM sessions{_special_where(special)} ORDER BY pinned DESC, last_active DESC" ) elif user_id is not None: rows = await conn.fetch( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " - "FROM sessions WHERE user_id = $1 ORDER BY pinned DESC, last_active DESC", + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + f"FROM sessions WHERE user_id = $1{_special_and(special)} ORDER BY pinned DESC, last_active DESC", user_id, ) else: rows = await conn.fetch( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " - "FROM sessions WHERE user_id IS NULL ORDER BY pinned DESC, last_active DESC" + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + f"FROM sessions WHERE user_id IS NULL{_special_and(special)} ORDER BY pinned DESC, last_active DESC" ) return await _build_sessions(conn, rows) @@ -798,6 +824,7 @@ profile_id: str | None = None, user_id: str | None = None, is_admin: bool = False, + special: bool | None = None, ) -> list[Session]: pool = await self._get_pool() async with pool.acquire() as conn: @@ -818,12 +845,15 @@ conditions.append(f"user_id = {add_param(user_id)}") if profile_id: conditions.append(f"profile_id = {add_param(profile_id)}") + frag = _special_frag(special) + if frag: + conditions.append(frag) where = "WHERE " + " AND ".join(conditions) if conditions else "" order_limit = f"ORDER BY pinned DESC, last_active DESC LIMIT {add_param(limit)} OFFSET {add_param(offset)}" rows = await conn.fetch( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " f"FROM sessions {where} {order_limit}", *params, ) @@ -847,6 +877,7 @@ user_id: str | None = None, is_admin: bool = False, search: str | None = None, + special: bool | None = None, ) -> int: pool = await self._get_pool() async with pool.acquire() as conn: @@ -870,6 +901,9 @@ conditions.append( f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)} OR EXISTS (SELECT 1 FROM session_messages m WHERE m.session_id = sessions.id AND m.is_display = true AND m.content ILIKE {add_param(like)}) OR EXISTS (SELECT 1 FROM session_messages_archive a WHERE a.session_id = sessions.id AND a.is_display = true AND a.content ILIKE {add_param(like)}))" ) + frag = _special_frag(special) + if frag: + conditions.append(frag) where = "WHERE " + " AND ".join(conditions) if conditions else "" row = await conn.fetchrow(f"SELECT COUNT(*) FROM sessions {where}", *params) @@ -885,6 +919,7 @@ search: str | None = None, sort_by: str = "last_active", sort_order: str = "desc", + special: bool | None = None, ) -> list[Session]: pool = await self._get_pool() async with pool.acquire() as conn: @@ -908,6 +943,9 @@ conditions.append( f"(id ILIKE {add_param(like)} OR name ILIKE {add_param(like)} OR user_id ILIKE {add_param(like)} OR profile_id ILIKE {add_param(like)} OR EXISTS (SELECT 1 FROM session_messages m WHERE m.session_id = sessions.id AND m.is_display = true AND m.content ILIKE {add_param(like)}) OR EXISTS (SELECT 1 FROM session_messages_archive a WHERE a.session_id = sessions.id AND a.is_display = true AND a.content ILIKE {add_param(like)}))" ) + frag = _special_frag(special) + if frag: + conditions.append(frag) where = "WHERE " + " AND ".join(conditions) if conditions else "" @@ -918,7 +956,7 @@ order_clause = f"ORDER BY pinned DESC, {col} {order} LIMIT {add_param(limit)} OFFSET {add_param(offset)}" rows = await conn.fetch( - "SELECT id, profile_id, user_id, pinned, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " + "SELECT id, profile_id, user_id, pinned, special, created_at, last_active, context_token_count, name, planning_logs, next_sequence, archive_threshold, session_metadata " f"FROM sessions {where} {order_clause}", *params, ) diff --git a/navi/core/session.py b/navi/core/session.py index f09a71f..4ad2074 100644 --- a/navi/core/session.py +++ b/navi/core/session.py @@ -17,6 +17,7 @@ context: list[Message] = Field(default_factory=list) # LLM context (may be compressed) context_token_count: int = 0 # accumulated total; reset to 0 after compression pinned: bool = False + special: bool = False # service sessions (e.g. Synapse reactions) — hidden from the default list name: str | None = None created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) last_active: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -38,7 +39,13 @@ async def save(self, session: Session) -> None: ... @abstractmethod - async def list_all(self, user_id: str | None = None, is_admin: bool = False) -> list[Session]: ... + async def list_all( + self, + user_id: str | None = None, + is_admin: bool = False, + special: bool | None = None, + ) -> list[Session]: + """special: None — no filtering, False — regular only, True — special only.""" @abstractmethod async def list_page( @@ -49,6 +56,7 @@ profile_id: str | None = None, user_id: str | None = None, is_admin: bool = False, + special: bool | None = None, ) -> list[Session]: ... @abstractmethod @@ -58,6 +66,7 @@ user_id: str | None = None, is_admin: bool = False, search: str | None = None, + special: bool | None = None, ) -> int: ... @abstractmethod @@ -71,6 +80,7 @@ search: str | None = None, sort_by: str = "last_active", sort_order: str = "desc", + special: bool | None = None, ) -> list[Session]: ... @abstractmethod @@ -107,7 +117,15 @@ session.last_active = datetime.now(timezone.utc) self._sessions[session.id] = session - async def list_all(self, user_id: str | None = None, is_admin: bool = False) -> list[Session]: + @staticmethod + def _special_filter(sessions: list["Session"], special: bool | None) -> list["Session"]: + if special is None: + return sessions + return [s for s in sessions if s.special == special] + + async def list_all( + self, user_id: str | None = None, is_admin: bool = False, special: bool | None = None + ) -> list[Session]: sessions = self._sessions.values() if is_admin: pass @@ -117,7 +135,7 @@ # user_id=None non-admin means the anonymous local user: NULL-owner rows only. sessions = [s for s in sessions if s.user_id is None] return sorted( - sessions, + self._special_filter(list(sessions), special), key=lambda s: (s.pinned, s.last_active), reverse=True, ) @@ -130,8 +148,9 @@ profile_id: str | None = None, user_id: str | None = None, is_admin: bool = False, + special: bool | None = None, ) -> list[Session]: - sessions = await self.list_all(user_id=user_id, is_admin=is_admin) + sessions = await self.list_all(user_id=user_id, is_admin=is_admin, special=special) if profile_id: sessions = [s for s in sessions if s.profile_id == profile_id] return sessions[offset:offset + limit] @@ -142,9 +161,10 @@ user_id: str | None = None, is_admin: bool = False, search: str | None = None, + special: bool | None = None, ) -> int: sessions = await self.search_list( - limit=10000, offset=0, user_id=user_id, is_admin=is_admin, search=search + limit=10000, offset=0, user_id=user_id, is_admin=is_admin, search=search, special=special ) return len(sessions) @@ -158,6 +178,7 @@ search: str | None = None, sort_by: str = "last_active", sort_order: str = "desc", + special: bool | None = None, ) -> list[Session]: sessions = list(self._sessions.values()) if is_admin: @@ -167,6 +188,7 @@ else: # user_id=None non-admin: anonymous local user — NULL-owner rows only. sessions = [s for s in sessions if s.user_id is None] + sessions = self._special_filter(sessions, special) if search: q = search.lower() sessions = [ diff --git a/navi/main.py b/navi/main.py index a58fab0..e1856cf 100644 --- a/navi/main.py +++ b/navi/main.py @@ -260,6 +260,7 @@ app.include_router(webhooks.router) app.include_router(synapse.webhook_router) app.include_router(synapse.targets_router) +app.include_router(synapse.settings_router) app.include_router(admin_router) # Eval endpoints spend LLM tokens and read session data — admin only. # (With auth disabled require_admin resolves to the anonymous admin user.) diff --git a/navi/synapse/_ddl.py b/navi/synapse/_ddl.py index 5783f1c..3f5f872 100644 --- a/navi/synapse/_ddl.py +++ b/navi/synapse/_ddl.py @@ -17,6 +17,28 @@ ); CREATE INDEX IF NOT EXISTS idx_synapse_targets_user_id ON synapse_targets (user_id); + +CREATE TABLE IF NOT EXISTS synapse_settings ( + user_id TEXT PRIMARY KEY REFERENCES navi_users(id) ON DELETE CASCADE, + reactions_enabled BOOLEAN NOT NULL DEFAULT FALSE, + push_target TEXT NOT NULL DEFAULT 'app', -- app | app_synapse | synapse + completion_notify TEXT NOT NULL DEFAULT 'important', -- always | important | never + instructions TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Version history of reaction instructions: the agent can edit its own +-- instructions (self-improvement), the history keeps that auditable. +CREATE TABLE IF NOT EXISTS synapse_instruction_versions ( + id SERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES navi_users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + edited_by TEXT NOT NULL, -- 'user' | 'navi' + reason TEXT, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_synapse_instruction_versions_user ON synapse_instruction_versions (user_id, created_at); """ diff --git a/navi/synapse/settings_store.py b/navi/synapse/settings_store.py new file mode 100644 index 0000000..c64a93a --- /dev/null +++ b/navi/synapse/settings_store.py @@ -0,0 +1,121 @@ +"""Per-user Synapse reaction settings + instruction version history (postgres). + +Reaction instructions are the contract between the user and the agent: the +dispatcher meta-pass and every reaction run read them before acting. The agent +itself may edit the document (self-improvement) — every edit lands in +synapse_instruction_versions with an `edited_by` label, so the history doubles +as the audit log of the agent touching its own rules. +""" + +from datetime import datetime, timezone +from typing import Literal + +from pydantic import BaseModel + +PushTarget = Literal["app", "app_synapse", "synapse"] +CompletionNotify = Literal["always", "important", "never"] + +_VERSIONS_KEEP = 100 # versions kept per user; older ones are trimmed + + +class SynapseSettings(BaseModel): + user_id: str + reactions_enabled: bool = False + push_target: PushTarget = "app" + completion_notify: CompletionNotify = "important" + instructions: str = "" + updated_at: datetime | None = None + + +class InstructionVersion(BaseModel): + content: str + edited_by: str + reason: str | None = None + created_at: datetime + + +class SynapseSettingsStore: + def __init__(self, pool): + self._pool = pool + + async def get(self, user_id: str) -> SynapseSettings: + row = await self._pool.fetchrow( + "SELECT user_id, reactions_enabled, push_target, completion_notify, instructions, updated_at " + "FROM synapse_settings WHERE user_id = $1", + user_id, + ) + if row is None: + return SynapseSettings(user_id=user_id) + return SynapseSettings( + user_id=row["user_id"], + reactions_enabled=row["reactions_enabled"], + push_target=row["push_target"], + completion_notify=row["completion_notify"], + instructions=row["instructions"], + updated_at=row["updated_at"], + ) + + async def save(self, settings: SynapseSettings) -> None: + now = datetime.now(timezone.utc) + await self._pool.execute( + """ + INSERT INTO synapse_settings + (user_id, reactions_enabled, push_target, completion_notify, instructions, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_id) DO UPDATE SET + reactions_enabled = EXCLUDED.reactions_enabled, + push_target = EXCLUDED.push_target, + completion_notify = EXCLUDED.completion_notify, + instructions = EXCLUDED.instructions, + updated_at = EXCLUDED.updated_at + """, + settings.user_id, settings.reactions_enabled, settings.push_target, + settings.completion_notify, settings.instructions, now, + ) + + async def save_instructions( + self, user_id: str, content: str, edited_by: Literal["user", "navi"], + reason: str | None = None, + ) -> None: + """Update the instructions document and record the edit in the history. + + One transaction: the version row and the settings row never disagree. + """ + now = datetime.now(timezone.utc) + async with self._pool.acquire() as conn, conn.transaction(): + await conn.execute( + """ + INSERT INTO synapse_settings (user_id, instructions, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT (user_id) DO UPDATE SET + instructions = EXCLUDED.instructions, + updated_at = EXCLUDED.updated_at + """, + user_id, content, now, + ) + await conn.execute( + """ + INSERT INTO synapse_instruction_versions (user_id, content, edited_by, reason, created_at) + VALUES ($1, $2, $3, $4, $5) + """, + user_id, content, edited_by, reason, now, + ) + await conn.execute( + """ + DELETE FROM synapse_instruction_versions + WHERE user_id = $1 AND id NOT IN ( + SELECT id FROM synapse_instruction_versions + WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2 + ) + """, + user_id, _VERSIONS_KEEP, + ) + + async def list_versions(self, user_id: str, limit: int = 20) -> list[InstructionVersion]: + rows = await self._pool.fetch( + "SELECT content, edited_by, reason, created_at " + "FROM synapse_instruction_versions WHERE user_id = $1 " + "ORDER BY created_at DESC, id DESC LIMIT $2", + user_id, limit, + ) + return [InstructionVersion(**r) for r in rows] \ No newline at end of file diff --git a/tests/unit/api/test_synapse.py b/tests/unit/api/test_synapse.py index 39ce312..7ecc4ec 100644 --- a/tests/unit/api/test_synapse.py +++ b/tests/unit/api/test_synapse.py @@ -149,4 +149,130 @@ assert listed.json()["items"][0]["token_ref"] == "tgt-9" revoked = client.delete("/synapse-targets/7") - assert revoked.status_code == 204 \ No newline at end of file + assert revoked.status_code == 204 + + +def test_synapse_settings_get_defaults(synapse_client, monkeypatch): + """No row yet — the endpoint returns the defaults, not a 500.""" + import navi.api.routes.synapse as synapse_mod + from navi.synapse.settings_store import SynapseSettings + + async def fake_get(self, user_id): + return SynapseSettings(user_id=user_id) + + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "get", fake_get) + + resp = synapse_client.get("/synapse-settings") + assert resp.status_code == 200 + body = resp.json() + assert body["reactions_enabled"] is False + assert body["push_target"] == "app" + assert body["completion_notify"] == "important" + assert body["instructions"] == "" + + +def test_synapse_settings_put_records_user_edit(synapse_client, monkeypatch): + """Changing instructions via PUT stores an edited_by='user' version.""" + import navi.api.routes.synapse as synapse_mod + from navi.synapse.settings_store import SynapseSettings + + current = SynapseSettings(user_id="u1", instructions="") + saved_instructions: list = [] + + async def fake_get(self, user_id): + return current + + async def fake_save_instructions(self, user_id, content, edited_by, reason=None): + saved_instructions.append((content, edited_by, reason)) + current.instructions = content + + async def fake_save(self, settings_obj): + pass + + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "get", fake_get) + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "save_instructions", fake_save_instructions) + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "save", fake_save) + + resp = synapse_client.put( + "/synapse-settings", + json={ + "reactions_enabled": True, + "push_target": "app_synapse", + "completion_notify": "always", + "instructions": "Тот, кто приходит от gntodo, отвечает.", + }, + ) + assert resp.status_code == 200 + assert resp.json()["reactions_enabled"] is True + assert saved_instructions == [( + "Тот, кто приходит от gntodo, отвечает.", "user", None + )] + + +def test_synapse_settings_put_same_instructions_skips_version(synapse_client, monkeypatch): + import navi.api.routes.synapse as synapse_mod + from navi.synapse.settings_store import SynapseSettings + + current = SynapseSettings(user_id="u1") + + async def fake_get(self, user_id): + return current + + async def fake_save_instructions(self, *args, **kwargs): + raise AssertionError("unchanged instructions must not record a version") + + async def fake_save(self, settings_obj): + pass + + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "get", fake_get) + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "save_instructions", fake_save_instructions) + monkeypatch.setattr(synapse_mod.SynapseSettingsStore, "save", fake_save) + + resp = synapse_client.put( + "/synapse-settings", + json={ + "reactions_enabled": False, + "push_target": "app", + "completion_notify": "never", + "instructions": "", + }, + ) + assert resp.status_code == 200 + + +def test_synapse_settings_validation(synapse_client, monkeypatch): + import navi.api.routes.synapse as synapse_mod + + monkeypatch.setattr( + synapse_mod, "_pool", AsyncMock(return_value=MagicMock()) + ) + + resp = synapse_client.put( + "/synapse-settings", + json={ + "reactions_enabled": True, + "push_target": "carrier_pigeon", + "completion_notify": "always", + "instructions": "", + }, + ) + assert resp.status_code == 422 + + +def test_synapse_settings_requires_auth(monkeypatch): + """auth ON: the settings endpoints are user-scoped, not anonymous.""" + import navi.api.routes.synapse as synapse_mod + import navi.auth.deps as auth_deps + from unittest.mock import patch + + monkeypatch.setattr( + auth_deps, "settings", + Settings(_env_file=None, navi_persona_file="", navi_auth_enabled=True), + ) + monkeypatch.setattr(synapse_mod, "_pool", AsyncMock(return_value=MagicMock())) + + from navi.main import app + + tc = TestClient(app, raise_server_exceptions=False) + resp = tc.get("/synapse-settings") + assert resp.status_code in (401, 403) \ No newline at end of file diff --git a/tests/unit/core/test_session_special_filter.py b/tests/unit/core/test_session_special_filter.py new file mode 100644 index 0000000..9940f81 --- /dev/null +++ b/tests/unit/core/test_session_special_filter.py @@ -0,0 +1,57 @@ +"""Special (service) session filtering in session stores. + +Store-level default is *no* special filtering (None): hiding service sessions +from the user-facing list is the REST layer's decision, explicit in the +`sessions` route (`?special=` — False hides, True lists only special). +""" + +import pytest + +from navi.core.session import InMemorySessionStore + + +@pytest.fixture +def store() -> InMemorySessionStore: + store = InMemorySessionStore() + store._sessions["reg"] = _fake("reg", special=False) + store._sessions["spec"] = _fake("spec", special=True) + return store + + +def _fake(session_id: str, special: bool): + from navi.core.session import Session + + return Session(id=session_id, profile_id="secretary", special=special) + + +async def test_list_all_no_filter_by_default(store): + ids = sorted(s.id for s in await store.list_all()) + assert ids == ["reg", "spec"] + + +async def test_list_all_regular_only(store): + ids = [s.id for s in await store.list_all(special=False)] + assert ids == ["reg"] + + +async def test_list_all_special_only(store): + ids = [s.id for s in await store.list_all(special=True)] + assert ids == ["spec"] + + +async def test_list_page_count_search_filter_special(store): + page = await store.list_page(limit=10, offset=0, special=False) + assert [s.id for s in page] == ["reg"] + + page_special = await store.list_page(limit=10, offset=0, special=True) + assert [s.id for s in page_special] == ["spec"] + + assert await store.count_all(special=False) == 1 + assert await store.count_all(special=True) == 1 + assert await store.count_all(special=None) == 2 + assert await store.count_all() == 2 # no filter — admin/back-end paths + + found = await store.search_list(limit=10, offset=0, search="reg", special=False) + assert [s.id for s in found] == ["reg"] + found_special = await store.search_list(limit=10, offset=0, search="reg", special=True) + assert found_special == [] \ No newline at end of file