mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
Compare commits
67
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c88c27c8 | ||
|
|
6edd771cc9 | ||
|
|
938251000b | ||
|
|
b52296471b | ||
|
|
53869d194d | ||
|
|
17ee856d1c | ||
|
|
e7eddbae13 | ||
|
|
93eb10d4f0 | ||
|
|
3f9633c44f | ||
|
|
858c872832 | ||
|
|
1939a6ad2d | ||
|
|
937c883c41 | ||
|
|
e0615cda47 | ||
|
|
1976fe1b60 | ||
|
|
adfe3ab379 | ||
|
|
d87a913729 | ||
|
|
bea48c749c | ||
|
|
5a016e492c | ||
|
|
93653120d6 | ||
|
|
c2b9666def | ||
|
|
1183fe0ff1 | ||
|
|
663d6879b7 | ||
|
|
3bea7a53ee | ||
|
|
22e0af2a58 | ||
|
|
c00ef8f9c2 | ||
|
|
1fef4929cf | ||
|
|
651bf714de | ||
|
|
d449a9d431 | ||
|
|
dbeed4b63f | ||
|
|
96aca52094 | ||
|
|
8f2f483725 | ||
|
|
42da399b4d | ||
|
|
48cf08328f | ||
|
|
e4fa4ae5dd | ||
|
|
378518f6df | ||
|
|
f06a0a30a8 | ||
|
|
99566d28b5 | ||
|
|
f1e96d102e | ||
|
|
36d4098421 | ||
|
|
5ddef23d94 | ||
|
|
c8a012d4d2 | ||
|
|
20e7fc0164 | ||
|
|
9d686180dd | ||
|
|
bb719f217a | ||
|
|
fb8c391a88 | ||
|
|
0de76c4056 | ||
|
|
25c9e735ef | ||
|
|
28c333e647 | ||
|
|
84709a00d9 | ||
|
|
578312200a | ||
|
|
f23221420f | ||
|
|
6a84398e75 | ||
|
|
3250a4ce68 | ||
|
|
cb0f6af002 | ||
|
|
9297bed5b9 | ||
|
|
2e631ad816 | ||
|
|
d183fe545b | ||
|
|
9914651cc9 | ||
|
|
46905ab9b0 | ||
|
|
61c138d9e7 | ||
|
|
25a4d134b1 | ||
|
|
98e4d8451b | ||
|
|
5104a9a967 | ||
|
|
01790c2f08 | ||
|
|
e222e92153 | ||
|
|
b91f48f50a | ||
|
|
d96c7af3df |
@@ -189,6 +189,7 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
||||
# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB)
|
||||
|
||||
# ============================================================
|
||||
# Host Docker access (explicit opt-in)
|
||||
|
||||
@@ -153,6 +153,16 @@ module.exports = async ({ github, context, core }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const LABEL_BAD = 'needs more info';
|
||||
const LABEL_GOOD = 'ready for review';
|
||||
|
||||
// Closed issues are no longer awaiting review.
|
||||
// This also prevents later edits to closed issues from restoring the label.
|
||||
if (issue.state === 'closed') {
|
||||
await dropLabel(LABEL_GOOD);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Find existing bot comment to update in-place ──────────────────────────
|
||||
const MARKER = '<!-- issue-description-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
@@ -160,9 +170,6 @@ module.exports = async ({ github, context, core }) => {
|
||||
});
|
||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
||||
|
||||
const LABEL_BAD = 'needs more info';
|
||||
const LABEL_GOOD = 'ready for review';
|
||||
|
||||
if (failures.length === 0) {
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||
|
||||
@@ -2,7 +2,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
|
||||
# Least privilege: none of the jobs write to the repo.
|
||||
@@ -103,10 +103,7 @@ jobs:
|
||||
python-tests:
|
||||
name: Python tests (pytest)
|
||||
runs-on: ubuntu-latest
|
||||
# Informational for now: the suite has known flaky / environment-dependent
|
||||
# failures (test isolation + embedding-model assertions). Tracked under the
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
# Make Python test validation authoritative for the configured scope.
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -2,7 +2,7 @@ name: ci / issue description check
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened]
|
||||
types: [opened, edited, reopened, closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -692,7 +692,7 @@ from routes.history.history_routes import setup_history_routes
|
||||
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
||||
|
||||
# Search
|
||||
from routes.search_routes import setup_search_routes
|
||||
from routes.search.search_routes import setup_search_routes
|
||||
app.include_router(setup_search_routes(config))
|
||||
|
||||
# Presets
|
||||
@@ -739,7 +739,7 @@ app.include_router(setup_stt_routes(stt_service))
|
||||
logger.info("STT service initialized (provider managed via settings)")
|
||||
|
||||
# Documents (artifacts/canvas)
|
||||
from routes.document_routes import setup_document_routes
|
||||
from routes.document.document_routes import setup_document_routes
|
||||
document_router = setup_document_routes(session_manager, upload_handler)
|
||||
app.include_router(document_router)
|
||||
|
||||
@@ -805,7 +805,7 @@ app.include_router(setup_font_routes())
|
||||
# MCP (Model Context Protocol)
|
||||
from src.mcp_manager import McpManager
|
||||
from src.agent_tools import set_mcp_manager
|
||||
from routes.mcp_routes import setup_mcp_routes
|
||||
from routes.mcp.mcp_routes import setup_mcp_routes
|
||||
|
||||
mcp_manager = McpManager()
|
||||
set_mcp_manager(mcp_manager)
|
||||
@@ -820,7 +820,7 @@ set_ai_rag_manager(rag_manager, personal_docs_mgr)
|
||||
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
||||
|
||||
# Webhooks
|
||||
from routes.webhook_routes import setup_webhook_routes
|
||||
from routes.webhook.webhook_routes import setup_webhook_routes
|
||||
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
||||
|
||||
# API Tokens
|
||||
@@ -852,7 +852,7 @@ app.include_router(setup_codex_routes(
|
||||
))
|
||||
app.include_router(setup_claude_routes())
|
||||
|
||||
from routes.vault_routes import setup_vault_routes
|
||||
from routes.vault.vault_routes import setup_vault_routes
|
||||
app.include_router(setup_vault_routes())
|
||||
|
||||
# Contacts (CardDAV)
|
||||
|
||||
+8
-4
@@ -15,17 +15,21 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
||||
"""Atomically persist `data` as JSON at `path`.
|
||||
|
||||
The temp file uses the live PID as a suffix so two processes saving the
|
||||
same file (e.g. unit tests) don't collide on the rename target.
|
||||
The temp file uses a random suffix so two concurrent writers saving the
|
||||
same file don't collide on the rename target. A PID suffix does not do
|
||||
this: the PID is constant for the life of a process, so two writers on
|
||||
the same path within one process (or one single-process container, where
|
||||
the PID never changes at all) still race for the same temp file.
|
||||
"""
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=indent)
|
||||
f.flush()
|
||||
@@ -37,7 +41,7 @@ def atomic_write_text(path: str, text: str) -> None:
|
||||
if not isinstance(text, str):
|
||||
raise TypeError("atomic_write_text expects a string")
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = f"{path}.tmp.{os.getpid()}"
|
||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
f.flush()
|
||||
|
||||
+218
-60
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import unquote, urlparse
|
||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||
from sqlalchemy import DDL, event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, inspect, text
|
||||
from sqlalchemy.engine import Engine, make_url
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
@@ -430,6 +430,93 @@ class EmailAccount(TimestampMixin, Base):
|
||||
)
|
||||
|
||||
|
||||
class EmailAccountOwnerLock(Base):
|
||||
"""Durable per-owner mutex for email-account default mutations.
|
||||
|
||||
Row-locking databases serialize mutations by locking this row before they
|
||||
inspect or stage EmailAccount changes. SQLite uses ``BEGIN IMMEDIATE``
|
||||
instead, because it ignores ``SELECT ... FOR UPDATE``; keeping the table in
|
||||
the shared metadata still makes the non-SQLite path available without a
|
||||
separate migration. The empty key represents the normalized legacy /
|
||||
unconfigured scope shared by ``owner IS NULL`` and ``owner = ''`` rows.
|
||||
"""
|
||||
__tablename__ = "email_account_owner_locks"
|
||||
|
||||
owner_key = Column(String, primary_key=True)
|
||||
|
||||
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX = "ux_email_accounts_one_default_per_owner"
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX_DDL = {
|
||||
"sqlite": (
|
||||
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||
"ON email_accounts (COALESCE(owner, '')) WHERE is_default = 1"
|
||||
),
|
||||
"postgresql": (
|
||||
f"CREATE UNIQUE INDEX IF NOT EXISTS {_EMAIL_ACCOUNT_DEFAULT_INDEX} "
|
||||
"ON email_accounts ((COALESCE(owner, ''))) WHERE is_default IS TRUE"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# SQLAlchemy cannot express one portable partial, functional index across the
|
||||
# two supported database families. Register dialect-specific DDL so fresh
|
||||
# databases get the invariant as part of create_all(); the startup migration
|
||||
# below installs the same index on existing databases after normalizing legacy
|
||||
# duplicate rows.
|
||||
for _dialect_name, _index_ddl in _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.items():
|
||||
event.listen(
|
||||
EmailAccount.__table__,
|
||||
"after_create",
|
||||
DDL(_index_ddl).execute_if(dialect=_dialect_name),
|
||||
)
|
||||
|
||||
|
||||
def lock_email_account_owner_mutations(db, *owners: str) -> None:
|
||||
"""Lock normalized email-account owner scopes in canonical order.
|
||||
|
||||
``NULL`` and the empty string are one legacy/single-user owner partition,
|
||||
matching the unique default-account index. SQLite has only a database
|
||||
writer reservation, while row-locking databases use durable mutex rows.
|
||||
Sorting all requested owner keys keeps multi-owner operations such as user
|
||||
rename from deadlocking with another mutation that requests the same keys
|
||||
in the opposite order.
|
||||
"""
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
owner_keys = sorted({owner or "" for owner in owners} or {""})
|
||||
if db.get_bind().dialect.name == "sqlite":
|
||||
db.execute(text("BEGIN IMMEDIATE"))
|
||||
return
|
||||
|
||||
for owner_key in owner_keys:
|
||||
lock_row = db.get(
|
||||
EmailAccountOwnerLock,
|
||||
owner_key,
|
||||
with_for_update=True,
|
||||
)
|
||||
if lock_row is not None:
|
||||
continue
|
||||
|
||||
inserted = False
|
||||
try:
|
||||
with db.begin_nested():
|
||||
db.add(EmailAccountOwnerLock(owner_key=owner_key))
|
||||
db.flush()
|
||||
inserted = True
|
||||
except IntegrityError:
|
||||
# A competing transaction created the mutex row first. Once its
|
||||
# insert commits, lock that durable row before touching accounts.
|
||||
pass
|
||||
|
||||
if not inserted:
|
||||
(
|
||||
db.query(EmailAccountOwnerLock)
|
||||
.filter(EmailAccountOwnerLock.owner_key == owner_key)
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
class ModelEndpoint(TimestampMixin, Base):
|
||||
"""Admin-configured model endpoints. Models are auto-discovered via /v1/models."""
|
||||
__tablename__ = "model_endpoints"
|
||||
@@ -1812,72 +1899,142 @@ class Integration(TimestampMixin, Base):
|
||||
|
||||
|
||||
|
||||
def _migrate_seed_email_account():
|
||||
"""If email_accounts is empty and settings.json has legacy flat imap_host/smtp_host
|
||||
keys, create a single default account from them so nothing breaks for users who
|
||||
upgraded. Safe to run repeatedly — it short-circuits once any row exists."""
|
||||
def _migrate_email_account_default_invariant():
|
||||
"""Normalize legacy duplicates and install durable at-most-one enforcement.
|
||||
|
||||
Older databases only had a non-unique ``(owner, is_default)`` lookup index.
|
||||
Keep the oldest default deterministically in each normalized owner scope,
|
||||
then add the same partial functional unique index used for fresh schemas.
|
||||
"""
|
||||
dialect_name = engine.dialect.name
|
||||
index_ddl = _EMAIL_ACCOUNT_DEFAULT_INDEX_DDL.get(dialect_name)
|
||||
if index_ddl is None:
|
||||
logger.warning(
|
||||
"Email-account default uniqueness is not available for database "
|
||||
"dialect %s; mutations remain serialized but are not protected by "
|
||||
"a database constraint",
|
||||
dialect_name,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
tables = [r[0] for r in conn.execute(text(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='email_accounts'"
|
||||
))]
|
||||
if "email_accounts" not in tables:
|
||||
return
|
||||
existing = conn.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
||||
if existing > 0:
|
||||
with engine.begin() as conn:
|
||||
if not inspect(conn).has_table(EmailAccount.__tablename__):
|
||||
return
|
||||
default_rows = conn.execute(text("""
|
||||
SELECT id, owner
|
||||
FROM email_accounts
|
||||
WHERE is_default IS TRUE
|
||||
ORDER BY
|
||||
COALESCE(owner, ''),
|
||||
CASE WHEN created_at IS NULL THEN 1 ELSE 0 END,
|
||||
created_at,
|
||||
id
|
||||
""")).mappings()
|
||||
seen_owner_keys = set()
|
||||
duplicate_ids = []
|
||||
for row in default_rows:
|
||||
owner_key = row["owner"] or ""
|
||||
if owner_key in seen_owner_keys:
|
||||
duplicate_ids.append(row["id"])
|
||||
else:
|
||||
seen_owner_keys.add(owner_key)
|
||||
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
from pathlib import Path
|
||||
settings_file = Path(SETTINGS_FILE)
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
for account_id in duplicate_ids:
|
||||
conn.execute(
|
||||
text("UPDATE email_accounts SET is_default = :value WHERE id = :id"),
|
||||
{"value": False, "id": account_id},
|
||||
)
|
||||
conn.execute(text(index_ddl))
|
||||
|
||||
imap_host = (s.get("imap_host") or "").strip()
|
||||
smtp_host = (s.get("smtp_host") or "").strip()
|
||||
if not imap_host and not smtp_host:
|
||||
return # nothing to migrate
|
||||
if duplicate_ids:
|
||||
logger.warning(
|
||||
"Normalized %d duplicate default email account(s) before "
|
||||
"installing %s",
|
||||
len(duplicate_ids),
|
||||
_EMAIL_ACCOUNT_DEFAULT_INDEX,
|
||||
)
|
||||
except Exception:
|
||||
# Starting without the constraint would silently retain the race this
|
||||
# migration is intended to close. Fail startup so an operator sees and
|
||||
# can repair an incompatible schema instead of accepting unsafe writes.
|
||||
logger.exception("Failed to enforce the email-account default invariant")
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_seed_email_account():
|
||||
"""Atomically seed one legacy default account when no account exists.
|
||||
|
||||
Reading settings is intentionally done before taking the owner mutex. The
|
||||
decisive emptiness check and insert share one locked transaction, so two
|
||||
application workers starting together cannot both seed a default row.
|
||||
"""
|
||||
import json as _json
|
||||
import uuid as _uuid
|
||||
|
||||
settings_file = Path(SETTINGS_FILE)
|
||||
if not settings_file.exists():
|
||||
return
|
||||
try:
|
||||
s = _json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
imap_host = (s.get("imap_host") or "").strip()
|
||||
smtp_host = (s.get("smtp_host") or "").strip()
|
||||
if not imap_host and not smtp_host:
|
||||
return
|
||||
|
||||
db = None
|
||||
try:
|
||||
if not inspect(engine).has_table(EmailAccount.__tablename__):
|
||||
return
|
||||
db = SessionLocal()
|
||||
lock_email_account_owner_mutations(db, "")
|
||||
existing = db.execute(text("SELECT COUNT(*) FROM email_accounts")).scalar() or 0
|
||||
if existing > 0:
|
||||
return
|
||||
|
||||
now = utcnow_naive()
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
from_address, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :owner, :name, :is_default, :enabled,
|
||||
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
||||
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
||||
:from_address, :created_at, :updated_at)
|
||||
"""), {
|
||||
"id": _uuid.uuid4().hex,
|
||||
"owner": None,
|
||||
"name": "Default",
|
||||
"is_default": True,
|
||||
"enabled": True,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": int(s.get("imap_port") or 993),
|
||||
"imap_user": s.get("imap_user") or "",
|
||||
"imap_password": s.get("imap_password") or "",
|
||||
"imap_starttls": bool(s.get("imap_starttls", True)),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": int(s.get("smtp_port") or 465),
|
||||
"smtp_user": s.get("smtp_user") or "",
|
||||
"smtp_password": s.get("smtp_password") or "",
|
||||
"from_address": s.get("email_from") or "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
logging.getLogger(__name__).info("Seeded email_accounts 'Default' from settings.json")
|
||||
db.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
(id, owner, name, is_default, enabled,
|
||||
imap_host, imap_port, imap_user, imap_password, imap_starttls,
|
||||
smtp_host, smtp_port, smtp_user, smtp_password,
|
||||
from_address, created_at, updated_at)
|
||||
VALUES
|
||||
(:id, :owner, :name, :is_default, :enabled,
|
||||
:imap_host, :imap_port, :imap_user, :imap_password, :imap_starttls,
|
||||
:smtp_host, :smtp_port, :smtp_user, :smtp_password,
|
||||
:from_address, :created_at, :updated_at)
|
||||
"""), {
|
||||
"id": _uuid.uuid4().hex,
|
||||
"owner": None,
|
||||
"name": "Default",
|
||||
"is_default": True,
|
||||
"enabled": True,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": int(s.get("imap_port") or 993),
|
||||
"imap_user": s.get("imap_user") or "",
|
||||
"imap_password": s.get("imap_password") or "",
|
||||
"imap_starttls": bool(s.get("imap_starttls", True)),
|
||||
"smtp_host": smtp_host,
|
||||
"smtp_port": int(s.get("smtp_port") or 465),
|
||||
"smtp_user": s.get("smtp_user") or "",
|
||||
"smtp_password": s.get("smtp_password") or "",
|
||||
"from_address": s.get("email_from") or "",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
db.commit()
|
||||
logger.info("Seeded email_accounts 'Default' from settings.json")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"seed email account migration: {e}")
|
||||
if db is not None:
|
||||
db.rollback()
|
||||
logger.warning("seed email account migration: %s", e)
|
||||
finally:
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
# WARNING: Foreign-key enforcement is enabled globally for all SQLite connections.
|
||||
@@ -1960,6 +2117,7 @@ def init_db():
|
||||
_migrate_add_crew_member_id()
|
||||
_migrate_add_assistant_columns()
|
||||
_migrate_add_email_smtp_security()
|
||||
_migrate_email_account_default_invariant()
|
||||
_migrate_seed_email_account()
|
||||
_migrate_add_calendar_metadata()
|
||||
_migrate_add_calendar_is_utc()
|
||||
|
||||
+41
-12
@@ -194,7 +194,12 @@ class SessionManager:
|
||||
is_important=getattr(db_session, 'is_important', False) or False,
|
||||
)
|
||||
|
||||
session.message_count = getattr(db_session, 'message_count', len(history))
|
||||
# The rows just loaded are the whole transcript, so they — not the
|
||||
# denormalized sessions.message_count column — are the truth for this
|
||||
# cached object. get_session's hydration gate compares against this
|
||||
# number; seeding it from a drifted column would ask for a reload that
|
||||
# can never close the gap.
|
||||
session.message_count = len(history)
|
||||
return session
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -398,30 +403,50 @@ class SessionManager:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_session(self, session_id: str) -> Session:
|
||||
"""Get a session by ID, loading from DB if needed.
|
||||
"""Get a session by ID, loading complete DB history when needed.
|
||||
|
||||
Sessions seeded by `load_sessions` start with empty history. The
|
||||
first read here hydrates them with the message rows.
|
||||
Sessions seeded by ``load_sessions`` start with empty history, and a
|
||||
cached session can also become partially stale. Refresh metadata first,
|
||||
then hydrate whenever the cached transcript is short of the stored rows.
|
||||
Model-send routes enter through this method before building context,
|
||||
while paginated display history reads SQLite directly.
|
||||
|
||||
The gate compares against ``sync_session_metadata``'s reconciled count
|
||||
(the real ``chat_messages`` total), never the denormalized column, so a
|
||||
hydrate always closes the gap and the next read is a cache hit.
|
||||
"""
|
||||
if session_id not in self.sessions:
|
||||
self._load_session_from_db(session_id)
|
||||
else:
|
||||
cached = self.sessions[session_id]
|
||||
# Lazy hydrate: metadata-only entries get their messages on first read.
|
||||
if not cached.history and getattr(cached, "message_count", 0) > 0:
|
||||
self._load_session_from_db(session_id)
|
||||
|
||||
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||
# DB row while a session object is still cached in RAM.
|
||||
# DB row while a session object is still cached in RAM. Refreshing first
|
||||
# also exposes the authoritative message count before completeness is
|
||||
# checked.
|
||||
self.sync_session_metadata(session_id)
|
||||
|
||||
cached = self.sessions[session_id]
|
||||
cached_count = len(cached.history or [])
|
||||
stored_count = int(getattr(cached, "message_count", 0) or 0)
|
||||
if cached_count < stored_count:
|
||||
self._load_session_from_db(session_id)
|
||||
|
||||
# Update last_accessed
|
||||
self._touch_session(session_id)
|
||||
|
||||
return self.sessions[session_id]
|
||||
|
||||
def sync_session_metadata(self, session_id: str) -> bool:
|
||||
"""Refresh non-message session fields from the DB into the cached object."""
|
||||
"""Refresh non-message session fields from the DB into the cached object.
|
||||
|
||||
``message_count`` is reconciled against the real ``chat_messages`` rows
|
||||
rather than copied from the denormalized ``sessions.message_count``
|
||||
column. That column drifts in normal operation — ``_persist_message``
|
||||
swallows a failed insert but ``add_message`` has already appended in
|
||||
memory, so the next successful persist writes rows+1, and a persist for
|
||||
an uncached session writes 0. Hydration keys off this number: a
|
||||
drifted-high column would reload the whole transcript on every warm
|
||||
read, and a drifted-low one would leave the model a truncated one.
|
||||
"""
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
return False
|
||||
@@ -444,7 +469,11 @@ class SessionManager:
|
||||
session.archived = db_session.archived
|
||||
session.owner = getattr(db_session, "owner", None)
|
||||
session.is_important = getattr(db_session, "is_important", False) or False
|
||||
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
||||
session.message_count = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.count()
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||
|
||||
@@ -67,6 +67,7 @@ services:
|
||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
|
||||
@@ -66,6 +66,7 @@ services:
|
||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
|
||||
@@ -55,6 +55,7 @@ services:
|
||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
|
||||
@@ -309,6 +309,32 @@ container. Cookbook **Serve** is a separate workflow for serving downloaded
|
||||
models through Odysseus/llama.cpp, so Windows users with an existing Ollama
|
||||
install usually only need to add the endpoint in Settings.
|
||||
|
||||
**Tool calls not firing on a manually-added Ollama `/v1` endpoint.** By
|
||||
design, a local Ollama `/v1` endpoint defaults to the conservative
|
||||
text-based (fenced-block) tool-calling path rather than native structured
|
||||
tool calls, since some locally-served models mishandle native schemas (see
|
||||
#1567). This is correct for most local setups, but if you know your specific
|
||||
model reliably supports native tool calling (check `ollama show <model>` for
|
||||
`tools` under Capabilities), you can opt that endpoint in explicitly. There
|
||||
is currently no UI control for this on manually-added endpoints (see #5192);
|
||||
the flag can still be set directly against the existing API, from a browser
|
||||
console on an authenticated admin session:
|
||||
|
||||
```js
|
||||
fetch('/api/model-endpoints/<endpoint-id>', {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({supports_tools: true})
|
||||
}).then(r => r.json()).then(console.log)
|
||||
```
|
||||
|
||||
Find `<endpoint-id>` by inspecting the `/api/model-endpoints` response (or
|
||||
your browser's network tab while Settings loads the endpoint list). Send
|
||||
`supports_tools: false` to disable native structured tool calls and force the
|
||||
conservative fenced/text path, or `supports_tools: null` to return the endpoint
|
||||
to the Auto heuristic.
|
||||
|
||||
**Useful checks.**
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,6 +17,8 @@ from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
|
||||
server = Server("memory")
|
||||
|
||||
# Late-initialized managers (set during first tool call)
|
||||
@@ -29,6 +31,10 @@ _OWNER_SCOPE_ERROR = (
|
||||
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
||||
"Set ODYSSEUS_MCP_MEMORY_OWNER for this server or use the owner-aware native memory tool."
|
||||
)
|
||||
_UNREADABLE_STORE_ERROR = (
|
||||
"Error: Memory store is temporarily unreadable — nothing was saved. "
|
||||
"Repair or restore memory.json, then retry."
|
||||
)
|
||||
|
||||
|
||||
def _configured_owner() -> str | None:
|
||||
@@ -51,9 +57,21 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
|
||||
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
||||
|
||||
|
||||
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||
"""Return configured owner, all entries, visible entries, and optional error."""
|
||||
entries = _memory_manager.load_all()
|
||||
def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||
"""Return configured owner, all entries, visible entries, and optional error.
|
||||
|
||||
``for_update=True`` is for read-modify-write callers. They save the ``all
|
||||
entries`` list back, so an unreadable store must be reported as an error
|
||||
instead of degrading to ``[]`` — otherwise the save writes their one new
|
||||
entry over the whole store (issue #5673).
|
||||
"""
|
||||
if for_update:
|
||||
try:
|
||||
entries = _memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
return None, [], [], f"{_UNREADABLE_STORE_ERROR} ({e})"
|
||||
else:
|
||||
entries = _memory_manager.load_all()
|
||||
owner = _configured_owner()
|
||||
if owner is None and _owner_scoped_store(entries):
|
||||
return None, entries, [], _OWNER_SCOPE_ERROR
|
||||
@@ -161,7 +179,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
category = arguments.get("category", "fact")
|
||||
if not text:
|
||||
return _text_result("Error: Memory text cannot be empty")
|
||||
owner, memories, _visible, scope_error = _scope_entries()
|
||||
owner, memories, _visible, scope_error = _scope_entries(for_update=True)
|
||||
if scope_error:
|
||||
return _text_result(scope_error)
|
||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||
|
||||
+4
-1
@@ -38,7 +38,10 @@ python-dateutil
|
||||
caldav
|
||||
cryptography
|
||||
bcrypt
|
||||
mcp
|
||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
||||
# servers are migrated together.
|
||||
mcp<2
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
croniter
|
||||
|
||||
+53
-1
@@ -345,9 +345,61 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
# docs, email accounts, tasks, etc.
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
from core.database import Base, SessionLocal
|
||||
from core.database import (
|
||||
Base,
|
||||
EmailAccount,
|
||||
SessionLocal,
|
||||
lock_email_account_owner_mutations,
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Email-account defaults are protected by per-owner mutex rows.
|
||||
# A rename crosses two owner partitions, so lock both in the
|
||||
# shared helper's canonical order before inspecting either.
|
||||
lock_email_account_owner_mutations(
|
||||
db, old_username, new_username
|
||||
)
|
||||
|
||||
source_default_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(EmailAccount.id)
|
||||
.filter(
|
||||
func.lower(EmailAccount.owner) == old_username,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
]
|
||||
destination_default_ids = [
|
||||
row[0]
|
||||
for row in (
|
||||
db.query(EmailAccount.id)
|
||||
.filter(
|
||||
func.lower(EmailAccount.owner) == new_username,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
]
|
||||
if destination_default_ids:
|
||||
clear_default_ids = (
|
||||
destination_default_ids[1:] + source_default_ids
|
||||
)
|
||||
else:
|
||||
clear_default_ids = source_default_ids[1:]
|
||||
if clear_default_ids:
|
||||
(
|
||||
db.query(EmailAccount)
|
||||
.filter(EmailAccount.id.in_(clear_default_ids))
|
||||
.update(
|
||||
{EmailAccount.is_default: False},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
|
||||
for mapper in Base.registry.mappers:
|
||||
model = mapper.class_
|
||||
if not hasattr(model, "owner"):
|
||||
|
||||
+10
-1
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from core.middleware import require_admin
|
||||
from services.memory import MemoryStoreUnreadable
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.settings import load_settings, save_settings, load_features, save_features
|
||||
|
||||
@@ -76,7 +77,15 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
||||
|
||||
# ── Memories ──
|
||||
if "memories" in body and isinstance(body["memories"], list):
|
||||
existing = memory_manager.load_all()
|
||||
# Strict load: importing on top of an unreadable store would write
|
||||
# only the incoming rows back and drop everything already saved.
|
||||
try:
|
||||
existing = memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Refusing to import memories: %s", e)
|
||||
raise HTTPException(
|
||||
503, "Memory store is temporarily unreadable — nothing was imported."
|
||||
)
|
||||
# Dedup against THIS user's own memories only. Using every tenant's
|
||||
# rows (load_all) meant a memory whose text matched any other
|
||||
# user's was silently skipped, so the importing user lost their own
|
||||
|
||||
+115
-7
@@ -10,6 +10,7 @@ from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from dateutil.rrule import rrulestr
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||
@@ -221,22 +222,125 @@ class EventUpdate(BaseModel):
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
_DEFAULT_CALENDAR_NAMESPACE = uuid.UUID("4840613a-9847-4a3b-bd75-19e6bc5fc3ce")
|
||||
|
||||
|
||||
def _default_calendar_id(owner: str, collision_index: int = 0) -> str:
|
||||
"""Return one stable primary-key candidate for an owner's lazy default.
|
||||
|
||||
Slot zero preserves the original owner-derived identifier. Later slots
|
||||
let a username be reused after its prior calendar was migrated to another
|
||||
owner during a rename, without making concurrent first use choose random
|
||||
and therefore divergent identifiers.
|
||||
"""
|
||||
if collision_index == 0:
|
||||
candidate_name = owner
|
||||
else:
|
||||
candidate_name = json.dumps(
|
||||
[owner, collision_index],
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return str(uuid.uuid5(_DEFAULT_CALENDAR_NAMESPACE, candidate_name))
|
||||
|
||||
|
||||
def _begin_sqlite_default_write(db) -> None:
|
||||
"""Serialize an absent-default check with other SQLite writers.
|
||||
|
||||
SQLite's default deferred transactions allow two workers to both read an
|
||||
empty calendar set before either writes. ``BEGIN IMMEDIATE`` acquires the
|
||||
writer reservation before the second, authoritative lookup. We issue it
|
||||
only when the driver has not already opened a write transaction; a caller
|
||||
with a pending write already owns the required reservation.
|
||||
"""
|
||||
connection = db.connection()
|
||||
dbapi_connection = connection.connection
|
||||
driver_connection = getattr(
|
||||
dbapi_connection,
|
||||
"driver_connection",
|
||||
dbapi_connection,
|
||||
)
|
||||
if not getattr(driver_connection, "in_transaction", False):
|
||||
connection.exec_driver_sql("BEGIN IMMEDIATE")
|
||||
|
||||
|
||||
def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
|
||||
"""Create default calendar if none exist for this owner."""
|
||||
"""Return the owner's calendar, staging a default in the caller's transaction.
|
||||
|
||||
A stable owner-derived primary key makes concurrent first-use inserts
|
||||
converge on one row on every SQL backend. SQLite additionally serializes
|
||||
the absent-row check because its deferred transactions otherwise permit
|
||||
both workers to read the gap before either writes. Other backends recover
|
||||
a lost insert race inside a savepoint so the caller's event transaction
|
||||
remains usable and atomic.
|
||||
"""
|
||||
owner = owner or FALLBACK_OWNER
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||
if not cal:
|
||||
if cal:
|
||||
return cal
|
||||
|
||||
dialect = db.get_bind().dialect.name
|
||||
if dialect == "sqlite":
|
||||
_begin_sqlite_default_write(db)
|
||||
# Another worker may have committed while BEGIN IMMEDIATE waited.
|
||||
cal = db.query(CalendarCal).filter(CalendarCal.owner == owner).first()
|
||||
if cal:
|
||||
return cal
|
||||
|
||||
collision_index = 0
|
||||
while True:
|
||||
default_id = _default_calendar_id(owner, collision_index)
|
||||
|
||||
if dialect == "sqlite":
|
||||
# BEGIN IMMEDIATE above makes this occupancy check authoritative:
|
||||
# another SQLite writer cannot rename, delete, or claim this slot
|
||||
# until the caller commits or rolls back.
|
||||
occupant = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == default_id,
|
||||
).first()
|
||||
if occupant is not None:
|
||||
if occupant.owner == owner:
|
||||
return occupant
|
||||
collision_index += 1
|
||||
continue
|
||||
|
||||
cal = CalendarCal(
|
||||
id=str(uuid.uuid4()),
|
||||
id=default_id,
|
||||
owner=owner,
|
||||
name="Personal",
|
||||
color="#5b8abf",
|
||||
source="local",
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
db.refresh(cal)
|
||||
return cal
|
||||
|
||||
if dialect == "sqlite":
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
return cal
|
||||
|
||||
try:
|
||||
# A uniqueness failure rolls back only this savepoint, not an event
|
||||
# or reminder already staged by the caller's outer transaction.
|
||||
with db.begin_nested():
|
||||
db.add(cal)
|
||||
db.flush()
|
||||
return cal
|
||||
except IntegrityError:
|
||||
# Use a locking/current read so repeatable-read backends can observe
|
||||
# the row that won after our transaction's original empty snapshot.
|
||||
occupant = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == default_id,
|
||||
).with_for_update().first()
|
||||
if occupant is None:
|
||||
# Do not misclassify an unrelated integrity failure as an ID
|
||||
# collision and loop forever. A concurrently deleted winner is
|
||||
# safe for the caller to retry as a fresh transaction.
|
||||
raise
|
||||
if occupant.owner == owner:
|
||||
return occupant
|
||||
# A renamed calendar owns this deterministic slot. Advance to the
|
||||
# next stable slot; concurrent callers for this owner will still
|
||||
# converge there.
|
||||
collision_index += 1
|
||||
|
||||
|
||||
# Per-request user time context. chat_routes sets this from browser timezone
|
||||
@@ -1015,6 +1119,9 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_ensure_default_calendar(db, owner)
|
||||
# Listing calendars intentionally lazily creates a durable default.
|
||||
# Other callers commit it with the event they are creating.
|
||||
db.commit()
|
||||
cals = db.query(CalendarCal).filter(CalendarCal.owner == owner).all()
|
||||
return {"calendars": [
|
||||
{"name": c.name, "href": c.id, "color": c.color, "source": c.source}
|
||||
@@ -1023,6 +1130,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Failed to list calendars: %s", e)
|
||||
raise HTTPException(500, "Failed to list calendars")
|
||||
finally:
|
||||
|
||||
+11
-11
@@ -21,6 +21,7 @@ from src import agent_runs
|
||||
from src.model_context import estimate_tokens
|
||||
from src.chat_helpers import coerce_message_and_session
|
||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
||||
from src.foreground_model_routing import build_foreground_model_candidates
|
||||
from src.session_search import search_session_messages
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
@@ -1399,14 +1400,14 @@ def setup_chat_routes(
|
||||
thinking_response = ""
|
||||
last_metrics = None
|
||||
|
||||
# Configured fallback chain for the default chat model. Tried in
|
||||
# order if the session's primary model fails before producing
|
||||
# output. Resolved once per request.
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_chat_fallback_candidates
|
||||
_fallback_candidates = resolve_chat_fallback_candidates(owner=_user)
|
||||
except Exception:
|
||||
_fallback_candidates = []
|
||||
# Foreground Chat and Agent requests use one owner-aware policy
|
||||
# boundary. Legacy `default_model_fallbacks` data is not eligible.
|
||||
_foreground_candidates = build_foreground_model_candidates(
|
||||
sess.endpoint_url,
|
||||
sess.model,
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
)
|
||||
|
||||
# Send model name early so the frontend can show it during streaming
|
||||
_model_suffix = "Research" if effective_do_research else None
|
||||
@@ -1522,9 +1523,8 @@ def setup_chat_routes(
|
||||
_actual_model = None
|
||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||
try:
|
||||
_chat_candidates = [(sess.endpoint_url, sess.model, sess.headers)] + _fallback_candidates
|
||||
async for chunk in stream_llm_with_fallback(
|
||||
_chat_candidates,
|
||||
_foreground_candidates,
|
||||
messages,
|
||||
temperature=ctx.preset.temperature,
|
||||
# Respect the preset; 0/unset = let the server decide (no
|
||||
@@ -1710,7 +1710,7 @@ def setup_chat_routes(
|
||||
disabled_tools=disabled_tools if disabled_tools else None,
|
||||
tool_policy=tool_policy,
|
||||
owner=_user,
|
||||
fallbacks=_fallback_candidates,
|
||||
fallbacks=_foreground_candidates[1:],
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
workspace=workspace or None,
|
||||
|
||||
@@ -73,6 +73,30 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
||||
)
|
||||
|
||||
|
||||
def _windows_local_pid_record_line(pid_path: Path, ready_path: Path) -> str:
|
||||
"""Build the Git Bash prelude that records a Win32-stoppable PID.
|
||||
|
||||
Python publishes the detached outer process's Win32 PID first, then touches
|
||||
``ready_path``. The inner Git Bash runner waits for that publication before
|
||||
replacing the fallback with its own Win32 PID from /proc/<msys-pid>/winpid.
|
||||
|
||||
Missing, malformed, or late mappings leave the valid outer PID untouched.
|
||||
"""
|
||||
pp = shlex.quote(pid_path.as_posix())
|
||||
rp = shlex.quote(ready_path.as_posix())
|
||||
return (
|
||||
"i=0; "
|
||||
f"while [ ! -e {rp} ] && [ \"$i\" -lt 500 ]; do "
|
||||
"i=$((i+1)); sleep 0.01; done; "
|
||||
f"if [ -e {rp} ]; then "
|
||||
"winpid=\"$(cat /proc/$$/winpid 2>/dev/null || true)\"; "
|
||||
"case \"$winpid\" in ''|*[!0-9]*) ;; "
|
||||
f"*) printf '%s\\n' \"$winpid\" > {pp} ;; esac; "
|
||||
"fi; "
|
||||
f"rm -f {rp}"
|
||||
)
|
||||
|
||||
|
||||
def _append_mlx_image_server_script(runner_lines: list[str]) -> None:
|
||||
"""Write the MLX image API helper next to the tmux runner on remote hosts."""
|
||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "mlx_image_server.py"
|
||||
@@ -978,15 +1002,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
directly (simple commands only). Returns the launched job record."""
|
||||
log_path = TMUX_LOG_DIR / f"{session_id}.log"
|
||||
pid_path = TMUX_LOG_DIR / f"{session_id}.pid"
|
||||
pid_ready_path: Path | None = None
|
||||
bash = find_bash()
|
||||
if bash:
|
||||
# Run the existing bash wrapper verbatim through Git Bash, redirecting
|
||||
# all output to the log the poller reads. Paths handed to bash use
|
||||
# POSIX form + shell-quoting so drive paths / spaces survive.
|
||||
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||
pp = shlex.quote(pid_path.as_posix())
|
||||
pid_ready_path = TMUX_LOG_DIR / f"{session_id}.pid.ready"
|
||||
pid_ready_path.unlink(missing_ok=True)
|
||||
inner.write_text(
|
||||
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
|
||||
_windows_local_pid_record_line(pid_path, pid_ready_path) + "\n"
|
||||
+ "\n".join(bash_lines) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
lp = shlex.quote(log_path.as_posix())
|
||||
@@ -1020,7 +1047,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
env=env,
|
||||
**detached_popen_kwargs(),
|
||||
)
|
||||
# Publish a valid Win32 ancestor first. The Git Bash runner may then
|
||||
# replace it with its own Win32 pid, but never before this fallback exists.
|
||||
pid_path.write_text(str(proc.pid), encoding="utf-8")
|
||||
if pid_ready_path is not None:
|
||||
try:
|
||||
pid_ready_path.touch()
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
"Could not publish Windows local PID handoff for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
return {"pid": proc.pid, "log_path": str(log_path)}
|
||||
|
||||
@router.post("/api/model/download")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Document route domain package (slice 2m, #4082/#4071).
|
||||
|
||||
Contains document_routes.py and document_helpers.py, migrated from the flat
|
||||
routes/ directory. Backward-compat shims at routes/document_routes.py and
|
||||
routes/document_helpers.py re-export from here.
|
||||
"""
|
||||
@@ -0,0 +1,243 @@
|
||||
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
||||
|
||||
"""Document routes — CRUD for living documents with version history."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- Request schemas ----
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
session_id: Optional[str] = None
|
||||
title: str = "Untitled"
|
||||
language: Optional[str] = None
|
||||
content: str = ""
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
content: str
|
||||
summary: Optional[str] = None
|
||||
force_version: bool = False
|
||||
|
||||
class DocumentPatch(BaseModel):
|
||||
title: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
session_id: Optional[str] = None # link/unlink document to a session
|
||||
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": doc.id,
|
||||
"session_id": doc.session_id,
|
||||
"title": doc.title,
|
||||
"language": doc.language,
|
||||
"current_content": doc.current_content,
|
||||
"version_count": doc.version_count,
|
||||
"is_active": doc.is_active,
|
||||
"archived": bool(getattr(doc, "archived", False)),
|
||||
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
||||
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
||||
# Source-email provenance (set when doc was created from an email
|
||||
# attachment) — drives the "Send signed reply" menu item.
|
||||
"source_email_uid": getattr(doc, "source_email_uid", None),
|
||||
"source_email_folder": getattr(doc, "source_email_folder", None),
|
||||
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
||||
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
||||
}
|
||||
|
||||
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": v.id,
|
||||
"document_id": v.document_id,
|
||||
"version_number": v.version_number,
|
||||
"content": v.content,
|
||||
"summary": v.summary,
|
||||
"source": v.source,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _verify_doc_owner(db, doc: Document, user: str):
|
||||
"""Verify `user` owns this document. Raise 404 if not.
|
||||
|
||||
Documents now carry their own `owner` column, so a doc whose session
|
||||
was deleted (session_id → NULL) can still prove ownership and stay
|
||||
openable / cloneable. We trust that column first and only fall back to
|
||||
the session join for any not-yet-backfilled legacy row.
|
||||
"""
|
||||
if user is None:
|
||||
if _auth_disabled():
|
||||
return # Single-user / no-auth mode: allow access
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if doc.owner is not None:
|
||||
if doc.owner != user:
|
||||
raise HTTPException(404, "Document not found")
|
||||
return
|
||||
# Legacy fallback: derive ownership from the linked session.
|
||||
if not doc.session_id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
||||
if not session or session.owner != user:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
|
||||
def _owner_session_filter(q, user):
|
||||
"""Restrict a documents query to those owned by `user`.
|
||||
|
||||
Documents now carry their own `owner` column (backfilled at boot from
|
||||
the linked session, or assigned to the admin user for legacy/orphaned
|
||||
docs). We filter on that directly rather than on a session join, so a
|
||||
document whose session was deleted (session_id → NULL) still shows up
|
||||
for its owner instead of silently vanishing from the Library + search.
|
||||
|
||||
The owner backfill runs in init_db before the app serves requests, so
|
||||
by the time this filter is live there are no NULL-owner rows to leak;
|
||||
we therefore match the owner strictly for authenticated callers."""
|
||||
if not user:
|
||||
if user == "" or _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
return q.filter(Document.owner == user)
|
||||
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
"""Filesystem-friendly version of a document title.
|
||||
|
||||
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
||||
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
||||
"""
|
||||
import re as _re
|
||||
s = (name or "").strip()
|
||||
# Drop the trailing extension if the title happens to include one
|
||||
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
||||
s = _re.sub(r'\s+', '_', s)
|
||||
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
||||
s = _re.sub(r'_+', '_', s).strip('_')
|
||||
return s or "form"
|
||||
|
||||
|
||||
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
||||
_PDF_RENDER_SCALE = 2.0
|
||||
|
||||
|
||||
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
||||
base = os.path.realpath(upload_dir)
|
||||
p = os.path.realpath(path)
|
||||
try:
|
||||
return os.path.commonpath([base, p]) == base
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_user_upload_path(
|
||||
upload_handler: Any,
|
||||
upload_id: str,
|
||||
owner: Optional[str],
|
||||
auth_manager=None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an upload id to a filesystem path the caller may read."""
|
||||
if upload_handler is None:
|
||||
return None
|
||||
resolved = upload_handler.resolve_upload(
|
||||
upload_id,
|
||||
owner=owner,
|
||||
auth_manager=auth_manager,
|
||||
)
|
||||
if not isinstance(resolved, dict) or not resolved:
|
||||
return None
|
||||
path = resolved.get("path")
|
||||
upload_dir = getattr(upload_handler, "upload_dir", None)
|
||||
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
||||
logger.warning("Upload path outside upload directory: %s", path)
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _locate_upload(
|
||||
upload_dir: str,
|
||||
file_id: str,
|
||||
owner: Optional[str] = None,
|
||||
auth_manager=None,
|
||||
upload_handler: Any = None,
|
||||
):
|
||||
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
||||
if upload_handler is None:
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
||||
upload_handler = UploadHandler(base_dir, upload_dir)
|
||||
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
||||
|
||||
|
||||
def _assert_pdf_marker_upload_owned(
|
||||
request: Request,
|
||||
content: str,
|
||||
user: Optional[str],
|
||||
upload_handler: Any,
|
||||
) -> None:
|
||||
"""Reject document content whose pdf_source marker points at another user's upload."""
|
||||
if upload_handler is None:
|
||||
return
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
|
||||
upload_id = find_source_upload_id(content or "")
|
||||
if not upload_id:
|
||||
return
|
||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Document PDF marker references an upload you do not own",
|
||||
)
|
||||
|
||||
|
||||
def _derive_title(content: str) -> str:
|
||||
"""Derive a title from document content."""
|
||||
import re
|
||||
if not isinstance(content, str):
|
||||
return "Untitled"
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return "Untitled"
|
||||
|
||||
# Markdown header
|
||||
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
||||
if md:
|
||||
title = md.group(1).strip()
|
||||
if len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title
|
||||
|
||||
# HTML heading
|
||||
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
||||
if html:
|
||||
title = html.group(1).strip()
|
||||
if len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title
|
||||
|
||||
# First non-empty line (if short enough)
|
||||
for line in text.split('\n'):
|
||||
line = line.strip()
|
||||
if line and 2 <= len(line) <= 60:
|
||||
title = re.sub(r'[:#*`]+$', '', line).strip()
|
||||
if title and len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title or "Untitled"
|
||||
|
||||
return "Untitled"
|
||||
File diff suppressed because it is too large
Load Diff
+10
-239
@@ -1,243 +1,14 @@
|
||||
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
||||
"""Backward-compat shim — canonical location is routes/document/document_helpers.py.
|
||||
|
||||
"""Document routes — CRUD for living documents with version history."""
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.document_helpers``, ``from routes.document_helpers import
|
||||
X``, and the ``sys.modules.pop("routes.document_helpers")`` + re-import
|
||||
pattern used by test_security_regressions.py all operate on the *same* object.
|
||||
Keeps existing import paths working after slice 2m (#4082/#4071).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
import sys as _sys
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from routes.document import document_helpers as _canonical # noqa: F401
|
||||
|
||||
from core.database import Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---- Request schemas ----
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
session_id: Optional[str] = None
|
||||
title: str = "Untitled"
|
||||
language: Optional[str] = None
|
||||
content: str = ""
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
content: str
|
||||
summary: Optional[str] = None
|
||||
force_version: bool = False
|
||||
|
||||
class DocumentPatch(BaseModel):
|
||||
title: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
session_id: Optional[str] = None # link/unlink document to a session
|
||||
|
||||
|
||||
# ---- Helpers ----
|
||||
|
||||
def _doc_to_dict(doc: Document) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": doc.id,
|
||||
"session_id": doc.session_id,
|
||||
"title": doc.title,
|
||||
"language": doc.language,
|
||||
"current_content": doc.current_content,
|
||||
"version_count": doc.version_count,
|
||||
"is_active": doc.is_active,
|
||||
"archived": bool(getattr(doc, "archived", False)),
|
||||
"created_at": (doc.created_at.isoformat() + "Z") if doc.created_at else None,
|
||||
"updated_at": (doc.updated_at.isoformat() + "Z") if doc.updated_at else None,
|
||||
# Source-email provenance (set when doc was created from an email
|
||||
# attachment) — drives the "Send signed reply" menu item.
|
||||
"source_email_uid": getattr(doc, "source_email_uid", None),
|
||||
"source_email_folder": getattr(doc, "source_email_folder", None),
|
||||
"source_email_account_id": getattr(doc, "source_email_account_id", None),
|
||||
"source_email_message_id": getattr(doc, "source_email_message_id", None),
|
||||
}
|
||||
|
||||
def _version_to_dict(v: DocumentVersion) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": v.id,
|
||||
"document_id": v.document_id,
|
||||
"version_number": v.version_number,
|
||||
"content": v.content,
|
||||
"summary": v.summary,
|
||||
"source": v.source,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _verify_doc_owner(db, doc: Document, user: str):
|
||||
"""Verify `user` owns this document. Raise 404 if not.
|
||||
|
||||
Documents now carry their own `owner` column, so a doc whose session
|
||||
was deleted (session_id → NULL) can still prove ownership and stay
|
||||
openable / cloneable. We trust that column first and only fall back to
|
||||
the session join for any not-yet-backfilled legacy row.
|
||||
"""
|
||||
if user is None:
|
||||
if _auth_disabled():
|
||||
return # Single-user / no-auth mode: allow access
|
||||
raise HTTPException(403, "Authentication required")
|
||||
if doc.owner is not None:
|
||||
if doc.owner != user:
|
||||
raise HTTPException(404, "Document not found")
|
||||
return
|
||||
# Legacy fallback: derive ownership from the linked session.
|
||||
if not doc.session_id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
session = db.query(DbSession).filter(DbSession.id == doc.session_id).first()
|
||||
if not session or session.owner != user:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
|
||||
def _owner_session_filter(q, user):
|
||||
"""Restrict a documents query to those owned by `user`.
|
||||
|
||||
Documents now carry their own `owner` column (backfilled at boot from
|
||||
the linked session, or assigned to the admin user for legacy/orphaned
|
||||
docs). We filter on that directly rather than on a session join, so a
|
||||
document whose session was deleted (session_id → NULL) still shows up
|
||||
for its owner instead of silently vanishing from the Library + search.
|
||||
|
||||
The owner backfill runs in init_db before the app serves requests, so
|
||||
by the time this filter is live there are no NULL-owner rows to leak;
|
||||
we therefore match the owner strictly for authenticated callers."""
|
||||
if not user:
|
||||
if user == "" or _auth_disabled():
|
||||
return q
|
||||
return q.filter(False)
|
||||
return q.filter(Document.owner == user)
|
||||
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
"""Filesystem-friendly version of a document title.
|
||||
|
||||
Whitespace becomes underscores; other unsafe punctuation is dropped.
|
||||
Preserves letters, digits, dot, hyphen, underscore. Idempotent.
|
||||
"""
|
||||
import re as _re
|
||||
s = (name or "").strip()
|
||||
# Drop the trailing extension if the title happens to include one
|
||||
s = _re.sub(r'\.pdf$', '', s, flags=_re.IGNORECASE)
|
||||
s = _re.sub(r'\s+', '_', s)
|
||||
s = _re.sub(r'[^A-Za-z0-9._-]', '', s)
|
||||
s = _re.sub(r'_+', '_', s).strip('_')
|
||||
return s or "form"
|
||||
|
||||
|
||||
# DPI scale for the interactive PDF view. ~150 DPI (2x of 72 PDF user-units).
|
||||
_PDF_RENDER_SCALE = 2.0
|
||||
|
||||
|
||||
def _upload_path_inside(upload_dir: str, path: str) -> bool:
|
||||
base = os.path.realpath(upload_dir)
|
||||
p = os.path.realpath(path)
|
||||
try:
|
||||
return os.path.commonpath([base, p]) == base
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_user_upload_path(
|
||||
upload_handler: Any,
|
||||
upload_id: str,
|
||||
owner: Optional[str],
|
||||
auth_manager=None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an upload id to a filesystem path the caller may read."""
|
||||
if upload_handler is None:
|
||||
return None
|
||||
resolved = upload_handler.resolve_upload(
|
||||
upload_id,
|
||||
owner=owner,
|
||||
auth_manager=auth_manager,
|
||||
)
|
||||
if not isinstance(resolved, dict) or not resolved:
|
||||
return None
|
||||
path = resolved.get("path")
|
||||
upload_dir = getattr(upload_handler, "upload_dir", None)
|
||||
if path and upload_dir and not _upload_path_inside(upload_dir, path):
|
||||
logger.warning("Upload path outside upload directory: %s", path)
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _locate_upload(
|
||||
upload_dir: str,
|
||||
file_id: str,
|
||||
owner: Optional[str] = None,
|
||||
auth_manager=None,
|
||||
upload_handler: Any = None,
|
||||
):
|
||||
"""Find an upload by its filename ID via UploadHandler.resolve_upload."""
|
||||
if upload_handler is None:
|
||||
from src.upload_handler import UploadHandler
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(upload_dir))
|
||||
upload_handler = UploadHandler(base_dir, upload_dir)
|
||||
return _resolve_user_upload_path(upload_handler, file_id, owner, auth_manager)
|
||||
|
||||
|
||||
def _assert_pdf_marker_upload_owned(
|
||||
request: Request,
|
||||
content: str,
|
||||
user: Optional[str],
|
||||
upload_handler: Any,
|
||||
) -> None:
|
||||
"""Reject document content whose pdf_source marker points at another user's upload."""
|
||||
if upload_handler is None:
|
||||
return
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
|
||||
upload_id = find_source_upload_id(content or "")
|
||||
if not upload_id:
|
||||
return
|
||||
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
|
||||
if not _resolve_user_upload_path(upload_handler, upload_id, user, auth_manager):
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Document PDF marker references an upload you do not own",
|
||||
)
|
||||
|
||||
|
||||
def _derive_title(content: str) -> str:
|
||||
"""Derive a title from document content."""
|
||||
import re
|
||||
if not isinstance(content, str):
|
||||
return "Untitled"
|
||||
text = content.strip()
|
||||
if not text:
|
||||
return "Untitled"
|
||||
|
||||
# Markdown header
|
||||
md = re.match(r'^#{1,3}\s+(.+)', text, re.MULTILINE)
|
||||
if md:
|
||||
title = md.group(1).strip()
|
||||
if len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title
|
||||
|
||||
# HTML heading
|
||||
html = re.search(r'<h[1-3][^>]*>([^<]+)</h[1-3]>', text, re.IGNORECASE)
|
||||
if html:
|
||||
title = html.group(1).strip()
|
||||
if len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title
|
||||
|
||||
# First non-empty line (if short enough)
|
||||
for line in text.split('\n'):
|
||||
line = line.strip()
|
||||
if line and 2 <= len(line) <= 60:
|
||||
title = re.sub(r'[:#*`]+$', '', line).strip()
|
||||
if title and len(title) > 50:
|
||||
title = title[:48] + "…"
|
||||
return title or "Untitled"
|
||||
|
||||
return "Untitled"
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
+13
-1806
File diff suppressed because it is too large
Load Diff
@@ -247,6 +247,7 @@ import re as _re_reply
|
||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
||||
_REPLY_CLOSE_RE = _re_reply.compile(r"<<<\s*END\s*>>+", _re_reply.I)
|
||||
_REPLY_ROLE_MARKER_RE = _re_reply.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", _re_reply.I)
|
||||
_SUMMARY_BULLET_RE = _re_reply.compile(r"^(?:[-*\u2022]\s+|\d+[.)]\s+)")
|
||||
|
||||
|
||||
def _extract_reply(text: str) -> str:
|
||||
@@ -277,6 +278,125 @@ def _extract_reply(text: str) -> str:
|
||||
return _strip_think(t).strip()
|
||||
|
||||
|
||||
def _build_email_summary_messages(sender: str, subject: str, body_for_llm: str) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an email summarizer. Format: 1-3 short bullet points "
|
||||
"(use '- '). Cover: main point, action items, deadlines. If the "
|
||||
"email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR "
|
||||
"CONTENTS - pull invoice totals, deadlines, key clauses, concrete "
|
||||
"numbers/dates from PDFs/docs into the bullets. Be terse.\n\n"
|
||||
"OUTPUT FORMAT: Put ONLY the bullet points between these exact "
|
||||
"markers, each on its own line:\n"
|
||||
"<<<SUMMARY>>>\n"
|
||||
"- ...\n"
|
||||
"<<<END>>>\n"
|
||||
"Any reasoning must come BEFORE <<<SUMMARY>>> (ideally inside "
|
||||
"<think>...</think>). Only the text between the markers is kept."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}"
|
||||
"\n\n---\n\nSummarize the email. Output the bullets between "
|
||||
"<<<SUMMARY>>> and <<<END>>>."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _generate_email_summary(
|
||||
url: str,
|
||||
model: str,
|
||||
sender: str,
|
||||
subject: str,
|
||||
body_for_llm: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
max_tokens: int = 8192,
|
||||
timeout: int = 180,
|
||||
) -> str:
|
||||
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
raw = await llm_call_async(
|
||||
url=url,
|
||||
model=model,
|
||||
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
workload="foreground",
|
||||
)
|
||||
return _normalize_email_summary(raw)
|
||||
|
||||
|
||||
async def _generate_scheduled_email_summary(
|
||||
url: str,
|
||||
model: str,
|
||||
sender: str,
|
||||
subject: str,
|
||||
body_for_llm: str,
|
||||
*,
|
||||
headers: dict | None = None,
|
||||
owner: str | None = None,
|
||||
max_tokens: int = 8192,
|
||||
timeout: int = 180,
|
||||
) -> str:
|
||||
"""Generate a scheduled summary through the background task candidate chain."""
|
||||
from src.task_endpoint import task_llm_call_async
|
||||
|
||||
raw = await task_llm_call_async(
|
||||
messages=_build_email_summary_messages(sender, subject, body_for_llm),
|
||||
fallback_url=url,
|
||||
fallback_model=model,
|
||||
fallback_headers=headers,
|
||||
owner=owner,
|
||||
temperature=0.3,
|
||||
max_tokens=max_tokens,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _normalize_email_summary(raw)
|
||||
|
||||
|
||||
def _normalize_email_summary(raw) -> str:
|
||||
"""Extract a stable cache/UI summary from provider output."""
|
||||
raw_text = raw or ""
|
||||
if _REPLY_OPEN_RE.search(raw_text):
|
||||
summary = _extract_reply(raw_text)
|
||||
if summary:
|
||||
return summary
|
||||
|
||||
cleaned = _strip_think(raw_text).strip()
|
||||
bullets = [
|
||||
line.strip()
|
||||
for line in cleaned.splitlines()
|
||||
if _SUMMARY_BULLET_RE.match(line.strip())
|
||||
]
|
||||
if bullets:
|
||||
return "\n".join(bullets)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
EMAIL_SUMMARY_ERROR_CODE = "email_summary_unavailable"
|
||||
EMAIL_SUMMARY_ERROR_MESSAGE = "Failed to summarize"
|
||||
|
||||
|
||||
def _email_summary_failure_log_detail(exc: BaseException) -> str:
|
||||
"""Return useful provider-failure metadata without echoing exception text."""
|
||||
detail = f"type={type(exc).__name__}"
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status is None:
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if isinstance(status, int):
|
||||
detail += f" status={status}"
|
||||
return detail
|
||||
|
||||
|
||||
def _apply_email_style_mechanics(text: str) -> str:
|
||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||
if not text:
|
||||
|
||||
+23
-9
@@ -40,6 +40,7 @@ from routes.email_helpers import (
|
||||
_pre_retrieve_context,
|
||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -653,6 +654,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
no_msgid = 0
|
||||
examined = 0
|
||||
_summaries_created = 0
|
||||
_summary_failed = 0
|
||||
_events_created = 0
|
||||
_replies_drafted = 0
|
||||
_reply_failed = 0
|
||||
@@ -785,16 +787,17 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
|
||||
if need_sum:
|
||||
try:
|
||||
summary = await task_llm_call_async(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull out invoice totals, deadlines, key clauses, any concrete numbers/dates in PDFs/docs, and reflect them in the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning or planning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
||||
],
|
||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||
summary = await _generate_scheduled_email_summary(
|
||||
url=url,
|
||||
model=model,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
body_for_llm=body_for_llm,
|
||||
headers=req_headers,
|
||||
owner=account_owner or None,
|
||||
temperature=0.3, max_tokens=16384, timeout=240,
|
||||
max_tokens=16384,
|
||||
timeout=240,
|
||||
)
|
||||
summary = _extract_reply((summary or "").strip())
|
||||
if summary:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
@@ -808,10 +811,19 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_summaries_created += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
else:
|
||||
_summary_failed += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary empty · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
except Exception as e:
|
||||
_summary_failed += 1
|
||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
||||
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||
logger.warning(f"Auto-summary {uid} failed: {e}")
|
||||
logger.warning(
|
||||
"Auto-summary uid=%s failed %s",
|
||||
_uid_text,
|
||||
_email_summary_failure_log_detail(e),
|
||||
)
|
||||
|
||||
if need_reply:
|
||||
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
||||
@@ -1320,6 +1332,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
parts.append(f"processed {processed} new")
|
||||
if auto_sum:
|
||||
parts.append(f"summarized {_summaries_created}")
|
||||
if _summary_failed:
|
||||
parts.append(f"{_summary_failed} summary failed")
|
||||
if auto_reply_draft:
|
||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||
if _reply_failed:
|
||||
|
||||
+242
-115
@@ -45,6 +45,7 @@ from src.upload_limits import read_upload_limited, EMAIL_COMPOSE_UPLOAD_MAX_BYTE
|
||||
|
||||
from routes.email_helpers import (
|
||||
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
|
||||
_account_visible_to_owner,
|
||||
_q, _attach_compose_uploads, _cleanup_compose_uploads,
|
||||
_load_settings, _save_settings, _get_email_config,
|
||||
_send_smtp_message, _smtp_security_mode,
|
||||
@@ -57,7 +58,8 @@ from routes.email_helpers import (
|
||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||
_friendly_email_auth_error,
|
||||
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
||||
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||
SendEmailRequest, ExtractStyleRequest,
|
||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
||||
@@ -194,6 +196,64 @@ def _coerce_port(value, default):
|
||||
return None, f"Invalid port {value!r}; must be a whole number"
|
||||
|
||||
|
||||
def _lock_email_account_owner_mutation(db, *owners: str) -> None:
|
||||
"""Delegate account/default serialization to the shared DB primitive."""
|
||||
from core.database import lock_email_account_owner_mutations
|
||||
|
||||
lock_email_account_owner_mutations(db, *owners)
|
||||
|
||||
|
||||
def _email_account_owner_scope(query, owner: str):
|
||||
"""Restrict a query to one normalized EmailAccount owner partition."""
|
||||
from core.database import EmailAccount
|
||||
from sqlalchemy import or_
|
||||
|
||||
if owner:
|
||||
return query.filter(EmailAccount.owner == owner)
|
||||
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
||||
|
||||
|
||||
def _discover_email_account_mutation_scope(account_id: str, owner: str) -> str:
|
||||
"""Read the initial lock key and fail closed before a mutation session."""
|
||||
from core.database import EmailAccount, SessionLocal
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if row is None or (owner and not _account_visible_to_owner(row, owner)):
|
||||
raise HTTPException(404, "Account not found")
|
||||
return row.owner or ""
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Account-owner mutation check failed: %s", exc)
|
||||
raise HTTPException(503, "Account check failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _lock_and_reload_email_account(db, account_id: str, owner: str, scope: str):
|
||||
"""Lock, reload, and revalidate an account, retrying if its owner moved."""
|
||||
from core.database import EmailAccount
|
||||
|
||||
owner_scopes = {scope or ""}
|
||||
while True:
|
||||
_lock_email_account_owner_mutation(db, *owner_scopes)
|
||||
row = db.get(EmailAccount, account_id, populate_existing=True)
|
||||
if row is None or (owner and not _account_visible_to_owner(row, owner)):
|
||||
raise HTTPException(404, "Account not found")
|
||||
|
||||
current_scope = row.owner or ""
|
||||
if current_scope in owner_scopes or db.get_bind().dialect.name == "sqlite":
|
||||
return row
|
||||
|
||||
# The account changed owner after discovery but before lock acquisition.
|
||||
# Release the partial lock set and reacquire all observed scopes in the
|
||||
# shared helper's canonical order, then validate from the database again.
|
||||
db.rollback()
|
||||
owner_scopes.add(current_scope)
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
aliases = [owner or ""]
|
||||
try:
|
||||
@@ -2860,13 +2920,22 @@ def setup_email_routes():
|
||||
return indexed_response
|
||||
return {"emails": [], "total": 0, "error": "Mail operation failed"}
|
||||
|
||||
def _read_email_sync(uid, folder, account_id, owner, mark_seen=True, full=False):
|
||||
def _read_email_sync(uid, folder, account_id, owner, mark_seen=False, full=False):
|
||||
"""Sync IMAP read — wrapped in to_thread by the async handler.
|
||||
|
||||
The normal reader path fetches the headers plus a bounded body prefix.
|
||||
That avoids downloading multi-megabyte attachments just to open a
|
||||
message. Full-message fetch remains available for flows that need
|
||||
attachment metadata immediately, such as forwarding.
|
||||
|
||||
`mark_seen` defaults to False because it mutates provider state: it
|
||||
selects the mailbox read-write and issues a STORE. Only a foreground
|
||||
open should ask for it, and it has to ask explicitly.
|
||||
|
||||
A failed \\Seen transition is reported as `mark_seen_failed` on an
|
||||
otherwise normal response, never as an error. The body has already been
|
||||
fetched at that point, so refusing to return it would turn a cosmetic
|
||||
flag failure into an unreadable message.
|
||||
"""
|
||||
import time as _t
|
||||
_t0 = _t.monotonic()
|
||||
@@ -2874,9 +2943,28 @@ def setup_email_routes():
|
||||
preview_bytes = 384 * 1024
|
||||
_t_select = 0.0
|
||||
_t_fetch = 0.0
|
||||
mark_seen_failed = False
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder), readonly=True)
|
||||
# A foreground open owns both the body fetch and the \Seen
|
||||
# transition. Keep them on one read-write IMAP selection so the
|
||||
# route never schedules a second connection that can race the
|
||||
# response. Prefetch/read-only callers retain BODY.PEEK and a
|
||||
# read-only mailbox selection.
|
||||
try:
|
||||
conn.select(_q(folder), readonly=not mark_seen)
|
||||
except Exception as select_exc:
|
||||
if not mark_seen:
|
||||
raise
|
||||
# Read-only mailboxes (shared archives, some provider
|
||||
# folders) reject a read-write SELECT. Serve the message
|
||||
# read-only and report the flag failure.
|
||||
logger.warning(
|
||||
f"read-write SELECT rejected for {folder!r}; "
|
||||
f"serving read-only without \\Seen: {select_exc}"
|
||||
)
|
||||
conn.select(_q(folder), readonly=True)
|
||||
mark_seen_failed = True
|
||||
_t_select = _t.monotonic() - _t0
|
||||
fetch_query = "(BODY.PEEK[])" if full else f"(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.{preview_bytes}>)"
|
||||
status, msg_data = _imap_uid_fetch(conn, uid, fetch_query)
|
||||
@@ -2902,22 +2990,44 @@ def setup_email_routes():
|
||||
header_part = msg_data[0][1] or b""
|
||||
raw = header_part + b"\r\n" + text_part
|
||||
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
# Parse the fetched payload before mutating provider state. If
|
||||
# the message is malformed enough that the reader cannot build
|
||||
# a response, the caller gets an error while the message stays
|
||||
# unread instead of receiving a false optimistic rollback.
|
||||
msg = email_mod.message_from_bytes(raw)
|
||||
|
||||
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||
sender = _decode_header(msg.get("From", "unknown"))
|
||||
to = _decode_header(msg.get("To", ""))
|
||||
cc = _decode_header(msg.get("Cc", ""))
|
||||
date_str = msg.get("Date", "")
|
||||
message_id = msg.get("Message-ID", "")
|
||||
in_reply_to = msg.get("In-Reply-To", "")
|
||||
references = msg.get("References", "")
|
||||
body = _extract_text(msg)
|
||||
body_html = _extract_html(msg)
|
||||
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||
sender = _decode_header(msg.get("From", "unknown"))
|
||||
to = _decode_header(msg.get("To", ""))
|
||||
cc = _decode_header(msg.get("Cc", ""))
|
||||
date_str = msg.get("Date", "")
|
||||
message_id = msg.get("Message-ID", "")
|
||||
in_reply_to = msg.get("In-Reply-To", "")
|
||||
references = msg.get("References", "")
|
||||
body = _extract_text(msg)
|
||||
body_html = _extract_html(msg)
|
||||
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
|
||||
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
|
||||
|
||||
if mark_seen and not mark_seen_failed:
|
||||
seen_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||
if seen_status != "OK":
|
||||
# Report, don't raise. The parsed body below is still a
|
||||
# valid response; only the flag claim is untrue.
|
||||
logger.warning(
|
||||
f"IMAP STORE \\Seen failed for UID {uid} in {folder!r}: {seen_status}"
|
||||
)
|
||||
mark_seen_failed = True
|
||||
|
||||
# Only record the local flag transition when the provider actually
|
||||
# accepted it, so the index and list cache cannot drift ahead of
|
||||
# the mailbox.
|
||||
if mark_seen and not mark_seen_failed:
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
|
||||
sender_name, sender_addr = email.utils.parseaddr(sender)
|
||||
parsed_date = email.utils.parsedate_to_datetime(date_str) if date_str else None
|
||||
attachments = _list_attachments_from_msg(msg) if full else (_email_attachment_meta_cache_get(owner, account_id, folder, uid) or [])
|
||||
related_attachments = []
|
||||
if full and not _has_visible_attachments(msg):
|
||||
related_attachments = _related_thread_attachments_sync(
|
||||
@@ -3038,20 +3148,29 @@ def setup_email_routes():
|
||||
"boundaries": cached_boundaries,
|
||||
"thread_turns": cached_turns,
|
||||
"sender_signature": cached_sender_sig,
|
||||
# Per-request, not part of the message: the route strips this
|
||||
# before caching so a one-off flag failure is never replayed to
|
||||
# later readers.
|
||||
"mark_seen_failed": mark_seen_failed,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read email {uid}: {e}")
|
||||
return {"error": "Mail operation failed"}
|
||||
|
||||
def _mark_email_seen_sync(uid, folder, account_id, owner):
|
||||
"""Synchronously mark a cached email seen and report success."""
|
||||
try:
|
||||
with _imap(account_id, owner=owner) as conn:
|
||||
conn.select(_q(folder))
|
||||
conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen")
|
||||
conn.select(_q(folder), readonly=False)
|
||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
||||
if status != "OK":
|
||||
return False
|
||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||
_update_list_cache_seen(account_id, folder, uid, True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||
return False
|
||||
|
||||
@router.get("/read/{uid}")
|
||||
async def read_email_by_uid(
|
||||
@@ -3077,32 +3196,32 @@ def setup_email_routes():
|
||||
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
|
||||
cached = None
|
||||
if cached is not None:
|
||||
if mark_seen:
|
||||
try:
|
||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
||||
except RuntimeError:
|
||||
pass
|
||||
# A cache hit already holds a complete, valid message. Await the
|
||||
# STORE so the response reports the real flag state, but never let
|
||||
# a failed STORE withhold a body we are holding in memory.
|
||||
if mark_seen and not await _asyncio.to_thread(
|
||||
_mark_email_seen_sync, uid, folder, account_id, owner
|
||||
):
|
||||
return {**cached, "mark_seen_failed": True}
|
||||
return cached
|
||||
if not full:
|
||||
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
|
||||
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
|
||||
_read_cache_put(ck, persisted)
|
||||
if mark_seen:
|
||||
try:
|
||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
||||
except RuntimeError:
|
||||
pass
|
||||
if mark_seen and not await _asyncio.to_thread(
|
||||
_mark_email_seen_sync, uid, folder, account_id, owner
|
||||
):
|
||||
return {**persisted, "mark_seen_failed": True}
|
||||
return persisted
|
||||
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
|
||||
if result and not result.get("error"):
|
||||
_read_cache_put(ck, result)
|
||||
# `mark_seen_failed` describes this request, not the message, so it
|
||||
# must not enter either cache — a later reader would otherwise be
|
||||
# told a STORE failed that it never issued.
|
||||
cacheable = {k: v for k, v in result.items() if k != "mark_seen_failed"}
|
||||
_read_cache_put(ck, cacheable)
|
||||
if not full:
|
||||
_email_preview_cache_put(owner, account_id, folder, uid, result)
|
||||
if mark_seen:
|
||||
try:
|
||||
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
||||
except RuntimeError:
|
||||
pass
|
||||
_email_preview_cache_put(owner, account_id, folder, uid, cacheable)
|
||||
return result
|
||||
|
||||
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
|
||||
@@ -4766,8 +4885,6 @@ def setup_email_routes():
|
||||
"""Generate a quick AI summary of an email body."""
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
|
||||
import requests as _req
|
||||
|
||||
body = data.get("body", "")
|
||||
subject = data.get("subject", "")
|
||||
@@ -4778,7 +4895,11 @@ def setup_email_routes():
|
||||
if account_id:
|
||||
_assert_owns_account(account_id, owner)
|
||||
if not body:
|
||||
return {"success": False, "error": "No body provided"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No body provided",
|
||||
"error_code": "email_summary_missing_body",
|
||||
}
|
||||
|
||||
# If we know which UID this is, fetch the raw message and pull
|
||||
# attachment text so the summary can reference invoice totals,
|
||||
@@ -4807,53 +4928,43 @@ def setup_email_routes():
|
||||
if not url:
|
||||
url, model, headers = resolve_endpoint("default", owner=owner)
|
||||
if not url or not model:
|
||||
return {"success": False, "error": "No LLM endpoint configured"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No model configured for email summaries",
|
||||
"error_code": "email_summary_not_configured",
|
||||
}
|
||||
|
||||
req_headers = {"Content-Type": "application/json"}
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are an email summarizer. Format: 1-3 short bullet points (use '- '). Cover: main point, action items, deadlines. If the email has attachments (marked '--- ATTACHMENTS ---'), USE THEIR CONTENTS — pull invoice totals, deadlines, key clauses, concrete numbers/dates from PDFs/docs into the bullets. Be terse.\n\nOUTPUT FORMAT: Put ONLY the bullet points between these exact markers, each on its own line:\n<<<SUMMARY>>>\n- ...\n<<<END>>>\nAny reasoning must come BEFORE <<<SUMMARY>>> (ideally inside <think>...</think>). Only the text between the markers is kept."},
|
||||
{"role": "user", "content": f"From: {sender}\nSubject: {subject}\n\n{body_for_llm[:12000]}\n\n---\n\nSummarize the email. Output the bullets between <<<SUMMARY>>> and <<<END>>>."},
|
||||
],
|
||||
tok_key: 8192,
|
||||
"temperature": 0.3,
|
||||
"stream": False,
|
||||
}
|
||||
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
||||
if _restricts_temperature(model):
|
||||
payload.pop("temperature", None)
|
||||
resp = await asyncio.to_thread(
|
||||
_req.post, url, json=payload, headers=req_headers, timeout=180
|
||||
)
|
||||
if not resp.ok:
|
||||
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
|
||||
rdata = resp.json()
|
||||
msg = (rdata.get("choices") or [{}])[0].get("message", {})
|
||||
content = (msg.get("content") or "").strip()
|
||||
content = _extract_reply(content)
|
||||
try:
|
||||
content = await _generate_email_summary(
|
||||
url=url,
|
||||
model=model,
|
||||
sender=sender,
|
||||
subject=subject,
|
||||
body_for_llm=body_for_llm,
|
||||
headers=req_headers,
|
||||
max_tokens=8192,
|
||||
timeout=180,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Email summary LLM call failed %s",
|
||||
_email_summary_failure_log_detail(e),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
||||
}
|
||||
|
||||
if not content:
|
||||
# Model put everything in reasoning_content — extract bullet points
|
||||
rc = (msg.get("reasoning_content") or "").strip()
|
||||
# Find bullet-point style output (lines starting with -, •, *, or numbered)
|
||||
bullet_lines = []
|
||||
for line in rc.split("\n"):
|
||||
stripped = line.strip()
|
||||
if re.match(r"^[-•*]\s+|^\d+[.)]\s+", stripped):
|
||||
bullet_lines.append(stripped)
|
||||
if bullet_lines:
|
||||
content = "\n".join(bullet_lines)
|
||||
else:
|
||||
# Last resort: take the last paragraph
|
||||
paragraphs = [p.strip() for p in rc.split("\n\n") if p.strip()]
|
||||
content = paragraphs[-1] if paragraphs else rc[:500]
|
||||
|
||||
if not content:
|
||||
return {"success": False, "error": "Empty response from model"}
|
||||
return {
|
||||
"success": False,
|
||||
"error": "The model returned an empty summary",
|
||||
"error_code": "email_summary_empty",
|
||||
}
|
||||
|
||||
# Cache the summary if we have a message_id
|
||||
mid = data.get("message_id", "")
|
||||
@@ -4876,8 +4987,15 @@ def setup_email_routes():
|
||||
|
||||
return {"success": True, "summary": content, "model_used": model}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to summarize: {e}")
|
||||
return {"success": False, "error": "Mail operation failed"}
|
||||
logger.error(
|
||||
"Email summary route failed %s",
|
||||
_email_summary_failure_log_detail(e),
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
||||
}
|
||||
|
||||
@router.post("/translate")
|
||||
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
||||
@@ -5209,9 +5327,9 @@ def setup_email_routes():
|
||||
# Build a candidate chain so a stale session-stored API key
|
||||
# (the most common cause of "authentication failed" here)
|
||||
# doesn't kill AI Reply outright — fall through to the
|
||||
# user's Utility / Default endpoints AND their configured
|
||||
# fallback chains. Dedupe by url+model so we don't retry
|
||||
# the same broken endpoint.
|
||||
# user's Utility / Default endpoints and the active Utility
|
||||
# fallback chain. The retired default-fallback hook stays empty.
|
||||
# Dedupe by url+model so we don't retry the same broken endpoint.
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
from src.endpoint_resolver import (
|
||||
resolve_utility_fallback_candidates,
|
||||
@@ -5240,7 +5358,7 @@ def setup_email_routes():
|
||||
_add(_d_url, _d_model, _d_headers)
|
||||
except Exception:
|
||||
pass
|
||||
# Configured fallback chains last.
|
||||
# Active Utility fallbacks, then the retired default hook.
|
||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||
_add(*cand)
|
||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
||||
@@ -5428,9 +5546,9 @@ def setup_email_routes():
|
||||
import uuid as _uuid
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
q = db.query(EmailAccount).filter(EmailAccount.is_default == True) # noqa: E712
|
||||
if owner:
|
||||
q = q.filter(EmailAccount.owner == owner)
|
||||
q = _email_account_owner_scope(q, owner)
|
||||
row = q.first()
|
||||
if row is None:
|
||||
row = EmailAccount(id=_uuid.uuid4().hex, owner=owner, name="Default", is_default=True, enabled=True)
|
||||
@@ -5456,8 +5574,7 @@ def setup_email_routes():
|
||||
if data.get("smtp_password"):
|
||||
row.smtp_password = _enc(data["smtp_password"])
|
||||
clear_q = db.query(EmailAccount).filter(EmailAccount.id != row.id)
|
||||
if owner:
|
||||
clear_q = clear_q.filter(EmailAccount.owner == owner)
|
||||
clear_q = _email_account_owner_scope(clear_q, owner)
|
||||
clear_q.update({EmailAccount.is_default: False})
|
||||
db.commit()
|
||||
finally:
|
||||
@@ -5552,6 +5669,7 @@ def setup_email_routes():
|
||||
return {"ok": False, "error": port_err}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_lock_email_account_owner_mutation(db, owner)
|
||||
row = EmailAccount(
|
||||
id=_uuid.uuid4().hex,
|
||||
name=name,
|
||||
@@ -5578,9 +5696,7 @@ def setup_email_routes():
|
||||
# the one-default invariant — but scope it to THIS user's accounts,
|
||||
# otherwise creating a default would clear every other user's
|
||||
# default flag too.
|
||||
scope_q = db.query(EmailAccount)
|
||||
if owner:
|
||||
scope_q = scope_q.filter(EmailAccount.owner == owner)
|
||||
scope_q = _email_account_owner_scope(db.query(EmailAccount), owner)
|
||||
existing_count = scope_q.count()
|
||||
if row.is_default or existing_count == 0:
|
||||
scope_q.update({EmailAccount.is_default: False})
|
||||
@@ -5631,28 +5747,39 @@ def setup_email_routes():
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
async def delete_email_account(account_id: str, owner: str = Depends(require_user)):
|
||||
_assert_owns_account(account_id, owner)
|
||||
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Account not found"}
|
||||
row = _lock_and_reload_email_account(
|
||||
db, account_id, owner, initial_scope
|
||||
)
|
||||
row_scope = row.owner or ""
|
||||
was_default = bool(row.is_default)
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
# Flush the removal before staging a replacement default. The
|
||||
# partial unique index is checked statement-by-statement, and the
|
||||
# ORM is otherwise free to UPDATE the promoted row before DELETE.
|
||||
db.flush()
|
||||
# If the deleted row was default, promote the next-oldest enabled
|
||||
# row owned by THIS user. Without the owner filter we'd promote
|
||||
# another user's account and the deleter would silently inherit
|
||||
# it as their default.
|
||||
if was_default:
|
||||
promote_q = db.query(EmailAccount).filter(EmailAccount.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
promote_q = promote_q.filter(EmailAccount.owner == owner)
|
||||
promote = promote_q.order_by(EmailAccount.created_at.asc()).first()
|
||||
promote_q = db.query(EmailAccount).filter(
|
||||
EmailAccount.id != account_id,
|
||||
EmailAccount.enabled == True, # noqa: E712
|
||||
)
|
||||
promote_q = _email_account_owner_scope(promote_q, row_scope)
|
||||
promote = promote_q.order_by(
|
||||
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
||||
).first()
|
||||
if promote:
|
||||
promote.is_default = True
|
||||
db.commit()
|
||||
# Deletion and any replacement promotion are one durable state
|
||||
# transition, so another worker can never observe or race the old
|
||||
# split-commit gap.
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -5865,18 +5992,18 @@ def setup_email_routes():
|
||||
|
||||
@router.post("/accounts/{account_id}/set-default")
|
||||
async def set_default_account(account_id: str, owner: str = Depends(require_user)):
|
||||
_assert_owns_account(account_id, owner)
|
||||
initial_scope = _discover_email_account_mutation_scope(account_id, owner)
|
||||
from core.database import SessionLocal, EmailAccount
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(EmailAccount, account_id)
|
||||
if not row:
|
||||
return {"ok": False, "error": "Account not found"}
|
||||
# SECURITY: scope the "clear other defaults" sweep to this user's
|
||||
# accounts so we don't unset another user's default flag.
|
||||
clear_q = db.query(EmailAccount)
|
||||
if owner:
|
||||
clear_q = clear_q.filter(EmailAccount.owner == owner)
|
||||
row = _lock_and_reload_email_account(
|
||||
db, account_id, owner, initial_scope
|
||||
)
|
||||
# Scope the sweep to the target row's normalized owner partition;
|
||||
# this also handles visible legacy NULL/empty-owner accounts.
|
||||
clear_q = _email_account_owner_scope(
|
||||
db.query(EmailAccount), row.owner or ""
|
||||
)
|
||||
clear_q.update({EmailAccount.is_default: False})
|
||||
row.is_default = True
|
||||
db.commit()
|
||||
@@ -5895,7 +6022,7 @@ def setup_email_routes():
|
||||
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
state = make_oauth_state(account_id, owner)
|
||||
params = urllib.parse.urlencode({
|
||||
@@ -5932,7 +6059,7 @@ def setup_email_routes():
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
redirect_uri = (
|
||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||
)
|
||||
import httpx as _httpx
|
||||
try:
|
||||
|
||||
@@ -127,6 +127,25 @@ def _load_grounding_backend():
|
||||
return cached
|
||||
|
||||
|
||||
def _model_input_to_device(value, device: str, torch):
|
||||
if not hasattr(value, "to"):
|
||||
return value
|
||||
if (
|
||||
device == "mps"
|
||||
and hasattr(torch, "float64")
|
||||
and getattr(value, "dtype", None) == torch.float64
|
||||
):
|
||||
return value.to(device=device, dtype=torch.float32)
|
||||
return value.to(device)
|
||||
|
||||
|
||||
def _model_inputs_to_device(inputs, device: str, torch) -> Dict[str, Any]:
|
||||
return {
|
||||
key: _model_input_to_device(value, device, torch)
|
||||
for key, value in inputs.items()
|
||||
}
|
||||
|
||||
|
||||
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||
query = (text or "").strip()
|
||||
if not query:
|
||||
@@ -142,10 +161,7 @@ def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||
labels.append(f"a photo of {query}")
|
||||
try:
|
||||
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
target_sizes = torch.tensor([[image.height, image.width]])
|
||||
@@ -1869,10 +1885,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
try:
|
||||
inputs = processor(image, **kwargs)
|
||||
model_inputs = {
|
||||
k: (v.to(device) if hasattr(v, "to") else v)
|
||||
for k, v in inputs.items()
|
||||
}
|
||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
||||
with torch.no_grad():
|
||||
outputs = model(**model_inputs)
|
||||
masks = processor.image_processor.post_process_masks(
|
||||
|
||||
@@ -137,44 +137,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = meta
|
||||
return entry
|
||||
|
||||
def _db_message_metadata(m: DbChatMessage) -> Dict[str, Any]:
|
||||
meta = {}
|
||||
if m.meta_data:
|
||||
try:
|
||||
meta = json.loads(m.meta_data) or {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
meta = {}
|
||||
if m.timestamp and "timestamp" not in meta:
|
||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||
return meta
|
||||
|
||||
def _hydrate_session_history_from_db(session_id: str, rows: list[DbChatMessage]) -> None:
|
||||
"""Rebuild in-memory context from raw DB rows after a history load.
|
||||
|
||||
The browser history endpoint can return paged/display-trimmed messages,
|
||||
but the next model call reads ``session.history``. After a restart or a
|
||||
stale in-memory session, selecting an old chat through the paged endpoint
|
||||
used to show the transcript while the model only saw fresh context.
|
||||
"""
|
||||
if not rows:
|
||||
return
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return
|
||||
session.history = [
|
||||
ChatMessage(role=m.role, content=m.content, metadata=_db_message_metadata(m) or None)
|
||||
for m in rows
|
||||
]
|
||||
session.message_count = len(session.history)
|
||||
|
||||
def _session_needs_db_history_hydration(session_id: str, total: int) -> bool:
|
||||
try:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
return False
|
||||
return len(session.history or []) < int(total or 0)
|
||||
|
||||
@router.get("/api/history/{session_id}")
|
||||
async def get_session_history(
|
||||
request: Request,
|
||||
@@ -198,6 +160,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
)
|
||||
page_offset = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||
page_offset = max(0, min(page_offset, total))
|
||||
# Keep display pagination page-scoped. ``get_session`` is the
|
||||
# full model-context hydration seam and must not be entered here.
|
||||
rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
@@ -206,14 +170,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.limit(page_limit)
|
||||
.all()
|
||||
)
|
||||
if _session_needs_db_history_hydration(session_id, total):
|
||||
full_rows = (
|
||||
db.query(DbChatMessage)
|
||||
.filter(DbChatMessage.session_id == session_id)
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
_hydrate_session_history_from_db(session_id, full_rows)
|
||||
history_dict = [
|
||||
entry for entry in (_db_history_entry(m) for m in rows)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
@@ -258,7 +214,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
entry["metadata"] = msg["metadata"]
|
||||
history_dict.append(entry)
|
||||
|
||||
# Fallback: load from DB if in-memory is empty
|
||||
# Fallback: load from DB if in-memory renders empty. Display only —
|
||||
# get_session above is the hydration seam, so nothing here writes back
|
||||
# into session.history — rebuilding it from raw rows would overwrite
|
||||
# parsed multimodal content and the _db_id edit/delete keys it just set.
|
||||
if not history_dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -268,17 +227,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
.order_by(DbChatMessage.timestamp)
|
||||
.all()
|
||||
)
|
||||
db_history = []
|
||||
for m in db_messages:
|
||||
db_history.append(_db_history_entry(m))
|
||||
if db_history:
|
||||
# Rebuild in-memory history from the full set so hidden
|
||||
# messages (e.g. compaction summaries) are kept for AI context.
|
||||
_hydrate_session_history_from_db(session_id, db_messages)
|
||||
# Response excludes hidden messages, matching the in-memory path.
|
||||
history_dict = [
|
||||
m for m in db_history
|
||||
if not (m.get("metadata") or {}).get("hidden")
|
||||
entry for entry in (_db_history_entry(m) for m in db_messages)
|
||||
if not (entry.get("metadata") or {}).get("hidden")
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||
@@ -645,8 +597,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
|
||||
# Get the source session
|
||||
source = session_manager.sessions.get(session_id)
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
# forks an empty transcript out of a metadata-only session after a
|
||||
# restart (display pagination no longer hydrates it).
|
||||
try:
|
||||
source = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
if not source:
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""MCP route domain package (slice 2o, #4082/#4071).
|
||||
|
||||
Contains mcp_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/mcp_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,697 @@
|
||||
# routes/mcp_routes.py
|
||||
"""MCP (Model Context Protocol) server management routes."""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
import html
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
import logging
|
||||
import httpx
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
from core.middleware import require_admin
|
||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
|
||||
def _mcp_oauth_base_dir() -> Path:
|
||||
"""Directory that may contain OAuth files managed by Odysseus."""
|
||||
return Path(MCP_OAUTH_DIR).resolve(strict=False)
|
||||
|
||||
|
||||
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
||||
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
base = _mcp_oauth_base_dir()
|
||||
path = Path(os.path.expanduser(raw))
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
resolved = path.resolve(strict=False)
|
||||
|
||||
try:
|
||||
resolved.relative_to(base)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Invalid OAuth {field_name}: path must stay under {base}",
|
||||
) from exc
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _sanitize_mcp_oauth_config(oauth_cfg):
|
||||
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
|
||||
if not oauth_cfg:
|
||||
return oauth_cfg
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return {}
|
||||
sanitized = dict(oauth_cfg)
|
||||
for field_name in ("keys_file", "token_file"):
|
||||
if sanitized.get(field_name):
|
||||
sanitized[field_name] = _resolve_mcp_oauth_path(
|
||||
sanitized[field_name],
|
||||
field_name,
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
|
||||
"""Check token existence without letting legacy bad paths break listing."""
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return False
|
||||
try:
|
||||
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
|
||||
except HTTPException:
|
||||
if strict:
|
||||
raise
|
||||
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
|
||||
return True
|
||||
return bool(token_file and not os.path.exists(token_file))
|
||||
|
||||
|
||||
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
|
||||
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
|
||||
if not oauth_cfg or not isinstance(env, dict):
|
||||
return
|
||||
keys_file = oauth_cfg.get("keys_file")
|
||||
token_file = oauth_cfg.get("token_file")
|
||||
if keys_file:
|
||||
env["GMAIL_OAUTH_PATH"] = keys_file
|
||||
if token_file:
|
||||
env["GMAIL_CREDENTIALS_PATH"] = token_file
|
||||
|
||||
|
||||
def _load_disabled_map():
|
||||
"""Load per-server disabled tool sets from DB."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
disabled_map = {}
|
||||
for srv in db.query(McpServer).all():
|
||||
if srv.disabled_tools:
|
||||
try:
|
||||
names = json.loads(srv.disabled_tools)
|
||||
if names:
|
||||
disabled_map[srv.id] = set(names)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return disabled_map
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _mcp_oauth_redirect_uri() -> str:
|
||||
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
|
||||
from src.mcp_oauth import REDIRECT_URI
|
||||
return REDIRECT_URI
|
||||
|
||||
|
||||
def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"""Setup MCP routes with the provided manager."""
|
||||
|
||||
@router.get("/servers")
|
||||
def list_servers(request: Request):
|
||||
"""List all configured MCP servers with connection status."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
servers = db.query(McpServer).all()
|
||||
result = []
|
||||
for srv in servers:
|
||||
status = mcp_manager.get_server_status(srv.id)
|
||||
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
|
||||
needs_oauth = False
|
||||
if oauth_cfg:
|
||||
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
|
||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||
total_tools = status.get("tool_count", 0)
|
||||
result.append({
|
||||
"id": srv.id,
|
||||
"name": srv.name,
|
||||
"transport": srv.transport,
|
||||
"command": srv.command,
|
||||
"args": json.loads(srv.args) if srv.args else [],
|
||||
"env": json.loads(srv.env) if srv.env else {},
|
||||
"url": srv.url,
|
||||
"is_enabled": srv.is_enabled,
|
||||
"status": status.get("status", "disconnected"),
|
||||
"tool_count": total_tools,
|
||||
"disabled_tool_count": len(disabled_list),
|
||||
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"has_oauth": oauth_cfg is not None,
|
||||
"needs_oauth": needs_oauth,
|
||||
})
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/servers")
|
||||
async def add_server(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
transport: str = Form("stdio"),
|
||||
command: str = Form(None),
|
||||
args: str = Form("[]"),
|
||||
env: str = Form("{}"),
|
||||
url: str = Form(None),
|
||||
oauth_file: str = Form(None),
|
||||
oauth_config: str = Form(None),
|
||||
):
|
||||
"""Add a new MCP server config and attempt connection. Admin-only:
|
||||
registering a stdio server is equivalent to executing arbitrary
|
||||
binaries on the host."""
|
||||
require_admin(request)
|
||||
server_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Validate
|
||||
if transport == "stdio" and not command:
|
||||
raise HTTPException(400, "command is required for stdio transport")
|
||||
if transport == "sse" and not url:
|
||||
raise HTTPException(400, "url is required for SSE transport")
|
||||
if transport == "http" and not url:
|
||||
raise HTTPException(400, "url is required for HTTP transport")
|
||||
|
||||
# Parse JSON fields
|
||||
try:
|
||||
parsed_args = json.loads(args) if args else []
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = []
|
||||
try:
|
||||
parsed_env = json.loads(env) if env else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed_env = {}
|
||||
if not isinstance(parsed_env, dict):
|
||||
parsed_env = {}
|
||||
|
||||
# Parse OAuth config
|
||||
parsed_oauth_config = None
|
||||
if oauth_config:
|
||||
try:
|
||||
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
|
||||
|
||||
# Write OAuth credentials file if provided (for Google MCP servers)
|
||||
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
|
||||
if oauth_file:
|
||||
try:
|
||||
oauth_data = json.loads(oauth_file)
|
||||
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
|
||||
oauth_filename = oauth_data.get("filename", "")
|
||||
client_id = oauth_data.get("client_id", "")
|
||||
client_secret = oauth_data.get("client_secret", "")
|
||||
if oauth_dir and oauth_filename and client_id and client_secret:
|
||||
filepath = _resolve_mcp_oauth_path(
|
||||
Path(oauth_dir) / str(oauth_filename),
|
||||
"filename",
|
||||
)
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
creds = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": ["http://localhost"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://accounts.google.com/o/oauth2/token",
|
||||
}
|
||||
}
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.info(f"Wrote OAuth credentials to {filepath}")
|
||||
parsed_env.pop("GOOGLE_CLIENT_ID", None)
|
||||
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Failed to write OAuth file: {e}")
|
||||
|
||||
# Save to DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = McpServer(
|
||||
id=server_id,
|
||||
name=name,
|
||||
transport=transport,
|
||||
command=command,
|
||||
args=json.dumps(parsed_args),
|
||||
env=json.dumps(parsed_env),
|
||||
url=url,
|
||||
is_enabled=True,
|
||||
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
|
||||
)
|
||||
db.add(srv)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Check if OAuth token already exists — skip connection attempt if not
|
||||
needs_oauth = False
|
||||
if parsed_oauth_config:
|
||||
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
|
||||
|
||||
connected = False
|
||||
if not needs_oauth:
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=name,
|
||||
transport=transport,
|
||||
command=command,
|
||||
args=parsed_args,
|
||||
env=parsed_env,
|
||||
url=url,
|
||||
)
|
||||
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
needs_auth = status.get("status") == "needs_auth"
|
||||
return {
|
||||
"id": server_id,
|
||||
"name": name,
|
||||
"connected": connected,
|
||||
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
|
||||
"needs_oauth": needs_oauth,
|
||||
"needs_auth": needs_auth,
|
||||
"auth_url": status.get("auth_url"),
|
||||
}
|
||||
|
||||
@router.post("/servers/{server_id}/reconnect")
|
||||
async def reconnect_server(server_id: str, request: Request):
|
||||
"""Reconnect to an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
return {
|
||||
"connected": connected,
|
||||
"status": status.get("status", "disconnected"),
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"needs_auth": status.get("status") == "needs_auth",
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.patch("/servers/{server_id}")
|
||||
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
|
||||
"""Enable or disable an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
enabled = str(is_enabled).lower() == "true"
|
||||
srv.is_enabled = enabled
|
||||
db.commit()
|
||||
|
||||
if enabled:
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
else:
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
return {"id": server_id, "is_enabled": enabled}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/servers/{server_id}")
|
||||
async def delete_server(server_id: str, request: Request):
|
||||
"""Remove an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
db.delete(srv)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/tools")
|
||||
def list_tools(request: Request):
|
||||
"""List all discovered MCP tools across all connected servers."""
|
||||
require_admin(request)
|
||||
disabled_map = _load_disabled_map()
|
||||
return mcp_manager.get_all_tools(disabled_map)
|
||||
|
||||
@router.get("/servers/{server_id}/tools")
|
||||
def list_server_tools(server_id: str, request: Request):
|
||||
"""List all tools for a specific MCP server with enabled/disabled state."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||
disabled_set = set(disabled_list)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
all_tools = mcp_manager.get_all_tools()
|
||||
server_tools = [t for t in all_tools if t["server_id"] == server_id]
|
||||
for t in server_tools:
|
||||
t["is_disabled"] = t["name"] in disabled_set
|
||||
return server_tools
|
||||
|
||||
@router.patch("/servers/{server_id}/tools")
|
||||
async def update_disabled_tools(server_id: str, request: Request):
|
||||
"""Bulk update disabled tools list for a server.
|
||||
|
||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||
"""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
body = await request.json()
|
||||
disabled = body.get("disabled", [])
|
||||
if not isinstance(disabled, list):
|
||||
raise HTTPException(400, "disabled must be a list of tool names")
|
||||
|
||||
srv.disabled_tools = json.dumps(disabled) if disabled else None
|
||||
db.commit()
|
||||
|
||||
return {"id": server_id, "disabled_count": len(disabled)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── OAuth flow for Google MCP servers ──────────────────────────
|
||||
|
||||
@router.get("/oauth/authorize/{server_id}")
|
||||
def oauth_authorize(server_id: str, request: Request):
|
||||
"""Show OAuth authorization page with Google sign-in link."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
if not srv.oauth_config:
|
||||
raise HTTPException(400, "Server has no OAuth config")
|
||||
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
if not keys_file or not os.path.exists(keys_file):
|
||||
raise HTTPException(400, "OAuth keys file not found")
|
||||
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
keys = keys_data.get("installed") or keys_data.get("web")
|
||||
if not keys:
|
||||
raise HTTPException(400, "Invalid OAuth keys file format")
|
||||
|
||||
client_id = keys["client_id"]
|
||||
scopes = oauth_cfg.get("scopes", [])
|
||||
|
||||
# For Desktop App creds, default to localhost — the user will
|
||||
# paste the resulting URL back if they're on a different device.
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(scopes),
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": server_id,
|
||||
}
|
||||
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
|
||||
|
||||
# Determine if user is accessing from the same machine
|
||||
host = request.headers.get("host", "")
|
||||
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
|
||||
|
||||
if is_local:
|
||||
# Same machine — just redirect, callback will work directly
|
||||
return RedirectResponse(auth_url)
|
||||
else:
|
||||
# Remote device — show paste-back page
|
||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/oauth/callback")
|
||||
async def oauth_callback(code: str, state: str, request: Request):
|
||||
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
||||
pending-state registry; Google flows fall through to the legacy path."""
|
||||
require_admin(request)
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
# Legacy Google path: state is the server_id
|
||||
return await _exchange_and_connect(state, code, request)
|
||||
|
||||
@router.post("/oauth/exchange/{server_id}")
|
||||
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
||||
"""Manual code exchange — user pastes the callback URL from their browser."""
|
||||
require_admin(request)
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(callback_url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
code = params.get("code", [None])[0]
|
||||
if not code:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
|
||||
except Exception:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
||||
|
||||
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
||||
# resolve it directly (the background connect finishes the handshake).
|
||||
state = params.get("state", [None])[0]
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if state and resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
|
||||
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
||||
"""Exchange auth code for tokens and connect the MCP server."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
|
||||
if not srv.oauth_config:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
|
||||
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
token_file = oauth_cfg.get("token_file", "")
|
||||
if not keys_file or not token_file:
|
||||
raise HTTPException(400, "OAuth keys/token file not configured")
|
||||
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
keys = keys_data.get("installed") or keys_data.get("web")
|
||||
client_id = keys["client_id"]
|
||||
client_secret = keys["client_secret"]
|
||||
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
err = resp.text
|
||||
logger.error(f"OAuth token exchange failed: {err}")
|
||||
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
logger.info(f"OAuth tokens received for server {server_id}")
|
||||
|
||||
# Save tokens to the file the MCP package expects
|
||||
os.makedirs(os.path.dirname(token_file), exist_ok=True)
|
||||
with open(token_file, "w", encoding="utf-8") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
logger.info(f"Saved OAuth tokens to {token_file}")
|
||||
|
||||
# Attempt to connect the MCP server now
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
|
||||
if connected:
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
tool_count = status.get("tool_count", 0)
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
f"{srv.name} connected with {tool_count} tools. You can close this window.",
|
||||
success=True,
|
||||
))
|
||||
else:
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorized but Connection Failed",
|
||||
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
|
||||
))
|
||||
except HTTPException as e:
|
||||
logger.warning(f"OAuth callback rejected: {e.detail}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback error: {e}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _oauth_authorize_page(
|
||||
auth_url: str,
|
||||
server_id: str,
|
||||
host: str,
|
||||
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
||||
) -> str:
|
||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
||||
# Escape values interpolated into the page: `host` comes from the request
|
||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
||||
auth_url = html.escape(auth_url, quote=True)
|
||||
server_id = html.escape(server_id, quote=True)
|
||||
host = html.escape(host, quote=True)
|
||||
redirect_uri = html.escape(redirect_uri, quote=True)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
|
||||
<style>
|
||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||
padding: 2rem; max-width: 480px; text-align: center; }}
|
||||
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
|
||||
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
|
||||
.step b {{ color: #e06c75; }}
|
||||
a.auth-link {{
|
||||
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
|
||||
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
|
||||
font-weight: 600; font-size: 0.9rem;
|
||||
}}
|
||||
a.auth-link:hover {{ background: #c55; }}
|
||||
input[type=text] {{
|
||||
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
|
||||
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
|
||||
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
|
||||
}}
|
||||
input:focus {{ outline: none; border-color: #e06c75; }}
|
||||
button {{
|
||||
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
|
||||
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
|
||||
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
|
||||
}}
|
||||
button:hover {{ background: #c55; }}
|
||||
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Authorize Google Account</h2>
|
||||
<div class="step">
|
||||
<b>1.</b> Click the button below to sign in with Google<br>
|
||||
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
|
||||
<b>3.</b> Copy the full URL from your browser's address bar<br>
|
||||
<b>4.</b> Paste it below and click Connect
|
||||
</div>
|
||||
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
||||
<div class="divider"></div>
|
||||
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
||||
<p>Paste the URL from your browser after signing in:</p>
|
||||
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
||||
<br><button type="submit">Connect</button>
|
||||
</form>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
|
||||
"""Generate a simple HTML page for the OAuth result."""
|
||||
safe_title = html.escape(title)
|
||||
safe_message = html.escape(message)
|
||||
color = "#00661a" if success else "#e06c75"
|
||||
icon = "✓" if success else "✗"
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8"><title>{safe_title}</title>
|
||||
<style>
|
||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||
padding: 2rem; max-width: 420px; text-align: center; }}
|
||||
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
|
||||
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<div class="icon">{icon}</div>
|
||||
<h2>{safe_title}</h2>
|
||||
<p>{safe_message}</p>
|
||||
</div></body></html>"""
|
||||
+14
-693
@@ -1,697 +1,18 @@
|
||||
# routes/mcp_routes.py
|
||||
"""MCP (Model Context Protocol) server management routes."""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
import html
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
import logging
|
||||
import httpx
|
||||
"""Backward-compat shim — canonical location is routes/mcp/mcp_routes.py.
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
from core.middleware import require_admin
|
||||
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||
from src.mcp_manager import McpManager
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.mcp_routes``, ``from routes.mcp_routes import X``,
|
||||
``importlib.import_module("routes.mcp_routes")``, the
|
||||
``sys.modules.pop("routes.mcp_routes")`` + re-import pattern in
|
||||
test_security_regressions.py, and the ``monkeypatch.setattr(mcp_routes,
|
||||
"MCP_OAUTH_DIR", ...)`` pattern all operate on the *same* object. This also
|
||||
makes ``mcp_routes.__file__`` resolve to the canonical file (which the
|
||||
source-introspection at line 839 reads). Keeps existing import paths working
|
||||
after slice 2o (#4082/#4071).
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import sys as _sys
|
||||
|
||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
from routes.mcp import mcp_routes as _canonical # noqa: F401
|
||||
|
||||
|
||||
def _mcp_oauth_base_dir() -> Path:
|
||||
"""Directory that may contain OAuth files managed by Odysseus."""
|
||||
return Path(MCP_OAUTH_DIR).resolve(strict=False)
|
||||
|
||||
|
||||
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
||||
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
base = _mcp_oauth_base_dir()
|
||||
path = Path(os.path.expanduser(raw))
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
resolved = path.resolve(strict=False)
|
||||
|
||||
try:
|
||||
resolved.relative_to(base)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Invalid OAuth {field_name}: path must stay under {base}",
|
||||
) from exc
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _sanitize_mcp_oauth_config(oauth_cfg):
|
||||
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
|
||||
if not oauth_cfg:
|
||||
return oauth_cfg
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return {}
|
||||
sanitized = dict(oauth_cfg)
|
||||
for field_name in ("keys_file", "token_file"):
|
||||
if sanitized.get(field_name):
|
||||
sanitized[field_name] = _resolve_mcp_oauth_path(
|
||||
sanitized[field_name],
|
||||
field_name,
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
|
||||
"""Check token existence without letting legacy bad paths break listing."""
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return False
|
||||
try:
|
||||
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
|
||||
except HTTPException:
|
||||
if strict:
|
||||
raise
|
||||
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
|
||||
return True
|
||||
return bool(token_file and not os.path.exists(token_file))
|
||||
|
||||
|
||||
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
|
||||
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
|
||||
if not oauth_cfg or not isinstance(env, dict):
|
||||
return
|
||||
keys_file = oauth_cfg.get("keys_file")
|
||||
token_file = oauth_cfg.get("token_file")
|
||||
if keys_file:
|
||||
env["GMAIL_OAUTH_PATH"] = keys_file
|
||||
if token_file:
|
||||
env["GMAIL_CREDENTIALS_PATH"] = token_file
|
||||
|
||||
|
||||
def _load_disabled_map():
|
||||
"""Load per-server disabled tool sets from DB."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
disabled_map = {}
|
||||
for srv in db.query(McpServer).all():
|
||||
if srv.disabled_tools:
|
||||
try:
|
||||
names = json.loads(srv.disabled_tools)
|
||||
if names:
|
||||
disabled_map[srv.id] = set(names)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return disabled_map
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _mcp_oauth_redirect_uri() -> str:
|
||||
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
|
||||
from src.mcp_oauth import REDIRECT_URI
|
||||
return REDIRECT_URI
|
||||
|
||||
|
||||
def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"""Setup MCP routes with the provided manager."""
|
||||
|
||||
@router.get("/servers")
|
||||
def list_servers(request: Request):
|
||||
"""List all configured MCP servers with connection status."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
servers = db.query(McpServer).all()
|
||||
result = []
|
||||
for srv in servers:
|
||||
status = mcp_manager.get_server_status(srv.id)
|
||||
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
|
||||
needs_oauth = False
|
||||
if oauth_cfg:
|
||||
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
|
||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||
total_tools = status.get("tool_count", 0)
|
||||
result.append({
|
||||
"id": srv.id,
|
||||
"name": srv.name,
|
||||
"transport": srv.transport,
|
||||
"command": srv.command,
|
||||
"args": json.loads(srv.args) if srv.args else [],
|
||||
"env": json.loads(srv.env) if srv.env else {},
|
||||
"url": srv.url,
|
||||
"is_enabled": srv.is_enabled,
|
||||
"status": status.get("status", "disconnected"),
|
||||
"tool_count": total_tools,
|
||||
"disabled_tool_count": len(disabled_list),
|
||||
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"has_oauth": oauth_cfg is not None,
|
||||
"needs_oauth": needs_oauth,
|
||||
})
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/servers")
|
||||
async def add_server(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
transport: str = Form("stdio"),
|
||||
command: str = Form(None),
|
||||
args: str = Form("[]"),
|
||||
env: str = Form("{}"),
|
||||
url: str = Form(None),
|
||||
oauth_file: str = Form(None),
|
||||
oauth_config: str = Form(None),
|
||||
):
|
||||
"""Add a new MCP server config and attempt connection. Admin-only:
|
||||
registering a stdio server is equivalent to executing arbitrary
|
||||
binaries on the host."""
|
||||
require_admin(request)
|
||||
server_id = str(uuid.uuid4())[:8]
|
||||
|
||||
# Validate
|
||||
if transport == "stdio" and not command:
|
||||
raise HTTPException(400, "command is required for stdio transport")
|
||||
if transport == "sse" and not url:
|
||||
raise HTTPException(400, "url is required for SSE transport")
|
||||
if transport == "http" and not url:
|
||||
raise HTTPException(400, "url is required for HTTP transport")
|
||||
|
||||
# Parse JSON fields
|
||||
try:
|
||||
parsed_args = json.loads(args) if args else []
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = []
|
||||
try:
|
||||
parsed_env = json.loads(env) if env else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed_env = {}
|
||||
if not isinstance(parsed_env, dict):
|
||||
parsed_env = {}
|
||||
|
||||
# Parse OAuth config
|
||||
parsed_oauth_config = None
|
||||
if oauth_config:
|
||||
try:
|
||||
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
|
||||
|
||||
# Write OAuth credentials file if provided (for Google MCP servers)
|
||||
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
|
||||
if oauth_file:
|
||||
try:
|
||||
oauth_data = json.loads(oauth_file)
|
||||
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
|
||||
oauth_filename = oauth_data.get("filename", "")
|
||||
client_id = oauth_data.get("client_id", "")
|
||||
client_secret = oauth_data.get("client_secret", "")
|
||||
if oauth_dir and oauth_filename and client_id and client_secret:
|
||||
filepath = _resolve_mcp_oauth_path(
|
||||
Path(oauth_dir) / str(oauth_filename),
|
||||
"filename",
|
||||
)
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
creds = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uris": ["http://localhost"],
|
||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||
"token_uri": "https://accounts.google.com/o/oauth2/token",
|
||||
}
|
||||
}
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.info(f"Wrote OAuth credentials to {filepath}")
|
||||
parsed_env.pop("GOOGLE_CLIENT_ID", None)
|
||||
parsed_env.pop("GOOGLE_CLIENT_SECRET", None)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Failed to write OAuth file: {e}")
|
||||
|
||||
# Save to DB
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = McpServer(
|
||||
id=server_id,
|
||||
name=name,
|
||||
transport=transport,
|
||||
command=command,
|
||||
args=json.dumps(parsed_args),
|
||||
env=json.dumps(parsed_env),
|
||||
url=url,
|
||||
is_enabled=True,
|
||||
oauth_config=json.dumps(parsed_oauth_config) if parsed_oauth_config else None,
|
||||
)
|
||||
db.add(srv)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Check if OAuth token already exists — skip connection attempt if not
|
||||
needs_oauth = False
|
||||
if parsed_oauth_config:
|
||||
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
|
||||
|
||||
connected = False
|
||||
if not needs_oauth:
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=name,
|
||||
transport=transport,
|
||||
command=command,
|
||||
args=parsed_args,
|
||||
env=parsed_env,
|
||||
url=url,
|
||||
)
|
||||
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
needs_auth = status.get("status") == "needs_auth"
|
||||
return {
|
||||
"id": server_id,
|
||||
"name": name,
|
||||
"connected": connected,
|
||||
"status": "needs_oauth" if needs_oauth else status.get("status", "disconnected"),
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
|
||||
"needs_oauth": needs_oauth,
|
||||
"needs_auth": needs_auth,
|
||||
"auth_url": status.get("auth_url"),
|
||||
}
|
||||
|
||||
@router.post("/servers/{server_id}/reconnect")
|
||||
async def reconnect_server(server_id: str, request: Request):
|
||||
"""Reconnect to an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
return {
|
||||
"connected": connected,
|
||||
"status": status.get("status", "disconnected"),
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"needs_auth": status.get("status") == "needs_auth",
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.patch("/servers/{server_id}")
|
||||
async def toggle_server(server_id: str, request: Request, is_enabled: str = Form(...)):
|
||||
"""Enable or disable an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
enabled = str(is_enabled).lower() == "true"
|
||||
srv.is_enabled = enabled
|
||||
db.commit()
|
||||
|
||||
if enabled:
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
else:
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
return {"id": server_id, "is_enabled": enabled}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/servers/{server_id}")
|
||||
async def delete_server(server_id: str, request: Request):
|
||||
"""Remove an MCP server."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
await mcp_manager.disconnect_server(server_id)
|
||||
|
||||
db.delete(srv)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/tools")
|
||||
def list_tools(request: Request):
|
||||
"""List all discovered MCP tools across all connected servers."""
|
||||
require_admin(request)
|
||||
disabled_map = _load_disabled_map()
|
||||
return mcp_manager.get_all_tools(disabled_map)
|
||||
|
||||
@router.get("/servers/{server_id}/tools")
|
||||
def list_server_tools(server_id: str, request: Request):
|
||||
"""List all tools for a specific MCP server with enabled/disabled state."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||
disabled_set = set(disabled_list)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
all_tools = mcp_manager.get_all_tools()
|
||||
server_tools = [t for t in all_tools if t["server_id"] == server_id]
|
||||
for t in server_tools:
|
||||
t["is_disabled"] = t["name"] in disabled_set
|
||||
return server_tools
|
||||
|
||||
@router.patch("/servers/{server_id}/tools")
|
||||
async def update_disabled_tools(server_id: str, request: Request):
|
||||
"""Bulk update disabled tools list for a server.
|
||||
|
||||
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
|
||||
"""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
|
||||
body = await request.json()
|
||||
disabled = body.get("disabled", [])
|
||||
if not isinstance(disabled, list):
|
||||
raise HTTPException(400, "disabled must be a list of tool names")
|
||||
|
||||
srv.disabled_tools = json.dumps(disabled) if disabled else None
|
||||
db.commit()
|
||||
|
||||
return {"id": server_id, "disabled_count": len(disabled)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── OAuth flow for Google MCP servers ──────────────────────────
|
||||
|
||||
@router.get("/oauth/authorize/{server_id}")
|
||||
def oauth_authorize(server_id: str, request: Request):
|
||||
"""Show OAuth authorization page with Google sign-in link."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
raise HTTPException(404, "Server not found")
|
||||
if not srv.oauth_config:
|
||||
raise HTTPException(400, "Server has no OAuth config")
|
||||
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
if not keys_file or not os.path.exists(keys_file):
|
||||
raise HTTPException(400, "OAuth keys file not found")
|
||||
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
keys = keys_data.get("installed") or keys_data.get("web")
|
||||
if not keys:
|
||||
raise HTTPException(400, "Invalid OAuth keys file format")
|
||||
|
||||
client_id = keys["client_id"]
|
||||
scopes = oauth_cfg.get("scopes", [])
|
||||
|
||||
# For Desktop App creds, default to localhost — the user will
|
||||
# paste the resulting URL back if they're on a different device.
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(scopes),
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
"state": server_id,
|
||||
}
|
||||
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(params)
|
||||
|
||||
# Determine if user is accessing from the same machine
|
||||
host = request.headers.get("host", "")
|
||||
is_local = host.startswith("localhost") or host.startswith("127.0.0.1")
|
||||
|
||||
if is_local:
|
||||
# Same machine — just redirect, callback will work directly
|
||||
return RedirectResponse(auth_url)
|
||||
else:
|
||||
# Remote device — show paste-back page
|
||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/oauth/callback")
|
||||
async def oauth_callback(code: str, state: str, request: Request):
|
||||
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
||||
pending-state registry; Google flows fall through to the legacy path."""
|
||||
require_admin(request)
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
# Legacy Google path: state is the server_id
|
||||
return await _exchange_and_connect(state, code, request)
|
||||
|
||||
@router.post("/oauth/exchange/{server_id}")
|
||||
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
||||
"""Manual code exchange — user pastes the callback URL from their browser."""
|
||||
require_admin(request)
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(callback_url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
code = params.get("code", [None])[0]
|
||||
if not code:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No authorization code found in the URL. Make sure you copied the full URL from your browser."), status_code=400)
|
||||
except Exception:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
||||
|
||||
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
||||
# resolve it directly (the background connect finishes the handshake).
|
||||
state = params.get("state", [None])[0]
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if state and resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
|
||||
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
||||
"""Exchange auth code for tokens and connect the MCP server."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
|
||||
if not srv:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Server not found."), status_code=404)
|
||||
if not srv.oauth_config:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
|
||||
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
token_file = oauth_cfg.get("token_file", "")
|
||||
if not keys_file or not token_file:
|
||||
raise HTTPException(400, "OAuth keys/token file not configured")
|
||||
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
keys = keys_data.get("installed") or keys_data.get("web")
|
||||
client_id = keys["client_id"]
|
||||
client_secret = keys["client_secret"]
|
||||
|
||||
redirect_uri = _mcp_oauth_redirect_uri()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
err = resp.text
|
||||
logger.error(f"OAuth token exchange failed: {err}")
|
||||
return HTMLResponse(_oauth_result_page("Authorization Failed", f"Google returned an error: {err}"), status_code=400)
|
||||
|
||||
tokens = resp.json()
|
||||
logger.info(f"OAuth tokens received for server {server_id}")
|
||||
|
||||
# Save tokens to the file the MCP package expects
|
||||
os.makedirs(os.path.dirname(token_file), exist_ok=True)
|
||||
with open(token_file, "w", encoding="utf-8") as f:
|
||||
json.dump(tokens, f, indent=2)
|
||||
logger.info(f"Saved OAuth tokens to {token_file}")
|
||||
|
||||
# Attempt to connect the MCP server now
|
||||
args = json.loads(srv.args) if srv.args else []
|
||||
env = json.loads(srv.env) if srv.env else {}
|
||||
connected = await mcp_manager.connect_server(
|
||||
server_id=server_id,
|
||||
name=srv.name,
|
||||
transport=srv.transport,
|
||||
command=srv.command,
|
||||
args=args,
|
||||
env=env,
|
||||
url=srv.url,
|
||||
)
|
||||
|
||||
if connected:
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
tool_count = status.get("tool_count", 0)
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
f"{srv.name} connected with {tool_count} tools. You can close this window.",
|
||||
success=True,
|
||||
))
|
||||
else:
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorized but Connection Failed",
|
||||
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
|
||||
))
|
||||
except HTTPException as e:
|
||||
logger.warning(f"OAuth callback rejected: {e.detail}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback error: {e}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _oauth_authorize_page(
|
||||
auth_url: str,
|
||||
server_id: str,
|
||||
host: str,
|
||||
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
||||
) -> str:
|
||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
||||
# Escape values interpolated into the page: `host` comes from the request
|
||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
||||
auth_url = html.escape(auth_url, quote=True)
|
||||
server_id = html.escape(server_id, quote=True)
|
||||
host = html.escape(host, quote=True)
|
||||
redirect_uri = html.escape(redirect_uri, quote=True)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
|
||||
<style>
|
||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||
padding: 2rem; max-width: 480px; text-align: center; }}
|
||||
h2 {{ color: #e06c75; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||
p {{ color: #aaa; font-size: 0.82rem; line-height: 1.6; margin: 0.8rem 0; }}
|
||||
.step {{ text-align: left; color: #ccc; font-size: 0.82rem; line-height: 1.7; margin: 1rem 0; }}
|
||||
.step b {{ color: #e06c75; }}
|
||||
a.auth-link {{
|
||||
display: inline-block; margin: 1rem 0; padding: 0.6rem 1.5rem;
|
||||
background: #e06c75; color: #fff; text-decoration: none; border-radius: 6px;
|
||||
font-weight: 600; font-size: 0.9rem;
|
||||
}}
|
||||
a.auth-link:hover {{ background: #c55; }}
|
||||
input[type=text] {{
|
||||
width: 100%; padding: 0.5rem; margin: 0.5rem 0;
|
||||
background: #0f0f0f; border: 1px solid #333; border-radius: 6px;
|
||||
color: #e0e0e0; font-family: 'Fira Code', monospace; font-size: 0.8rem;
|
||||
}}
|
||||
input:focus {{ outline: none; border-color: #e06c75; }}
|
||||
button {{
|
||||
padding: 0.5rem 1.5rem; border: none; border-radius: 6px;
|
||||
background: #e06c75; color: #fff; font-weight: 600; cursor: pointer;
|
||||
font-family: 'Fira Code', monospace; font-size: 0.85rem; margin-top: 0.3rem;
|
||||
}}
|
||||
button:hover {{ background: #c55; }}
|
||||
.divider {{ border-top: 1px solid #333; margin: 1.2rem 0; }}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Authorize Google Account</h2>
|
||||
<div class="step">
|
||||
<b>1.</b> Click the button below to sign in with Google<br>
|
||||
<b>2.</b> After approving, your browser will show an error page — that's normal<br>
|
||||
<b>3.</b> Copy the full URL from your browser's address bar<br>
|
||||
<b>4.</b> Paste it below and click Connect
|
||||
</div>
|
||||
<a class="auth-link" href="{auth_url}" target="_blank" rel="noopener">Sign in with Google</a>
|
||||
<div class="divider"></div>
|
||||
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
||||
<p>Paste the URL from your browser after signing in:</p>
|
||||
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
||||
<br><button type="submit">Connect</button>
|
||||
</form>
|
||||
</div></body></html>"""
|
||||
|
||||
|
||||
def _oauth_result_page(title: str, message: str, success: bool = False) -> str:
|
||||
"""Generate a simple HTML page for the OAuth result."""
|
||||
safe_title = html.escape(title)
|
||||
safe_message = html.escape(message)
|
||||
color = "#00661a" if success else "#e06c75"
|
||||
icon = "✓" if success else "✗"
|
||||
return f"""<!DOCTYPE html>
|
||||
<html><head>
|
||||
<meta charset="UTF-8"><title>{safe_title}</title>
|
||||
<style>
|
||||
body {{ font-family: 'Fira Code', monospace; background: #0f0f0f; color: #e0e0e0;
|
||||
display: flex; justify-content: center; align-items: center; min-height: 100vh; }}
|
||||
.card {{ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
||||
padding: 2rem; max-width: 420px; text-align: center; }}
|
||||
.icon {{ font-size: 3rem; color: {color}; margin-bottom: 1rem; }}
|
||||
h2 {{ color: {color}; margin-bottom: 0.5rem; font-size: 1.1rem; }}
|
||||
p {{ color: #aaa; font-size: 0.85rem; line-height: 1.5; }}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<div class="icon">{icon}</div>
|
||||
<h2>{safe_title}</h2>
|
||||
<p>{safe_message}</p>
|
||||
</div></body></html>"""
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
|
||||
return text
|
||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
||||
|
||||
from services.memory import MemoryManager
|
||||
from services.memory import MemoryManager, MemoryStoreUnreadable
|
||||
from core.session_manager import SessionManager
|
||||
from src.request_models import MemoryAddRequest
|
||||
from core.database import SessionLocal
|
||||
@@ -35,6 +35,22 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _load_for_update(memory_manager) -> List[Dict[str, Any]]:
|
||||
"""Load the whole store for a read-modify-write cycle.
|
||||
|
||||
A transient read failure must not look like an empty store: the caller
|
||||
would append to ``[]`` and save that back, atomically destroying every
|
||||
existing memory (issue #5673). Surface it as a 503 and change nothing.
|
||||
"""
|
||||
try:
|
||||
return memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Refusing to rewrite the memory store: %s", e)
|
||||
raise HTTPException(
|
||||
503, "Memory store is temporarily unreadable — no changes were made."
|
||||
)
|
||||
|
||||
|
||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||
"""Set up memory-related routes."""
|
||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||
@@ -116,7 +132,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||
if memory_data.session_id:
|
||||
new_entry["session_id"] = memory_data.session_id
|
||||
all_mem = memory_manager.load_all()
|
||||
all_mem = _load_for_update(memory_manager)
|
||||
all_mem.append(new_entry)
|
||||
memory_manager.save(all_mem)
|
||||
# Sync vector index
|
||||
@@ -487,7 +503,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
||||
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
||||
user = _owner(request)
|
||||
all_mem = memory_manager.load_all()
|
||||
all_mem = _load_for_update(memory_manager)
|
||||
for i, memory in enumerate(all_mem):
|
||||
if memory["id"] == memory_id:
|
||||
_verify_memory_owner(memory, user)
|
||||
@@ -512,7 +528,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
def update_memory(request: Request, memory_id: str, text: str = Form(...), category: str = Form(None)):
|
||||
"""Update an existing memory item with new text and optional category."""
|
||||
user = _owner(request)
|
||||
all_mem = memory_manager.load_all()
|
||||
all_mem = _load_for_update(memory_manager)
|
||||
for i, memory in enumerate(all_mem):
|
||||
if memory["id"] == memory_id:
|
||||
_verify_memory_owner(memory, user)
|
||||
@@ -534,7 +550,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
||||
def delete_memory(request: Request, memory_id: str):
|
||||
"""Delete a memory item by its ID."""
|
||||
user = _owner(request)
|
||||
all_mem = memory_manager.load_all()
|
||||
all_mem = _load_for_update(memory_manager)
|
||||
|
||||
# Find and verify ownership before deleting
|
||||
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
||||
|
||||
+2
-32
@@ -46,10 +46,11 @@ _ENDPOINT_SETTING_FIELDS = {
|
||||
}
|
||||
|
||||
_ENDPOINT_FALLBACK_FIELDS = {
|
||||
"default_model_fallbacks": "Default Model Fallbacks",
|
||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||
"vision_model_fallbacks": "Vision Model Fallbacks",
|
||||
}
|
||||
# `default_model_fallbacks` is intentionally absent. The legacy data remains
|
||||
# stored as-is even when an endpoint is removed, but no longer affects routing.
|
||||
|
||||
|
||||
def _speech_settings_using_endpoint(settings: dict, ep_id: str) -> list:
|
||||
@@ -2437,7 +2438,6 @@ def setup_model_routes(model_discovery):
|
||||
_user_prefs = _load_for_user(_user) or {}
|
||||
ep_id = (_user_prefs.get("default_endpoint_id") or "").strip()
|
||||
model = (_user_prefs.get("default_model") or "").strip()
|
||||
_fallbacks = _user_prefs.get("default_model_fallbacks") or []
|
||||
# If user has no personal default, fall back to global default
|
||||
# But only based on the "share_defaults_with_users" flag
|
||||
# (only if share_defaults_with_users is enabled)
|
||||
@@ -2446,12 +2446,9 @@ def setup_model_routes(model_discovery):
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
if not model:
|
||||
model = settings.get("default_model", "")
|
||||
if not _fallbacks:
|
||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||
else:
|
||||
ep_id = settings.get("default_endpoint_id", "")
|
||||
model = settings.get("default_model", "")
|
||||
_fallbacks = settings.get("default_model_fallbacks") or []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = None
|
||||
@@ -2466,33 +2463,6 @@ def setup_model_routes(model_discovery):
|
||||
if _user and not _is_admin:
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, _user)
|
||||
ep = ep_q.first()
|
||||
# Configured fallback chain — when the chosen default endpoint is
|
||||
# gone/disabled, honor the user's configured `default_model_fallbacks`
|
||||
# in order BEFORE arbitrarily grabbing the first enabled endpoint.
|
||||
# (Previously this jumped straight to "first enabled", which is why
|
||||
# deleting/changing the main endpoint silently reassigned the default
|
||||
# chat to some unrelated endpoint instead of the fallback.)
|
||||
if not ep:
|
||||
for entry in _fallbacks:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fid = (entry.get("endpoint_id") or "").strip()
|
||||
if not fid:
|
||||
continue
|
||||
cand_q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.id == fid, ModelEndpoint.is_enabled == True
|
||||
)
|
||||
if _user and not _is_admin:
|
||||
cand_q = owner_filter(cand_q, ModelEndpoint, _user)
|
||||
cand = cand_q.first()
|
||||
if cand:
|
||||
ep = cand
|
||||
# Use the fallback entry's model. Reset even when empty
|
||||
# so we don't carry the prior endpoint's stale model onto
|
||||
# this fallback — the cached-models lookup below then
|
||||
# fills it from the fallback endpoint.
|
||||
model = (entry.get("model") or "").strip()
|
||||
break
|
||||
# Last resort: first enabled endpoint owned by THIS user. Do not
|
||||
# include null-owner/shared endpoints here: a brand-new user with
|
||||
# no explicit default should not auto-open a pending chat using an
|
||||
|
||||
+163
-92
@@ -1,11 +1,13 @@
|
||||
# routes/personal_routes.py
|
||||
"""Routes for personal documents management."""
|
||||
import asyncio
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from src.request_models import DirectoryRequest
|
||||
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
|
||||
from src.rag_singleton import get_rag_manager
|
||||
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
|
||||
"""Return the per-owner upload directory used for direct RAG uploads."""
|
||||
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
|
||||
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
"""
|
||||
router = APIRouter(prefix="/api/personal")
|
||||
|
||||
# Serializes directory index jobs across requests. Indexing runs in the
|
||||
# threadpool (#5558), so concurrent requests would otherwise run in parallel
|
||||
# and race PersonalDocsManager's unsynchronized list mutations and file
|
||||
# writes; before the threadpool move they serialized on the blocked event
|
||||
# loop, so one-at-a-time is behavior parity.
|
||||
#
|
||||
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
|
||||
# request parks on the event loop instead of pinning a threadpool worker (an
|
||||
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
|
||||
# tokens while blocked, starving every other run_in_threadpool caller).
|
||||
# add/remove/reload all take this lock, so their mutations never interleave.
|
||||
# Per-router (not module-global) so each app binds it to its own event loop.
|
||||
# Scope is the single process: multi-worker deployments would need a shared
|
||||
# lock (out of scope for #5558).
|
||||
_index_job_lock = asyncio.Lock()
|
||||
|
||||
def _rag():
|
||||
"""Get the current RAG manager, retrying init if needed."""
|
||||
return get_rag_manager()
|
||||
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
return {"files": files, "directories": directories}
|
||||
|
||||
@router.post("/reload")
|
||||
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
personal_docs_manager.refresh_index()
|
||||
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
# refresh_index() re-extracts text across every tracked directory —
|
||||
# blocking work. Take the shared job lock (so it cannot race an add /
|
||||
# remove) and run it off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(personal_docs_manager.refresh_index)
|
||||
return {"ok": True, "count": len(personal_docs_manager.index)}
|
||||
|
||||
@router.post("/add_directory")
|
||||
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
# Use the RAGManager to index the directory
|
||||
rag = _rag()
|
||||
if rag:
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
|
||||
def _index_directory():
|
||||
result = rag.index_personal_documents(directory, owner=owner)
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this
|
||||
# directory. Kept inside the offloaded call: it triggers
|
||||
# refresh_index(), which re-extracts text across tracked
|
||||
# directories.
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
return result
|
||||
|
||||
# Indexing walks, embeds, and stores the whole tree — minutes
|
||||
# on a real directory. The handler is async, so calling it
|
||||
# inline runs it on the event loop and every other request
|
||||
# queues behind it until it finishes (#5558). Serialize on the
|
||||
# async job lock BEFORE offloading so a queued request parks on
|
||||
# the loop instead of pinning a threadpool worker.
|
||||
async with _index_job_lock:
|
||||
result = await run_in_threadpool(_index_directory)
|
||||
|
||||
if result["success"]:
|
||||
# Also update the personal_docs_manager to track this directory
|
||||
personal_docs_manager.add_directory(directory, index=False)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
|
||||
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
|
||||
logger.info(f"Removing directory from RAG: {directory}")
|
||||
|
||||
# Always remove from personal_docs_manager tracking
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
|
||||
# Remove from RAG vector store (best-effort)
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
def _remove_directory():
|
||||
# Always remove from personal_docs_manager tracking. This
|
||||
# mutates the same unsynchronized list/index an add job touches
|
||||
# and re-extracts text (refresh_index), so it is blocking work.
|
||||
if hasattr(personal_docs_manager, 'remove_directory'):
|
||||
personal_docs_manager.remove_directory(directory)
|
||||
# Remove from RAG vector store (best-effort).
|
||||
if rag:
|
||||
try:
|
||||
rag.remove_directory(directory)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for directory {directory}: {e}")
|
||||
|
||||
# Same job lock as add/reload so remove cannot interleave with an
|
||||
# in-flight add; offloaded off the event loop.
|
||||
async with _index_job_lock:
|
||||
await run_in_threadpool(_remove_directory)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
total_failed = 0
|
||||
uploaded_files = []
|
||||
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
# Chunk and index
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
total_indexed += 1
|
||||
else:
|
||||
# Chunking, embedding and the tracking update are blocking work over the
|
||||
# same vector/tracking state add_directory mutates (#5634). Take the
|
||||
# shared job lock BEFORE offloading so a queued request parks on the loop
|
||||
# instead of pinning a threadpool worker, matching add_directory.
|
||||
# Read and process one capped payload at a time so a multi-file request
|
||||
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
|
||||
async with _index_job_lock:
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(
|
||||
upload_dir, upload.filename
|
||||
)
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
uploaded_files.append(safe_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
def _index_upload():
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content_bytes)
|
||||
|
||||
# Track uploads directory
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
personal_docs_manager.add_directory(upload_dir, index=False)
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext == ".pdf":
|
||||
from src.personal_docs import extract_pdf_text
|
||||
text = extract_pdf_text(file_path)
|
||||
else:
|
||||
text = content_bytes.decode("utf-8", errors="replace")
|
||||
|
||||
if not text or not text.strip():
|
||||
return 0, 1, None
|
||||
|
||||
indexed = 0
|
||||
failed = 0
|
||||
chunks = rag._split_into_chunks(text, chunk_size=500)
|
||||
for i, chunk in enumerate(chunks):
|
||||
metadata = {
|
||||
"source": file_path,
|
||||
"filename": safe_name,
|
||||
"stored_filename": stored_name,
|
||||
"directory": upload_dir,
|
||||
"type": ext,
|
||||
"chunk_id": i,
|
||||
}
|
||||
if user:
|
||||
metadata["owner"] = user
|
||||
if rag.add_document(chunk, metadata):
|
||||
indexed += 1
|
||||
else:
|
||||
failed += 1
|
||||
return indexed, failed, safe_name
|
||||
|
||||
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
|
||||
total_indexed += indexed
|
||||
total_failed += failed
|
||||
if uploaded_name:
|
||||
uploaded_files.append(uploaded_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload/index {upload.filename}: {e}")
|
||||
total_failed += 1
|
||||
|
||||
# Same transition, same lock: the tracking update must not land
|
||||
# while another job is mid-write over the same state.
|
||||
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
|
||||
await run_in_threadpool(
|
||||
personal_docs_manager.add_directory, upload_dir, index=False
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
|
||||
"""Delete a specific file from RAG index and optionally from disk."""
|
||||
try:
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
def _delete_file():
|
||||
# Remove chunks from RAG vector store (best-effort)
|
||||
removed = 0
|
||||
rag = _rag()
|
||||
if rag:
|
||||
try:
|
||||
removed = rag.delete_by_source(filepath)
|
||||
except Exception as e:
|
||||
logger.warning(f"RAG removal failed for {filepath}: {e}")
|
||||
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
# Delete file from disk if it's in the caller's own uploads dir.
|
||||
# Scope to the per-owner subdir, not the shared uploads root, so one
|
||||
# admin can't delete another user's personal files by path.
|
||||
deleted_from_disk = False
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
abs_target = os.path.realpath(filepath)
|
||||
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
|
||||
in_uploads = (
|
||||
abs_target == base_abs
|
||||
or os.path.commonpath([abs_target, base_abs]) == base_abs
|
||||
)
|
||||
except ValueError:
|
||||
# commonpath raises on mixed drives / non-comparable paths
|
||||
in_uploads = False
|
||||
if in_uploads and abs_target != base_abs:
|
||||
try:
|
||||
os.remove(abs_target)
|
||||
deleted_from_disk = True
|
||||
except FileNotFoundError:
|
||||
pass # already gone — race with another request or cleanup
|
||||
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
# Exclude the file from the listing (persists across restarts)
|
||||
personal_docs_manager.exclude_file(filepath)
|
||||
return removed, deleted_from_disk
|
||||
|
||||
# Vector removal, the disk unlink and the exclusion write are one
|
||||
# transition over the same state add_directory mutates (#5634), and
|
||||
# all three block. Take the shared job lock BEFORE offloading, as
|
||||
# add_directory does.
|
||||
async with _index_job_lock:
|
||||
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""User preferences API — per-user key/value store backed by a JSON file."""
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Request
|
||||
from core.atomic_io import atomic_write_json
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.constants import USER_PREFS_FILE
|
||||
|
||||
@@ -20,13 +20,7 @@ def _load():
|
||||
|
||||
|
||||
def _save(prefs):
|
||||
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
|
||||
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(prefs, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, PREFS_FILE)
|
||||
atomic_write_json(PREFS_FILE, prefs, indent=2)
|
||||
|
||||
|
||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Search route domain package (slice 2j, #4082/#4071).
|
||||
|
||||
Contains search_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/search_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Search routes — /api/search/config GET, /api/search POST."""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
import time
|
||||
|
||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||
from services.search.core import _call_provider
|
||||
from services.search.providers import _get_provider_key, _get_search_instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _request_values(request: Request) -> Dict[str, Any]:
|
||||
"""Accept JSON, form data, or query params for search endpoints.
|
||||
|
||||
The browser UI posts FormData, while the agent's generic app_api tool
|
||||
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
||||
runs, which made the model think SearXNG was broken.
|
||||
"""
|
||||
values: Dict[str, Any] = dict(request.query_params)
|
||||
content_type = (request.headers.get("content-type") or "").lower()
|
||||
try:
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
values.update(body)
|
||||
else:
|
||||
form = await request.form()
|
||||
values.update(dict(form))
|
||||
except Exception:
|
||||
pass
|
||||
return values
|
||||
|
||||
|
||||
def setup_search_routes(config) -> APIRouter:
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@router.get("/api/search/config")
|
||||
async def get_search_settings() -> Dict[str, Any]:
|
||||
return get_search_config()
|
||||
|
||||
@router.post("/api/search")
|
||||
async def do_web_search(request: Request) -> Dict[str, Any]:
|
||||
"""Standalone web search — returns context string + source list.
|
||||
|
||||
Used by Compare mode to pre-search once and share results across panes.
|
||||
"""
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
if not query:
|
||||
return {"context": "", "sources": [], "error": "query is required"}
|
||||
time_filter = values.get("time_filter") or values.get("freshness")
|
||||
if time_filter is not None:
|
||||
time_filter = str(time_filter).strip() or None
|
||||
try:
|
||||
context, sources = comprehensive_web_search(
|
||||
query, return_sources=True, time_filter=time_filter,
|
||||
)
|
||||
return {"context": context, "sources": sources}
|
||||
except Exception as e:
|
||||
logger.error(f"Standalone web search failed: {e}")
|
||||
return {"context": "", "sources": [], "error": str(e)}
|
||||
|
||||
@router.get("/api/search/providers")
|
||||
async def list_search_providers():
|
||||
"""Return available search providers with config status."""
|
||||
providers = []
|
||||
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
||||
if pid == "disabled":
|
||||
continue
|
||||
available = True
|
||||
if needs_key and not _get_provider_key(pid):
|
||||
available = False
|
||||
if needs_url and pid == "searxng" and not _get_search_instance():
|
||||
available = False
|
||||
providers.append({
|
||||
"id": pid,
|
||||
"label": label,
|
||||
"available": available,
|
||||
})
|
||||
return providers
|
||||
|
||||
@router.post("/api/search/query")
|
||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||
"""Search using a specific provider. Used by compare search mode."""
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
provider = str(values.get("provider") or "").strip()
|
||||
try:
|
||||
count = int(values.get("count") or values.get("limit") or 10)
|
||||
except Exception:
|
||||
count = 10
|
||||
if not query:
|
||||
return {"results": [], "provider": provider, "error": "query is required"}
|
||||
if provider not in PROVIDER_INFO or provider == "disabled":
|
||||
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
||||
t0 = time.time()
|
||||
try:
|
||||
results = _call_provider(provider, query, min(count, 20))
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
return {"results": results, "provider": provider, "time": elapsed}
|
||||
except Exception as e:
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
logger.error(f"Search provider {provider} failed: {e}")
|
||||
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
||||
|
||||
return router
|
||||
+9
-107
@@ -1,111 +1,13 @@
|
||||
"""Search routes — /api/search/config GET, /api/search POST."""
|
||||
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.search_routes`` and ``from routes.search_routes import X``
|
||||
keep resolving to the canonical module. Keeps existing import paths working
|
||||
after slice 2j (#4082/#4071).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import sys as _sys
|
||||
|
||||
import time
|
||||
from routes.search import search_routes as _canonical # noqa: F401
|
||||
|
||||
from services.search import get_search_config, comprehensive_web_search, PROVIDER_INFO
|
||||
from services.search.core import _call_provider
|
||||
from services.search.providers import _get_provider_key, _get_search_instance
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _request_values(request: Request) -> Dict[str, Any]:
|
||||
"""Accept JSON, form data, or query params for search endpoints.
|
||||
|
||||
The browser UI posts FormData, while the agent's generic app_api tool
|
||||
posts JSON. FastAPI Form(...) rejects JSON with a 422 before our handler
|
||||
runs, which made the model think SearXNG was broken.
|
||||
"""
|
||||
values: Dict[str, Any] = dict(request.query_params)
|
||||
content_type = (request.headers.get("content-type") or "").lower()
|
||||
try:
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
values.update(body)
|
||||
else:
|
||||
form = await request.form()
|
||||
values.update(dict(form))
|
||||
except Exception:
|
||||
pass
|
||||
return values
|
||||
|
||||
|
||||
def setup_search_routes(config) -> APIRouter:
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@router.get("/api/search/config")
|
||||
async def get_search_settings() -> Dict[str, Any]:
|
||||
return get_search_config()
|
||||
|
||||
@router.post("/api/search")
|
||||
async def do_web_search(request: Request) -> Dict[str, Any]:
|
||||
"""Standalone web search — returns context string + source list.
|
||||
|
||||
Used by Compare mode to pre-search once and share results across panes.
|
||||
"""
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
if not query:
|
||||
return {"context": "", "sources": [], "error": "query is required"}
|
||||
time_filter = values.get("time_filter") or values.get("freshness")
|
||||
if time_filter is not None:
|
||||
time_filter = str(time_filter).strip() or None
|
||||
try:
|
||||
context, sources = comprehensive_web_search(
|
||||
query, return_sources=True, time_filter=time_filter,
|
||||
)
|
||||
return {"context": context, "sources": sources}
|
||||
except Exception as e:
|
||||
logger.error(f"Standalone web search failed: {e}")
|
||||
return {"context": "", "sources": [], "error": str(e)}
|
||||
|
||||
@router.get("/api/search/providers")
|
||||
async def list_search_providers():
|
||||
"""Return available search providers with config status."""
|
||||
providers = []
|
||||
for pid, (label, needs_key, needs_url) in PROVIDER_INFO.items():
|
||||
if pid == "disabled":
|
||||
continue
|
||||
available = True
|
||||
if needs_key and not _get_provider_key(pid):
|
||||
available = False
|
||||
if needs_url and pid == "searxng" and not _get_search_instance():
|
||||
available = False
|
||||
providers.append({
|
||||
"id": pid,
|
||||
"label": label,
|
||||
"available": available,
|
||||
})
|
||||
return providers
|
||||
|
||||
@router.post("/api/search/query")
|
||||
async def search_with_provider(request: Request) -> Dict[str, Any]:
|
||||
"""Search using a specific provider. Used by compare search mode."""
|
||||
values = await _request_values(request)
|
||||
query = str(values.get("query") or values.get("q") or "").strip()
|
||||
provider = str(values.get("provider") or "").strip()
|
||||
try:
|
||||
count = int(values.get("count") or values.get("limit") or 10)
|
||||
except Exception:
|
||||
count = 10
|
||||
if not query:
|
||||
return {"results": [], "provider": provider, "error": "query is required"}
|
||||
if provider not in PROVIDER_INFO or provider == "disabled":
|
||||
return {"results": [], "provider": provider, "error": "Unknown provider"}
|
||||
t0 = time.time()
|
||||
try:
|
||||
results = _call_provider(provider, query, min(count, 20))
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
return {"results": results, "provider": provider, "time": elapsed}
|
||||
except Exception as e:
|
||||
elapsed = round(time.time() - t0, 2)
|
||||
logger.error(f"Search provider {provider} failed: {e}")
|
||||
return {"results": [], "provider": provider, "time": elapsed, "error": str(e)}
|
||||
|
||||
return router
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
@@ -801,15 +801,6 @@ def setup_session_routes(
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/history/{sid}")
|
||||
def get_history(request: Request, sid: str):
|
||||
_verify_session_owner(request, sid)
|
||||
try:
|
||||
session = session_manager.get_session(sid)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session {sid} not found")
|
||||
return {"history": [msg.to_dict() for msg in session.history]}
|
||||
|
||||
@router.get("/session/{sid}/export")
|
||||
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
||||
"""Export conversation history as a downloadable file.
|
||||
|
||||
@@ -1409,7 +1409,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
|
||||
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
||||
# session's model. Fall back to the caller's session model only if unset.
|
||||
url, model, headers = resolve_endpoint("default", owner=user)
|
||||
url, model, headers = resolve_endpoint("utility", owner=user)
|
||||
if not url or not model:
|
||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||
model = model or ((body.get("model") or "").strip() or None)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Vault route domain package (slice 2k, #4082/#4071).
|
||||
|
||||
Contains vault_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/vault_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
vault_routes.py
|
||||
|
||||
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
||||
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.middleware import require_admin
|
||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VAULT_FILE = Path(_VAULT_FILE)
|
||||
|
||||
|
||||
def _find_bw() -> str:
|
||||
"""Locate the bw binary, checking PATH and common npm-global locations.
|
||||
|
||||
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
||||
which_tool via PATHEXT.
|
||||
"""
|
||||
p = which_tool("bw")
|
||||
if p:
|
||||
return p
|
||||
if IS_WINDOWS:
|
||||
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||
for candidate in (
|
||||
os.path.join(appdata, "npm", "bw.cmd"),
|
||||
os.path.join(appdata, "npm", "bw.exe"),
|
||||
):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return "bw"
|
||||
home = os.path.expanduser("~")
|
||||
for candidate in (
|
||||
f"{home}/.npm-global/bin/bw",
|
||||
f"{home}/.nvm/versions/node/*/bin/bw",
|
||||
"/usr/local/bin/bw",
|
||||
"/opt/homebrew/bin/bw",
|
||||
):
|
||||
if "*" in candidate:
|
||||
import glob
|
||||
for m in glob.glob(candidate):
|
||||
if os.path.isfile(m) and os.access(m, os.X_OK):
|
||||
return m
|
||||
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
if VAULT_FILE.exists():
|
||||
try:
|
||||
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_config(cfg: dict):
|
||||
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
||||
# is ACL-restricted already).
|
||||
safe_chmod(str(VAULT_FILE), 0o600)
|
||||
|
||||
|
||||
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
||||
bw_password: str = None) -> tuple:
|
||||
env = {}
|
||||
env.update(os.environ)
|
||||
if session:
|
||||
env["BW_SESSION"] = session
|
||||
# Secrets must never be passed as argv — process arguments are world-readable
|
||||
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
||||
# support for bw commands that need it; unlock/login callers should prefer
|
||||
# stdin so the master password is not left in the child environment either.
|
||||
if bw_password is not None:
|
||||
env["BW_PASSWORD"] = bw_password
|
||||
bw_path = _find_bw()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
bw_path, *args,
|
||||
stdin=asyncio.subprocess.PIPE if input_text else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
||||
except Exception as e:
|
||||
return "", f"Failed to launch bw: {e}", 1
|
||||
try:
|
||||
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
||||
except Exception as e:
|
||||
return "", f"bw subprocess error: {e}", 1
|
||||
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
||||
|
||||
|
||||
class VaultConfig(BaseModel):
|
||||
server_url: str = ""
|
||||
email: str = ""
|
||||
|
||||
|
||||
class VaultUnlockRequest(BaseModel):
|
||||
master_password: str
|
||||
|
||||
|
||||
class VaultLoginRequest(BaseModel):
|
||||
email: str
|
||||
master_password: str
|
||||
|
||||
|
||||
def setup_vault_routes():
|
||||
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(request: Request):
|
||||
"""Return vault config (no sensitive fields)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
return {
|
||||
"server_url": cfg.get("server_url", ""),
|
||||
"email": cfg.get("email", ""),
|
||||
"unlocked": bool(cfg.get("session")),
|
||||
"unlocked_at": cfg.get("unlocked_at", ""),
|
||||
"bw_installed": await _check_bw_installed(),
|
||||
}
|
||||
|
||||
@router.post("/config")
|
||||
async def save_config(req: VaultConfig, request: Request):
|
||||
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
||||
cfg["email"] = req.email.strip()
|
||||
|
||||
if cfg["server_url"]:
|
||||
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
||||
if rc != 0:
|
||||
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
||||
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: VaultLoginRequest, request: Request):
|
||||
"""Log in to Vaultwarden (required once per account)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
# Update email
|
||||
cfg["email"] = req.email
|
||||
_save_config(cfg)
|
||||
|
||||
stdout, stderr, rc = await _run_bw(
|
||||
["login", req.email, "--raw"],
|
||||
input_text=req.master_password + "\n",
|
||||
)
|
||||
if rc != 0:
|
||||
# Already logged in is OK
|
||||
if "already logged in" in stderr.lower():
|
||||
return {"ok": True, "already": True}
|
||||
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
||||
# bw login --raw prints session key on success (when 2FA disabled)
|
||||
if stdout:
|
||||
cfg["session"] = stdout
|
||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/unlock")
|
||||
async def unlock(req: VaultUnlockRequest, request: Request):
|
||||
"""Unlock the vault and save the session key."""
|
||||
require_admin(request)
|
||||
# Pass the master password on stdin, not argv. argv is visible through
|
||||
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
||||
# the child process environment.
|
||||
stdout, stderr, rc = await _run_bw(
|
||||
["unlock", "--raw"],
|
||||
input_text=req.master_password + "\n",
|
||||
)
|
||||
if rc != 0:
|
||||
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
||||
session = stdout.strip()
|
||||
if not session:
|
||||
return {"ok": False, "error": "bw returned empty session"}
|
||||
cfg = _load_config()
|
||||
cfg["session"] = session
|
||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||
_save_config(cfg)
|
||||
return {"ok": True, "message": "Vault unlocked"}
|
||||
|
||||
@router.post("/lock")
|
||||
async def lock(request: Request):
|
||||
"""Lock the vault (clear session from config)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
cfg.pop("session", None)
|
||||
cfg.pop("unlocked_at", None)
|
||||
_save_config(cfg)
|
||||
# Also tell bw to lock
|
||||
await _run_bw(["lock"])
|
||||
return {"ok": True, "message": "Vault locked"}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Log out of the Bitwarden CLI completely."""
|
||||
require_admin(request)
|
||||
await _run_bw(["logout"])
|
||||
cfg = _load_config()
|
||||
cfg.pop("session", None)
|
||||
cfg.pop("email", None)
|
||||
cfg.pop("unlocked_at", None)
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
async def _check_bw_installed() -> bool:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_bw(), "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
return proc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
+9
-237
@@ -1,242 +1,14 @@
|
||||
"""
|
||||
vault_routes.py
|
||||
"""Backward-compat shim — canonical location is routes/vault/vault_routes.py.
|
||||
|
||||
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
||||
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.vault_routes``, ``from routes.vault_routes import X``,
|
||||
and the ``import ... as vr`` + ``monkeypatch.setattr(vr, ...)`` pattern used
|
||||
by test_vault_password_not_in_argv.py all operate on the *same* object.
|
||||
Keeps existing import paths working after slice 2k (#4082/#4071).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
import sys as _sys
|
||||
|
||||
from core.middleware import require_admin
|
||||
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
||||
from src.constants import VAULT_FILE as _VAULT_FILE
|
||||
from routes.vault import vault_routes as _canonical # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VAULT_FILE = Path(_VAULT_FILE)
|
||||
|
||||
|
||||
def _find_bw() -> str:
|
||||
"""Locate the bw binary, checking PATH and common npm-global locations.
|
||||
|
||||
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
||||
which_tool via PATHEXT.
|
||||
"""
|
||||
p = which_tool("bw")
|
||||
if p:
|
||||
return p
|
||||
if IS_WINDOWS:
|
||||
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
||||
for candidate in (
|
||||
os.path.join(appdata, "npm", "bw.cmd"),
|
||||
os.path.join(appdata, "npm", "bw.exe"),
|
||||
):
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
return "bw"
|
||||
home = os.path.expanduser("~")
|
||||
for candidate in (
|
||||
f"{home}/.npm-global/bin/bw",
|
||||
f"{home}/.nvm/versions/node/*/bin/bw",
|
||||
"/usr/local/bin/bw",
|
||||
"/opt/homebrew/bin/bw",
|
||||
):
|
||||
if "*" in candidate:
|
||||
import glob
|
||||
for m in glob.glob(candidate):
|
||||
if os.path.isfile(m) and os.access(m, os.X_OK):
|
||||
return m
|
||||
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
if VAULT_FILE.exists():
|
||||
try:
|
||||
data = json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_config(cfg: dict):
|
||||
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
||||
# is ACL-restricted already).
|
||||
safe_chmod(str(VAULT_FILE), 0o600)
|
||||
|
||||
|
||||
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
||||
bw_password: str = None) -> tuple:
|
||||
env = {}
|
||||
env.update(os.environ)
|
||||
if session:
|
||||
env["BW_SESSION"] = session
|
||||
# Secrets must never be passed as argv — process arguments are world-readable
|
||||
# via `ps` / `/proc/<pid>/cmdline` to any local user. Keep --passwordenv
|
||||
# support for bw commands that need it; unlock/login callers should prefer
|
||||
# stdin so the master password is not left in the child environment either.
|
||||
if bw_password is not None:
|
||||
env["BW_PASSWORD"] = bw_password
|
||||
bw_path = _find_bw()
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
bw_path, *args,
|
||||
stdin=asyncio.subprocess.PIPE if input_text else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
||||
except Exception as e:
|
||||
return "", f"Failed to launch bw: {e}", 1
|
||||
try:
|
||||
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
||||
except Exception as e:
|
||||
return "", f"bw subprocess error: {e}", 1
|
||||
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
||||
|
||||
|
||||
class VaultConfig(BaseModel):
|
||||
server_url: str = ""
|
||||
email: str = ""
|
||||
|
||||
|
||||
class VaultUnlockRequest(BaseModel):
|
||||
master_password: str
|
||||
|
||||
|
||||
class VaultLoginRequest(BaseModel):
|
||||
email: str
|
||||
master_password: str
|
||||
|
||||
|
||||
def setup_vault_routes():
|
||||
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config(request: Request):
|
||||
"""Return vault config (no sensitive fields)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
return {
|
||||
"server_url": cfg.get("server_url", ""),
|
||||
"email": cfg.get("email", ""),
|
||||
"unlocked": bool(cfg.get("session")),
|
||||
"unlocked_at": cfg.get("unlocked_at", ""),
|
||||
"bw_installed": await _check_bw_installed(),
|
||||
}
|
||||
|
||||
@router.post("/config")
|
||||
async def save_config(req: VaultConfig, request: Request):
|
||||
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
||||
cfg["email"] = req.email.strip()
|
||||
|
||||
if cfg["server_url"]:
|
||||
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
||||
if rc != 0:
|
||||
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
||||
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: VaultLoginRequest, request: Request):
|
||||
"""Log in to Vaultwarden (required once per account)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
# Update email
|
||||
cfg["email"] = req.email
|
||||
_save_config(cfg)
|
||||
|
||||
stdout, stderr, rc = await _run_bw(
|
||||
["login", req.email, "--raw"],
|
||||
input_text=req.master_password + "\n",
|
||||
)
|
||||
if rc != 0:
|
||||
# Already logged in is OK
|
||||
if "already logged in" in stderr.lower():
|
||||
return {"ok": True, "already": True}
|
||||
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
||||
# bw login --raw prints session key on success (when 2FA disabled)
|
||||
if stdout:
|
||||
cfg["session"] = stdout
|
||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/unlock")
|
||||
async def unlock(req: VaultUnlockRequest, request: Request):
|
||||
"""Unlock the vault and save the session key."""
|
||||
require_admin(request)
|
||||
# Pass the master password on stdin, not argv. argv is visible through
|
||||
# `ps` / /proc/<pid>/cmdline; stdin also avoids leaving the secret in
|
||||
# the child process environment.
|
||||
stdout, stderr, rc = await _run_bw(
|
||||
["unlock", "--raw"],
|
||||
input_text=req.master_password + "\n",
|
||||
)
|
||||
if rc != 0:
|
||||
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
||||
session = stdout.strip()
|
||||
if not session:
|
||||
return {"ok": False, "error": "bw returned empty session"}
|
||||
cfg = _load_config()
|
||||
cfg["session"] = session
|
||||
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
||||
_save_config(cfg)
|
||||
return {"ok": True, "message": "Vault unlocked"}
|
||||
|
||||
@router.post("/lock")
|
||||
async def lock(request: Request):
|
||||
"""Lock the vault (clear session from config)."""
|
||||
require_admin(request)
|
||||
cfg = _load_config()
|
||||
cfg.pop("session", None)
|
||||
cfg.pop("unlocked_at", None)
|
||||
_save_config(cfg)
|
||||
# Also tell bw to lock
|
||||
await _run_bw(["lock"])
|
||||
return {"ok": True, "message": "Vault locked"}
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request):
|
||||
"""Log out of the Bitwarden CLI completely."""
|
||||
require_admin(request)
|
||||
await _run_bw(["logout"])
|
||||
cfg = _load_config()
|
||||
cfg.pop("session", None)
|
||||
cfg.pop("email", None)
|
||||
cfg.pop("unlocked_at", None)
|
||||
_save_config(cfg)
|
||||
return {"ok": True}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
async def _check_bw_installed() -> bool:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_bw(), "--version",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
return proc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Webhook route domain package (slice 2l, #4082/#4071).
|
||||
|
||||
Contains webhook_routes.py, migrated from the flat routes/ directory.
|
||||
Backward-compat shim at routes/webhook_routes.py re-exports from here.
|
||||
"""
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Webhook, API Token, and sync chat routes."""
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Form
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.url_security import validate_public_http_url
|
||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["webhooks"])
|
||||
|
||||
# Input limits
|
||||
MAX_NAME_LEN = 100
|
||||
MAX_URL_LEN = 2048
|
||||
MAX_SECRET_LEN = 256
|
||||
MAX_MESSAGE_LEN = 32_000
|
||||
|
||||
|
||||
from core.middleware import require_admin as _require_admin
|
||||
|
||||
|
||||
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||
let a chat-scoped token fall back onto another user's private endpoint and
|
||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||
"""
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if token_owner:
|
||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||
|
||||
|
||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||
"""Strict session-ownership gate for the token-authenticated sync-chat
|
||||
endpoint (`POST /api/v1/chat`).
|
||||
|
||||
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
||||
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
||||
its owner matches them exactly. A null/empty session owner (legacy or
|
||||
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
||||
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
||||
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
||||
device) could resume such a session, inject a message, and read back its
|
||||
history and reuse the owner's endpoint credentials. Fail closed: an
|
||||
unresolvable caller also returns False.
|
||||
"""
|
||||
if not caller:
|
||||
return False
|
||||
return sess_owner == caller
|
||||
|
||||
|
||||
def setup_webhook_routes(
|
||||
webhook_manager: WebhookManager,
|
||||
auth_manager,
|
||||
session_manager=None,
|
||||
api_key_manager=None,
|
||||
) -> APIRouter:
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
hooks = db.query(Webhook).all()
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"name": w.name,
|
||||
"url": w.url,
|
||||
"has_secret": bool(w.secret),
|
||||
"events": w.events.split(",") if w.events else [],
|
||||
"is_active": w.is_active,
|
||||
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
||||
"last_status_code": w.last_status_code,
|
||||
"last_error": w.last_error,
|
||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||
}
|
||||
for w in hooks
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/webhooks")
|
||||
def create_webhook(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
url: str = Form(""),
|
||||
secret: str = Form(""),
|
||||
events: str = Form(""),
|
||||
):
|
||||
_require_admin(request)
|
||||
name = name.strip()[:MAX_NAME_LEN]
|
||||
if not name:
|
||||
raise HTTPException(400, "Webhook name is required")
|
||||
try:
|
||||
url = validate_webhook_url(url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
try:
|
||||
events = validate_events(events)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
||||
# Encrypt the secret at rest using the same Fernet key as API keys
|
||||
encrypted_secret = None
|
||||
if secret_val and api_key_manager:
|
||||
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
||||
elif secret_val:
|
||||
encrypted_secret = secret_val # Fallback if no encryption available
|
||||
|
||||
webhook_id = str(uuid.uuid4())[:8]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(Webhook(
|
||||
id=webhook_id,
|
||||
name=name,
|
||||
url=url,
|
||||
secret=encrypted_secret,
|
||||
events=events,
|
||||
is_active=True,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"id": webhook_id, "name": name}
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not wh:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
url, secret = wh.url, wh.secret
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
await webhook_manager.deliver_test(webhook_id, url, secret)
|
||||
return {"status": "sent"}
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
def toggle_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not wh:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
wh.is_active = not wh.is_active
|
||||
db.commit()
|
||||
return {"id": webhook_id, "is_active": wh.is_active}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
||||
db.commit()
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
finally:
|
||||
db.close()
|
||||
return {"status": "deleted"}
|
||||
|
||||
# ================================================================
|
||||
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
||||
# ================================================================
|
||||
|
||||
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
||||
KNOWN_PROVIDERS = {
|
||||
"deepseek": "https://api.deepseek.com/v1",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"mistral": "https://api.mistral.ai/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"together": "https://api.together.xyz/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"ollama": "https://ollama.com/api",
|
||||
"opencode-zen": "https://opencode.ai/zen/v1",
|
||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
"kimicode": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
# Model prefix → provider mapping for auto-detection
|
||||
MODEL_PROVIDER_MAP = {
|
||||
"deepseek": "deepseek",
|
||||
"gpt-": "openai",
|
||||
"o1": "openai",
|
||||
"o3": "openai",
|
||||
"o4": "openai",
|
||||
"mistral": "mistral",
|
||||
"llama": "groq",
|
||||
"mixtral": "groq",
|
||||
"kimi-for-coding": "kimi-code",
|
||||
"kimi": "kimi-code",
|
||||
}
|
||||
|
||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
||||
if provider and provider.lower() in KNOWN_PROVIDERS:
|
||||
return KNOWN_PROVIDERS[provider.lower()]
|
||||
if model:
|
||||
model_lower = model.lower()
|
||||
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
||||
if model_lower.startswith(prefix):
|
||||
return KNOWN_PROVIDERS[prov]
|
||||
return None
|
||||
|
||||
class SyncChatRequest(BaseModel):
|
||||
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
||||
model: Optional[str] = Field(None, max_length=200)
|
||||
session: Optional[str] = Field(None, max_length=100)
|
||||
api_key: Optional[str] = Field(None, max_length=256)
|
||||
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
||||
provider: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
@router.post("/v1/chat")
|
||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||
if not getattr(request.state, "api_token", False):
|
||||
raise HTTPException(403, "This endpoint requires an API token")
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if "chat" not in scopes:
|
||||
raise HTTPException(403, "API token is not scoped for chat")
|
||||
token_owner = getattr(request.state, "api_token_owner", None)
|
||||
|
||||
from core.models import ChatMessage
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||
|
||||
message = body.message.strip()
|
||||
if not message:
|
||||
raise HTTPException(400, "Message is required")
|
||||
|
||||
session_id = body.session
|
||||
sess = None
|
||||
|
||||
# --- Case 1: Resume an existing session ---
|
||||
if session_id and session_manager:
|
||||
try:
|
||||
sess = session_manager.get_session(session_id)
|
||||
except (KeyError, Exception):
|
||||
raise HTTPException(404, "Session not found")
|
||||
# SECURITY: verify the API-token's user owns this session — without
|
||||
# this any token holder could resume any user's chat by passing its
|
||||
# ID. The token's user is on request.state.user (set by API-token
|
||||
# middleware); fall back to require_user if not present.
|
||||
try:
|
||||
from src.auth_helpers import get_current_user as _gcu
|
||||
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
||||
except Exception:
|
||||
_tok_user = None
|
||||
# Strict ownership (see _caller_owns_session): fail closed so a
|
||||
# null-owner / cross-owner session can't be resumed by an arbitrary
|
||||
# chat-scoped token.
|
||||
_sess_owner = getattr(sess, "owner", None)
|
||||
if not _caller_owns_session(_sess_owner, _tok_user):
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
||||
if not sess and body.api_key:
|
||||
api_key = body.api_key.strip()
|
||||
model = body.model or "deepseek-chat"
|
||||
|
||||
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
||||
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
||||
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
||||
if direct_base_url:
|
||||
try:
|
||||
base_url = validate_public_http_url(direct_base_url)
|
||||
except ValueError as e:
|
||||
detail = str(e).replace("URL", "base_url", 1)
|
||||
raise HTTPException(400, detail)
|
||||
else:
|
||||
base_url = _resolve_base_url(model, body.provider)
|
||||
if not base_url:
|
||||
raise HTTPException(400,
|
||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||
base_url = normalize_base(base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
sess = session_manager.create_session(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
|
||||
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
||||
if not sess:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not ep:
|
||||
raise HTTPException(400,
|
||||
"No session, api_key, or configured endpoints. "
|
||||
"Pass api_key + model, or configure an endpoint in Admin.")
|
||||
|
||||
base_url = normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
model = body.model or "auto"
|
||||
api_key = ep.api_key
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||
|
||||
if model == "auto":
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
models_url = build_models_url(base_url)
|
||||
hdrs = build_headers(api_key, base_url)
|
||||
if models_url:
|
||||
resp = await client.get(models_url, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
if not ids and isinstance(data, dict):
|
||||
ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
import json as _json
|
||||
ids = _json.loads(ep.cached_models or "[]")
|
||||
model = ids[0] if ids else "auto"
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not discover models from endpoint")
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
sess = session_manager.create_session(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
if api_key:
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
|
||||
# --- Send message and get response ---
|
||||
sess.add_message(ChatMessage("user", message))
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
||||
|
||||
reply = await llm_call_async(
|
||||
sess.endpoint_url, sess.model, messages,
|
||||
headers=sess.headers, timeout=120,
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", reply))
|
||||
session_manager.save_sessions()
|
||||
|
||||
webhook_manager.fire_and_forget("chat.completed", {
|
||||
"session_id": session_id, "model": sess.model,
|
||||
"user_message": message[:2000], "response": reply[:2000],
|
||||
})
|
||||
|
||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||
|
||||
return router
|
||||
+12
-391
@@ -1,395 +1,16 @@
|
||||
"""Webhook, API Token, and sync chat routes."""
|
||||
"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
|
||||
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Optional
|
||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||
that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
|
||||
``importlib.import_module("routes.webhook_routes")``, and the
|
||||
``__import__("routes.webhook_routes", fromlist=[...])`` + ``setattr(wh_mod,
|
||||
...)`` pattern used by test_null_owner_gates.py all operate on the *same*
|
||||
object. Keeps existing import paths working after slice 2l (#4082/#4071).
|
||||
Source-introspection tests read the canonical file by path.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Form
|
||||
from pydantic import BaseModel, Field
|
||||
import sys as _sys
|
||||
|
||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.url_security import validate_public_http_url
|
||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||
from routes.webhook import webhook_routes as _canonical # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["webhooks"])
|
||||
|
||||
# Input limits
|
||||
MAX_NAME_LEN = 100
|
||||
MAX_URL_LEN = 2048
|
||||
MAX_SECRET_LEN = 256
|
||||
MAX_MESSAGE_LEN = 32_000
|
||||
|
||||
|
||||
from core.middleware import require_admin as _require_admin
|
||||
|
||||
|
||||
def _select_api_chat_fallback_endpoint(db, token_owner: Optional[str]):
|
||||
"""First enabled ModelEndpoint visible to token_owner — their own rows plus
|
||||
legacy null-owner ("shared") rows. Owner-scoped: an unscoped .first() would
|
||||
let a chat-scoped token fall back onto another user's private endpoint and
|
||||
silently spend that owner's API key/quota. Prefer owner rows before shared
|
||||
rows. Fails closed to null-owner rows only when token_owner is absent.
|
||||
Does not validate base_url — admin-configured local/LAN endpoints remain allowed.
|
||||
"""
|
||||
query = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True) # noqa: E712
|
||||
if token_owner:
|
||||
query = owner_filter(query, ModelEndpoint, token_owner)
|
||||
return query.order_by(ModelEndpoint.owner.desc(), ModelEndpoint.created_at).first()
|
||||
return query.filter(ModelEndpoint.owner == None).order_by(ModelEndpoint.created_at).first() # noqa: E711
|
||||
|
||||
|
||||
def _caller_owns_session(sess_owner, caller) -> bool:
|
||||
"""Strict session-ownership gate for the token-authenticated sync-chat
|
||||
endpoint (`POST /api/v1/chat`).
|
||||
|
||||
Mirrors ``_verify_session_owner`` in session_routes.py and the null-owner
|
||||
gates in notes/calendar/gallery: a caller may resume a session ONLY when
|
||||
its owner matches them exactly. A null/empty session owner (legacy or
|
||||
migrated rows) is deliberately NOT resumable by an arbitrary token — the
|
||||
old ``sess_owner and sess_owner != caller`` form skipped the check whenever
|
||||
``sess_owner`` was falsy, so any chat-scoped token (e.g. a paired mobile
|
||||
device) could resume such a session, inject a message, and read back its
|
||||
history and reuse the owner's endpoint credentials. Fail closed: an
|
||||
unresolvable caller also returns False.
|
||||
"""
|
||||
if not caller:
|
||||
return False
|
||||
return sess_owner == caller
|
||||
|
||||
|
||||
def setup_webhook_routes(
|
||||
webhook_manager: WebhookManager,
|
||||
auth_manager,
|
||||
session_manager=None,
|
||||
api_key_manager=None,
|
||||
) -> APIRouter:
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
hooks = db.query(Webhook).all()
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"name": w.name,
|
||||
"url": w.url,
|
||||
"has_secret": bool(w.secret),
|
||||
"events": w.events.split(",") if w.events else [],
|
||||
"is_active": w.is_active,
|
||||
"last_triggered_at": w.last_triggered_at.isoformat() if w.last_triggered_at else None,
|
||||
"last_status_code": w.last_status_code,
|
||||
"last_error": w.last_error,
|
||||
"created_at": w.created_at.isoformat() if w.created_at else None,
|
||||
}
|
||||
for w in hooks
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/webhooks")
|
||||
def create_webhook(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
url: str = Form(""),
|
||||
secret: str = Form(""),
|
||||
events: str = Form(""),
|
||||
):
|
||||
_require_admin(request)
|
||||
name = name.strip()[:MAX_NAME_LEN]
|
||||
if not name:
|
||||
raise HTTPException(400, "Webhook name is required")
|
||||
try:
|
||||
url = validate_webhook_url(url)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
try:
|
||||
events = validate_events(events)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
secret_val = secret.strip()[:MAX_SECRET_LEN] or None
|
||||
# Encrypt the secret at rest using the same Fernet key as API keys
|
||||
encrypted_secret = None
|
||||
if secret_val and api_key_manager:
|
||||
encrypted_secret = api_key_manager.encrypt_api_key(secret_val)
|
||||
elif secret_val:
|
||||
encrypted_secret = secret_val # Fallback if no encryption available
|
||||
|
||||
webhook_id = str(uuid.uuid4())[:8]
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.add(Webhook(
|
||||
id=webhook_id,
|
||||
name=name,
|
||||
url=url,
|
||||
secret=encrypted_secret,
|
||||
events=events,
|
||||
is_active=True,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"id": webhook_id, "name": name}
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not wh:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
url, secret = wh.url, wh.secret
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
await webhook_manager.deliver_test(webhook_id, url, secret)
|
||||
return {"status": "sent"}
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
def toggle_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
wh = db.query(Webhook).filter(Webhook.id == webhook_id).first()
|
||||
if not wh:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
wh.is_active = not wh.is_active
|
||||
db.commit()
|
||||
return {"id": webhook_id, "is_active": wh.is_active}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook(request: Request, webhook_id: str):
|
||||
_require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = db.query(Webhook).filter(Webhook.id == webhook_id).delete()
|
||||
db.commit()
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
finally:
|
||||
db.close()
|
||||
return {"status": "deleted"}
|
||||
|
||||
# ================================================================
|
||||
# Sync Chat Endpoint (for n8n / Make / Activepieces)
|
||||
# ================================================================
|
||||
|
||||
# Known provider base URLs — auto-resolved from api_key prefix or model name
|
||||
KNOWN_PROVIDERS = {
|
||||
"deepseek": "https://api.deepseek.com/v1",
|
||||
"openai": "https://api.openai.com/v1",
|
||||
"mistral": "https://api.mistral.ai/v1",
|
||||
"groq": "https://api.groq.com/openai/v1",
|
||||
"together": "https://api.together.xyz/v1",
|
||||
"openrouter": "https://openrouter.ai/api/v1",
|
||||
"ollama": "https://ollama.com/api",
|
||||
"opencode-zen": "https://opencode.ai/zen/v1",
|
||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||
"venice": "https://api.venice.ai/api/v1",
|
||||
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||
"kimicode": "https://api.kimi.com/coding/v1",
|
||||
}
|
||||
|
||||
# Model prefix → provider mapping for auto-detection
|
||||
MODEL_PROVIDER_MAP = {
|
||||
"deepseek": "deepseek",
|
||||
"gpt-": "openai",
|
||||
"o1": "openai",
|
||||
"o3": "openai",
|
||||
"o4": "openai",
|
||||
"mistral": "mistral",
|
||||
"llama": "groq",
|
||||
"mixtral": "groq",
|
||||
"kimi-for-coding": "kimi-code",
|
||||
"kimi": "kimi-code",
|
||||
}
|
||||
|
||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||
"""Try to auto-resolve a base URL from provider name or model prefix."""
|
||||
if provider and provider.lower() in KNOWN_PROVIDERS:
|
||||
return KNOWN_PROVIDERS[provider.lower()]
|
||||
if model:
|
||||
model_lower = model.lower()
|
||||
for prefix, prov in MODEL_PROVIDER_MAP.items():
|
||||
if model_lower.startswith(prefix):
|
||||
return KNOWN_PROVIDERS[prov]
|
||||
return None
|
||||
|
||||
class SyncChatRequest(BaseModel):
|
||||
message: str = Field(..., max_length=MAX_MESSAGE_LEN)
|
||||
model: Optional[str] = Field(None, max_length=200)
|
||||
session: Optional[str] = Field(None, max_length=100)
|
||||
api_key: Optional[str] = Field(None, max_length=256)
|
||||
base_url: Optional[str] = Field(None, max_length=MAX_URL_LEN)
|
||||
provider: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
@router.post("/v1/chat")
|
||||
async def sync_chat(request: Request, body: SyncChatRequest):
|
||||
if not getattr(request.state, "api_token", False):
|
||||
raise HTTPException(403, "This endpoint requires an API token")
|
||||
scopes = set(getattr(request.state, "api_token_scopes", []) or [])
|
||||
if "chat" not in scopes:
|
||||
raise HTTPException(403, "API token is not scoped for chat")
|
||||
token_owner = getattr(request.state, "api_token_owner", None)
|
||||
|
||||
from core.models import ChatMessage
|
||||
from src.llm_core import llm_call_async
|
||||
from src.endpoint_resolver import build_chat_url, build_headers, build_models_url, normalize_base
|
||||
|
||||
message = body.message.strip()
|
||||
if not message:
|
||||
raise HTTPException(400, "Message is required")
|
||||
|
||||
session_id = body.session
|
||||
sess = None
|
||||
|
||||
# --- Case 1: Resume an existing session ---
|
||||
if session_id and session_manager:
|
||||
try:
|
||||
sess = session_manager.get_session(session_id)
|
||||
except (KeyError, Exception):
|
||||
raise HTTPException(404, "Session not found")
|
||||
# SECURITY: verify the API-token's user owns this session — without
|
||||
# this any token holder could resume any user's chat by passing its
|
||||
# ID. The token's user is on request.state.user (set by API-token
|
||||
# middleware); fall back to require_user if not present.
|
||||
try:
|
||||
from src.auth_helpers import get_current_user as _gcu
|
||||
_tok_user = token_owner or getattr(request.state, "user", None) or _gcu(request)
|
||||
except Exception:
|
||||
_tok_user = None
|
||||
# Strict ownership (see _caller_owns_session): fail closed so a
|
||||
# null-owner / cross-owner session can't be resumed by an arbitrary
|
||||
# chat-scoped token.
|
||||
_sess_owner = getattr(sess, "owner", None)
|
||||
if not _caller_owns_session(_sess_owner, _tok_user):
|
||||
raise HTTPException(404, "Session not found")
|
||||
|
||||
# --- Case 2: Direct API key + model (no pre-configured endpoint needed) ---
|
||||
if not sess and body.api_key:
|
||||
api_key = body.api_key.strip()
|
||||
model = body.model or "deepseek-chat"
|
||||
|
||||
# Validate only token-supplied direct base_url; auto-resolved known-provider
|
||||
# URLs are not subject to extra local/LAN blocking beyond existing provider logic.
|
||||
direct_base_url = body.base_url.strip().rstrip("/") if body.base_url else None
|
||||
if direct_base_url:
|
||||
try:
|
||||
base_url = validate_public_http_url(direct_base_url)
|
||||
except ValueError as e:
|
||||
detail = str(e).replace("URL", "base_url", 1)
|
||||
raise HTTPException(400, detail)
|
||||
else:
|
||||
base_url = _resolve_base_url(model, body.provider)
|
||||
if not base_url:
|
||||
raise HTTPException(400,
|
||||
"Could not auto-detect provider. Pass base_url (e.g. 'https://api.deepseek.com/v1') "
|
||||
"or provider ('deepseek', 'openai', 'groq', etc.)")
|
||||
base_url = normalize_base(base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
sess = session_manager.create_session(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
|
||||
# --- Case 3: Fall back to first configured ModelEndpoint ---
|
||||
if not sess:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _select_api_chat_fallback_endpoint(db, token_owner)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not ep:
|
||||
raise HTTPException(400,
|
||||
"No session, api_key, or configured endpoints. "
|
||||
"Pass api_key + model, or configure an endpoint in Admin.")
|
||||
|
||||
base_url = normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
model = body.model or "auto"
|
||||
api_key = ep.api_key
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||
|
||||
if model == "auto":
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
models_url = build_models_url(base_url)
|
||||
hdrs = build_headers(api_key, base_url)
|
||||
if models_url:
|
||||
resp = await client.get(models_url, headers=hdrs)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
items = data if isinstance(data, list) else (data.get("data") or [])
|
||||
ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")]
|
||||
if not ids and isinstance(data, dict):
|
||||
ids = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
else:
|
||||
import json as _json
|
||||
ids = _json.loads(ep.cached_models or "[]")
|
||||
model = ids[0] if ids else "auto"
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not discover models from endpoint")
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
sid = str(uuid.uuid4())
|
||||
sess = session_manager.create_session(
|
||||
session_id=sid, name="API Chat", endpoint_url=endpoint_url,
|
||||
model=model, owner=token_owner,
|
||||
)
|
||||
if api_key:
|
||||
sess.headers = build_headers(api_key, base_url)
|
||||
session_manager.save_sessions()
|
||||
session_id = sid
|
||||
|
||||
# --- Send message and get response ---
|
||||
sess.add_message(ChatMessage("user", message))
|
||||
|
||||
messages = [{"role": m.role, "content": m.content} for m in sess.history]
|
||||
|
||||
reply = await llm_call_async(
|
||||
sess.endpoint_url, sess.model, messages,
|
||||
headers=sess.headers, timeout=120,
|
||||
)
|
||||
sess.add_message(ChatMessage("assistant", reply))
|
||||
session_manager.save_sessions()
|
||||
|
||||
webhook_manager.fire_and_forget("chat.completed", {
|
||||
"session_id": session_id, "model": sess.model,
|
||||
"user_message": message[:2000], "response": reply[:2000],
|
||||
})
|
||||
|
||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||
|
||||
return router
|
||||
_sys.modules[__name__] = _canonical
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create/remove the switchable, non-default 'Demo' EmailAccount in Odysseus.
|
||||
"""Create/remove the switchable 'Demo' EmailAccount in Odysseus.
|
||||
|
||||
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
|
||||
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
|
||||
@@ -20,7 +20,14 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core.database import SessionLocal, EmailAccount, Base, engine # noqa: E402
|
||||
from core.database import ( # noqa: E402
|
||||
Base,
|
||||
EmailAccount,
|
||||
SessionLocal,
|
||||
engine,
|
||||
lock_email_account_owner_mutations,
|
||||
)
|
||||
from sqlalchemy import or_ # noqa: E402
|
||||
from src.secret_storage import encrypt # noqa: E402
|
||||
|
||||
NAME = "Demo"
|
||||
@@ -31,18 +38,98 @@ IMAP_PASSWORD = "demodemo"
|
||||
OWNER = ""
|
||||
|
||||
|
||||
def setup() -> int:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
def _owner_scope(query, owner: str):
|
||||
if owner:
|
||||
return query.filter(EmailAccount.owner == owner)
|
||||
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
||||
|
||||
|
||||
def _discover_demo_scopes() -> set[str]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
acct = db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
||||
).first()
|
||||
return {
|
||||
row.owner or ""
|
||||
for row in db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME,
|
||||
EmailAccount.imap_user == IMAP_USER,
|
||||
).all()
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _lock_and_load_demo_rows(db, scopes: set[str]):
|
||||
"""Reload Demo rows under every observed owner lock."""
|
||||
scopes = set(scopes) or {OWNER}
|
||||
while True:
|
||||
lock_email_account_owner_mutations(db, *scopes)
|
||||
rows = (
|
||||
db.query(EmailAccount)
|
||||
.filter(
|
||||
EmailAccount.name == NAME,
|
||||
EmailAccount.imap_user == IMAP_USER,
|
||||
)
|
||||
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
||||
.all()
|
||||
)
|
||||
current_scopes = {row.owner or "" for row in rows}
|
||||
if current_scopes.issubset(scopes) or db.get_bind().dialect.name == "sqlite":
|
||||
return rows
|
||||
db.rollback()
|
||||
scopes.update(current_scopes)
|
||||
|
||||
|
||||
def _promote_oldest_enabled(db, owner: str, excluded_ids: list[str]) -> None:
|
||||
remaining = _owner_scope(
|
||||
db.query(EmailAccount).filter(
|
||||
EmailAccount.enabled == True, # noqa: E712
|
||||
~EmailAccount.id.in_(excluded_ids),
|
||||
),
|
||||
owner,
|
||||
)
|
||||
if remaining.filter(EmailAccount.is_default == True).first() is not None: # noqa: E712
|
||||
return
|
||||
promote = remaining.order_by(
|
||||
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
||||
).first()
|
||||
if promote is not None:
|
||||
promote.is_default = True
|
||||
|
||||
|
||||
def setup() -> int:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
scopes = _discover_demo_scopes() | {OWNER}
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = _lock_and_load_demo_rows(db, scopes)
|
||||
acct = rows[0] if rows else None
|
||||
if acct is None:
|
||||
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
|
||||
db.add(acct)
|
||||
old_scope = acct.owner or ""
|
||||
was_default = bool(acct.is_default)
|
||||
if old_scope != OWNER:
|
||||
# Move a non-default row first so the unique index cannot see two
|
||||
# defaults transiently while SQLAlchemy flushes the owner move and
|
||||
# old-scope promotion in separate UPDATE statements.
|
||||
acct.is_default = False
|
||||
acct.owner = OWNER
|
||||
db.flush()
|
||||
if was_default:
|
||||
_promote_oldest_enabled(db, old_scope, [acct.id])
|
||||
|
||||
target_default = _owner_scope(
|
||||
db.query(EmailAccount).filter(
|
||||
EmailAccount.id != acct.id,
|
||||
EmailAccount.is_default == True, # noqa: E712
|
||||
),
|
||||
OWNER,
|
||||
).first()
|
||||
acct.owner = OWNER
|
||||
acct.is_default = False # never default — user switches to it
|
||||
# Keep Demo non-default when a real default exists. If it is the only
|
||||
# enabled account, it must be default to preserve normal create
|
||||
# semantics and avoid leaving the owner partition without one.
|
||||
acct.is_default = target_default is None
|
||||
acct.enabled = True
|
||||
acct.imap_host = "localhost"
|
||||
acct.imap_port = 31143
|
||||
@@ -57,20 +144,27 @@ def setup() -> int:
|
||||
acct.smtp_password = encrypt(IMAP_PASSWORD)
|
||||
acct.from_address = IMAP_USER
|
||||
db.commit()
|
||||
print(f"'{NAME}' account ready (id={acct.id}, non-default, switchable).")
|
||||
state = "default" if acct.is_default else "non-default"
|
||||
print(f"'{NAME}' account ready (id={acct.id}, {state}, switchable).")
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def teardown() -> int:
|
||||
scopes = _discover_demo_scopes()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.query(EmailAccount).filter(
|
||||
EmailAccount.name == NAME, EmailAccount.imap_user == IMAP_USER
|
||||
).all()
|
||||
rows = _lock_and_load_demo_rows(db, scopes)
|
||||
deleted_ids = [row.id for row in rows]
|
||||
default_scopes = {row.owner or "" for row in rows if row.is_default}
|
||||
for r in rows:
|
||||
db.delete(r)
|
||||
# Ensure the old default DELETE reaches the database before a
|
||||
# replacement UPDATE; the unique index is enforced per statement.
|
||||
db.flush()
|
||||
for owner in default_scopes:
|
||||
_promote_oldest_enabled(db, owner, deleted_ids)
|
||||
db.commit()
|
||||
print(f"removed {len(rows)} '{NAME}' account row(s).")
|
||||
return 0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Memory service — persistent memory storage and retrieval."""
|
||||
|
||||
from .service import MemoryService, Memory, MemorySearchResult
|
||||
from .memory import MemoryManager
|
||||
from .memory import MemoryManager, MemoryStoreUnreadable
|
||||
from .memory_vector import MemoryVectorStore
|
||||
|
||||
__all__ = [
|
||||
@@ -10,5 +10,6 @@ __all__ = [
|
||||
"Memory",
|
||||
"MemorySearchResult",
|
||||
"MemoryManager",
|
||||
"MemoryStoreUnreadable",
|
||||
"MemoryVectorStore",
|
||||
]
|
||||
|
||||
@@ -5,6 +5,16 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
||||
parallel implementation here risks silent drift between import paths.
|
||||
"""
|
||||
|
||||
from src.memory import MemoryManager, get_text_similarity, tokenize
|
||||
from src.memory import (
|
||||
MemoryManager,
|
||||
MemoryStoreUnreadable,
|
||||
get_text_similarity,
|
||||
tokenize,
|
||||
)
|
||||
|
||||
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
||||
__all__ = [
|
||||
"MemoryManager",
|
||||
"MemoryStoreUnreadable",
|
||||
"get_text_similarity",
|
||||
"tokenize",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -387,7 +389,13 @@ async def extract_and_store(
|
||||
# Get owner from session
|
||||
_owner = getattr(session, 'owner', None)
|
||||
|
||||
existing = memory_manager.load_all()
|
||||
# Strict load: this is a read-modify-write. Degrading to [] here would
|
||||
# save only the newly extracted facts and drop the entire store.
|
||||
try:
|
||||
existing = memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Skipping auto memory extraction, store unreadable: %s", e)
|
||||
return
|
||||
added = 0
|
||||
|
||||
for fact in facts:
|
||||
@@ -626,7 +634,18 @@ async def audit_memories(
|
||||
|
||||
# Merge audited entries back with other users' entries
|
||||
if owner:
|
||||
all_entries = memory_manager.load_all()
|
||||
# Strict load: the merge below reconstructs the whole file. If this
|
||||
# degraded to [] we would save only this owner's audited slice and
|
||||
# destroy every other tenant's memories.
|
||||
try:
|
||||
all_entries = memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Aborting memory audit save, store unreadable: %s", e)
|
||||
return {
|
||||
"before": before_count,
|
||||
"after": before_count,
|
||||
"error": "store_unreadable",
|
||||
}
|
||||
audited_ids = {e["id"] for e in final_entries}
|
||||
other_entries = [e for e in all_entries if e.get("owner") != owner and (e.get("owner") is not None)]
|
||||
# Also keep legacy entries that weren't part of this audit
|
||||
|
||||
@@ -50,7 +50,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -100,6 +100,18 @@ def _parse_scalar(raw: str) -> Any:
|
||||
if raw.lower() in ("null", "none", "~"):
|
||||
return None
|
||||
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
|
||||
if raw[0] == '"':
|
||||
# _emit_scalar writes double-quoted scalars with json.dumps, so
|
||||
# decode the escapes instead of only stripping the quotes. Without
|
||||
# this, `\"` / `\\` / `\uXXXX` stayed verbatim in the value and the
|
||||
# next save escaped their backslashes again, doubling them on every
|
||||
# load/save cycle (issue #5210).
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
# Hand-written file using escapes JSON rejects (e.g. a bare
|
||||
# Windows path). Keep the previous literal reading.
|
||||
pass
|
||||
return raw[1:-1]
|
||||
# Try number
|
||||
try:
|
||||
@@ -171,6 +183,26 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
||||
return fm, body
|
||||
|
||||
|
||||
# Characters that force a quoted scalar. The punctuation would otherwise change
|
||||
# how the value reads back; the second row is every character str.splitlines()
|
||||
# treats as a line break, and parse_frontmatter() reads one scalar per line, so
|
||||
# emitting one of those bare would split the value across lines.
|
||||
_FM_MUST_QUOTE = (
|
||||
":", "#", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@",
|
||||
"\n", "\r", "\v", "\f", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029",
|
||||
)
|
||||
|
||||
# json.dumps escapes every C0 control character, but with ensure_ascii=False it
|
||||
# passes NEL / LINE SEPARATOR / PARAGRAPH SEPARATOR through literally, and
|
||||
# str.splitlines() still breaks on all three. Re-escape exactly those, which
|
||||
# json.loads decodes again on the way in, so the pair stays symmetric.
|
||||
_FM_POST_DUMPS_ESCAPES = (
|
||||
("\x85", "\\u0085"),
|
||||
("\u2028", "\\u2028"),
|
||||
("\u2029", "\\u2029"),
|
||||
)
|
||||
|
||||
|
||||
def _emit_scalar(v: Any) -> str:
|
||||
if v is None:
|
||||
return "null"
|
||||
@@ -181,8 +213,15 @@ def _emit_scalar(v: Any) -> str:
|
||||
if isinstance(v, list):
|
||||
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
||||
s = str(v)
|
||||
if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")):
|
||||
return json.dumps(s)
|
||||
if any(c in s for c in _FM_MUST_QUOTE):
|
||||
# ensure_ascii=False keeps non-ASCII text as itself. SKILL.md is UTF-8 at
|
||||
# both ends (skills.py reads it, atomic_write_text writes it), so the
|
||||
# \uXXXX form bought nothing and leaked into the parsed value (#5210).
|
||||
out = json.dumps(s, ensure_ascii=False)
|
||||
for ch, esc in _FM_POST_DUMPS_ESCAPES:
|
||||
if ch in out:
|
||||
out = out.replace(ch, esc)
|
||||
return out
|
||||
return s
|
||||
|
||||
|
||||
@@ -441,4 +480,4 @@ class Skill:
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import wave
|
||||
import logging
|
||||
import hashlib
|
||||
@@ -41,6 +42,11 @@ class TTSService:
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._kokoro = None # lazy-init
|
||||
|
||||
try:
|
||||
self.max_cache_bytes = int(os.getenv("ODYSSEUS_TTS_CACHE_MAX_BYTES", 500 * 1024 * 1024))
|
||||
except ValueError:
|
||||
self.max_cache_bytes = 500 * 1024 * 1024
|
||||
|
||||
# ── Settings ──
|
||||
|
||||
@@ -89,6 +95,53 @@ class TTSService:
|
||||
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
||||
(self.cache_dir / f"{key}{ext}").write_bytes(data)
|
||||
|
||||
self._enforce_cache_limit()
|
||||
|
||||
def _enforce_cache_limit(self):
|
||||
"""Evicts oldest files if the cache exceeds the configured byte limit."""
|
||||
if self.max_cache_bytes <= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
files = []
|
||||
total_size = 0
|
||||
|
||||
# Safely scan files and sum sizes, ignoring files deleted mid-scan
|
||||
for f in self.cache_dir.iterdir():
|
||||
try:
|
||||
if f.is_file() and f.suffix.lower() in (".mp3", ".wav"):
|
||||
files.append(f)
|
||||
total_size += f.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if total_size > self.max_cache_bytes:
|
||||
logger.info(
|
||||
f"TTS cache ({total_size} bytes) exceeded limit ({self.max_cache_bytes} bytes). Evicting oldest files."
|
||||
)
|
||||
|
||||
# Sort files by modification time (oldest first)
|
||||
try:
|
||||
files.sort(key=lambda f: f.stat().st_mtime)
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to sort cache files by mtime: {e}")
|
||||
|
||||
# Trim down to 80% of max capacity
|
||||
target_size = self.max_cache_bytes * 0.8
|
||||
|
||||
while files and total_size > target_size:
|
||||
f = files.pop(0)
|
||||
try:
|
||||
size = f.stat().st_size
|
||||
f.unlink()
|
||||
total_size -= size
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to evict cache file {f}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error enforcing TTS cache limit: {e}", exc_info=True)
|
||||
|
||||
def clear_cache(self):
|
||||
count = 0
|
||||
for f in self.cache_dir.glob("*.*"):
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import json
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import AsyncGenerator, List, Dict, Optional, Set
|
||||
from typing import Any, AsyncGenerator, List, Dict, Optional, Set
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.llm_core import (
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
import time
|
||||
import collections
|
||||
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
||||
from core.platform_compat import IS_WINDOWS, find_bash
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
|
||||
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
||||
@@ -16,6 +17,27 @@ PROGRESS_TAIL_LINES = 12
|
||||
TMUX_CAPTURE_LINES = 2000
|
||||
|
||||
|
||||
async def _create_bash_subprocess(command: str, **kwargs):
|
||||
"""Start the agent shell with Bash semantics on every supported OS.
|
||||
|
||||
``asyncio.create_subprocess_shell`` delegates to ``cmd.exe`` on native
|
||||
Windows. That contradicts the Bash tool contract and makes POSIX commands
|
||||
such as ``pwd``, ``ls -la``, and ``cat`` unreliable even when the launcher
|
||||
has found Git Bash. Pass the selected workspace as a structural ``cwd``
|
||||
argument; Git Bash inherits that native Windows directory and exposes it
|
||||
using its normal ``/c/...`` representation.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
bash = find_bash()
|
||||
if not bash:
|
||||
raise RuntimeError(
|
||||
"Git Bash is required for the Bash tool on Windows; "
|
||||
"install Git for Windows and restart Odysseus"
|
||||
)
|
||||
return await asyncio.create_subprocess_exec(bash, "-c", command, **kwargs)
|
||||
return await asyncio.create_subprocess_shell(command, **kwargs)
|
||||
|
||||
|
||||
def _tmux_session_name(session_id: Optional[str]) -> str:
|
||||
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
||||
return f"ody-agent-{raw[:80] or 'default'}"
|
||||
@@ -280,7 +302,10 @@ class BashTool:
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
session_id = ctx.get("session_id")
|
||||
if session_id and shutil.which("tmux"):
|
||||
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
|
||||
# native Windows must not bypass the Git Bash launcher below: the tmux
|
||||
# setup hard-codes /bin/bash and cannot safely consume a native cwd.
|
||||
if session_id and not IS_WINDOWS and shutil.which("tmux"):
|
||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||
content,
|
||||
session_id=str(session_id),
|
||||
@@ -307,13 +332,16 @@ class BashTool:
|
||||
"tmux_session": _tmux_session_name(str(session_id)),
|
||||
}
|
||||
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
try:
|
||||
proc = await _create_bash_subprocess(
|
||||
content,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
return {"error": f"bash: {e}", "exit_code": 1}
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
timeout=DEFAULT_BASH_TIMEOUT,
|
||||
|
||||
+10
-1
@@ -22,6 +22,7 @@ import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
from src.memory import MemoryStoreUnreadable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -384,7 +385,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
return {"error": "Memory text cannot be empty"}
|
||||
|
||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||
memories = _memory_manager.load_all()
|
||||
# Strict load: this is a read-modify-write, and it is the path an
|
||||
# ordinary "remember that I prefer X" takes. Degrading to [] here would
|
||||
# save just this one entry over a store we only failed to read,
|
||||
# atomically destroying every memory in it (issue #5673).
|
||||
try:
|
||||
memories = _memory_manager.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Refusing to add memory, store unreadable: %s", e)
|
||||
return {"error": "Memory store is temporarily unreadable — nothing was saved."}
|
||||
memories.append(entry)
|
||||
_memory_manager.save(memories)
|
||||
|
||||
|
||||
+682
-105
@@ -20,6 +20,395 @@ from src.interactive_gate import wait_for_interactive_quiet
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_email_urgency_state(state_path):
|
||||
"""Read one atomic urgency checkpoint, tolerating the legacy shape."""
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
try:
|
||||
state = (
|
||||
json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if state_path.exists()
|
||||
else {}
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
return state if isinstance(state, dict) else {}
|
||||
|
||||
|
||||
def _email_urgency_account_generations(state):
|
||||
"""Return normalized per-account checkpoint/complete generations.
|
||||
|
||||
Checkpoint generations fence every accepted state mutation. Complete
|
||||
generations advance only for a non-stale complete scan. Missing metadata
|
||||
is the legacy generation zero.
|
||||
"""
|
||||
raw = state.get("account_generations", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
generations = {}
|
||||
for account_id, value in raw.items():
|
||||
if isinstance(value, dict):
|
||||
checkpoint = value.get("checkpoint", 0)
|
||||
complete = value.get("complete", 0)
|
||||
else:
|
||||
# Tolerate an intermediate scalar representation as one completed
|
||||
# checkpoint generation instead of discarding its fence.
|
||||
checkpoint = value
|
||||
complete = value
|
||||
try:
|
||||
checkpoint = max(0, int(checkpoint))
|
||||
except (TypeError, ValueError):
|
||||
checkpoint = 0
|
||||
try:
|
||||
complete = max(0, int(complete))
|
||||
except (TypeError, ValueError):
|
||||
complete = 0
|
||||
generations[str(account_id)] = {
|
||||
"checkpoint": checkpoint,
|
||||
"complete": complete,
|
||||
}
|
||||
return generations
|
||||
|
||||
|
||||
def _email_urgency_string_set(value):
|
||||
if not isinstance(value, (list, tuple, set, frozenset)):
|
||||
return set()
|
||||
return {str(item) for item in value if isinstance(item, (str, int))}
|
||||
|
||||
|
||||
def _acquire_email_urgency_state_lock(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
timeout_seconds=120,
|
||||
):
|
||||
"""Acquire the cross-process urgency lock without blocking the app loop."""
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
|
||||
while not cancel_event.is_set():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise sqlite3.OperationalError("timed out waiting for urgency state lock")
|
||||
conn = sqlite3.connect(
|
||||
str(lock_db_path),
|
||||
timeout=min(0.25, max(0.01, remaining)),
|
||||
check_same_thread=False,
|
||||
)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
except sqlite3.OperationalError as exc:
|
||||
conn.close()
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
cancel_event.wait(min(0.05, max(0.0, remaining)))
|
||||
continue
|
||||
except BaseException:
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
if cancel_event.is_set():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return None, None
|
||||
return conn, _read_email_urgency_state(state_path)
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def _close_email_urgency_state_lock(conn):
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _commit_email_urgency_state(conn, state_path, next_state):
|
||||
"""Atomically publish JSON before releasing the SQLite write lock."""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
state_path = Path(state_path)
|
||||
temp_path = state_path.with_name(
|
||||
f".{state_path.name}.{uuid.uuid4().hex}.tmp"
|
||||
)
|
||||
try:
|
||||
temp_path.write_text(json.dumps(next_state), encoding="utf-8")
|
||||
temp_path.replace(state_path)
|
||||
conn.commit()
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _run_email_urgency_state_transaction(
|
||||
state_path,
|
||||
lock_db_path,
|
||||
operation,
|
||||
):
|
||||
"""Serialize one urgency decision while keeping async work on this loop.
|
||||
|
||||
Only lock acquisition waits in a worker thread. ``operation`` is awaited
|
||||
on the caller's long-lived event loop, where shared async clients, locks,
|
||||
and the browser-notification queue belong. Cancellation rolls back the
|
||||
SQLite transaction and never publishes a checkpoint.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
cancel_event = threading.Event()
|
||||
acquire_future = loop.run_in_executor(
|
||||
None,
|
||||
_acquire_email_urgency_state_lock,
|
||||
state_path,
|
||||
lock_db_path,
|
||||
cancel_event,
|
||||
)
|
||||
try:
|
||||
conn, prior = await asyncio.shield(acquire_future)
|
||||
except asyncio.CancelledError as cancelled:
|
||||
cancel_event.set()
|
||||
# The acquisition worker owns any connection until it returns. Wait
|
||||
# for its short busy-poll to observe cancellation, then close a lock it
|
||||
# may have won concurrently with the cancellation request.
|
||||
while True:
|
||||
try:
|
||||
conn, _prior = await asyncio.shield(acquire_future)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
continue
|
||||
except Exception:
|
||||
conn = None
|
||||
break
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise cancelled
|
||||
|
||||
if conn is None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
try:
|
||||
result, next_state = await operation(prior)
|
||||
# Keep this small atomic publish synchronous. There is no await between
|
||||
# the successful operation and commit, so cancellation cannot be
|
||||
# observed and then followed by a checkpoint.
|
||||
try:
|
||||
_commit_email_urgency_state(conn, state_path, next_state)
|
||||
finally:
|
||||
conn = None
|
||||
return result
|
||||
except BaseException:
|
||||
_close_email_urgency_state_lock(conn)
|
||||
raise
|
||||
|
||||
|
||||
def _email_urgency_account_key(message_key):
|
||||
return str(message_key).split(":", 1)[0]
|
||||
|
||||
|
||||
def _email_urgency_payload_account_ids(state):
|
||||
"""Return account IDs that still own user-visible urgency payload."""
|
||||
if not isinstance(state, dict):
|
||||
return set()
|
||||
|
||||
per_uid = state.get("per_uid", {})
|
||||
per_uid_keys = per_uid if isinstance(per_uid, dict) else {}
|
||||
return {
|
||||
_email_urgency_account_key(key) for key in per_uid_keys
|
||||
} | {
|
||||
_email_urgency_account_key(key)
|
||||
for key in _email_urgency_string_set(state.get("notified_uids", []))
|
||||
}
|
||||
|
||||
|
||||
def _email_urgency_known_account_ids(state):
|
||||
"""Return payload owners plus generation-only active/retired markers."""
|
||||
return _email_urgency_payload_account_ids(state) | set(
|
||||
_email_urgency_account_generations(state)
|
||||
)
|
||||
|
||||
|
||||
def _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
account_ids,
|
||||
):
|
||||
prior_generations = _email_urgency_account_generations(prior)
|
||||
base_generations = _email_urgency_account_generations(
|
||||
{"account_generations": base_account_generations}
|
||||
)
|
||||
return {
|
||||
str(account_id)
|
||||
for account_id in account_ids
|
||||
if prior_generations.get(str(account_id), {}).get("checkpoint", 0)
|
||||
!= base_generations.get(str(account_id), {}).get("checkpoint", 0)
|
||||
}
|
||||
|
||||
|
||||
def _merge_email_urgency_state(
|
||||
prior,
|
||||
*,
|
||||
owner,
|
||||
per_uid_scores,
|
||||
notified_uids,
|
||||
all_unread_keys,
|
||||
fully_scanned_account_ids,
|
||||
base_account_generations,
|
||||
timestamp,
|
||||
retired_account_ids=(),
|
||||
base_payload_account_ids=(),
|
||||
known_account_ids=(),
|
||||
):
|
||||
"""Merge a scan without letting an older snapshot erase newer facts."""
|
||||
prior_per_uid = prior.get("per_uid", {})
|
||||
if not isinstance(prior_per_uid, dict):
|
||||
prior_per_uid = {}
|
||||
complete = {str(account_id) for account_id in fully_scanned_account_ids}
|
||||
prior_generations = _email_urgency_account_generations(prior)
|
||||
retire_requested = {str(account_id) for account_id in retired_account_ids}
|
||||
observed_accounts = {
|
||||
_email_urgency_account_key(key) for key in per_uid_scores
|
||||
} | complete | retire_requested
|
||||
stale_accounts = _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
observed_accounts,
|
||||
)
|
||||
prior_payload_accounts = _email_urgency_payload_account_ids(prior)
|
||||
base_payload_accounts = {
|
||||
str(account_id) for account_id in base_payload_account_ids
|
||||
}
|
||||
# A selected account can be absent from the base snapshot. If another
|
||||
# worker creates its first payload before this transaction wins the lock,
|
||||
# membership itself is a fence even when both snapshots normalize to the
|
||||
# legacy generation zero.
|
||||
retired_accounts = {
|
||||
account_id
|
||||
for account_id in retire_requested - stale_accounts
|
||||
if not (
|
||||
account_id in prior_payload_accounts
|
||||
and account_id not in base_payload_accounts
|
||||
)
|
||||
}
|
||||
fresh_complete = complete - stale_accounts - retired_accounts
|
||||
changed_accounts = set(fresh_complete)
|
||||
|
||||
merged_per_uid = {
|
||||
key: value
|
||||
for key, value in prior_per_uid.items()
|
||||
if _email_urgency_account_key(key) not in retired_accounts
|
||||
}
|
||||
for key in list(merged_per_uid):
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in fresh_complete:
|
||||
merged_per_uid.pop(key, None)
|
||||
changed_accounts.add(account_id)
|
||||
# Partial scans may add or refresh facts, but absence from a partial scan
|
||||
# is not evidence that another checkpoint or UI row is stale. When another
|
||||
# worker committed after this scan captured its base generation, discard
|
||||
# this account's whole stale snapshot. A key absent from the newer state
|
||||
# may have been removed/read, so even a stale-only key is not safely
|
||||
# additive without another fresh scan.
|
||||
for key, value in per_uid_scores.items():
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in stale_accounts or account_id in retired_accounts:
|
||||
continue
|
||||
if merged_per_uid.get(key) != value:
|
||||
changed_accounts.add(account_id)
|
||||
merged_per_uid[key] = value
|
||||
|
||||
prior_notified = _email_urgency_string_set(prior.get("notified_uids", []))
|
||||
merged_notified = {
|
||||
key
|
||||
for key in prior_notified
|
||||
if _email_urgency_account_key(key) not in retired_accounts
|
||||
}
|
||||
for key in _email_urgency_string_set(notified_uids) - prior_notified:
|
||||
account_id = _email_urgency_account_key(key)
|
||||
if account_id in stale_accounts or account_id in retired_accounts:
|
||||
continue
|
||||
merged_notified.add(key)
|
||||
changed_accounts.add(account_id)
|
||||
for key in list(merged_notified):
|
||||
if (
|
||||
_email_urgency_account_key(key) in fresh_complete
|
||||
and key not in all_unread_keys
|
||||
):
|
||||
merged_notified.discard(key)
|
||||
changed_accounts.add(_email_urgency_account_key(key))
|
||||
|
||||
next_generations = {
|
||||
account_id: dict(value)
|
||||
for account_id, value in prior_generations.items()
|
||||
}
|
||||
for account_id in changed_accounts:
|
||||
generation = next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
generation["checkpoint"] += 1
|
||||
if account_id in fresh_complete:
|
||||
generation["complete"] += 1
|
||||
for account_id in {str(value) for value in known_account_ids}:
|
||||
next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
for account_id in retired_accounts:
|
||||
# Every authoritative absence advances its generation, even when the
|
||||
# prior state is already a payload-empty tombstone. A re-enabled scan
|
||||
# may have captured that previous tombstone immediately before the
|
||||
# account was disabled/deleted again; monotonic advancement is what
|
||||
# makes that in-flight scan stale.
|
||||
generation = next_generations.setdefault(
|
||||
account_id,
|
||||
{"checkpoint": 0, "complete": 0},
|
||||
)
|
||||
generation["checkpoint"] += 1
|
||||
|
||||
total_unread = 0
|
||||
total_urgent = 0
|
||||
max_score = 0
|
||||
for value in merged_per_uid.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
try:
|
||||
score = max(0, min(3, int(value.get("score", 0))))
|
||||
except (TypeError, ValueError):
|
||||
score = 0
|
||||
max_score = max(max_score, score)
|
||||
if value.get("unread"):
|
||||
total_unread += 1
|
||||
if score >= 2:
|
||||
total_urgent += 1
|
||||
|
||||
return {
|
||||
"ts": timestamp,
|
||||
"owner": owner or "",
|
||||
"total_unread": total_unread,
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": merged_per_uid,
|
||||
"notified_uids": sorted(merged_notified),
|
||||
"account_generations": next_generations,
|
||||
}
|
||||
|
||||
|
||||
class TaskNoop(BaseException):
|
||||
"""Raised by an action when it determined there's nothing to do.
|
||||
|
||||
@@ -1878,6 +2267,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
# filename for single-user installs (matches prior behaviour).
|
||||
_owner_slug = "".join(c if (c.isalnum() or c in "-_.@") else "_" for c in (owner or "default"))
|
||||
STATE_PATH = _P(DATA_DIR) / f"email_urgency_state_{_owner_slug}.json"
|
||||
STATE_LOCK_DB = STATE_PATH.with_suffix(".lock.sqlite3")
|
||||
CACHE_DIR = _P(EMAIL_URGENCY_CACHE_DIR)
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1892,35 +2282,144 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"shopping", "social", "work", "personal", "legal", "support", "promo",
|
||||
}
|
||||
|
||||
# ── 1. Resolve LLM candidates (utility primary + utility fallbacks; fall
|
||||
# through to default chat as a last resort).
|
||||
# Resolve with the task owner as before, but defer the availability
|
||||
# gate until after authoritative account cleanup. State retirement must
|
||||
# still run when no model is configured.
|
||||
from src.task_endpoint import resolve_task_candidates
|
||||
candidates = resolve_task_candidates(owner=owner)
|
||||
if not candidates:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
target_account_id = _email_task_account_id(kwargs)
|
||||
|
||||
# ── 2. Enumerate enabled accounts. Match this task's owner AND fall
|
||||
# ── 1. Enumerate enabled accounts. Match this task's owner AND fall
|
||||
# back to the legacy "unowned account whose imap_user / from_address
|
||||
# == this owner" pattern — same rule `_get_email_config` uses, so a
|
||||
# pre-multi-user account row still gets picked up for the seeded task.
|
||||
db = _SL()
|
||||
try:
|
||||
from sqlalchemy import and_ as _and, or_ as _or
|
||||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||
same_mailbox = _or(_EA.imap_user == owner, _EA.from_address == owner)
|
||||
q = q.filter(_or(_EA.owner == owner, _and(unowned, same_mailbox)))
|
||||
if target_account_id:
|
||||
q = q.filter(_EA.id == target_account_id)
|
||||
accounts = q.all()
|
||||
finally:
|
||||
db.close()
|
||||
def _enumerate_enabled_accounts():
|
||||
db = _SL()
|
||||
try:
|
||||
from sqlalchemy import and_ as _and, or_ as _or
|
||||
q = db.query(_EA).filter(_EA.enabled == True) # noqa: E712
|
||||
if owner:
|
||||
unowned = _or(_EA.owner == None, _EA.owner == "") # noqa: E711
|
||||
same_mailbox = _or(
|
||||
_EA.imap_user == owner,
|
||||
_EA.from_address == owner,
|
||||
)
|
||||
q = q.filter(
|
||||
_or(_EA.owner == owner, _and(unowned, same_mailbox))
|
||||
)
|
||||
if target_account_id:
|
||||
q = q.filter(_EA.id == target_account_id)
|
||||
return q.all()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
initial_accounts = _enumerate_enabled_accounts()
|
||||
initial_account_ids = {
|
||||
str(account.id) for account in initial_accounts
|
||||
}
|
||||
|
||||
# Register every account before IMAP work, including its first-ever
|
||||
# scan. A concurrent zero-account cleanup can then advance this marker
|
||||
# and fence delivery even before the scan has produced payload.
|
||||
registered_state = None
|
||||
if initial_account_ids:
|
||||
async def _register_accounts(prior):
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores={},
|
||||
notified_uids=prior.get("notified_uids", []),
|
||||
all_unread_keys=set(),
|
||||
fully_scanned_account_ids=set(),
|
||||
base_account_generations=(
|
||||
_email_urgency_account_generations(prior)
|
||||
),
|
||||
timestamp=_time.time(),
|
||||
known_account_ids=initial_account_ids,
|
||||
)
|
||||
# Return the exact state committed by registration. This is
|
||||
# the scan's generation token: adopting a later checkpoint
|
||||
# after account cleanup would let the stale scan appear fresh.
|
||||
return next_state, next_state
|
||||
|
||||
registered_state = await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_register_accounts,
|
||||
)
|
||||
|
||||
# Revalidate after registration. If deletion/disable and its cleanup
|
||||
# completed before the marker was published, this second enumeration
|
||||
# observes the absence and this action retires its own marker instead
|
||||
# of starting IMAP. Accounts newly appearing between the two reads are
|
||||
# left for the next pass rather than scanned without prior registration.
|
||||
verified_accounts = _enumerate_enabled_accounts()
|
||||
enabled_account_ids = {
|
||||
str(account.id) for account in verified_accounts
|
||||
}
|
||||
accounts = [
|
||||
account
|
||||
for account in verified_accounts
|
||||
if str(account.id) in initial_account_ids
|
||||
]
|
||||
|
||||
# Capture the checkpoint basis before cleanup or IMAP. A full
|
||||
# owner-wide enumeration authoritatively retires all known state IDs
|
||||
# absent from the current enabled/visible set. A scoped task may retire
|
||||
# only its selected missing/disabled account. Existing accounts remain
|
||||
# present even if their later network scan fails, so transient IMAP
|
||||
# failure never erases their last known state.
|
||||
base_state = (
|
||||
registered_state
|
||||
if registered_state is not None
|
||||
else _read_email_urgency_state(STATE_PATH)
|
||||
)
|
||||
base_account_generations = _email_urgency_account_generations(
|
||||
base_state
|
||||
)
|
||||
base_payload_account_ids = _email_urgency_payload_account_ids(base_state)
|
||||
known_state_account_ids = _email_urgency_known_account_ids(base_state)
|
||||
if target_account_id:
|
||||
retired_account_ids = (
|
||||
{str(target_account_id)}
|
||||
if str(target_account_id) not in enabled_account_ids
|
||||
else set()
|
||||
)
|
||||
else:
|
||||
retired_account_ids = (
|
||||
known_state_account_ids - enabled_account_ids
|
||||
)
|
||||
|
||||
if retired_account_ids:
|
||||
async def _retire_accounts(prior):
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores={},
|
||||
notified_uids=prior.get("notified_uids", []),
|
||||
all_unread_keys=set(),
|
||||
fully_scanned_account_ids=set(),
|
||||
base_account_generations=base_account_generations,
|
||||
timestamp=_time.time(),
|
||||
retired_account_ids=retired_account_ids,
|
||||
base_payload_account_ids=base_payload_account_ids,
|
||||
)
|
||||
return None, next_state
|
||||
|
||||
await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_retire_accounts,
|
||||
)
|
||||
if not accounts:
|
||||
raise TaskNoop("no email accounts configured")
|
||||
|
||||
# ── 2. Account retirement above is state maintenance and does not
|
||||
# depend on model availability. Scanning still requires the utility
|
||||
# primary/fallback candidates resolved for this task owner.
|
||||
if not candidates:
|
||||
return "No LLM endpoint available", False
|
||||
|
||||
urgency_prompt = settings.get("urgent_email_prompt", "")
|
||||
per_uid_scores = {} # key = "<acc_id>:<uid>" → {"score": 0-3, "reason": "..."}
|
||||
all_unread_keys = set()
|
||||
@@ -1929,6 +2428,7 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
failed_classifications = []
|
||||
tag_write_details = []
|
||||
scanned = 0
|
||||
fully_scanned_account_ids = set()
|
||||
|
||||
def _heuristic_email_verdict(item: dict) -> dict:
|
||||
blob = (
|
||||
@@ -2024,16 +2524,27 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
def _scan_one(account=acc, cache_uids=cache.get("uids", {})):
|
||||
"""Sync IMAP work runs in a thread."""
|
||||
results = []
|
||||
scan_complete = True
|
||||
conn = _imap_connect(account.id)
|
||||
try:
|
||||
conn.select("INBOX", readonly=True)
|
||||
select_status, _select_data = conn.select("INBOX", readonly=True)
|
||||
if select_status != "OK":
|
||||
return results, False
|
||||
# Tag recent inbox mail, not only unread mail. Urgency
|
||||
# reminders below still only notify for unread messages.
|
||||
since_str = AGE_CUTOFF.strftime("%d-%b-%Y")
|
||||
status, data = conn.uid("SEARCH", None, f'(SINCE {since_str})')
|
||||
if status != "OK" or not data or not data[0]:
|
||||
return results
|
||||
uids = data[0].split()[-30:]
|
||||
if status != "OK":
|
||||
return results, False
|
||||
if not data or not data[0]:
|
||||
return results, True
|
||||
matching_uids = data[0].split()
|
||||
if len(matching_uids) > 30:
|
||||
# The scale guard deliberately processes only the most
|
||||
# recent 30. That is a partial account snapshot, so it
|
||||
# cannot justify pruning older checkpoint facts.
|
||||
scan_complete = False
|
||||
uids = matching_uids[-30:]
|
||||
for uid_b in uids:
|
||||
uid = uid_b.decode() if isinstance(uid_b, bytes) else str(uid_b)
|
||||
key = f"{account.id}:{uid}"
|
||||
@@ -2041,12 +2552,41 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
cached_ok = isinstance(cached, dict) and cached.get("triage_version") == TRIAGE_VERSION
|
||||
results.append({"key": key, "uid": uid, "cached": cached if cached_ok else None})
|
||||
if cached_ok:
|
||||
# Already classified — skip the fetch.
|
||||
# Cached verdicts still need a lightweight FLAGS
|
||||
# refresh. Without it a cached unread message looks
|
||||
# read and its successful notification checkpoint
|
||||
# is pruned on the next pass.
|
||||
try:
|
||||
st, flag_data = conn.uid("FETCH", uid_b, "(UID FLAGS)")
|
||||
if st != "OK" or not flag_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flag_parts = []
|
||||
for part in flag_data:
|
||||
if isinstance(part, (bytes, bytearray)):
|
||||
flag_parts.append(bytes(part))
|
||||
elif (
|
||||
isinstance(part, tuple)
|
||||
and part
|
||||
and isinstance(part[0], (bytes, bytearray))
|
||||
):
|
||||
flag_parts.append(bytes(part[0]))
|
||||
flags_blob = b" ".join(flag_parts)
|
||||
results[-1]["unread"] = b"\\Seen" not in flags_blob
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(
|
||||
f"urgency: flag fetch for uid {uid} failed: {_fe}"
|
||||
)
|
||||
continue
|
||||
# Pull headers + first ~800 chars of plaintext body.
|
||||
try:
|
||||
st, msg_data = conn.uid("FETCH", uid_b, "(UID FLAGS RFC822.HEADER BODY.PEEK[TEXT]<0.800>)")
|
||||
if st != "OK" or not msg_data:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
flags_blob = b" ".join(
|
||||
part[0] for part in msg_data
|
||||
@@ -2060,6 +2600,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if isinstance(part, tuple) and part[1]:
|
||||
raw += part[1] + b"\n\n"
|
||||
if not raw:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
continue
|
||||
msg = _email_mod.message_from_bytes(raw)
|
||||
# Skip Odysseus-generated reminders so the scanner
|
||||
@@ -2115,17 +2657,21 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"unread": is_unread,
|
||||
})
|
||||
except Exception as _fe:
|
||||
scan_complete = False
|
||||
results.pop()
|
||||
logger.debug(f"urgency: header fetch for uid {uid} failed: {_fe}")
|
||||
finally:
|
||||
try: conn.logout()
|
||||
except Exception: pass
|
||||
return results
|
||||
return results, scan_complete
|
||||
|
||||
try:
|
||||
items = await _aio.to_thread(_scan_one)
|
||||
items, scan_complete = await _aio.to_thread(_scan_one)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: IMAP scan failed for account {acc.id}: {e}")
|
||||
continue
|
||||
if scan_complete:
|
||||
fully_scanned_account_ids.add(str(acc.id))
|
||||
|
||||
for item in items:
|
||||
scanned += 1
|
||||
@@ -2262,13 +2808,13 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
logger.debug(f"urgency: LLM classify failed for {key}: {e}")
|
||||
continue
|
||||
|
||||
# ── Prune cache entries for UIDs that are no longer in the recent
|
||||
# scan window. Read messages remain cached because tags are useful
|
||||
# on read mail too; unread state is refreshed per scan above.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
if scan_complete:
|
||||
# Only a complete account scan proves a cached UID left the
|
||||
# recent window. Partial/failing scans preserve prior facts.
|
||||
seen_uids = {it["uid"] for it in items}
|
||||
cache_uids = cache.get("uids", {})
|
||||
for stale in [u for u in cache_uids if u not in seen_uids]:
|
||||
cache_uids.pop(stale, None)
|
||||
|
||||
try:
|
||||
cache_file.write_text(_json.dumps(cache), encoding="utf-8")
|
||||
@@ -2372,40 +2918,34 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
# ── 4. Aggregate state. urgent = score ≥ 2.
|
||||
urgent_keys = [k for k, v in per_uid_scores.items() if v.get("score", 0) >= 2 and v.get("unread")]
|
||||
max_score = max((v.get("score", 0) for v in per_uid_scores.values()), default=0)
|
||||
total_urgent = len(urgent_keys)
|
||||
|
||||
# Load prior state to know which urgent UIDs we've already notified.
|
||||
try:
|
||||
prior = _json.loads(STATE_PATH.read_text(encoding="utf-8")) if STATE_PATH.exists() else {}
|
||||
except Exception:
|
||||
prior = {}
|
||||
notified_uids = set(prior.get("notified_uids", []))
|
||||
|
||||
# ── 5. Fire reminder ONLY when a previously-unnotified UID scores urgent.
|
||||
new_urgent = [k for k in urgent_keys if k not in notified_uids]
|
||||
# ── 5. Fire a reminder only when a previously-unnotified UID scores
|
||||
# urgent. The read, decision, delivery, and checkpoint are serialized
|
||||
# below so two scheduler workers cannot both act on the same stale
|
||||
# state or overwrite each other's successful checkpoint.
|
||||
newly_notified = set()
|
||||
notify_failed = set()
|
||||
if new_urgent:
|
||||
title = "Urgent email" if total_urgent == 1 else f"{total_urgent} urgent emails"
|
||||
# Build a real listing — subject · sender · reason for each urgent
|
||||
# one — so the reminder email tells you which messages to act on,
|
||||
# not just "4 needing reply". Optional deep-link when the user has
|
||||
# `app_public_url` configured in Settings (so the email row links
|
||||
# straight into the Odysseus Email tab).
|
||||
# Sort: highest-scored UIDs first; cap at 10 to keep the email tidy.
|
||||
|
||||
def _urgency_reminder_payload(reminder_keys):
|
||||
total = len(reminder_keys)
|
||||
title = "Urgent email" if total == 1 else f"{total} urgent emails"
|
||||
sorted_urgent = sorted(
|
||||
((k, per_uid_scores[k]) for k in urgent_keys),
|
||||
key=lambda kv: kv[1].get("score", 0), reverse=True,
|
||||
((key, per_uid_scores[key]) for key in reminder_keys),
|
||||
key=lambda item: item[1].get("score", 0),
|
||||
reverse=True,
|
||||
)[:10]
|
||||
_pub = (settings.get("app_public_url") or "").strip().rstrip("/")
|
||||
from urllib.parse import quote as _quote
|
||||
lines = [f"{total_urgent} email" + ("" if total_urgent == 1 else "s") + " need an urgent reply:", ""]
|
||||
for i, (k, v) in enumerate(sorted_urgent, 1):
|
||||
subj = (v.get("subject") or "(no subject)")[:160]
|
||||
frm = v.get("from") or ""
|
||||
why = v.get("reason") or ""
|
||||
uid_for_link = str(k).split(":", 1)[-1]
|
||||
lines = [
|
||||
f"{total} email" + ("" if total == 1 else "s")
|
||||
+ " need an urgent reply:",
|
||||
"",
|
||||
]
|
||||
for i, (key, value) in enumerate(sorted_urgent, 1):
|
||||
subj = (value.get("subject") or "(no subject)")[:160]
|
||||
frm = value.get("from") or ""
|
||||
why = value.get("reason") or ""
|
||||
uid_for_link = str(key).split(":", 1)[-1]
|
||||
hash_link = f"#email={_quote('INBOX', safe='')}:{uid_for_link}"
|
||||
open_link = f"{_pub}/{hash_link}" if _pub else hash_link
|
||||
line = f"{i}. {subj}"
|
||||
@@ -2415,57 +2955,94 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
line += f" · {why}"
|
||||
lines.append(line)
|
||||
lines.append(f" Open email: {open_link}")
|
||||
if total_urgent > len(sorted_urgent):
|
||||
if total > len(sorted_urgent):
|
||||
lines.append("")
|
||||
lines.append(f"…and {total_urgent - len(sorted_urgent)} more.")
|
||||
body = "\n".join(lines)
|
||||
try:
|
||||
# Call dispatch_reminder DIRECTLY (no HTTP/auth roundtrip — the
|
||||
# endpoint version 401's the background scheduler because it
|
||||
# has no session cookie).
|
||||
from routes.note_routes import dispatch_reminder
|
||||
dispatch_result = await dispatch_reminder(
|
||||
title=title, note_body=body, note_id="urgent-email",
|
||||
owner=owner or "",
|
||||
)
|
||||
channel = (settings.get("reminder_channel") or "browser").strip().lower()
|
||||
delivered = bool(dispatch_result.get("browser_sent"))
|
||||
if channel == "email":
|
||||
delivered = bool(dispatch_result.get("email_sent"))
|
||||
elif channel == "ntfy":
|
||||
delivered = bool(dispatch_result.get("ntfy_sent"))
|
||||
elif channel == "webhook":
|
||||
delivered = bool(dispatch_result.get("webhook_sent"))
|
||||
if delivered:
|
||||
newly_notified.update(new_urgent)
|
||||
else:
|
||||
lines.append(f"…and {total - len(sorted_urgent)} more.")
|
||||
return title, "\n".join(lines)
|
||||
|
||||
async def _dispatch_urgency_reminder(reminder_keys):
|
||||
# Call dispatch_reminder directly: a scheduler has no browser
|
||||
# session cookie with which to call the HTTP endpoint.
|
||||
from routes.note_routes import dispatch_reminder
|
||||
title, body = _urgency_reminder_payload(reminder_keys)
|
||||
return await dispatch_reminder(
|
||||
title=title,
|
||||
note_body=body,
|
||||
note_id="urgent-email",
|
||||
owner=owner or "",
|
||||
)
|
||||
|
||||
async def _dispatch_and_checkpoint(prior):
|
||||
notified_uids = _email_urgency_string_set(
|
||||
prior.get("notified_uids", [])
|
||||
)
|
||||
observed_accounts = {
|
||||
_email_urgency_account_key(key) for key in per_uid_scores
|
||||
} | fully_scanned_account_ids
|
||||
stale_accounts = _email_urgency_stale_accounts(
|
||||
prior,
|
||||
base_account_generations,
|
||||
observed_accounts,
|
||||
)
|
||||
# Generation fencing must happen before delivery, not only during
|
||||
# merge. A stale-only unread UID may have been removed, read, or
|
||||
# downgraded by the newer completed scan.
|
||||
deliverable_urgent = [
|
||||
key
|
||||
for key in urgent_keys
|
||||
if _email_urgency_account_key(key) not in stale_accounts
|
||||
]
|
||||
new_urgent = [
|
||||
key
|
||||
for key in deliverable_urgent
|
||||
if key not in notified_uids
|
||||
]
|
||||
if new_urgent:
|
||||
try:
|
||||
dispatch_result = await _dispatch_urgency_reminder(
|
||||
deliverable_urgent
|
||||
)
|
||||
channel = (settings.get("reminder_channel") or "browser").strip().lower()
|
||||
delivered = bool(dispatch_result.get("browser_sent"))
|
||||
if channel == "email":
|
||||
delivered = bool(dispatch_result.get("email_sent"))
|
||||
elif channel == "ntfy":
|
||||
delivered = bool(dispatch_result.get("ntfy_sent"))
|
||||
elif channel == "webhook":
|
||||
delivered = bool(dispatch_result.get("webhook_sent"))
|
||||
if delivered:
|
||||
newly_notified.update(new_urgent)
|
||||
notified_uids.update(new_urgent)
|
||||
else:
|
||||
notify_failed.update(new_urgent)
|
||||
logger.warning(
|
||||
"urgency: reminder dispatch returned no successful "
|
||||
f"delivery path: {dispatch_result}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: reminder dispatch failed: {e}")
|
||||
notify_failed.update(new_urgent)
|
||||
logger.warning(f"urgency: reminder dispatch returned no successful delivery path: {dispatch_result}")
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: reminder dispatch failed: {e}")
|
||||
notify_failed.update(new_urgent)
|
||||
# Mark only successfully delivered UIDs as notified so a transient
|
||||
# SMTP/ntfy/browser failure retries instead of lying forever.
|
||||
notified_uids.update(newly_notified)
|
||||
|
||||
# Prune notified_uids that aren't unread anymore (so a future re-urgent
|
||||
# message with the same UID — rare but possible after archive→unarchive
|
||||
# — can re-notify). Keep only UIDs still in `all_unread_keys`.
|
||||
notified_uids = {u for u in notified_uids if u in all_unread_keys}
|
||||
next_state = _merge_email_urgency_state(
|
||||
prior,
|
||||
owner=owner,
|
||||
per_uid_scores=per_uid_scores,
|
||||
notified_uids=notified_uids,
|
||||
all_unread_keys=all_unread_keys,
|
||||
fully_scanned_account_ids=fully_scanned_account_ids,
|
||||
base_account_generations=base_account_generations,
|
||||
timestamp=_time.time(),
|
||||
)
|
||||
return notified_uids, next_state
|
||||
|
||||
state = {
|
||||
"ts": _time.time(),
|
||||
"owner": owner or "",
|
||||
"total_unread": len(all_unread_keys),
|
||||
"total_urgent": total_urgent,
|
||||
"max_score": max_score,
|
||||
"per_uid": per_uid_scores,
|
||||
"notified_uids": sorted(notified_uids),
|
||||
}
|
||||
try:
|
||||
STATE_PATH.write_text(_json.dumps(state), encoding="utf-8")
|
||||
await _run_email_urgency_state_transaction(
|
||||
STATE_PATH,
|
||||
STATE_LOCK_DB,
|
||||
_dispatch_and_checkpoint,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"urgency: state write failed: {e}")
|
||||
logger.warning(f"urgency: state transaction failed: {e}")
|
||||
|
||||
# ── 6. Activity-log summary — counts line on top, then per-tier
|
||||
# bulleted breakdown so the user can see WHICH emails ranked where
|
||||
|
||||
@@ -443,28 +443,14 @@ def resolve_endpoint_by_id(
|
||||
|
||||
|
||||
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Build the configured default-chat fallback chain as a list of
|
||||
(chat_url, model, headers) tuples, skipping any that can't resolve.
|
||||
"""Compatibility shim for the retired default-chat fallback chain."""
|
||||
|
||||
The primary model is NOT included — callers prepend their session's
|
||||
current (url, model, headers) so per-session model overrides are honored.
|
||||
"""
|
||||
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
|
||||
del owner
|
||||
return []
|
||||
|
||||
|
||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
settings = load_settings()
|
||||
utility_ep = (get_user_setting("utility_endpoint_id", owner or "", settings.get("utility_endpoint_id", "")) or "").strip()
|
||||
if not utility_ep:
|
||||
utility_chain = get_user_setting("utility_model_fallbacks", owner or "", settings.get("utility_model_fallbacks") or []) or []
|
||||
if utility_chain:
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||
return _resolve_fallback_candidates("default_model_fallbacks", owner=owner)
|
||||
except Exception:
|
||||
pass
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Foreground Chat and Agent model-routing policy.
|
||||
|
||||
The selected session model is strict by default. Historical
|
||||
``default_model_fallbacks`` values remain stored for compatibility, but this
|
||||
policy intentionally does not read or migrate them.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
"""Return fallback candidates for a foreground Chat or Agent request.
|
||||
|
||||
Foreground routing is strict, so no alternate endpoint/model is eligible.
|
||||
``owner`` is accepted to keep this policy boundary owner-aware.
|
||||
"""
|
||||
|
||||
del owner
|
||||
return []
|
||||
|
||||
|
||||
def build_foreground_model_candidates(
|
||||
endpoint_url: str,
|
||||
model: str,
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
) -> list:
|
||||
"""Build the ordered candidate list for a foreground request."""
|
||||
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
|
||||
+172
-3
@@ -1,11 +1,14 @@
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import urljoin, urlparse, urlunparse
|
||||
|
||||
import httpcore
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -354,6 +357,152 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
# httpcore raises its own exception hierarchy; map the ones a simple request can
|
||||
# surface back to their httpx equivalents so the caller's `except httpx.*` blocks
|
||||
# below behave exactly as they did with the default transport.
|
||||
_HTTPCORE_TO_HTTPX_EXC = {
|
||||
httpcore.ConnectError: httpx.ConnectError,
|
||||
httpcore.ConnectTimeout: httpx.ConnectTimeout,
|
||||
httpcore.NetworkError: httpx.NetworkError,
|
||||
httpcore.PoolTimeout: httpx.PoolTimeout,
|
||||
httpcore.ProtocolError: httpx.ProtocolError,
|
||||
httpcore.ReadError: httpx.ReadError,
|
||||
httpcore.ReadTimeout: httpx.ReadTimeout,
|
||||
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
|
||||
httpcore.TimeoutException: httpx.TimeoutException,
|
||||
httpcore.WriteError: httpx.WriteError,
|
||||
httpcore.WriteTimeout: httpx.WriteTimeout,
|
||||
}
|
||||
|
||||
|
||||
class _PinnedAsyncBackend(httpcore.AsyncNetworkBackend):
|
||||
"""Network backend that connects only to the pre-validated IPs, in order.
|
||||
|
||||
Every address here came out of the single SSRF resolution, so moving to the
|
||||
next one after a connect failure is not re-resolution — it's ordinary
|
||||
multi-address fallback restricted to the set the guard already approved.
|
||||
httpcore takes TLS SNI and the ``Host`` header from the request URL rather
|
||||
than the connect host, so pinning the socket destination leaves certificate
|
||||
validation and vhost routing pointed at the original hostname.
|
||||
"""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._ips = [str(ip) for ip in ips]
|
||||
self._real = httpcore.AnyIOBackend()
|
||||
|
||||
async def connect_tcp(self, host, port, timeout=None, local_address=None,
|
||||
socket_options=None):
|
||||
# One shared connect budget: each attempt gets the time left until the
|
||||
# original deadline, so N dead addresses can't stretch the connect
|
||||
# phase to N * timeout.
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
last_exc: Optional[Exception] = None
|
||||
for ip in self._ips:
|
||||
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
||||
try:
|
||||
return await self._real.connect_tcp(
|
||||
ip, port, remaining, local_address, socket_options
|
||||
)
|
||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
|
||||
last_exc = exc
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
break
|
||||
raise last_exc
|
||||
|
||||
async def connect_unix_socket(self, path, timeout=None, socket_options=None):
|
||||
return await self._real.connect_unix_socket(path, timeout, socket_options)
|
||||
|
||||
async def sleep(self, seconds: float) -> None:
|
||||
return await self._real.sleep(seconds)
|
||||
|
||||
|
||||
class _PinnedAsyncTransport(httpx.AsyncBaseTransport):
|
||||
"""httpx transport that pins the TCP connect to the pre-resolved IP(s).
|
||||
|
||||
Kept local, mirroring the per-module pinned transports web fetch and
|
||||
webhook delivery already carry, rather than coupling api_call to the
|
||||
webhook subsystem. The request URL passes through unchanged, so SNI and the
|
||||
``Host`` header stay the original hostname; only the socket destination is
|
||||
pinned, which is what closes the rebinding window.
|
||||
"""
|
||||
|
||||
def __init__(self, ips: List[ipaddress._BaseAddress]):
|
||||
self._pinned_ips = list(ips)
|
||||
self._pool = httpcore.AsyncConnectionPool(
|
||||
# Reuse the CA trust the default httpx client would build (certifi
|
||||
# plus SSL_CERT_FILE / SSL_CERT_DIR when trust_env is set) so
|
||||
# swapping in this transport doesn't quietly change which chains
|
||||
# verify. ssl.create_default_context() would use system roots.
|
||||
ssl_context=httpx.create_ssl_context(),
|
||||
http1=True,
|
||||
http2=False,
|
||||
network_backend=_PinnedAsyncBackend(ips),
|
||||
)
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
core_req = httpcore.Request(
|
||||
method=request.method,
|
||||
url=httpcore.URL(
|
||||
scheme=request.url.raw_scheme,
|
||||
host=request.url.raw_host,
|
||||
port=request.url.port,
|
||||
target=request.url.raw_path,
|
||||
),
|
||||
headers=request.headers.raw,
|
||||
content=request.stream,
|
||||
extensions=request.extensions,
|
||||
)
|
||||
try:
|
||||
core_resp = await self._pool.handle_async_request(core_req)
|
||||
content = b"".join([chunk async for chunk in core_resp.aiter_stream()])
|
||||
await core_resp.aclose()
|
||||
except Exception as exc:
|
||||
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
|
||||
if mapped is not None:
|
||||
raise mapped(str(exc)) from exc
|
||||
raise
|
||||
return httpx.Response(
|
||||
status_code=core_resp.status,
|
||||
headers=core_resp.headers,
|
||||
content=content,
|
||||
extensions=core_resp.extensions,
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._pool.aclose()
|
||||
|
||||
|
||||
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
|
||||
"""Return every entry that parses as an IP address, de-duplicated, order
|
||||
preserved.
|
||||
|
||||
check_outbound_url only reports ok when *all* of these classify as safe, so
|
||||
the whole list is guard-approved and any of them is a legitimate connect
|
||||
target. Skipping unparseable entries mirrors how the guard walks the same
|
||||
resolver output.
|
||||
|
||||
De-duplication matters because the resolver is getaddrinfo(host, None) with
|
||||
no socktype filter, so glibc reports the same address once per socktype
|
||||
(SOCK_STREAM/SOCK_DGRAM/SOCK_RAW) — a single-homed host comes back three
|
||||
times. Without this, the connect fallback would spend the shared deadline
|
||||
retrying one dead address instead of moving on to a genuinely different one.
|
||||
"""
|
||||
ips: List[ipaddress._BaseAddress] = []
|
||||
seen = set()
|
||||
for raw in raw_ips:
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
try:
|
||||
ip = ipaddress.ip_address(raw.split("%")[0]) # strip IPv6 zone id
|
||||
except ValueError:
|
||||
continue
|
||||
if ip in seen:
|
||||
continue
|
||||
seen.add(ip)
|
||||
ips.append(ip)
|
||||
return ips
|
||||
|
||||
|
||||
async def execute_api_call(
|
||||
integration_id: str,
|
||||
method: str,
|
||||
@@ -409,13 +558,31 @@ async def execute_api_call(
|
||||
# loopback for locked-down deployments. Private stays allowed by default
|
||||
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
||||
# primary use case.
|
||||
from src.url_safety import check_outbound_url
|
||||
from src.url_safety import check_outbound_url, _default_resolver
|
||||
block_private = os.getenv(
|
||||
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
||||
).lower() == "true"
|
||||
ok, reason = check_outbound_url(url, block_private=block_private)
|
||||
# Resolve the host exactly once and remember the IPs the guard validated so
|
||||
# the request below can be pinned to them. check_outbound_url only reports
|
||||
# (ok, reason); a plain httpx client re-resolves the host at connect time,
|
||||
# which reopens a DNS-rebinding TOCTOU — a base_url host that answers with a
|
||||
# public IP for the guard and then flips to 169.254.169.254 for the connect
|
||||
# would reach cloud metadata with the integration's auth headers attached.
|
||||
resolved_ips: List[str] = []
|
||||
|
||||
def _recording_resolver(host: str) -> List[str]:
|
||||
ips = _default_resolver(host)
|
||||
resolved_ips[:] = ips
|
||||
return ips
|
||||
|
||||
ok, reason = check_outbound_url(
|
||||
url, block_private=block_private, resolver=_recording_resolver
|
||||
)
|
||||
if not ok:
|
||||
return {"error": f"URL rejected: {reason}", "exit_code": 1}
|
||||
pinned_ips = _validated_ips(resolved_ips)
|
||||
if not pinned_ips:
|
||||
return {"error": "URL rejected: host did not resolve to a usable address", "exit_code": 1}
|
||||
|
||||
method = method.upper()
|
||||
|
||||
@@ -455,7 +622,9 @@ async def execute_api_call(
|
||||
auth = httpx.BasicAuth(parts[0], parts[1])
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
|
||||
) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
|
||||
+95
-19
@@ -1,6 +1,7 @@
|
||||
# src/llm_core.py
|
||||
import httpx
|
||||
import asyncio
|
||||
import copy
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
@@ -644,7 +645,7 @@ def _build_ollama_payload(
|
||||
if options:
|
||||
payload["options"] = options
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tools"] = _alias_harmony_tools(tools, model)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1055,6 +1056,57 @@ def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
|
||||
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
||||
|
||||
|
||||
# gpt-oss (harmony) ships BUILT-IN tools named `python` and `browser`, invoked
|
||||
# with the raw body as the argument (`to=python` + bare source), while custom
|
||||
# functions use `to=functions.NAME` + JSON. A tool we expose under a built-in's
|
||||
# name therefore gets called with the built-in convention: the model emits raw
|
||||
# code, the server tries to parse it as JSON, and the whole request dies
|
||||
# ("error parsing tool call: raw='import sys, ...'"). In streaming mode Ollama
|
||||
# does not even report it — it truncates the stream, so the turn looks like an
|
||||
# empty response. `bash` collides the same way in practice.
|
||||
#
|
||||
# Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
|
||||
# tools named python+bash ............ 2/6 succeeded (4 parse failures)
|
||||
# python renamed ..................... 5/6
|
||||
# python and bash renamed ............ 6/6
|
||||
#
|
||||
# So rename the colliding tools on the way out and map the names back on the
|
||||
# way in. Confined to the transport layer: callers keep using the real names.
|
||||
_HARMONY_TOOL_ALIASES = {
|
||||
"python": "run_python_code",
|
||||
"bash": "run_shell_command",
|
||||
"browser": "web_browser_tool",
|
||||
}
|
||||
_HARMONY_TOOL_ALIASES_REVERSE = {v: k for k, v in _HARMONY_TOOL_ALIASES.items()}
|
||||
|
||||
|
||||
def _is_harmony_model(model: str) -> bool:
|
||||
"""True for gpt-oss / harmony-format models, which have built-in tool names."""
|
||||
return "gpt-oss" in (model or "").lower()
|
||||
|
||||
|
||||
def _alias_harmony_tools(tools: Optional[List[Dict]], model: str) -> Optional[List[Dict]]:
|
||||
"""Rename tools that collide with harmony built-ins. Returns a copy."""
|
||||
if not tools or not _is_harmony_model(model):
|
||||
return tools
|
||||
out = []
|
||||
for t in tools:
|
||||
fn = t.get("function") or {}
|
||||
alias = _HARMONY_TOOL_ALIASES.get(fn.get("name"))
|
||||
if alias:
|
||||
t = copy.deepcopy(t)
|
||||
t["function"]["name"] = alias
|
||||
out.append(t)
|
||||
return out
|
||||
|
||||
|
||||
def _unalias_harmony_tool_name(name: str, model: str) -> str:
|
||||
"""Map an aliased tool name in a model response back to the real name."""
|
||||
if not _is_harmony_model(model):
|
||||
return name
|
||||
return _HARMONY_TOOL_ALIASES_REVERSE.get(name, name)
|
||||
|
||||
|
||||
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
||||
if not payload.get("tools"):
|
||||
return
|
||||
@@ -1237,15 +1289,27 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
||||
return False
|
||||
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
||||
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
||||
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
|
||||
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
|
||||
# minor match, kept) instead of reading the date `20250514` as a giant minor
|
||||
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
|
||||
# 20260201`) keep their explicit minor and are still matched.
|
||||
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
|
||||
# temperature). Both version components are capped at 1-2 digits and forbid a
|
||||
# trailing digit, so an 8-digit date can never be read as a version number:
|
||||
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
|
||||
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
|
||||
# Opus, date directly after "opus-") fails to match at all rather than reading
|
||||
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
|
||||
# keep their explicit minor and are still matched.
|
||||
#
|
||||
# The minor is optional and a missing minor reads as `.0`, so major-only ids
|
||||
# like `claude-opus-5` are correctly treated as >= 4.7 (issue #5753). Without
|
||||
# this, every Opus 5 call kept `temperature` and failed with HTTP 400 — visible
|
||||
# only on paths that pass a temperature, e.g. scheduled tasks inheriting
|
||||
# `stream_agent_loop`'s 0.3 default, which returned empty responses.
|
||||
match = re.search(
|
||||
r"(?<![a-z])opus[-_]?(\d{1,2})(?!\d)(?:[-_.](\d{1,2})(?!\d))?", model.lower()
|
||||
)
|
||||
if not match:
|
||||
return False
|
||||
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||
major = int(match.group(1))
|
||||
minor = int(match.group(2)) if match.group(2) else 0
|
||||
return (major, minor) >= (4, 7)
|
||||
|
||||
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||
# API accepts "high", "medium", "low", "none" — see
|
||||
@@ -1255,8 +1319,8 @@ _MISTRAL_REASONING_EFFORT = os.getenv("ODYSSEUS_MISTRAL_REASONING_EFFORT", "high
|
||||
|
||||
# Models that support structured thinking — may output </think> without opening tag
|
||||
_THINKING_MODEL_PATTERNS = (
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "minimax",
|
||||
"m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
|
||||
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
)
|
||||
|
||||
@@ -1885,11 +1949,10 @@ def _dedupe_candidates(candidates):
|
||||
"""Filter malformed entries and drop a later repeat of an already-seen
|
||||
``(url, model)`` route, preserving order (first occurrence wins).
|
||||
|
||||
The chain is the primary target followed by the configured fallbacks, so a
|
||||
fallback that repeats the session's current model — a common misconfiguration,
|
||||
since callers prepend the live ``(url, model)`` to ``default_model_fallbacks``
|
||||
— would otherwise make the chain re-attempt the very route that just failed:
|
||||
a wasted round-trip plus a spurious ``fallback`` notice for a switch that did
|
||||
The chain is the primary target followed by any caller-authorized
|
||||
fallbacks. A fallback that repeats the session's current model would
|
||||
otherwise make the chain re-attempt the very route that just failed: a
|
||||
wasted round-trip plus a spurious ``fallback`` notice for a switch that did
|
||||
not happen. Headers are not part of the key; the first tuple (with its
|
||||
headers) is the one kept.
|
||||
"""
|
||||
@@ -2097,7 +2160,17 @@ async def llm_call_async(
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
msg = data["choices"][0]["message"]
|
||||
response = msg.get("content") or msg.get("reasoning_content") or ""
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
# (same contract as llm_call / stream_llm; see #5435).
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(cache_key, response)
|
||||
return response
|
||||
except Exception:
|
||||
@@ -2214,7 +2287,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tools"] = _alias_harmony_tools(tools, model)
|
||||
elif tool_choice_none:
|
||||
payload["tool_choice"] = "none"
|
||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||
@@ -2348,7 +2421,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
if fn.get("name"):
|
||||
_ollama_tool_calls.append({
|
||||
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
||||
"name": fn.get("name") or "",
|
||||
"name": _unalias_harmony_tool_name(fn.get("name") or "", model),
|
||||
"arguments": json.dumps(fn.get("arguments") or {}),
|
||||
})
|
||||
if j.get("done"):
|
||||
@@ -2728,7 +2801,10 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
if tc.get("extra_content"):
|
||||
_tc_acc[idx]["extra_content"] = tc["extra_content"]
|
||||
if func.get("name"):
|
||||
_tc_acc[idx]["name"] = func["name"]
|
||||
# Map harmony aliases back to real
|
||||
# tool names before anything
|
||||
# downstream sees them.
|
||||
_tc_acc[idx]["name"] = _unalias_harmony_tool_name(func["name"], model)
|
||||
if "arguments" in func:
|
||||
# Guard against a null arguments delta: `func` can be
|
||||
# {"arguments": None} (JSON null), and a raw `+= None`
|
||||
|
||||
+80
-10
@@ -10,6 +10,18 @@ from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryStoreUnreadable(RuntimeError):
|
||||
"""memory.json exists on disk but could not be read or parsed.
|
||||
|
||||
"The contents are unknown" is categorically different from "there are no
|
||||
memories". A read-modify-write caller that conflates the two appends to an
|
||||
empty view and then persists it, destroying the whole store — the writes
|
||||
are atomic, so the loss is durable. Raised by
|
||||
:meth:`MemoryManager.load_all_for_update` so those callers fail closed.
|
||||
"""
|
||||
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
||||
return [word.strip('.,!?";') for word in text.split()]
|
||||
@@ -110,21 +122,69 @@ class MemoryManager:
|
||||
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
||||
json.dump([], f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load_all(self) -> List[Dict]:
|
||||
"""Load all memory entries from JSON file (unfiltered)."""
|
||||
def _read_entries(self) -> List[Dict]:
|
||||
"""Parse the store, or raise :class:`MemoryStoreUnreadable`.
|
||||
|
||||
Returns ``[]`` only when the file genuinely does not exist. Every other
|
||||
failure mode raises, so callers can tell "no memories" apart from
|
||||
"couldn't read the memories".
|
||||
"""
|
||||
if not os.path.exists(self.memory_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.memory_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return self._validate_entries(data)
|
||||
except (json.JSONDecodeError, PermissionError) as e:
|
||||
logger.error("Error loading memory.json: %s", e)
|
||||
return self._migrate_from_legacy()
|
||||
except OSError as e:
|
||||
# PermissionError is an OSError (a scanner holding the file, a
|
||||
# permissions problem, bad media).
|
||||
raise MemoryStoreUnreadable(
|
||||
f"cannot read {self.memory_file}: {e}"
|
||||
) from e
|
||||
except json.JSONDecodeError as e:
|
||||
# This is the branch that actually destroyed stores: the file reads
|
||||
# back fine, so nothing stops the save that follows. A truncated
|
||||
# memory.json is reachable because core/database.py rewrites it with
|
||||
# a plain open(..,"w") + json.dump during migration.
|
||||
#
|
||||
# Preserved behaviour: a corrupt store still gets one shot at the
|
||||
# pre-JSON memory.txt migration. Only raise when that finds nothing,
|
||||
# so we never report "empty" for a store we simply failed to parse.
|
||||
legacy = self._migrate_from_legacy()
|
||||
if legacy:
|
||||
return legacy
|
||||
raise MemoryStoreUnreadable(
|
||||
f"{self.memory_file} is not valid JSON: {e}"
|
||||
) from e
|
||||
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
raise MemoryStoreUnreadable(
|
||||
f"{self.memory_file} is not a JSON array (got {type(data).__name__})"
|
||||
)
|
||||
return self._validate_entries(data)
|
||||
|
||||
def load_all(self) -> List[Dict]:
|
||||
"""Load all memory entries from JSON file (unfiltered).
|
||||
|
||||
Lenient by design: this feeds display, search, and context-injection
|
||||
paths, so an unreadable store degrades to an empty list rather than
|
||||
breaking chat. Never build a value from this that you intend to save
|
||||
back — use :meth:`load_all_for_update` for that.
|
||||
"""
|
||||
try:
|
||||
return self._read_entries()
|
||||
except MemoryStoreUnreadable as e:
|
||||
logger.error("Error loading memory.json: %s", e)
|
||||
return []
|
||||
|
||||
def load_all_for_update(self) -> List[Dict]:
|
||||
"""Load for a read-modify-write cycle.
|
||||
|
||||
Propagates :class:`MemoryStoreUnreadable` instead of degrading to ``[]``
|
||||
so a caller can never append to an empty view and persist it over a
|
||||
store that was only temporarily unreadable (issue #5673).
|
||||
"""
|
||||
return self._read_entries()
|
||||
|
||||
def load(self, owner: str = None) -> List[Dict]:
|
||||
"""Load memory entries, optionally filtered by owner."""
|
||||
@@ -135,7 +195,12 @@ class MemoryManager:
|
||||
|
||||
def claim_ownerless(self, owner: str):
|
||||
"""Assign all ownerless memory entries to the given owner."""
|
||||
entries = self.load_all()
|
||||
try:
|
||||
entries = self.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
# Skip the sweep rather than rewrite the store from an unknown view.
|
||||
logger.error("Skipping ownerless claim, memory store unreadable: %s", e)
|
||||
return
|
||||
changed = False
|
||||
claimed = 0
|
||||
for entry in entries:
|
||||
@@ -235,7 +300,12 @@ class MemoryManager:
|
||||
if not ids:
|
||||
return
|
||||
id_set = set(ids)
|
||||
entries = self.load_all()
|
||||
try:
|
||||
entries = self.load_all_for_update()
|
||||
except MemoryStoreUnreadable as e:
|
||||
# Best-effort counter; never worth rewriting the store blind.
|
||||
logger.error("Skipping uses bump, memory store unreadable: %s", e)
|
||||
return
|
||||
changed = False
|
||||
for e in entries:
|
||||
if e.get("id") in id_set:
|
||||
|
||||
@@ -157,7 +157,11 @@ class NativeMemoryProvider(MemoryProvider):
|
||||
if metadata:
|
||||
entry["metadata"] = dict(metadata)
|
||||
|
||||
memories = self.memory_manager.load_all()
|
||||
# Strict load: read-modify-write. `load_all` degrades an unreadable
|
||||
# store to [], which would save this single entry over everything
|
||||
# already stored (issue #5673). The provider API has no error channel,
|
||||
# so MemoryStoreUnreadable propagates to the caller.
|
||||
memories = self.memory_manager.load_all_for_update()
|
||||
memories.append(entry)
|
||||
self.memory_manager.save(memories)
|
||||
|
||||
@@ -223,7 +227,10 @@ class NativeMemoryProvider(MemoryProvider):
|
||||
]
|
||||
|
||||
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
||||
memories = self.memory_manager.load_all()
|
||||
# Strict load for the same reason: `remaining` is derived from this
|
||||
# list and saved back, so it must never be built from a store we
|
||||
# failed to read.
|
||||
memories = self.memory_manager.load_all_for_update()
|
||||
remaining = []
|
||||
deleted_id = None
|
||||
|
||||
|
||||
+6
-7
@@ -138,14 +138,13 @@ DEFAULT_SETTINGS = {
|
||||
# Email replies use email_writing_style instead because greetings,
|
||||
# signatures, and mailbox identity rules are medium-specific.
|
||||
"document_writing_style": "",
|
||||
# Ordered fallback chain for the default chat model. Each entry is
|
||||
# {"endpoint_id": "...", "model": "..."}. If the primary model fails
|
||||
# before producing output (endpoint offline / errors), the chat
|
||||
# dispatch retries the next entry in order.
|
||||
# Legacy ordered fallback chain for the default chat model. Values remain
|
||||
# stored for compatibility and rollback reference, but model routing no
|
||||
# longer reads this key.
|
||||
"default_model_fallbacks": [],
|
||||
# When True, non-admin users inherit global default model/endpoint/fallbacks
|
||||
# when they have no personal defaults. When False, users only use their
|
||||
# personal defaults (no global fallback). Default is False.
|
||||
# When True, non-admin users inherit the global default model/endpoint when
|
||||
# they have no personal defaults. When False, users only use their personal
|
||||
# defaults. Default is False.
|
||||
"share_defaults_with_users": False,
|
||||
"utility_endpoint_id": "",
|
||||
"utility_model": "",
|
||||
|
||||
@@ -32,7 +32,7 @@ def resolve_task_candidates(
|
||||
2. Utility endpoint/model
|
||||
3. Default endpoint/model
|
||||
4. Utility fallback chain
|
||||
5. Default fallback chain
|
||||
5. Retired default-fallback compatibility hook (currently empty)
|
||||
"""
|
||||
candidates = []
|
||||
|
||||
|
||||
@@ -233,7 +233,8 @@ async def _call_teacher(teacher_model_spec: str, prompt: str,
|
||||
owner: Optional[str] = None) -> Optional[str]:
|
||||
"""Call the configured teacher endpoint with the escalation prompt."""
|
||||
from src.llm_core import llm_call_async
|
||||
from src.ai_interaction import _resolve_model, _TEACHER_SYSTEM_PROMPT
|
||||
from src.ai_interaction import _resolve_model
|
||||
from src.agent_tools.model_interaction_tools import _TEACHER_SYSTEM_PROMPT
|
||||
try:
|
||||
url, model, headers = await asyncio.to_thread(_resolve_model, teacher_model_spec, owner=owner)
|
||||
except Exception as e:
|
||||
|
||||
+69
-4
@@ -187,9 +187,13 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
|
||||
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", re.IGNORECASE)
|
||||
_QWEN_ROLE_MARKER_RE = re.compile(r"</?\|(?:assistant|assistan|user|system|tool)\|>?|</\|end\|>?", re.IGNORECASE)
|
||||
# At least one pipe is required around `end`. Both pipes used to be optional
|
||||
# (`\|?end\|?`), which also matched a bare `end` on its own line and deleted it
|
||||
# from ordinary prose and from Ruby/Lua/shell snippets that close blocks with
|
||||
# one; see #5547. `|end`, `end|`, `|end|` and `/|end|` still strip as before.
|
||||
_QWEN_BARE_MARKER_RE = re.compile(
|
||||
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
|
||||
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
|
||||
r"(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -925,6 +929,46 @@ def _parse_xml_direct_tool(name, body) -> Optional[ToolBlock]:
|
||||
return function_call_to_tool_block(mapped, json.dumps(params))
|
||||
|
||||
|
||||
def _looks_like_json_body(body: str) -> bool:
|
||||
"""True when a <tool_call> wrapper body is JSON, not XML markup."""
|
||||
return body.lstrip()[:1] in ("{", "[")
|
||||
|
||||
|
||||
def _parse_json_tool_call_body(body: str) -> Optional[ToolBlock]:
|
||||
"""Parse a Qwen/Hermes text-mode wrapper body: bare JSON inside <tool_call>.
|
||||
|
||||
<tool_call>
|
||||
{"name": "bash", "arguments": {"command": "mkdir -p agent-test"}}
|
||||
</tool_call>
|
||||
|
||||
Strict by design (issue #5187 / tracker #5333): the body must decode to an
|
||||
object with a string "name", and "arguments" — when present — must itself
|
||||
be an object. Anything else returns None rather than being coerced, so a
|
||||
malformed call is dropped instead of dispatching with mangled arguments.
|
||||
raw_decode tolerates trailing chatter after the JSON object; the trailing
|
||||
text is never scanned for tool markup. Conversion goes through
|
||||
function_call_to_tool_block so aliases and per-tool argument formatting
|
||||
stay identical to the XML invoke path.
|
||||
"""
|
||||
stripped = body.strip()
|
||||
if not stripped.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
parsed, _end = json.JSONDecoder().raw_decode(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
name = parsed.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
if "arguments" in parsed and not isinstance(parsed["arguments"], dict):
|
||||
return None
|
||||
args = parsed.get("arguments", {})
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(name.strip().lower(), json.dumps(args))
|
||||
|
||||
|
||||
def _iter_stepfun_tool_calls(text: str):
|
||||
"""Yield StepFun native tool-call token bodies without regex backtracking."""
|
||||
pos = 0
|
||||
@@ -1326,10 +1370,21 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if blocks:
|
||||
return blocks
|
||||
# Try wrapped: <tool_call><invoke ...>...</invoke></tool_call>
|
||||
# A wrapper body that is JSON (Qwen/Hermes text mode, issue #5187) is
|
||||
# parsed as JSON or dropped — never scanned by the XML iterators, so
|
||||
# XML-like text inside JSON argument values stays data instead of
|
||||
# selecting a different tool.
|
||||
json_body_seen = False
|
||||
for _ms, inner_start, inner_end, _me in _iter_delimited(
|
||||
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
||||
):
|
||||
body = text[inner_start:inner_end]
|
||||
if _looks_like_json_body(body):
|
||||
json_body_seen = True
|
||||
block = _parse_json_tool_call_body(body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
continue
|
||||
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
@@ -1344,6 +1399,13 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
if not blocks:
|
||||
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
||||
body = m.group(1)
|
||||
if _looks_like_json_body(body):
|
||||
# Same fail-closed rule as above for an unclosed wrapper.
|
||||
json_body_seen = True
|
||||
block = _parse_json_tool_call_body(body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
break
|
||||
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
@@ -1354,8 +1416,11 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
||||
block = _parse_xml_direct_tool(d_name, d_body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
# Try bare <invoke> without wrapper
|
||||
if not blocks:
|
||||
# Try bare <invoke> without wrapper. Skipped when a JSON wrapper body
|
||||
# was seen but produced no block: this rescan covers the full text,
|
||||
# wrapper bodies included, and <invoke> markup inside a (possibly
|
||||
# malformed) JSON payload must stay data rather than dispatch.
|
||||
if not blocks and not json_body_seen:
|
||||
for inv_name, inv_body in _iter_xml_invoke(text):
|
||||
block = _parse_xml_invoke(inv_name, inv_body)
|
||||
if block:
|
||||
|
||||
@@ -196,6 +196,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
||||
try:
|
||||
if action == "list_calendars":
|
||||
_ensure_default_calendar(db, owner)
|
||||
# This read path intentionally persists the lazily-created default;
|
||||
# event creation commits it in the event's transaction instead.
|
||||
db.commit()
|
||||
cals = _calendar_query().all()
|
||||
result = [{"name": c.name, "href": c.id} for c in cals]
|
||||
if result:
|
||||
|
||||
+4
-2
@@ -46,7 +46,9 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
|
||||
action = (args.get("action") or "").lower()
|
||||
action = (args.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
return {"error": "action is required (list|view|view_ref|add|edit|patch|publish|delete|search)", "exit_code": 1}
|
||||
from services.memory.skills import SkillsManager
|
||||
from services.memory.skill_format import Skill, slugify
|
||||
from src.constants import DATA_DIR
|
||||
@@ -55,7 +57,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# Accept legacy `skill_id` as an alias for `name`.
|
||||
name = (args.get("name") or args.get("skill_id") or "").strip()
|
||||
|
||||
if action in ("list", "index", ""):
|
||||
if action in ("list", "index"):
|
||||
all_skills = sm.load(owner=owner)
|
||||
if not all_skills:
|
||||
return {"results": "No skills yet. Create one with action='add'."}
|
||||
|
||||
+105
-38
@@ -35,6 +35,16 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UploadIndexFileSignature = tuple[
|
||||
str,
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
Optional[int],
|
||||
]
|
||||
UploadIndexSignature = tuple[UploadIndexFileSignature, ...]
|
||||
|
||||
|
||||
class UploadCleanupSafetyError(RuntimeError):
|
||||
"""Raised when cleanup cannot prove that destructive work is safe."""
|
||||
@@ -242,7 +252,7 @@ class UploadHandler:
|
||||
|
||||
# In-memory index cache to avoid O(N) disk I/O on every request
|
||||
self._index_cache: Optional[Dict[str, Any]] = None
|
||||
self._index_mtime: float = 0.0
|
||||
self._index_signature: Optional[UploadIndexSignature] = None
|
||||
|
||||
def inside_base_dir(self, path: str) -> bool:
|
||||
"""Check if path is inside base directory"""
|
||||
@@ -727,62 +737,119 @@ class UploadHandler:
|
||||
# Update cache if this is the main index
|
||||
if path.endswith("uploads.json"):
|
||||
self._index_cache = data
|
||||
self._index_signature = self._upload_index_signature(
|
||||
(path, path + ".bak")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upload_index_signature(
|
||||
paths: tuple[str, ...],
|
||||
) -> Optional[UploadIndexSignature]:
|
||||
"""Return file identities strong enough to validate the index cache.
|
||||
|
||||
Modification time alone is insufficient: a torn write can change a
|
||||
file without receiving a strictly newer timestamp on some filesystems.
|
||||
Size, inode, and nanosecond change times make those mutations visible
|
||||
while preserving the cache fast path for unchanged files.
|
||||
"""
|
||||
signature: list[UploadIndexFileSignature] = []
|
||||
for candidate in paths:
|
||||
try:
|
||||
self._index_mtime = os.path.getmtime(path)
|
||||
stat_result = os.stat(candidate)
|
||||
except FileNotFoundError:
|
||||
signature.append((candidate, None, None, None, None, None))
|
||||
continue
|
||||
except OSError:
|
||||
self._index_mtime = time.time()
|
||||
return None
|
||||
signature.append(
|
||||
(
|
||||
candidate,
|
||||
stat_result.st_dev,
|
||||
stat_result.st_ino,
|
||||
stat_result.st_size,
|
||||
stat_result.st_mtime_ns,
|
||||
stat_result.st_ctime_ns,
|
||||
)
|
||||
)
|
||||
return tuple(signature)
|
||||
|
||||
def _load_upload_index(self, *, fail_on_error: bool = False) -> Dict[str, Any]:
|
||||
"""Load the upload index from disk/cache. Uses mtime-based validation
|
||||
to avoid redundant parsing on hot paths. When ``fail_on_error`` is
|
||||
true, a missing, malformed, or unreadable live index raises so
|
||||
destructive callers cannot mistake corruption for an empty store.
|
||||
"""Load the upload index from disk/cache. Uses file-identity validation
|
||||
to avoid redundant parsing on hot paths without missing same-timestamp
|
||||
mutations. When ``fail_on_error`` is true, a missing, malformed, or
|
||||
unreadable live index raises so destructive callers cannot mistake
|
||||
corruption for an empty store.
|
||||
"""
|
||||
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
|
||||
candidates = (uploads_db_path, uploads_db_path + ".bak")
|
||||
if fail_on_error:
|
||||
# A backup is intentionally the previous snapshot. It is useful for
|
||||
# non-destructive reads, but cannot authorize deletion when the live
|
||||
# index is missing or corrupt.
|
||||
if not os.path.exists(uploads_db_path):
|
||||
raise ValueError("live uploads database is missing")
|
||||
existing_candidates = [uploads_db_path]
|
||||
else:
|
||||
existing_candidates = [path for path in candidates if os.path.exists(path)]
|
||||
if not existing_candidates:
|
||||
self._index_cache = {}
|
||||
self._index_mtime = 0.0
|
||||
return {}
|
||||
for _attempt in range(3):
|
||||
signature = self._upload_index_signature(candidates)
|
||||
if fail_on_error:
|
||||
# A backup is intentionally the previous snapshot. It is useful for
|
||||
# non-destructive reads, but cannot authorize deletion when the live
|
||||
# index is missing or corrupt.
|
||||
if not os.path.exists(uploads_db_path):
|
||||
raise ValueError("live uploads database is missing")
|
||||
existing_candidates = [uploads_db_path]
|
||||
else:
|
||||
existing_candidates = [
|
||||
path for path in candidates if os.path.exists(path)
|
||||
]
|
||||
if not existing_candidates:
|
||||
self._index_cache = {}
|
||||
self._index_signature = signature
|
||||
return {}
|
||||
|
||||
# Check cache validity
|
||||
try:
|
||||
mtime = max(os.path.getmtime(path) for path in existing_candidates)
|
||||
# Check cache validity
|
||||
if (
|
||||
not fail_on_error
|
||||
and signature is not None
|
||||
and self._index_cache is not None
|
||||
and mtime <= self._index_mtime
|
||||
and signature == self._index_signature
|
||||
):
|
||||
return self._index_cache
|
||||
except OSError:
|
||||
mtime = 0.0
|
||||
|
||||
# Try the live file first, fall back to the .bak sibling if the
|
||||
# live file is truncated/corrupted.
|
||||
for candidate in existing_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
self._index_cache = data
|
||||
self._index_mtime = mtime
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
|
||||
# Try the live file first, fall back to the .bak sibling if the
|
||||
# live file is truncated/corrupted. A candidate parsed from an old
|
||||
# inode is accepted only when the whole index signature stays
|
||||
# stable through the read; otherwise retry so the cache cannot pair
|
||||
# stale data with a fresh replacement signature.
|
||||
index_changed_during_read = False
|
||||
for candidate in existing_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
verified_signature = self._upload_index_signature(candidates)
|
||||
if (
|
||||
signature is not None
|
||||
and verified_signature is not None
|
||||
and verified_signature != signature
|
||||
):
|
||||
index_changed_during_read = True
|
||||
break
|
||||
if isinstance(data, dict):
|
||||
self._index_cache = data
|
||||
self._index_signature = verified_signature
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read uploads database ({candidate}): {e}")
|
||||
verified_signature = self._upload_index_signature(candidates)
|
||||
if (
|
||||
signature is not None
|
||||
and verified_signature is not None
|
||||
and verified_signature != signature
|
||||
):
|
||||
index_changed_during_read = True
|
||||
break
|
||||
continue
|
||||
if index_changed_during_read:
|
||||
continue
|
||||
break
|
||||
|
||||
if fail_on_error:
|
||||
raise ValueError("live uploads database is unreadable")
|
||||
self._index_cache = {}
|
||||
self._index_signature = self._upload_index_signature(candidates)
|
||||
return {}
|
||||
|
||||
def get_upload_info(self, upload_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+50
-147
@@ -10,18 +10,25 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
||||
import ragModule from './js/rag.js';
|
||||
import presetsModule from './js/presets.js';
|
||||
import searchModule from './js/search.js';
|
||||
import chatModule from './js/chat.js?v=20260722ctxheader4';
|
||||
import chatModule from './js/chat.js?v=20260801fix1';
|
||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import {
|
||||
revealApplicationShellAfterPaint,
|
||||
runDeferredRouteOpener,
|
||||
deferRouteOpener,
|
||||
settleSessionHydration
|
||||
} from './js/startupShell.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
|
||||
import sessionModule from './js/sessions.js';
|
||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||
import censorModule from './js/censor.js';
|
||||
import galleryModule from './js/gallery.js';
|
||||
import { UI_VIS_DEFAULT_OFF, resolveVisibility } from './js/ui_visibility.js';
|
||||
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||
import calendarModule from './js/calendar.js';
|
||||
import notesModule from './js/notes.js';
|
||||
@@ -1217,12 +1224,13 @@ function initializeEventListeners() {
|
||||
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
||||
};
|
||||
const _opener = _routeOpen[urlPath];
|
||||
// Defer the opener — at this point in init, the modules whose handlers
|
||||
// we trigger (#rail-new-session click handler, the email-section header
|
||||
// click handler in emailInbox, sessionModule's loaded session list) are
|
||||
// still being wired up further down in this same function. Stash the
|
||||
// opener so it runs from sessionModule.loadSessions().finally() below.
|
||||
if (_opener) window._odysseusRouteOpener = _opener;
|
||||
// Defer the opener — at this point in init, the modules whose handlers we
|
||||
// trigger (#rail-new-session click handler, the email-section header click
|
||||
// handler in emailInbox, sessionModule) are still being wired up further
|
||||
// down in this same function. startupShell decides when it can run: as soon
|
||||
// as wiring completes, or — for the routes that read the session list —
|
||||
// once /api/sessions has settled.
|
||||
deferRouteOpener(urlPath, _opener);
|
||||
|
||||
// Archive browser tool button
|
||||
const toolLibraryBtn = el('tool-library-btn');
|
||||
@@ -1689,12 +1697,20 @@ function initializeEventListeners() {
|
||||
|
||||
const newMemoryInput = el('new-memory-input');
|
||||
if (newMemoryInput) {
|
||||
newMemoryInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
// keydown, not the deprecated keypress: keypress is not guaranteed to
|
||||
// fire for Enter everywhere, which left the Add Memory form with no
|
||||
// working submit path (#5828).
|
||||
newMemoryInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
memoryModule.addNewMemory();
|
||||
}
|
||||
});
|
||||
}
|
||||
const newMemoryAddBtn = el('new-memory-add-btn');
|
||||
if (newMemoryAddBtn) {
|
||||
newMemoryAddBtn.addEventListener('click', () => memoryModule.addNewMemory());
|
||||
}
|
||||
|
||||
// Voice recording is handled by the dual-purpose send/mic button (see below)
|
||||
|
||||
@@ -2710,46 +2726,6 @@ function initializeEventListeners() {
|
||||
// ── UI Visibility (Customize UI modal) ──
|
||||
const UI_VIS_KEY = 'odysseus-ui-visibility';
|
||||
|
||||
// Selector map: key → CSS selector(s) for targets
|
||||
const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool visibility — fine-grained control over which entries show
|
||||
// inside the Tools section in the sidebar.
|
||||
'tool-calendar': '#tool-calendar-btn',
|
||||
'tool-compare': '#tool-compare-btn',
|
||||
'tool-cookbook': '#tool-cookbook-btn',
|
||||
'tool-research': '#tool-research-btn',
|
||||
'tool-gallery': '#tool-gallery-btn',
|
||||
'tool-library': '#tool-library-btn',
|
||||
'tool-memory': '#tool-memory-btn',
|
||||
'tool-notes': '#tool-notes-btn',
|
||||
'tool-tasks': '#tool-tasks-btn',
|
||||
'tool-theme': '#tool-theme-btn',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet)
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
// Keys that need admin to toggle off (reserved for future use)
|
||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||
|
||||
@@ -2762,14 +2738,14 @@ function initializeEventListeners() {
|
||||
}
|
||||
|
||||
function applyUIVis(state) {
|
||||
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
|
||||
// section-drag-reorder uses a body class instead of inline styles
|
||||
if (key === 'section-drag-reorder') return;
|
||||
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
// resolveVisibility computes selector→visible (pure; ui_visibility.js),
|
||||
// including the tools-section parent rule that hides every tool rail
|
||||
// launcher when Tools is off. Apply the result to the DOM here.
|
||||
for (const [selector, visible] of Object.entries(resolveVisibility(state))) {
|
||||
document.querySelectorAll(selector).forEach(el => {
|
||||
el.style.display = visible ? '' : 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
// Drag reorder: use body class so dynamically created handles are covered
|
||||
const dragEnabled = state['section-drag-reorder'] === true;
|
||||
document.body.classList.toggle('rearrange-mode', dragEnabled);
|
||||
@@ -3908,85 +3884,10 @@ function startOdysseusApp() {
|
||||
const messageInput = el('message');
|
||||
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
||||
|
||||
function _readComposerPromptHistory() {
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
if (!chatBox) return [];
|
||||
return Array.from(chatBox.querySelectorAll('.msg-user'))
|
||||
.reverse()
|
||||
.map(msg => {
|
||||
const body = msg.querySelector('.body');
|
||||
return msg.dataset?.raw || (body ? body.textContent : '') || '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (messageInput && !messageInput._odysseusPromptRecallCapture) {
|
||||
messageInput._odysseusPromptRecallCapture = true;
|
||||
let recallHistory = [];
|
||||
let recallIndex = -1;
|
||||
let lastRecalled = '';
|
||||
const norm = (v) => String(v || '').replace(/\r\n/g, '\n').trimEnd();
|
||||
messageInput.addEventListener('input', () => {
|
||||
if (norm(messageInput.value) === norm(lastRecalled)) return;
|
||||
recallHistory = [];
|
||||
recallIndex = -1;
|
||||
lastRecalled = '';
|
||||
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||
}, true);
|
||||
messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||||
if (window._ghostAutocomplete?.isActive?.()) return;
|
||||
const fresh = _readComposerPromptHistory();
|
||||
const history = fresh.length ? fresh : recallHistory;
|
||||
if (!history.length) return;
|
||||
const current = norm(messageInput.value);
|
||||
let currentIndex = current ? history.findIndex(item => norm(item) === current) : -1;
|
||||
if (current && currentIndex < 0 && current === norm(lastRecalled)) currentIndex = recallIndex;
|
||||
if (current && currentIndex < 0) {
|
||||
const markedIndex = Number(messageInput.dataset.odysseusRecallIndex);
|
||||
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
|
||||
currentIndex = markedIndex;
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (currentIndex < 0) return;
|
||||
const nextIndex = currentIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
recallHistory = history;
|
||||
recallIndex = -1;
|
||||
lastRecalled = '';
|
||||
try { delete messageInput.dataset.odysseusRecallIndex; } catch {}
|
||||
messageInput.value = '';
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = 0; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
return;
|
||||
}
|
||||
const recalled = history[nextIndex];
|
||||
recallHistory = history;
|
||||
recallIndex = nextIndex;
|
||||
lastRecalled = recalled;
|
||||
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||
messageInput.value = recalled;
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
return;
|
||||
}
|
||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||
const recalled = history[nextIndex];
|
||||
if (!recalled) return;
|
||||
recallHistory = history;
|
||||
recallIndex = nextIndex;
|
||||
lastRecalled = recalled;
|
||||
try { messageInput.dataset.odysseusRecallIndex = String(nextIndex); } catch {}
|
||||
messageInput.value = recalled;
|
||||
try { messageInput.selectionStart = messageInput.selectionEnd = recalled.length; } catch {}
|
||||
try { uiModule.autoResize(messageInput); } catch {}
|
||||
}, true);
|
||||
}
|
||||
// ArrowUp/ArrowDown prompt recall on #message lives in
|
||||
// static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
|
||||
// copy here: two capture-phase listeners on the same textarea meant the one
|
||||
// without the draft guard won and ate unsent multi-line prompts (#5862).
|
||||
|
||||
const _sendIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
||||
const _micIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>';
|
||||
@@ -4382,6 +4283,10 @@ function startOdysseusApp() {
|
||||
// Load initial data
|
||||
presetsModule.loadPresets(uiModule.showError);
|
||||
|
||||
// Core wiring is complete for this turn — reveal the shell independently of
|
||||
// the session-list request.
|
||||
revealApplicationShellAfterPaint();
|
||||
|
||||
if (sessionModule) {
|
||||
sessionModule.initDependencies({
|
||||
API_BASE: API_BASE,
|
||||
@@ -4393,21 +4298,19 @@ function startOdysseusApp() {
|
||||
scrollHistory: uiModule.scrollHistoryInstant
|
||||
});
|
||||
|
||||
// Load sessions first (critical path) — remove loader when done
|
||||
sessionModule.loadSessions()
|
||||
.catch(e => console.warn('loadSessions error:', e))
|
||||
.finally(() => {
|
||||
const loader = document.getElementById('app-loader');
|
||||
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
|
||||
// Fire any URL route opener now that sessions + module wiring are
|
||||
// ready. Deferred from up top of init for exactly this reason.
|
||||
if (window._odysseusRouteOpener) {
|
||||
try { window._odysseusRouteOpener(); } catch (_) {}
|
||||
window._odysseusRouteOpener = null;
|
||||
}
|
||||
});
|
||||
// sessionModule is now wired, so every route opener has the modules it
|
||||
// drives. The ones that read no session data open here rather than
|
||||
// queueing behind /api/sessions.
|
||||
runDeferredRouteOpener();
|
||||
|
||||
// The shell is already usable at this point; session hydration is
|
||||
// sidebar-local and settles on its own schedule.
|
||||
settleSessionHydration(() => sessionModule.loadSessions());
|
||||
} else {
|
||||
console.error('Session module not loaded!');
|
||||
// Nothing will hydrate. Settle immediately so the sidebar exposes the
|
||||
// failure; session-dependent routes must remain unopened without data.
|
||||
settleSessionHydration(null);
|
||||
}
|
||||
|
||||
const runNonCriticalStartup = (fn, delay = 4000) => {
|
||||
|
||||
+33
-11
@@ -248,11 +248,20 @@
|
||||
}, { once: true });
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
|
||||
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
||||
the app font and the weight the sidebar and header text render at. They
|
||||
are declared in style.css, so without a hint they are only discovered
|
||||
after the stylesheet parses and then queue behind the module graph.
|
||||
crossorigin is required even though these are same-origin: fonts are
|
||||
always fetched in CORS mode, and a preload whose mode does not match the
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260808startupshell1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260801fix1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||
</head>
|
||||
<body>
|
||||
@@ -286,7 +295,13 @@
|
||||
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
||||
render();
|
||||
},150);
|
||||
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
||||
// startupShell.js hides the loader as soon as the shell is wired; it calls
|
||||
// back here to stop the wave because this interval is owned by this script.
|
||||
window.__odysseusLoaderWaveStop=function(){clearInterval(iv)};
|
||||
// Last-resort fallback for a boot that never reaches app.js at all. Must
|
||||
// still REMOVE the node: sessions.js reads its presence as "startup in
|
||||
// progress" and stops clearing the composer while it is around.
|
||||
setTimeout(function(){var l=document.getElementById('app-loader');if(l){clearInterval(iv);l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
||||
})();
|
||||
</script>
|
||||
<!-- Memory Management Modal -->
|
||||
@@ -365,6 +380,7 @@
|
||||
<span class="skill-rich-ph"><span class="k">Add a memory</span> — e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
</div>
|
||||
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
|
||||
<button type="button" id="new-memory-add-btn" class="theme-io-btn" title="Save this memory" style="flex:none;height:28px;font-size:12px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
@@ -812,7 +828,13 @@
|
||||
<button class="session-bulk-btn" id="session-bulk-cancel" title="Cancel"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="session-list" role="listbox"></div>
|
||||
<div id="session-list" role="listbox">
|
||||
<!-- Sidebar-local bootstrap state. renderSessionList() replaces the
|
||||
whole list on first hydration, so this row is transient. -->
|
||||
<div id="session-list-loading" class="list-item session-list-bootstrap" role="option" aria-disabled="true" aria-live="polite" aria-atomic="true">
|
||||
<span class="grow muted" data-session-list-status>Loading chats…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden dropdown for session actions -->
|
||||
<div id="session-actions-dropdown" class="dropdown hidden">
|
||||
@@ -1005,7 +1027,7 @@
|
||||
var tips = mobile ? phone : desktop;
|
||||
var el = document.getElementById('welcome-tip');
|
||||
if (el) {
|
||||
el.textContent = 'Pick a model if you want, or just type.';
|
||||
el.textContent = tips[Math.floor(Math.random() * tips.length)];
|
||||
}
|
||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||
if (d.version) window._appVersion = d.version;
|
||||
@@ -1482,7 +1504,7 @@
|
||||
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||
</div>
|
||||
<div class="settings-row" style="align-items:flex-start;">
|
||||
<div class="settings-row" style="align-items:flex-start;" hidden>
|
||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
|
||||
@@ -2504,7 +2526,7 @@
|
||||
<script type="module" src="/static/js/ui.js"></script>
|
||||
<script type="module" src="/static/js/markdown.js"></script>
|
||||
<script type="module" src="/static/js/dragSort.js"></script>
|
||||
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
|
||||
<script type="module" src="/static/js/sessions.js"></script>
|
||||
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></script>
|
||||
<script type="module" src="/static/js/skills.js"></script>
|
||||
<script type="module" src="/static/js/tourHints.js"></script>
|
||||
@@ -2522,7 +2544,7 @@
|
||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
@@ -2530,7 +2552,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script>
|
||||
<script type="module" src="/static/js/assistant.js"></script>
|
||||
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||
|
||||
@@ -61,6 +61,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog
|
||||
| **`chatRenderer.js`** | Message DOM construction: `addMessage`, role labels, model route labels, color coding, footers, metrics, code blocks, sources boxes (`web`/`research`/`RAG`), findings box, images, report links, ask-user cards, welcome screen, and transcript utilities. |
|
||||
| **`streamingRenderer.js`** | Incremental streaming renderer used by `chat.js`. Freezes finalized DOM blocks and only re-renders the growing tail to avoid flicker and O(N²) re-parsing. |
|
||||
| **`streamingSegmenter.js`** | Splits a token stream into display units (text vs code fences) for `streamingRenderer.js`. |
|
||||
| **`liveThinkingThrottle.js`** | Trailing-edge coalescer for the live thinking block in `chat.js`: one DOM commit per 100 ms carrying the latest reasoning text, with `flush`/`cancel` for terminal and session-switch paths. |
|
||||
| **`slashCommands.js`** | Slash-command registry (`/help`, `/setup`, etc.), parsing, and dispatch handlers. Exported functions are consumed by `chat.js` and `slashAutocomplete.js`. |
|
||||
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
|
||||
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
|
||||
|
||||
+522
-233
File diff suppressed because it is too large
Load Diff
@@ -478,7 +478,10 @@ const DSML_STRAY_RE = /<\s*\/?\s*[||]+\s*DSML\s*[||]+[^>]*>/gi;
|
||||
const DSML_INVOKE_RE = /<\s*[||]+\s*DSML\s*[||]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[||]+\s*DSML\s*[||]+\s*invoke\s*>|$)/gi;
|
||||
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
|
||||
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
|
||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
||||
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
|
||||
// pipe is required around `end`: with both optional (`\|?end\|?`) this also ate
|
||||
// a bare `end` on its own line, breaking Ruby/Lua/shell snippets (#5547).
|
||||
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)/gi;
|
||||
// Self-narration about tool results (model echoing stdout/exit_code)
|
||||
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
|
||||
|
||||
|
||||
@@ -143,9 +143,9 @@ export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowUp owns prompt history in the chat composer. If the current text
|
||||
// is not already a recalled prompt, start from newest instead of letting
|
||||
// the browser move the caret inside the textarea.
|
||||
// ArrowUp walks older prompts. An unmatched draft already returned above,
|
||||
// so reaching here means the composer is empty or holds a recalled prompt
|
||||
// — the caret-navigation case is never hijacked.
|
||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||
const recalled = history[nextIndex];
|
||||
if (!recalled) {
|
||||
|
||||
+49
-10
@@ -149,6 +149,7 @@ let _loading = false;
|
||||
let _expanded = false;
|
||||
let _docModule = null;
|
||||
let _listSpinner = null;
|
||||
let _openEmailRequestSeq = 0;
|
||||
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
||||
let _senderFilterLabel = null; // display label for the active filter chip
|
||||
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
||||
@@ -187,7 +188,7 @@ export function init(documentModule) {
|
||||
} catch (_) {}
|
||||
if (opts.compose) { _composeNew(); return; }
|
||||
if (opts.email) {
|
||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
|
||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '', '', opts.mailboxContext || null);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -751,7 +752,21 @@ function _createEmailItem(em) {
|
||||
return item;
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '', mailboxContext = null) {
|
||||
const openRequestSeq = ++_openEmailRequestSeq;
|
||||
const folderAtStart = mailboxContext?.messageFolder || _currentFolder;
|
||||
const accountAtStart = mailboxContext?.accountId ?? (window.__odysseusActiveEmailAccount || '');
|
||||
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const mailboxContextIsCurrent = typeof mailboxContext?.isCurrent === 'function'
|
||||
? mailboxContext.isCurrent
|
||||
: () => (
|
||||
folderAtStart === _currentFolder &&
|
||||
accountAtStart === (window.__odysseusActiveEmailAccount || '')
|
||||
);
|
||||
const isCurrentOpen = () => (
|
||||
openRequestSeq === _openEmailRequestSeq &&
|
||||
mailboxContextIsCurrent()
|
||||
);
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||
@@ -780,9 +795,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
let data = preloadedData;
|
||||
if (!data) {
|
||||
const fullQS = mode === 'forward' ? '&full=1' : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true${fullQS}`);
|
||||
data = await res.json();
|
||||
}
|
||||
if (!isCurrentOpen()) return;
|
||||
if (data.error) {
|
||||
console.error('Failed to read email:', data.error);
|
||||
return;
|
||||
@@ -808,7 +824,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
message_id: _fallback(data.message_id, em.message_id),
|
||||
};
|
||||
if (wantsAiReply) {
|
||||
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
|
||||
const activeReplyAccount = data.account_id || em.account_id || accountAtStart;
|
||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||
} else {
|
||||
@@ -834,7 +850,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
session_id: currentSessionId,
|
||||
message_id: data.message_id || '',
|
||||
uid: String(em.uid || ''),
|
||||
folder: _currentFolder,
|
||||
folder: folderAtStart,
|
||||
account_id: activeReplyAccount,
|
||||
fast: true,
|
||||
user_hint: (noteHint || '').trim() || undefined,
|
||||
@@ -842,6 +858,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
});
|
||||
const result = await res.json();
|
||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||
if (!isCurrentOpen()) return;
|
||||
if (result.success && result.reply) {
|
||||
aiSuggestedBody = _cleanAiReplyText(result.reply);
|
||||
} else {
|
||||
@@ -855,6 +872,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}
|
||||
} catch (e) {
|
||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||
if (!isCurrentOpen()) return;
|
||||
console.error('AI reply generation failed:', e);
|
||||
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
||||
return;
|
||||
@@ -862,8 +880,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}
|
||||
}
|
||||
|
||||
em.is_read = true;
|
||||
if (itemEl) itemEl.classList.remove('email-unread');
|
||||
if (!isCurrentOpen()) return;
|
||||
// Only claim the message is read when the provider accepted the \Seen
|
||||
// transition. A failed STORE still opens the message; it just stays unread.
|
||||
const markedSeen = !data.mark_seen_failed;
|
||||
em.is_read = markedSeen;
|
||||
if (itemEl) itemEl.classList.toggle('email-unread', !markedSeen);
|
||||
|
||||
// Addresses to exclude from Reply All. Prefer the full set of configured
|
||||
// accounts (so a multi-account user's other mailboxes are excluded too),
|
||||
@@ -911,7 +933,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
|
||||
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : data.message_id}`;
|
||||
content += `\nX-Source-UID: ${em.uid}`;
|
||||
content += `\nX-Source-Folder: ${_currentFolder}`;
|
||||
content += `\nX-Source-Folder: ${folderAtStart}`;
|
||||
if (data.attachments && data.attachments.length > 0) {
|
||||
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
||||
content += `\nX-Attachments: ${attStr}`;
|
||||
@@ -980,21 +1002,27 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
// and block Send on long threads.
|
||||
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
||||
? _docModule.findEmailDocId(em.uid, folderAtStart)
|
||||
: null;
|
||||
if (existingDocId) {
|
||||
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
if (!isCurrentOpen()) return;
|
||||
await _docModule.loadDocument(existingDocId);
|
||||
if (!isCurrentOpen()) return;
|
||||
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
||||
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
||||
if (!isCurrentOpen()) return;
|
||||
}
|
||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
||||
if (!isCurrentOpen()) return;
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
} else {
|
||||
if (!isCurrentOpen()) return;
|
||||
let activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (!isCurrentOpen()) return;
|
||||
if (!activeSid) {
|
||||
console.error('reply: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
@@ -1012,13 +1040,20 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}),
|
||||
});
|
||||
let docRes = await createReplyDoc(activeSid);
|
||||
if (!isCurrentOpen()) return;
|
||||
if (docRes.status === 404) {
|
||||
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
||||
if (!isCurrentOpen()) return;
|
||||
activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (activeSid) docRes = await createReplyDoc(activeSid);
|
||||
if (!isCurrentOpen()) return;
|
||||
if (activeSid) {
|
||||
docRes = await createReplyDoc(activeSid);
|
||||
if (!isCurrentOpen()) return;
|
||||
}
|
||||
}
|
||||
if (!docRes.ok) {
|
||||
const errText = await docRes.text();
|
||||
if (!isCurrentOpen()) return;
|
||||
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
||||
// uiModule isn't statically imported here — use the dynamic
|
||||
// import pattern the rest of this file uses. (Previously this
|
||||
@@ -1028,10 +1063,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
return;
|
||||
}
|
||||
const doc = await docRes.json();
|
||||
if (!isCurrentOpen()) return;
|
||||
if (doc.id) {
|
||||
const wasOpen = _docModule.isPanelOpen();
|
||||
if (!wasOpen) _docModule.openPanel();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
if (!isCurrentOpen()) return;
|
||||
// Use the doc dict from the POST directly — avoids a 404 race
|
||||
// when the GET fires before the new row is visible to the read
|
||||
// connection (or when caching is interfering). loadDocument's
|
||||
@@ -1040,12 +1077,14 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
_docModule.injectFreshDoc(doc);
|
||||
} else {
|
||||
await _docModule.loadDocument(doc.id);
|
||||
if (!isCurrentOpen()) return;
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isCurrentOpen()) return;
|
||||
console.error('Failed to open email:', e);
|
||||
// Surface the failure so a silent throw in the reply flow doesn't
|
||||
// look like "nothing happened". Dynamic import — uiModule isn't a
|
||||
|
||||
+423
-157
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
||||
import {
|
||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
||||
_sanitizeHtml,
|
||||
_sanitizeHtml, _renderEmailSummaryError,
|
||||
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
||||
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
||||
} from './emailLibrary/utils.js';
|
||||
@@ -30,6 +30,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
let _libLoadSeq = 0;
|
||||
let _emailMailboxGeneration = 0;
|
||||
let _emailCardOpenSeq = 0;
|
||||
let _emailReadMutationSeq = 0;
|
||||
const _emailReadMutations = new Map();
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
@@ -837,14 +841,41 @@ document.addEventListener('keydown', (e) => {
|
||||
e.stopImmediatePropagation?.();
|
||||
}, true);
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true) {
|
||||
function _emailReadContextKey(context) {
|
||||
return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000');
|
||||
}
|
||||
|
||||
function _emailReadContextIsCurrent(context) {
|
||||
if (!context) return true;
|
||||
return (
|
||||
String(state._libAccountId || '') === context.accountId &&
|
||||
String(state._libFolder || 'INBOX') === context.libraryFolder &&
|
||||
_emailMailboxGeneration === context.mailboxGeneration
|
||||
);
|
||||
}
|
||||
|
||||
function _emailMatchesReadContext(email, context) {
|
||||
if (String(email?.uid || '') !== context.uid) return false;
|
||||
const accountId = String(email?.account_id || context.accountId);
|
||||
const folder = String(email?.folder || context.folder);
|
||||
return accountId === context.accountId && folder === context.folder;
|
||||
}
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true, context = null) {
|
||||
if (uid == null) return;
|
||||
const uidStr = String(uid);
|
||||
const read = !!isRead;
|
||||
const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
|
||||
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
|
||||
const match = (state._libEmails || []).find(x => (
|
||||
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
|
||||
));
|
||||
if (match) match.is_read = read;
|
||||
|
||||
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
|
||||
if (context && (
|
||||
String(card.dataset.emailAccount || '') !== context.accountId ||
|
||||
String(card.dataset.emailFolder || '') !== context.folder
|
||||
)) return;
|
||||
card.classList.toggle('email-card-unread', !read);
|
||||
const titleRow = card.querySelector('.email-card-titlerow');
|
||||
if (read) {
|
||||
@@ -1762,11 +1793,18 @@ function _rememberedEmailAccountId() {
|
||||
// results and __scheduled__ are deliberately not cached.
|
||||
const _libListCache = new Map();
|
||||
const _LIB_CACHE_MAX = 24;
|
||||
const _LIB_INITIAL_PAGE_SIZE = 100;
|
||||
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
||||
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
||||
let _libPrewarmTimer = null;
|
||||
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
|
||||
let _libPrewarmDelayTimer = null;
|
||||
let _libPrewarmIdleHandle = null;
|
||||
let _libPrewarmPromise = null;
|
||||
let _libPrewarmResolve = null;
|
||||
let _libPrewarmAbortController = null;
|
||||
let _libPrewarmDetachPriorityListeners = null;
|
||||
let _libPrewarmGeneration = 0;
|
||||
let _libLastPrewarmAt = 0;
|
||||
let _libUnreadPrewarmKey = '';
|
||||
let _libUnreadPrewarmAt = 0;
|
||||
@@ -1908,6 +1946,7 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
|
||||
_exitEmailReaderModeForList();
|
||||
_resetBulkSelectionForContextChange();
|
||||
state._libOffset = 0;
|
||||
_emailMailboxGeneration += 1;
|
||||
_libLoadSeq += 1;
|
||||
const ck = _libCacheKey();
|
||||
const cached = useCache ? _libCacheGet(ck) : null;
|
||||
@@ -2076,162 +2115,319 @@ function _isChatInteractionBusy() {
|
||||
}
|
||||
}
|
||||
|
||||
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
|
||||
const run = () => {
|
||||
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
||||
if (_isChatInteractionBusy() && retries > 0) {
|
||||
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
||||
function _canRunEmailPrewarm() {
|
||||
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||
return !_isChatInteractionBusy();
|
||||
}
|
||||
|
||||
function _isEmailPrewarmTemporarilyBlocked() {
|
||||
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
||||
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
||||
return _isChatInteractionBusy();
|
||||
}
|
||||
|
||||
function _isEmailPrewarmCurrent(generation, signal) {
|
||||
return generation === _libPrewarmGeneration
|
||||
&& !signal?.aborted
|
||||
&& _canRunEmailPrewarm();
|
||||
}
|
||||
|
||||
function _settleEmailPrewarm(generation, value = false) {
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
const resolve = _libPrewarmResolve;
|
||||
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||
_libPrewarmDelayTimer = null;
|
||||
_libPrewarmIdleHandle = null;
|
||||
_libPrewarmPromise = null;
|
||||
_libPrewarmResolve = null;
|
||||
_libPrewarmAbortController = null;
|
||||
_libPrewarmDetachPriorityListeners = null;
|
||||
detachPriorityListeners?.();
|
||||
resolve?.(value);
|
||||
}
|
||||
|
||||
function _cancelEmailPrewarm() {
|
||||
const resolve = _libPrewarmResolve;
|
||||
const detachPriorityListeners = _libPrewarmDetachPriorityListeners;
|
||||
_libPrewarmGeneration += 1;
|
||||
if (_libPrewarmDelayTimer !== null) {
|
||||
clearTimeout(_libPrewarmDelayTimer);
|
||||
}
|
||||
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||
}
|
||||
try { _libPrewarmAbortController?.abort(); } catch (_) {}
|
||||
_libPrewarmDelayTimer = null;
|
||||
_libPrewarmIdleHandle = null;
|
||||
_libPrewarmPromise = null;
|
||||
_libPrewarmResolve = null;
|
||||
_libPrewarmAbortController = null;
|
||||
_libPrewarmDetachPriorityListeners = null;
|
||||
detachPriorityListeners?.();
|
||||
resolve?.(false);
|
||||
}
|
||||
|
||||
function _scheduleEmailPrewarm(task, { delay = 0 } = {}) {
|
||||
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||
// Do not disguise a timer as idle work. Browsers without the genuine idle
|
||||
// callback simply skip this optional optimization and load on demand.
|
||||
if (typeof window.requestIdleCallback !== 'function') return Promise.resolve(false);
|
||||
|
||||
const generation = ++_libPrewarmGeneration;
|
||||
_libPrewarmPromise = new Promise(resolve => { _libPrewarmResolve = resolve; });
|
||||
const promise = _libPrewarmPromise;
|
||||
let attemptPending = false;
|
||||
let retryRequested = false;
|
||||
|
||||
function clearScheduledAttempt() {
|
||||
if (_libPrewarmDelayTimer !== null) clearTimeout(_libPrewarmDelayTimer);
|
||||
if (_libPrewarmIdleHandle !== null && typeof window.cancelIdleCallback === 'function') {
|
||||
try { window.cancelIdleCallback(_libPrewarmIdleHandle); } catch (_) {}
|
||||
}
|
||||
_libPrewarmDelayTimer = null;
|
||||
_libPrewarmIdleHandle = null;
|
||||
}
|
||||
|
||||
function scheduleIdleRetry(delay = 500) {
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
retryRequested = true;
|
||||
if (attemptPending || _libPrewarmDelayTimer !== null || _libPrewarmIdleHandle !== null) return;
|
||||
if (document.visibilityState && document.visibilityState !== 'visible') return;
|
||||
_libPrewarmDelayTimer = setTimeout(requestIdle, Math.max(50, Number(delay) || 500));
|
||||
}
|
||||
|
||||
function handlePriorityChange() {
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
if (_canRunEmailPrewarm()) {
|
||||
scheduleIdleRetry(50);
|
||||
return;
|
||||
}
|
||||
_loadEmails(options);
|
||||
|
||||
const priorityBlocked = _isChatInteractionBusy()
|
||||
|| (document.visibilityState && document.visibilityState !== 'visible');
|
||||
if (!priorityBlocked) return;
|
||||
|
||||
retryRequested = true;
|
||||
clearScheduledAttempt();
|
||||
const controller = _libPrewarmAbortController;
|
||||
_libPrewarmAbortController = null;
|
||||
try { controller?.abort(); } catch (_) {}
|
||||
// A hidden page waits for visibilitychange. Chat priority also retains the
|
||||
// timer fallback for busy-until windows whose final transition has no event.
|
||||
if (!document.visibilityState || document.visibilityState === 'visible') {
|
||||
scheduleIdleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||
document.addEventListener('visibilitychange', handlePriorityChange);
|
||||
_libPrewarmDetachPriorityListeners = () => {
|
||||
window.removeEventListener('odysseus:chat-busy-change', handlePriorityChange);
|
||||
document.removeEventListener('visibilitychange', handlePriorityChange);
|
||||
};
|
||||
setTimeout(run, Math.max(0, Number(delay) || 0));
|
||||
|
||||
function requestIdle() {
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
_libPrewarmDelayTimer = null;
|
||||
try {
|
||||
_libPrewarmIdleHandle = window.requestIdleCallback((deadline) => {
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
_libPrewarmIdleHandle = null;
|
||||
const hasIdleBudget = Boolean(
|
||||
deadline
|
||||
&& !deadline.didTimeout
|
||||
&& typeof deadline.timeRemaining === 'function'
|
||||
&& deadline.timeRemaining() > 0
|
||||
);
|
||||
if (!_canRunEmailPrewarm()) {
|
||||
if (_isEmailPrewarmTemporarilyBlocked()) {
|
||||
scheduleIdleRetry();
|
||||
} else {
|
||||
_settleEmailPrewarm(generation, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasIdleBudget) {
|
||||
scheduleIdleRetry();
|
||||
return;
|
||||
}
|
||||
if (generation !== _libPrewarmGeneration) {
|
||||
_settleEmailPrewarm(generation, false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
_libPrewarmAbortController = controller;
|
||||
attemptPending = true;
|
||||
retryRequested = false;
|
||||
Promise.resolve()
|
||||
.then(() => task({ signal: controller.signal, generation }))
|
||||
.then(value => {
|
||||
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||
_settleEmailPrewarm(generation, Boolean(value));
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller !== _libPrewarmAbortController || controller.signal.aborted) return;
|
||||
_settleEmailPrewarm(generation, false);
|
||||
})
|
||||
.finally(() => {
|
||||
attemptPending = false;
|
||||
if (generation !== _libPrewarmGeneration) return;
|
||||
if (retryRequested) scheduleIdleRetry();
|
||||
});
|
||||
});
|
||||
} catch (_) {
|
||||
_settleEmailPrewarm(generation, false);
|
||||
}
|
||||
}
|
||||
|
||||
const wait = Math.max(0, Number(delay) || 0);
|
||||
if (wait > 0) _libPrewarmDelayTimer = setTimeout(requestIdle, wait);
|
||||
else requestIdle();
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
||||
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
||||
if (_libPrewarmPromise) return _libPrewarmPromise;
|
||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
|
||||
_libPrewarmTimer = setTimeout(() => {
|
||||
_libPrewarmTimer = null;
|
||||
_libPrewarmPromise = _prewarmEmailViews()
|
||||
.catch(() => {})
|
||||
.finally(() => { _libPrewarmPromise = null; });
|
||||
}, Math.max(0, Number(delay) || 0));
|
||||
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
|
||||
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
|
||||
}
|
||||
|
||||
async function _ensureEmailAccountsForPrewarm() {
|
||||
function _chooseEmailPrewarmAccountId(accounts) {
|
||||
const enabled = Array.isArray(accounts) ? accounts.filter(a => a && a.enabled !== false) : [];
|
||||
const remembered = _rememberedEmailAccountId();
|
||||
const current = String(state._libAccountId || '').trim();
|
||||
const chosen = enabled.find(a => String(a.id || '') === remembered)
|
||||
|| enabled.find(a => String(a.id || '') === current)
|
||||
|| enabled.find(a => a.is_default)
|
||||
|| enabled[0]
|
||||
|| null;
|
||||
return String(chosen?.id || '').trim();
|
||||
}
|
||||
|
||||
async function _ensureEmailAccountsForPrewarm({ signal, generation } = {}) {
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
||||
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
|
||||
if (!state._libAccountId) {
|
||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||
state._libAccountId = def?.id || null;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (!accountsRes.ok) return;
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
if (!state._libAccountId && state._libAccounts.length) {
|
||||
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||
state._libAccountId = def?.id || null;
|
||||
_publishActiveAccount();
|
||||
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
|
||||
try {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') return null;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
||||
if (!accountId) return null;
|
||||
if (accountId && state._libAccountId !== accountId) {
|
||||
state._libAccountId = accountId;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
return accountId;
|
||||
}
|
||||
|
||||
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||
if (state._libOpen) return;
|
||||
await _ensureEmailAccountsForPrewarm();
|
||||
if (state._libOpen) return;
|
||||
const accountId = state._libAccountId || '';
|
||||
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||
return _scheduleEmailPrewarm(
|
||||
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
|
||||
{ delay: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
async function _prewarmUnreadEmailsNow({ limit = 8, maxUid = 0 } = {}, { signal, generation } = {}) {
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
const n = Math.max(1, Math.min(20, Number(limit) || 8));
|
||||
const key = `${accountId}|${maxUid || 0}|${n}`;
|
||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
|
||||
_libUnreadPrewarmKey = key;
|
||||
_libUnreadPrewarmAt = Date.now();
|
||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
|
||||
try {
|
||||
const folder = 'INBOX';
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: n,
|
||||
offset: 0,
|
||||
filter: 'unread',
|
||||
account_id: accountId || undefined,
|
||||
}), { credentials: 'same-origin' });
|
||||
if (state._libOpen) return;
|
||||
if (!res.ok) return;
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: n,
|
||||
offset: 0,
|
||||
filter: 'unread',
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
|
||||
const sync = data.sync || {};
|
||||
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
||||
emails: data.emails,
|
||||
total: data.total || data.emails.length,
|
||||
sync,
|
||||
});
|
||||
} catch (_) {}
|
||||
_libUnreadPrewarmKey = key;
|
||||
_libUnreadPrewarmAt = Date.now();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function _prewarmEmailViews() {
|
||||
if (state._libOpen) return;
|
||||
_libLastPrewarmAt = Date.now();
|
||||
async function _prewarmEmailViews({ signal, generation } = {}) {
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
_setEmailSyncStatus({ warming: true });
|
||||
const folder = 'INBOX';
|
||||
const filter = 'all';
|
||||
|
||||
// The accounts request is cheap and warms the account strip for first open.
|
||||
// Then folder/list requests warm both the client cache and the backend
|
||||
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
|
||||
try {
|
||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||
if (Array.isArray(accountsData.accounts)) {
|
||||
state._libAccounts = accountsData.accounts;
|
||||
_libAccountsLoadedAt = Date.now();
|
||||
}
|
||||
const accountId = await _ensureEmailAccountsForPrewarm({ signal, generation });
|
||||
if (accountId === null || !_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||
if (_libCacheGet(ck)) {
|
||||
_libLastPrewarmAt = Date.now();
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
|
||||
const preferred = state._libAccountId
|
||||
|| (accounts.find(a => a.is_default)?.id)
|
||||
|| (accounts[0]?.id)
|
||||
|| '';
|
||||
if (!state._libAccountId && preferred) {
|
||||
state._libAccountId = preferred;
|
||||
_publishActiveAccount();
|
||||
}
|
||||
const orderedAccountIds = [
|
||||
preferred,
|
||||
...accounts.map(a => a.id).filter(id => id && id !== preferred),
|
||||
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
||||
if (!orderedAccountIds.length) orderedAccountIds.push('');
|
||||
|
||||
try {
|
||||
for (const accountId of orderedAccountIds.slice(0, 4)) {
|
||||
if (state._libOpen) return;
|
||||
const ck = _libCacheKeyFor(accountId, folder, filter, false);
|
||||
if (_libCacheGet(ck)) continue;
|
||||
await fetch(emailApiUrl('/api/email/folders', { account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
||||
await fetch(emailApiUrl('/api/email/unread-state', { folder, account_id: accountId || undefined }), { credentials: 'same-origin' }).catch(() => null);
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
filter,
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
if (data && !data.error) {
|
||||
const sync = data.sync || {};
|
||||
_libCachePut(ck, {
|
||||
emails: data.emails || [],
|
||||
total: data.total || 0,
|
||||
sync,
|
||||
});
|
||||
_setEmailSyncStatus({
|
||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
||||
source: sync.source || '',
|
||||
warming: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await _sleep(900);
|
||||
}
|
||||
// One optional first-page request only. Folder metadata, unread state, and
|
||||
// other accounts remain demand-driven so startup cannot fan out into IMAP.
|
||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||
folder,
|
||||
limit: _LIB_INITIAL_PAGE_SIZE,
|
||||
offset: 0,
|
||||
filter,
|
||||
account_id: accountId || undefined,
|
||||
}), {
|
||||
credentials: 'same-origin',
|
||||
signal,
|
||||
});
|
||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
||||
if (!data || data.error || !Array.isArray(data.emails)) return false;
|
||||
const sync = data.sync || {};
|
||||
_libCachePut(ck, {
|
||||
emails: data.emails,
|
||||
total: data.total || 0,
|
||||
sync,
|
||||
});
|
||||
_libLastPrewarmAt = Date.now();
|
||||
_setEmailSyncStatus({
|
||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
||||
source: sync.source || '',
|
||||
warming: true,
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_setEmailSyncStatus({ warming: false });
|
||||
}
|
||||
@@ -2286,16 +2482,34 @@ function _publishActiveAccount() {
|
||||
|
||||
export function initEmailLibrary(config) {
|
||||
state._docModule = config.documentModule;
|
||||
state._onEmailClick = config.onEmailClick;
|
||||
const onEmailClick = config.onEmailClick;
|
||||
state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => {
|
||||
const accountId = String(state._libAccountId || '');
|
||||
const libraryFolder = String(state._libFolder || 'INBOX');
|
||||
const messageFolder = String(options.email?.folder || libraryFolder);
|
||||
const mailboxGeneration = _emailMailboxGeneration;
|
||||
const mailboxContext = Object.freeze({
|
||||
accountId,
|
||||
libraryFolder,
|
||||
messageFolder,
|
||||
mailboxGeneration,
|
||||
isCurrent: () => (
|
||||
String(state._libAccountId || '') === accountId &&
|
||||
String(state._libFolder || 'INBOX') === libraryFolder &&
|
||||
_emailMailboxGeneration === mailboxGeneration
|
||||
),
|
||||
});
|
||||
return onEmailClick({ ...options, mailboxContext });
|
||||
} : null;
|
||||
}
|
||||
|
||||
export function isOpen() { return state._libOpen; }
|
||||
|
||||
export function openEmailLibrary(opts = {}) {
|
||||
if (_libPrewarmTimer) {
|
||||
clearTimeout(_libPrewarmTimer);
|
||||
_libPrewarmTimer = null;
|
||||
}
|
||||
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
||||
// one optional request if it has already started. Generation checks make a
|
||||
// non-abortable response harmless if it races this transition.
|
||||
_cancelEmailPrewarm();
|
||||
// Force-clean any stale state from previous attempts
|
||||
const existing = document.getElementById('email-lib-modal');
|
||||
if (existing) existing.remove();
|
||||
@@ -2303,6 +2517,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
document.removeEventListener('keydown', state._libEscHandler, true);
|
||||
state._libEscHandler = null;
|
||||
}
|
||||
_emailMailboxGeneration += 1;
|
||||
state._libOpen = true;
|
||||
// On mobile the sidebar overlays content — close it so the email view isn't
|
||||
// opened behind it (same pattern as session-switch/delete).
|
||||
@@ -2926,7 +3141,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
const fastAccountAtOpen = state._libAccountId || '';
|
||||
if (fastAccountAtOpen) {
|
||||
_loadEmailsWhenChatIdle({ delay: 0 });
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
// If we already know the previous/default account, paint that inbox first
|
||||
// from the durable index and validate accounts in parallel. Cold refreshes
|
||||
@@ -2936,7 +3151,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
_loadFolders();
|
||||
_loadEmailReminderBellVisibility();
|
||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||
_loadEmailsWhenChatIdle();
|
||||
_loadEmails({ useCache: true });
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -3121,6 +3336,7 @@ export async function openEmailLibrarySettings() {
|
||||
}
|
||||
|
||||
export function closeEmailLibrary() {
|
||||
_cancelEmailPrewarm();
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (modal) modal.remove();
|
||||
if (_libSyncTicker) {
|
||||
@@ -4554,7 +4770,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||
try {
|
||||
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
||||
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const fastData = await fastRes.json().catch(() => null);
|
||||
@@ -4581,7 +4797,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
// opens omit it so rapid close/reopen returns instantly; the
|
||||
// Refresh button passes `force: true` to add it back.
|
||||
const buster = force ? `&_=${Date.now()}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
||||
const data = await res.json();
|
||||
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
||||
if (data.error) throw new Error(data.error);
|
||||
@@ -4836,6 +5052,8 @@ function _createCard(em) {
|
||||
else if (!em.is_read) cls += ' email-card-unread';
|
||||
card.className = cls;
|
||||
card.dataset.uid = String(em.uid);
|
||||
card.dataset.emailAccount = String(em.account_id || state._libAccountId || '');
|
||||
card.dataset.emailFolder = String(em.folder || state._libFolder || 'INBOX');
|
||||
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
|
||||
|
||||
// Checkbox in select mode
|
||||
@@ -5162,6 +5380,25 @@ async function _toggleCardPreview(card, em) {
|
||||
// currently-selected folder for normal inbox cards.
|
||||
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
||||
const wasReadAtStart = !!em?.is_read;
|
||||
const openGeneration = ++_emailCardOpenSeq;
|
||||
const readContext = Object.freeze({
|
||||
accountId: String(accountAtStart),
|
||||
libraryFolder: String(libraryFolderAtStart),
|
||||
folder: String(folderAtStart),
|
||||
uid: uidAtStart,
|
||||
mailboxGeneration: _emailMailboxGeneration,
|
||||
});
|
||||
const readContextKey = _emailReadContextKey(readContext);
|
||||
const isCurrentOpen = () => (
|
||||
openGeneration === _emailCardOpenSeq &&
|
||||
_emailReadContextIsCurrent(readContext) &&
|
||||
accountAtStart === (state._libAccountId || '') &&
|
||||
libraryFolderAtStart === (state._libFolder || 'INBOX') &&
|
||||
uidAtStart === String(card?.dataset?.uid || '') &&
|
||||
card.isConnected &&
|
||||
card.classList.contains('email-card-expanded')
|
||||
);
|
||||
const grid = card.closest('.doclib-grid');
|
||||
const gridRect = grid?.getBoundingClientRect?.();
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
@@ -5186,6 +5423,30 @@ async function _toggleCardPreview(card, em) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every authoritative open supersedes any older optimistic mutation for the
|
||||
// same immutable mailbox identity. Carry the original unread state forward
|
||||
// so a close/reopen followed by failure still rolls back exactly once, while
|
||||
// a late failure from the superseded request cannot undo a newer success.
|
||||
const previousMutation = _emailReadMutations.get(readContextKey);
|
||||
const readMutation = {
|
||||
generation: ++_emailReadMutationSeq,
|
||||
rollbackUnread: !wasReadAtStart || !!previousMutation?.rollbackUnread,
|
||||
};
|
||||
_emailReadMutations.set(readContextKey, readMutation);
|
||||
const restoreUnreadState = () => {
|
||||
if (_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation) return;
|
||||
_emailReadMutations.delete(readContextKey);
|
||||
if (readMutation.rollbackUnread) _syncEmailReadState(uidAtStart, false, readContext);
|
||||
};
|
||||
const commitReadState = () => {
|
||||
// A successful STORE/mark_seen is authoritative for this immutable
|
||||
// mailbox identity even when a newer open is still pending. Retire that
|
||||
// newer rollback token too, otherwise its later failure could restore an
|
||||
// unread state that no longer exists at the provider.
|
||||
_emailReadMutations.delete(readContextKey);
|
||||
_syncEmailReadState(uidAtStart, true, readContext);
|
||||
};
|
||||
|
||||
// Collapse any other expanded card
|
||||
if (grid) {
|
||||
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
||||
@@ -5207,10 +5468,10 @@ async function _toggleCardPreview(card, em) {
|
||||
requestAnimationFrame(() => {
|
||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
||||
});
|
||||
if (!em.is_read) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
|
||||
.catch(err => console.error('Failed to mark email read:', err));
|
||||
if (!wasReadAtStart) {
|
||||
// Keep the current optimistic visual update, but let the read request below
|
||||
// own the provider-side \Seen transition. A failure restores unread state.
|
||||
_syncEmailReadState(uidAtStart, true, readContext);
|
||||
}
|
||||
// Class hook on the modal so the header-hide / padding rules work on
|
||||
// browsers without :has() support (Firefox mobile) — the :has() versions
|
||||
@@ -5239,25 +5500,28 @@ async function _toggleCardPreview(card, em) {
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
let authoritativeReadSucceeded = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uidAtStart)}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (
|
||||
accountAtStart !== (state._libAccountId || '') ||
|
||||
libraryFolderAtStart !== (state._libFolder || 'INBOX') ||
|
||||
uidAtStart !== String(card?.dataset?.uid || '') ||
|
||||
!card.isConnected ||
|
||||
!card.classList.contains('email-card-expanded')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data.error) {
|
||||
showFailedReader(`Failed to load email: ${data.error}`);
|
||||
restoreUnreadState();
|
||||
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
// Mark as read locally
|
||||
_syncEmailReadState(em.uid, true);
|
||||
if (data.mark_seen_failed) {
|
||||
// The body is authoritative even when the provider refused the \Seen
|
||||
// transition. Render the message and roll the unread marker back so the
|
||||
// list keeps telling the truth, rather than refusing to open a message
|
||||
// we successfully read.
|
||||
restoreUnreadState();
|
||||
} else {
|
||||
authoritativeReadSucceeded = true;
|
||||
commitReadState();
|
||||
}
|
||||
if (!isCurrentOpen()) return;
|
||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||
|
||||
// Build the attachments wrap using the shared helper so the signature-
|
||||
@@ -5439,7 +5703,10 @@ async function _toggleCardPreview(card, em) {
|
||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||
} catch (e) {
|
||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||
if (!authoritativeReadSucceeded) restoreUnreadState();
|
||||
if (isCurrentOpen()) {
|
||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7259,12 +7526,11 @@ async function _generateSummary(reader, data, btn) {
|
||||
if (label) label.textContent = 'Summary';
|
||||
}
|
||||
} else {
|
||||
content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
|
||||
panel.remove();
|
||||
_renderEmailSummaryError(content, result);
|
||||
}
|
||||
} catch (e) {
|
||||
sp.destroy();
|
||||
panel.remove();
|
||||
_renderEmailSummaryError(content, null);
|
||||
if (uiModule) uiModule.showError?.('Failed to summarize');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
|
||||
@@ -30,6 +30,25 @@ export function _esc(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const _EMAIL_SUMMARY_ERROR_MESSAGES = Object.freeze({
|
||||
email_summary_missing_body: 'No email body to summarize',
|
||||
email_summary_not_configured: 'No model configured for email summaries',
|
||||
email_summary_empty: 'The model returned an empty summary',
|
||||
email_summary_unavailable: 'Failed to summarize',
|
||||
});
|
||||
|
||||
export function _emailSummaryErrorMessage(result) {
|
||||
const code = String(result?.error_code || '');
|
||||
return _EMAIL_SUMMARY_ERROR_MESSAGES[code] || 'Failed to summarize';
|
||||
}
|
||||
|
||||
export function _renderEmailSummaryError(container, result) {
|
||||
const message = container.ownerDocument.createElement('span');
|
||||
message.style.color = 'var(--red)';
|
||||
message.textContent = _emailSummaryErrorMessage(result);
|
||||
container.replaceChildren(message);
|
||||
}
|
||||
|
||||
function _attrEsc(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// liveThinkingThrottle.js
|
||||
//
|
||||
// Pure trailing-edge coalescer for the live "thinking" block in chat.js.
|
||||
//
|
||||
// A reasoning stream delivers deltas far faster than a human can read them, and
|
||||
// the only thing that matters on screen is the LATEST cumulative text. Committing
|
||||
// every delta to the DOM makes the work grow with the length of the stream. This
|
||||
// throttle collapses a burst of updates into one commit per `delay` ms, always
|
||||
// carrying the most recent value.
|
||||
//
|
||||
// Timers are injected so the behaviour is testable without a browser or a clock:
|
||||
//
|
||||
// const throttle = createLiveThinkingThrottle(commit, { prepare, schedule, cancel });
|
||||
//
|
||||
// Lifecycle contract, which the terminal paths in chat.js depend on:
|
||||
//
|
||||
// update(value) queue `value`; schedule a commit if one is not already pending
|
||||
// flush() commit any pending value NOW and drop the timer; returns whether
|
||||
// a commit happened, so a clean flush cannot duplicate a commit
|
||||
// cancel() drop the timer AND the pending value — nothing lands later
|
||||
//
|
||||
// `cancel()` is what stops a finished (or backgrounded) stream from mutating a
|
||||
// view the user has since navigated away to.
|
||||
|
||||
export function stripLiveThinkingTags(text) {
|
||||
return String(text ?? '').replace(
|
||||
/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
const THINKING_BOUNDARY_RE = /<\/?(?:(?:mm:)?think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>(?:thought|response)|<channel\|>/gi;
|
||||
const REPLY_PREFIX_SOURCE = "(?:Hey|Hi |Hi!|Hello|Sure|Yes|No |No,|Yo|OK|Here|Absolutely|Of course|Great|Alright|Thanks|Welcome|Good |I'm happy|I'd be)";
|
||||
const REPLY_LINE_RE = new RegExp('(?:^|\\n)\\s*' + REPLY_PREFIX_SOURCE, 'gi');
|
||||
const REPLY_INLINE_RE = new RegExp('[.!?]\\s*' + REPLY_PREFIX_SOURCE, 'gi');
|
||||
const REASONING_PREFIX_CANDIDATES = [
|
||||
'thinking:', 'thinking process:', 'the user ', 'user wants', 'we need ',
|
||||
'i need ', 'i should ', 'i will ', "i'll ", 'i am going ', 'let me think',
|
||||
'let me look', 'let me see', 'let me check', 'let me read', 'let me review',
|
||||
'let me analyze', 'let me parse', 'let me figure', 'let me draft', 'let me write',
|
||||
'they are ', 'the question ', 'i can ',
|
||||
];
|
||||
|
||||
const DISPLAY_FILTER_BOUNDARY_RE = /\[\/?TOOL_CALL\]|```(?:create_document|documen(?:t)?)(?:\s|$)|```[\w-]+[ \t]*[\[{]|<(?:[\w]+:)?(?:tool_call|function_call)>|<invoke\b|<\s*\/?\s*[||]+\s*DSML\s*[||]+|(?:\[\s*)?\{\s*"function"\s*:|<\/?\|(?:assistant|assistan|user|system|tool|end)\|?>|(?:^|[\r\n])\s*(?:stdout|stderr|exit_code):/i;
|
||||
|
||||
function hasFreshMatch(text, regex, cursor, minStart = 0) {
|
||||
regex.lastIndex = 0;
|
||||
for (const match of text.matchAll(regex)) {
|
||||
const end = match.index + match[0].length;
|
||||
if (end > cursor && match.index >= minStart) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Incrementally decides when chat.js needs its compatibility-heavy cumulative
|
||||
// thinking analysis. The gate inspects only a short overlap plus the new text;
|
||||
// ordinary answer/reasoning deltas therefore stay O(delta) while split tags,
|
||||
// namespaced tags, non-tag reply boundaries, and false-close grace deadlines
|
||||
// still request the canonical full analysis.
|
||||
export function createThinkingAnalysisGate({
|
||||
startsWithReasoningPrefix = () => false,
|
||||
now = () => Date.now(),
|
||||
overlap = 512,
|
||||
} = {}) {
|
||||
let cursor = 0;
|
||||
let prefixSettled = false;
|
||||
let prefixProbe = '';
|
||||
|
||||
return {
|
||||
shouldAnalyze(text, {
|
||||
isThinking = false,
|
||||
nonTagThinking = false,
|
||||
recheckAt = 0,
|
||||
} = {}) {
|
||||
const fullText = String(text ?? '');
|
||||
if (fullText.length < cursor) {
|
||||
cursor = 0;
|
||||
prefixSettled = false;
|
||||
prefixProbe = '';
|
||||
}
|
||||
const previousCursor = cursor;
|
||||
if (!prefixSettled && prefixProbe.length < overlap) {
|
||||
// Build the initial probe from deltas so arbitrary leading whitespace
|
||||
// cannot strand the gate in its undecided state. The retained state is
|
||||
// bounded even if a provider emits a very large whitespace prefix.
|
||||
prefixProbe = (prefixProbe + fullText.slice(previousCursor))
|
||||
.trimStart()
|
||||
.slice(0, overlap);
|
||||
}
|
||||
const scanStart = Math.max(0, previousCursor - overlap);
|
||||
const freshText = fullText.slice(scanStart);
|
||||
const relativeCursor = previousCursor - scanStart;
|
||||
const hasBoundary = hasFreshMatch(freshText, THINKING_BOUNDARY_RE, relativeCursor);
|
||||
const hasReplyBoundary = nonTagThinking && (
|
||||
hasFreshMatch(freshText, REPLY_LINE_RE, relativeCursor)
|
||||
|| hasFreshMatch(freshText, REPLY_INLINE_RE, relativeCursor, Math.max(0, 20 - scanStart))
|
||||
);
|
||||
cursor = fullText.length;
|
||||
|
||||
if (hasBoundary || hasReplyBoundary) return true;
|
||||
if (isThinking) return recheckAt > 0 && now() >= recheckAt;
|
||||
if (prefixSettled) return false;
|
||||
|
||||
if (!prefixProbe) return false;
|
||||
if (startsWithReasoningPrefix(prefixProbe)) {
|
||||
prefixSettled = true;
|
||||
return true;
|
||||
}
|
||||
const lowerProbe = prefixProbe.toLowerCase();
|
||||
if (REASONING_PREFIX_CANDIDATES.some((candidate) => candidate.startsWith(lowerProbe))) {
|
||||
return false;
|
||||
}
|
||||
prefixSettled = true;
|
||||
return false;
|
||||
},
|
||||
reset() {
|
||||
cursor = 0;
|
||||
prefixSettled = false;
|
||||
prefixProbe = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Keep the common prose path append-only. At the first structured/tool
|
||||
// boundary, filter only the preceding visible prefix and hide the structured
|
||||
// tail until the authoritative terminal render.
|
||||
export function createIncrementalDisplayProjector(filter, { overlap = 512 } = {}) {
|
||||
let projected = '';
|
||||
let boundaryTail = '';
|
||||
let rawLength = 0;
|
||||
let structuredTailHidden = false;
|
||||
|
||||
return {
|
||||
append(delta, fullText) {
|
||||
const chunk = String(delta ?? '');
|
||||
const raw = String(fullText ?? '');
|
||||
if (raw.length < rawLength) this.reset();
|
||||
const boundaryProbe = boundaryTail + chunk;
|
||||
const boundaryMatch = !structuredTailHidden
|
||||
? DISPLAY_FILTER_BOUNDARY_RE.exec(boundaryProbe)
|
||||
: null;
|
||||
if (boundaryMatch) {
|
||||
// Filter the visible prefix, not the incomplete marker itself: several
|
||||
// compatibility regexes intentionally match only completed blocks.
|
||||
const boundaryStart = Math.max(0, raw.length - boundaryProbe.length + boundaryMatch.index);
|
||||
structuredTailHidden = true;
|
||||
projected = String(filter(raw.slice(0, boundaryStart)) ?? '');
|
||||
} else if (!structuredTailHidden) {
|
||||
projected += chunk;
|
||||
}
|
||||
boundaryTail = (boundaryTail + chunk).slice(-overlap);
|
||||
rawLength = raw.length;
|
||||
return projected;
|
||||
},
|
||||
current() {
|
||||
return projected;
|
||||
},
|
||||
reset() {
|
||||
projected = '';
|
||||
boundaryTail = '';
|
||||
rawLength = 0;
|
||||
structuredTailHidden = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveThinkingThrottle(commit, {
|
||||
delay = 100,
|
||||
prepare = (value) => String(value ?? ''),
|
||||
schedule = (callback, ms) => setTimeout(callback, ms),
|
||||
cancel = (timer) => clearTimeout(timer),
|
||||
} = {}) {
|
||||
let timer = null;
|
||||
let latest = null;
|
||||
let dirty = false;
|
||||
|
||||
const commitLatest = () => {
|
||||
timer = null;
|
||||
if (!dirty) return false;
|
||||
dirty = false;
|
||||
commit(prepare(latest));
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
update(value) {
|
||||
latest = value;
|
||||
dirty = true;
|
||||
if (timer === null) timer = schedule(commitLatest, delay);
|
||||
},
|
||||
flush() {
|
||||
if (timer !== null) {
|
||||
cancel(timer);
|
||||
timer = null;
|
||||
}
|
||||
return commitLatest();
|
||||
},
|
||||
cancel() {
|
||||
if (timer !== null) cancel(timer);
|
||||
timer = null;
|
||||
dirty = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default createLiveThinkingThrottle;
|
||||
+13
-7
@@ -758,30 +758,36 @@ export function mdToHtml(src, opts) {
|
||||
// Remove empty paragraphs
|
||||
s = s.replace(/<p><\/p>/g, '');
|
||||
|
||||
// Every restore below passes a function replacer rather than the block string
|
||||
// itself. With a string replacement, `String.replace` reads `$&`, `` $` ``,
|
||||
// `$'` and `$$` in the *replacement* as substitution patterns, so a restored
|
||||
// block containing them is corrupted: `$&` re-inserts the placeholder, `` $` ``
|
||||
// and `$'` splice in the surrounding document, and `$$` collapses to `$`. Those
|
||||
// sequences are ordinary content in fenced code (`perl -pe 's/x/$& y/'`,
|
||||
// `echo "$$USD"`). A function replacer inserts its return value verbatim.
|
||||
|
||||
// CRITICAL: Restore allowed HTML blocks first
|
||||
allowedHtmlBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___ALLOWED_HTML_${index}___`, block);
|
||||
s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
|
||||
});
|
||||
|
||||
// Restore math blocks
|
||||
mathBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___MATH_BLOCK_${index}___`, block);
|
||||
s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
|
||||
});
|
||||
|
||||
// Restore mermaid diagram blocks
|
||||
mermaidBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___MERMAID_BLOCK_${index}___`, block);
|
||||
s = s.replace(`___MERMAID_BLOCK_${index}___`, () => block);
|
||||
});
|
||||
|
||||
// CRITICAL: Restore code blocks at the end
|
||||
codeBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, () => block);
|
||||
});
|
||||
|
||||
// Restore inline code spans last, so placeholders carried inside restored
|
||||
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
||||
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
||||
// as a regex back-reference.
|
||||
// <a>/allowed-HTML blocks are resolved too.
|
||||
inlineCodeBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||
});
|
||||
|
||||
+25
-1
@@ -1683,8 +1683,21 @@ export async function loadSessions() {
|
||||
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const payload = await res.json();
|
||||
detail = payload?.detail || payload?.error || '';
|
||||
} catch (_) {}
|
||||
const error = new Error(detail || `Session request failed (HTTP ${res.status})`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
fetched = await res.json();
|
||||
}
|
||||
if (!Array.isArray(fetched)) {
|
||||
throw new Error('Session request returned an invalid response');
|
||||
}
|
||||
sessions = _normalizeSessionsList(fetched);
|
||||
renderSessionList();
|
||||
|
||||
@@ -1807,9 +1820,15 @@ export async function loadSessions() {
|
||||
_autoCreateInProgress = false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in loadSessions:', error);
|
||||
uiModule.showError('Failed to load sessions: ' + error.message);
|
||||
// app.js's global fetch wrapper owns expired-auth navigation. Avoid
|
||||
// flashing a redundant session error while that 401 redirect is pending.
|
||||
if (error?.status !== 401) {
|
||||
uiModule.showError('Failed to load sessions: ' + error.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1847,6 +1866,10 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
||||
if (!_isTransientChat) {
|
||||
Storage.set('lastSessionId', id);
|
||||
// Update URL hash without triggering hashchange handler
|
||||
if (window.location.hash !== '#' + id) {
|
||||
history.replaceState(null, '', '#' + id);
|
||||
}
|
||||
}
|
||||
// Restore character preset for persistent chats
|
||||
try {
|
||||
@@ -2313,6 +2336,7 @@ export async function materializePendingSession() {
|
||||
currentSessionId = payload.id;
|
||||
if (!isIncognito) {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
history.replaceState(null, '', '#' + payload.id);
|
||||
}
|
||||
|
||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||
|
||||
+27
-31
@@ -448,7 +448,7 @@ async function initDefaultChat() {
|
||||
var fbContainer = el('set-defaultFallbacks');
|
||||
var addFbBtn = el('set-defaultAddFallback');
|
||||
var _endpoints = [];
|
||||
var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails
|
||||
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
|
||||
|
||||
function enabledEndpoints() {
|
||||
return _endpoints.filter(function(e) { return e.is_enabled; });
|
||||
@@ -534,11 +534,6 @@ async function initDefaultChat() {
|
||||
var settings = await res.json();
|
||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||
refreshModels(settings.default_model || '');
|
||||
_fallbacks = Array.isArray(settings.default_model_fallbacks)
|
||||
? settings.default_model_fallbacks.map(function(f) {
|
||||
return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' };
|
||||
})
|
||||
: [];
|
||||
renderFallbacks();
|
||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||
|
||||
@@ -547,13 +542,11 @@ async function initDefaultChat() {
|
||||
|
||||
async function saveDefault() {
|
||||
try {
|
||||
var clean = _fallbacks.filter(function(f) { return f.endpoint_id && f.model; });
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
default_endpoint_id: epSel.value,
|
||||
default_model: modelSel.value,
|
||||
default_model_fallbacks: clean
|
||||
default_model: modelSel.value
|
||||
})
|
||||
});
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||
@@ -3031,12 +3024,14 @@ async function initEmailAccountsSettings() {
|
||||
const body = {
|
||||
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||
from_address: el('eaf-from').value.trim(),
|
||||
display_name: el('eaf-display-name').value.trim(),
|
||||
imap_host: el('eaf-imap-host').value.trim(),
|
||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||
imap_user: el('eaf-imap-user').value.trim(),
|
||||
imap_starttls: el('eaf-imap-starttls').checked,
|
||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||
smtp_security: el('eaf-smtp-security').value,
|
||||
smtp_user: el('eaf-imap-user').value.trim(),
|
||||
};
|
||||
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||
@@ -5788,29 +5783,30 @@ export function close() {
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
// Open settings → integrations after the app has initialised.
|
||||
function _tryOpen() {
|
||||
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
||||
window.settingsModule.open('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? '✓ Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
} else {
|
||||
setTimeout(_tryOpen, 100);
|
||||
}
|
||||
// Open settings → integrations once the document is ready. This module owns
|
||||
// the open() API, so it does not need to wait for a window-level alias.
|
||||
function _showResult() {
|
||||
open('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? 'Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
||||
} else {
|
||||
_showResult();
|
||||
}
|
||||
_tryOpen();
|
||||
})();
|
||||
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||
|
||||
+3
-5
@@ -83,11 +83,9 @@ export async function loadSkills(cascade = false) {
|
||||
// Play the domino-in entrance on this load (set when the tab is opened,
|
||||
// not for the silent re-loads after an edit/delete).
|
||||
if (cascade) _cascadeNext = true;
|
||||
if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
|
||||
_cascadeNext = false;
|
||||
updateCount();
|
||||
return;
|
||||
}
|
||||
// Always re-fetch when the tab is explicitly opened — the cascade
|
||||
// animation is handled inside renderSkillsList() via _cascadeNext.
|
||||
// Skipping the fetch here caused stale data on panel close/reopen (#5870).
|
||||
if (_loadPromise) return _loadPromise;
|
||||
_loadPromise = (async () => {
|
||||
try {
|
||||
|
||||
+86
-13
@@ -4,6 +4,13 @@
|
||||
* ASCII Spinner Module for AI thinking/processing status
|
||||
*/
|
||||
|
||||
// How long a canvas spinner may keep animating before its element has ever
|
||||
// been inserted into the document. start() runs synchronously, before the
|
||||
// caller appends the element, so frame 1 is always disconnected. Callers do
|
||||
// append in the same task, so anything past this window means the element is
|
||||
// never coming and the frames are drawing for nobody.
|
||||
const UNATTACHED_GRACE_MS = 2000;
|
||||
|
||||
class Spinner {
|
||||
constructor(message = "AI is processing", style = "right", animation = "spinner") {
|
||||
// Different animation frames
|
||||
@@ -21,6 +28,9 @@ class Spinner {
|
||||
this.intervalId = null;
|
||||
this.rafId = null;
|
||||
this.element = null;
|
||||
this._wpWasConnected = false;
|
||||
this._wpUnattachedSince = null;
|
||||
this._visHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +84,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
_drawSineWave() {
|
||||
if (!this.isRunning) return;
|
||||
const ctx = this._ctx;
|
||||
const W = this._canvas.width;
|
||||
const H = this._canvas.height;
|
||||
@@ -120,9 +131,7 @@ class Spinner {
|
||||
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
|
||||
ctx.fill();
|
||||
|
||||
if (this.isRunning) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawSineWave());
|
||||
}
|
||||
if (this.isRunning) this._requestFrame();
|
||||
}
|
||||
|
||||
_createWhirlpoolElement() {
|
||||
@@ -158,6 +167,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
_drawWhirlpool() {
|
||||
if (!this.isRunning) return;
|
||||
const ctx = this._wpCtx;
|
||||
const W = this._wpCanvas.width;
|
||||
const H = this._wpCanvas.height;
|
||||
@@ -229,18 +239,77 @@ class Spinner {
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
if (!this.isRunning) return;
|
||||
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||
// before it's first appended — start() runs synchronously, before the
|
||||
// caller inserts the element, so it isn't connected on frame 1.
|
||||
// Leak-safe self-terminate. "Nobody can see this spinner" has two shapes
|
||||
// and we have to catch both:
|
||||
// 1. the element WAS in the DOM and then got removed (a loading row
|
||||
// replaced by results);
|
||||
// 2. the element was NEVER inserted, and the grace window for inserting
|
||||
// it has expired. The caller started a spinner and then took an early
|
||||
// return (aborted request, panel that resolved from cache), so no
|
||||
// frame we draw will ever be observed.
|
||||
// Case 2 is why this needs a deadline at all: while the element has never
|
||||
// been connected, `!this._wpWasConnected` stays true forever, so without
|
||||
// the grace check the loop re-arms until the tab closes.
|
||||
const connected = !!(this.element && this.element.isConnected);
|
||||
if (connected) this._wpWasConnected = true;
|
||||
if (connected || !this._wpWasConnected) {
|
||||
this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
|
||||
} else {
|
||||
this.isRunning = false;
|
||||
if (connected) {
|
||||
this._wpWasConnected = true;
|
||||
this._wpUnattachedSince = null;
|
||||
} else if (!this._wpWasConnected) {
|
||||
if (this._wpUnattachedSince === null) this._wpUnattachedSince = performance.now();
|
||||
if (performance.now() - this._wpUnattachedSince > UNATTACHED_GRACE_MS) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (connected || !this._wpWasConnected) {
|
||||
this._requestFrame();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm the next animation frame. Clearing rafId as the callback enters keeps
|
||||
* it a truthful "a frame is pending" flag, which is what stop() and the
|
||||
* visibility handler cancel against.
|
||||
*/
|
||||
_requestFrame() {
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
this.rafId = null;
|
||||
if (this.animation === 'sinewave') this._drawSineWave();
|
||||
else this._drawWhirlpool();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop drawing while the tab is hidden. Browsers throttle background rAF but
|
||||
* do not reliably stop the canvas work, and a spinner nobody is looking at
|
||||
* should cost nothing. The listener is owned by start()/stop() so it is never
|
||||
* left behind on a dead spinner.
|
||||
*/
|
||||
_armVisibilityPause() {
|
||||
if (this._visHandler) return;
|
||||
this._visHandler = () => {
|
||||
if (document.hidden) {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
} else if (this.isRunning && !this.rafId) {
|
||||
// Reset the wave clock so the hidden interval doesn't arrive as one
|
||||
// huge dt and skip the animation forward.
|
||||
this._wavePrev = performance.now();
|
||||
this._requestFrame();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', this._visHandler);
|
||||
}
|
||||
|
||||
_disarmVisibilityPause() {
|
||||
if (!this._visHandler) return;
|
||||
document.removeEventListener('visibilitychange', this._visHandler);
|
||||
this._visHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,12 +341,15 @@ class Spinner {
|
||||
|
||||
if (this.animation === 'sinewave') {
|
||||
this._wavePrev = performance.now();
|
||||
this._armVisibilityPause();
|
||||
this._drawSineWave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.animation === 'whirlpool') {
|
||||
this._wpStartedAt = performance.now();
|
||||
this._wpUnattachedSince = null;
|
||||
this._armVisibilityPause();
|
||||
this._drawWhirlpool();
|
||||
return;
|
||||
}
|
||||
@@ -302,6 +374,7 @@ class Spinner {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
this._disarmVisibilityPause();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// Odysseus UI — startup shell sequencing
|
||||
// ES6 module — no application dependencies, DOM only.
|
||||
//
|
||||
// Revealing the application shell, retiring the boot loader, settling the
|
||||
// sidebar's own loading state, and firing a deferred URL route are separate
|
||||
// startup concerns that used to sit inline in app.js behind a single promise.
|
||||
// They live here so each step has one owner and so the whole contract can be
|
||||
// exercised directly (tests/test_startup_shell_js.py) without booting the app.
|
||||
|
||||
const LOADER_ID = 'app-loader';
|
||||
const SESSION_BOOTSTRAP_ROW_ID = 'session-list-loading';
|
||||
|
||||
// Route openers that read the hydrated session list. Everything else only
|
||||
// needs module wiring and must not wait on /api/sessions. `/email` spawns a
|
||||
// fresh chat, and that path falls back to the most recent session's model
|
||||
// (_createDirectChatFromPreferredModel in app.js) when there is no default
|
||||
// chat configured, so it genuinely needs the list.
|
||||
const ROUTES_NEEDING_SESSIONS = new Set(['/email']);
|
||||
|
||||
let _routeOpener = null;
|
||||
let _routeOpenerNeedsSessions = false;
|
||||
|
||||
function _loader() {
|
||||
return document.getElementById(LOADER_ID);
|
||||
}
|
||||
|
||||
/** Run `fn` after the next paint has committed (two animation frames). */
|
||||
export function afterNextPaint(fn) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(fn));
|
||||
}
|
||||
|
||||
// The loader node stays in the DOM while sessions hydrate — sidebar-layout.js
|
||||
// and sessions.js both read its presence as a "still starting up" sentinel —
|
||||
// but it must stop covering, announcing, and animating over a usable shell.
|
||||
function _makeLoaderInert(loader) {
|
||||
if (!loader || loader.dataset.shellRevealed === 'true') return;
|
||||
loader.dataset.shellRevealed = 'true';
|
||||
loader.setAttribute('aria-hidden', 'true');
|
||||
loader.style.pointerEvents = 'none';
|
||||
loader.style.opacity = '0';
|
||||
// index.html's inline bootstrap animates the wave on a 150ms interval.
|
||||
// Nothing of it is visible any more, so stop rendering into it.
|
||||
try { window.__odysseusLoaderWaveStop?.(); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the shell to the user once core wiring is done. Deferred by one paint
|
||||
* so the first frame lands with the app already laid out.
|
||||
*/
|
||||
export function revealApplicationShellAfterPaint() {
|
||||
const loader = _loader();
|
||||
if (!loader || loader.dataset.shellRevealScheduled === 'true') return;
|
||||
loader.dataset.shellRevealScheduled = 'true';
|
||||
afterNextPaint(() => _makeLoaderInert(_loader()));
|
||||
}
|
||||
|
||||
/** Retire the loader node for good. Safe to call after a reveal. */
|
||||
export function removeApplicationLoader() {
|
||||
const loader = _loader();
|
||||
if (!loader) return;
|
||||
_makeLoaderInert(loader);
|
||||
setTimeout(() => loader.remove(), 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the sidebar's bootstrap row into a failure row. The write is delayed
|
||||
* until the session renderer's frame has committed so a late success cannot
|
||||
* leave stale failure text behind.
|
||||
*/
|
||||
export function markSessionListUnavailableIfStillBootstrapping() {
|
||||
afterNextPaint(() => {
|
||||
const row = document.getElementById(SESSION_BOOTSTRAP_ROW_ID);
|
||||
if (!row) return;
|
||||
const status = row.querySelector('[data-session-list-status]') || row;
|
||||
status.textContent = 'Chats unavailable';
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `path`'s route opener reads the hydrated session list. */
|
||||
export function routeNeedsSessionData(path) {
|
||||
return ROUTES_NEEDING_SESSIONS.has(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash a URL route opener for later. At the point app.js resolves the route,
|
||||
* the modules its handlers drive (the rail new-chat handler, the email
|
||||
* section header handler, sessionModule) are still being wired further down
|
||||
* the same init pass, so the opener cannot run inline.
|
||||
*/
|
||||
export function deferRouteOpener(path, opener) {
|
||||
if (!opener) return;
|
||||
_routeOpener = opener;
|
||||
_routeOpenerNeedsSessions = routeNeedsSessionData(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the deferred route opener if its data is ready. Called once when
|
||||
* wiring completes and again after authoritative session hydration; a route
|
||||
* that needs no session data takes the first call, one that does takes the
|
||||
* second.
|
||||
*
|
||||
* @returns {boolean} whether an opener ran.
|
||||
*/
|
||||
export function runDeferredRouteOpener({ sessionsSettled = false } = {}) {
|
||||
if (!_routeOpener) return false;
|
||||
if (_routeOpenerNeedsSessions && !sessionsSettled) return false;
|
||||
const opener = _routeOpener;
|
||||
_routeOpener = null;
|
||||
_routeOpenerNeedsSessions = false;
|
||||
try { opener(); } catch (e) { console.warn('route opener failed:', e); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive session hydration and everything that hangs off it settling: the
|
||||
* sidebar's failure row, the loader node, and any session-dependent route.
|
||||
*
|
||||
* @param {(() => Promise<boolean>)|null} loadSessions — resolves true only
|
||||
* after the session list was authoritatively loaded and applied. Null means
|
||||
* the session module failed to load.
|
||||
*/
|
||||
export function settleSessionHydration(loadSessions) {
|
||||
const settle = (succeeded) => {
|
||||
if (!succeeded) {
|
||||
markSessionListUnavailableIfStillBootstrapping();
|
||||
// A later unrelated caller must not be able to release a stale startup
|
||||
// opener against unknown session state.
|
||||
_routeOpener = null;
|
||||
_routeOpenerNeedsSessions = false;
|
||||
}
|
||||
removeApplicationLoader();
|
||||
if (succeeded) runDeferredRouteOpener({ sessionsSettled: true });
|
||||
return succeeded;
|
||||
};
|
||||
if (!loadSessions) {
|
||||
return Promise.resolve(settle(false));
|
||||
}
|
||||
// Kick the request off synchronously — a microtask hop here would delay the
|
||||
// fetch this whole change exists to get off the critical path.
|
||||
let pending;
|
||||
try {
|
||||
pending = loadSessions();
|
||||
} catch (e) {
|
||||
console.warn('loadSessions error:', e);
|
||||
return Promise.resolve(settle(false));
|
||||
}
|
||||
return Promise.resolve(pending)
|
||||
.then(result => settle(result === true))
|
||||
.catch(e => {
|
||||
console.warn('loadSessions error:', e);
|
||||
return settle(false);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// static/js/ui_visibility.js
|
||||
//
|
||||
// Per-item visibility for the sidebar and collapsed icon rail. Drives the
|
||||
// Settings → Appearance ("Customize UI") checkboxes, persisted in localStorage
|
||||
// under `odysseus-ui-visibility` (loaded/saved by app.js).
|
||||
//
|
||||
// Each key maps to the CSS selector(s) it controls. Tool/section selectors pair
|
||||
// the full-sidebar element with its #rail-* launcher so a tab hidden in the
|
||||
// full view also hides when the sidebar is minimized to the icon rail (id
|
||||
// mapping mirrors _railToolMap in app.js; #tool-library-btn ↔ #rail-archive).
|
||||
|
||||
// Selector map: UI customization key → CSS selector(s) for its target(s).
|
||||
export const UI_VIS_MAP = {
|
||||
'sidebar-brand': '.sidebar-brand-title',
|
||||
'sidebar-new-chat': '#sidebar-new-chat-btn',
|
||||
'sidebar-search': '#sidebar-search-btn',
|
||||
'sessions-section': '#sessions-section',
|
||||
'email-section': '#email-section, #rail-email',
|
||||
'tools-section': '#tools-section',
|
||||
// Per-tool entries pair the sidebar button with its rail launcher.
|
||||
'tool-calendar': '#tool-calendar-btn, #rail-calendar',
|
||||
'tool-compare': '#tool-compare-btn, #rail-compare',
|
||||
'tool-cookbook': '#tool-cookbook-btn, #rail-cookbook',
|
||||
'tool-research': '#tool-research-btn, #rail-research',
|
||||
'tool-gallery': '#tool-gallery-btn, #rail-gallery',
|
||||
'tool-library': '#tool-library-btn, #rail-archive',
|
||||
'tool-memory': '#tool-memory-btn, #rail-memory',
|
||||
'tool-notes': '#tool-notes-btn, #rail-notes',
|
||||
'tool-tasks': '#tool-tasks-btn, #rail-tasks',
|
||||
'tool-theme': '#tool-theme-btn, #rail-theme',
|
||||
'user-bar': '#user-bar-profile',
|
||||
'sidebar-settings-btn':'#user-bar-settings',
|
||||
'chat-meta': '.chat-meta-overlay',
|
||||
'welcome-text': '.welcome-name, .welcome-sub, #welcome-tip',
|
||||
'incognito-btn': '.incognito-btn',
|
||||
'web-toggle-btn': '#web-toggle-btn',
|
||||
'doc-toggle-btn': '#overflow-doc-btn',
|
||||
'rag-toggle-btn': '#overflow-rag-btn',
|
||||
'bash-toggle-btn': '#bash-toggle-btn',
|
||||
'overflow-plus-btn': '.overflow-wrapper',
|
||||
'mode-toggle': '.mode-toggle',
|
||||
'preset-mini-btn': '#overflow-preset-btn',
|
||||
'attach-btn': '#overflow-attach-btn',
|
||||
'research-btn': '#overflow-research-btn',
|
||||
'rail-new-chat': '#rail-new-session',
|
||||
};
|
||||
|
||||
// Keys hidden by default on first run (no localStorage yet).
|
||||
export const UI_VIS_DEFAULT_OFF = new Set(['rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
/**
|
||||
* Resolve every UI_VIS_MAP selector to visible (true) or hidden (false) for the
|
||||
* given saved state. Pure: no DOM, no localStorage — app.js applies the result.
|
||||
*
|
||||
* A key is visible when its stored value is not `false`, defaulting to on
|
||||
* unless it is in UI_VIS_DEFAULT_OFF. Per-tool entries also require the Tools
|
||||
* section to be on: hiding Tools hides every tool, mirroring the full sidebar
|
||||
* where the #tools-section container already hides them (the rail has no
|
||||
* container, so this rule keeps it in sync).
|
||||
*
|
||||
* @param {Record<string, boolean>} state
|
||||
* @returns {Record<string, boolean>} selector → visible
|
||||
*/
|
||||
export const resolveVisibility = (state = {}) => {
|
||||
const toolsOn = state['tools-section'] !== false;
|
||||
const out = {};
|
||||
for (const [key, selector] of Object.entries(UI_VIS_MAP)) {
|
||||
let visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||
if (!toolsOn && key.startsWith('tool-')) visible = false;
|
||||
out[selector] = visible;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -38015,6 +38015,12 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
|
||||
outline-offset: 2px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
/* Bootstrap row shown while the session list hydrates, and on load failure.
|
||||
Reads as a normal list row but is not selectable. */
|
||||
.session-list-bootstrap {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
#email-lib-grid .date-section-header {
|
||||
padding: 10px 5px 3px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
// Tests for the live-thinking throttle that bounds DOM work during long
|
||||
// reasoning streams (see static/js/liveThinkingThrottle.js).
|
||||
//
|
||||
// The throttle's contract is what the terminal paths in chat.js lean on:
|
||||
// a burst of deltas becomes ONE commit carrying the latest text; flush()
|
||||
// lands trailing text synchronously and cannot double-commit; cancel()
|
||||
// guarantees nothing lands after a stream is finished or backgrounded.
|
||||
//
|
||||
// Timers are injected, so this runs with no DOM and no real clock.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createIncrementalDisplayProjector,
|
||||
createLiveThinkingThrottle,
|
||||
createThinkingAnalysisGate,
|
||||
stripLiveThinkingTags,
|
||||
} from '../static/js/liveThinkingThrottle.js';
|
||||
|
||||
function fakeTimers() {
|
||||
let nextId = 1;
|
||||
const callbacks = new Map();
|
||||
const delays = [];
|
||||
return {
|
||||
schedule(callback, delay) {
|
||||
const id = nextId++;
|
||||
callbacks.set(id, callback);
|
||||
delays.push(delay);
|
||||
return id;
|
||||
},
|
||||
cancel(id) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
run(id) {
|
||||
const callback = callbacks.get(id);
|
||||
assert.ok(callback, `missing timer ${id}`);
|
||||
callbacks.delete(id);
|
||||
callback();
|
||||
},
|
||||
pendingIds() {
|
||||
return [...callbacks.keys()];
|
||||
},
|
||||
delays,
|
||||
};
|
||||
}
|
||||
|
||||
test('coalesces a burst and commits only the latest text after 100 ms', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
throttle.update('a');
|
||||
throttle.update('ab');
|
||||
throttle.update('abc');
|
||||
|
||||
assert.deepEqual(commits, []);
|
||||
assert.deepEqual(timers.delays, [100], 'a burst must schedule exactly one commit');
|
||||
const [timer] = timers.pendingIds();
|
||||
timers.run(timer);
|
||||
assert.deepEqual(commits, ['abc']);
|
||||
});
|
||||
|
||||
test('commit count stays flat as the stream grows', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
// 500 deltas arriving inside one window is the regression this guards:
|
||||
// the old code committed once per delta, so work grew with stream length.
|
||||
let text = '';
|
||||
for (let i = 0; i < 500; i++) {
|
||||
text += 'token ';
|
||||
throttle.update(text);
|
||||
}
|
||||
assert.deepEqual(commits, []);
|
||||
assert.equal(timers.pendingIds().length, 1);
|
||||
timers.run(timers.pendingIds()[0]);
|
||||
assert.equal(commits.length, 1);
|
||||
assert.equal(commits[0], text);
|
||||
});
|
||||
|
||||
test('prepares a 200K cumulative stream only at scheduled commit cadence', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
let prepareCalls = 0;
|
||||
let scannedCharacters = 0;
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
|
||||
...timers,
|
||||
prepare(value) {
|
||||
prepareCalls += 1;
|
||||
scannedCharacters += value.length;
|
||||
return stripLiveThinkingTags(value);
|
||||
},
|
||||
});
|
||||
|
||||
const delta = 'reasoning '.repeat(10); // 100 characters
|
||||
let cumulative = '';
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
cumulative += delta;
|
||||
throttle.update(cumulative);
|
||||
}
|
||||
|
||||
assert.equal(cumulative.length, 200_000);
|
||||
assert.equal(prepareCalls, 0, 'cumulative extraction must not run per delta');
|
||||
assert.equal(timers.pendingIds().length, 1);
|
||||
timers.run(timers.pendingIds()[0]);
|
||||
assert.equal(prepareCalls, 1);
|
||||
assert.equal(scannedCharacters, 200_000);
|
||||
assert.deepEqual(commits, [cumulative]);
|
||||
});
|
||||
|
||||
test('ordinary answers and reasoning deltas do not request cumulative analysis', () => {
|
||||
const startsReasoning = (text) => /^\s*thinking(?:\s+process)?\s*:/i.test(text);
|
||||
const ordinaryGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||
let ordinary = '';
|
||||
let ordinaryAnalyses = 0;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
ordinary += i === 0 ? 'Here is the answer. ' : 'answer '.repeat(10);
|
||||
if (ordinaryGate.shouldAnalyze(ordinary)) ordinaryAnalyses += 1;
|
||||
}
|
||||
assert.equal(ordinaryAnalyses, 0);
|
||||
|
||||
const thinkingGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||
let thinking = 'Thin';
|
||||
assert.equal(thinkingGate.shouldAnalyze(thinking), false);
|
||||
thinking += 'king: inspect the problem';
|
||||
assert.equal(thinkingGate.shouldAnalyze(thinking), true);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
thinking += ' reasoning'.repeat(10);
|
||||
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), false);
|
||||
}
|
||||
thinking += '\n\nHere is the answer';
|
||||
assert.equal(thinkingGate.shouldAnalyze(thinking, { isThinking: true, nonTagThinking: true }), true);
|
||||
|
||||
const whitespaceGate = createThinkingAnalysisGate({ startsWithReasoningPrefix: startsReasoning });
|
||||
let whitespaceThinking = ' '.repeat(250);
|
||||
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), false);
|
||||
whitespaceThinking += 'Thinking: bounded probe';
|
||||
assert.equal(whitespaceGate.shouldAnalyze(whitespaceThinking), true);
|
||||
});
|
||||
|
||||
test('split namespaced closes and false-close deadlines request analysis', () => {
|
||||
let clock = 100;
|
||||
const gate = createThinkingAnalysisGate({ now: () => clock });
|
||||
let text = '<mm:think>x</mm:';
|
||||
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'fresh opening tag is analyzed');
|
||||
text += 'think>answer';
|
||||
assert.equal(gate.shouldAnalyze(text, { isThinking: true }), true, 'split namespaced close is analyzed');
|
||||
|
||||
text += ' still waiting';
|
||||
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), false);
|
||||
clock = 500;
|
||||
text += ' next delta';
|
||||
assert.equal(gate.shouldAnalyze(text, { isThinking: true, recheckAt: 500 }), true);
|
||||
|
||||
const attributedGate = createThinkingAnalysisGate();
|
||||
let attributed = `<think data-provider="${'x'.repeat(400)}"`;
|
||||
assert.equal(attributedGate.shouldAnalyze(attributed), false);
|
||||
attributed += '>reasoning';
|
||||
assert.equal(attributedGate.shouldAnalyze(attributed), true, 'bounded carry preserves split tag attributes');
|
||||
});
|
||||
|
||||
test('display projection is append-only and filters a structured tail once', () => {
|
||||
let filterCalls = 0;
|
||||
let filteredCharacters = 0;
|
||||
const projector = createIncrementalDisplayProjector((text) => {
|
||||
filterCalls += 1;
|
||||
filteredCharacters += text.length;
|
||||
return text.replace(/\[TOOL_CALL\][\s\S]*$/i, '');
|
||||
});
|
||||
|
||||
let text = '';
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const delta = i === 0 ? 'Here is the answer. ' : 'ordinary text ';
|
||||
text += delta;
|
||||
assert.equal(projector.append(delta, text), text);
|
||||
}
|
||||
assert.equal(filterCalls, 0, 'ordinary deltas never run the cumulative filter');
|
||||
|
||||
text += '[TOOL_';
|
||||
projector.append('[TOOL_', text);
|
||||
text += 'CALL]{"name":"read"}';
|
||||
const beforeToolPayload = projector.append('CALL]{"name":"read"}', text);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const delta = 'payload ';
|
||||
text += delta;
|
||||
assert.equal(projector.append(delta, text), beforeToolPayload);
|
||||
}
|
||||
assert.equal(filterCalls, 1, 'structured payload filtering happens only at its boundary');
|
||||
assert.ok(filteredCharacters < text.length, 'filter work is bounded by the first structured boundary');
|
||||
});
|
||||
|
||||
test('literal escaped tags survive and malformed live tags retain trailing text', () => {
|
||||
assert.equal(
|
||||
stripLiveThinkingTags('<think>literal</think>'),
|
||||
'<think>literal</think>',
|
||||
);
|
||||
assert.equal(
|
||||
stripLiveThinkingTags('<think>first</think> middle <thinking mode="deep">trailing'),
|
||||
'first middle trailing',
|
||||
);
|
||||
assert.equal(stripLiveThinkingTags('answer with 2 < 3 and 5 > 4'), 'answer with 2 < 3 and 5 > 4');
|
||||
});
|
||||
|
||||
test('terminal flush prepares and commits the complete trailing cumulative text', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), {
|
||||
...timers,
|
||||
prepare: stripLiveThinkingTags,
|
||||
});
|
||||
|
||||
throttle.update('<think>reasoning without a closing tag');
|
||||
assert.equal(throttle.flush(), true);
|
||||
assert.deepEqual(commits, ['reasoning without a closing tag']);
|
||||
assert.deepEqual(timers.pendingIds(), []);
|
||||
});
|
||||
|
||||
test('independent throttles cannot commit cancelled text into another session', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const first = createLiveThinkingThrottle((value) => commits.push(['first', value]), timers);
|
||||
const second = createLiveThinkingThrottle((value) => commits.push(['second', value]), timers);
|
||||
|
||||
first.update('stale first-session text');
|
||||
second.update('current second-session text');
|
||||
first.cancel();
|
||||
assert.equal(second.flush(), true);
|
||||
|
||||
assert.deepEqual(timers.pendingIds(), []);
|
||||
assert.deepEqual(commits, [['second', 'current second-session text']]);
|
||||
});
|
||||
|
||||
test('flush synchronously preserves trailing text and cancels the pending callback', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
throttle.update('trailing text');
|
||||
assert.equal(throttle.flush(), true);
|
||||
assert.deepEqual(commits, ['trailing text']);
|
||||
assert.deepEqual(timers.pendingIds(), []);
|
||||
assert.equal(throttle.flush(), false, 'clean flush must not duplicate the commit');
|
||||
});
|
||||
|
||||
test('cancel discards pending work without a late DOM commit', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
throttle.update('stale session text');
|
||||
throttle.cancel();
|
||||
assert.deepEqual(timers.pendingIds(), []);
|
||||
assert.deepEqual(commits, []);
|
||||
});
|
||||
|
||||
test('a cancelled throttle accepts new work again', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
throttle.update('discarded');
|
||||
throttle.cancel();
|
||||
throttle.update('fresh');
|
||||
assert.equal(throttle.flush(), true);
|
||||
assert.deepEqual(commits, ['fresh']);
|
||||
});
|
||||
|
||||
test('coerces nullish updates instead of committing undefined', () => {
|
||||
const timers = fakeTimers();
|
||||
const commits = [];
|
||||
const throttle = createLiveThinkingThrottle((value) => commits.push(value), timers);
|
||||
|
||||
throttle.update(null);
|
||||
throttle.flush();
|
||||
assert.deepEqual(commits, ['']);
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
|
||||
the indexing job on the event loop.
|
||||
|
||||
The handler is ``async def`` but called ``rag.index_personal_documents``
|
||||
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
|
||||
FastAPI ran the whole job on the event loop and every other request queued
|
||||
behind it: indexing a real directory froze the UI and API for 25+ minutes.
|
||||
``personal_docs_manager.add_directory`` sits in the same blocking section — it
|
||||
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
|
||||
|
||||
These tests build the real router with fake managers and compare the thread
|
||||
the indexing work runs on against the event loop's thread.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _serialization_probe():
|
||||
"""Shared counter proving two critical sections never overlap."""
|
||||
state = {"active": 0, "max_active": 0}
|
||||
lock = threading.Lock()
|
||||
|
||||
def enter():
|
||||
with lock:
|
||||
state["active"] += 1
|
||||
state["max_active"] = max(state["max_active"], state["active"])
|
||||
|
||||
def leave():
|
||||
with lock:
|
||||
state["active"] -= 1
|
||||
|
||||
return state, enter, leave
|
||||
|
||||
|
||||
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
|
||||
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
|
||||
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
|
||||
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
|
||||
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
|
||||
# requests on the test's own loop.
|
||||
def _async_client(app):
|
||||
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
|
||||
|
||||
import routes.personal_routes as personal_routes
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import require_user
|
||||
|
||||
|
||||
class _FakeRag:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
|
||||
def index_personal_documents(self, directory, owner=None):
|
||||
self._record["index_thread"] = threading.get_ident()
|
||||
return {"success": True, "indexed_count": 3, "failed_count": 0}
|
||||
|
||||
def _split_into_chunks(self, text, chunk_size=500):
|
||||
return [text]
|
||||
|
||||
def add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
return True
|
||||
|
||||
def delete_by_source(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
return 1
|
||||
|
||||
|
||||
class _FakeDocsManager:
|
||||
def __init__(self, record):
|
||||
self._record = record
|
||||
self.index = []
|
||||
|
||||
def add_directory(self, directory, *, index=True, owner=None):
|
||||
self._record["bookkeeping_thread"] = threading.get_ident()
|
||||
self._record["bookkeeping_index_flag"] = index
|
||||
|
||||
def exclude_file(self, filepath):
|
||||
self._record["exclude_thread"] = threading.get_ident()
|
||||
|
||||
|
||||
def _build_app(tmp_path, monkeypatch, record):
|
||||
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
|
||||
)
|
||||
app.dependency_overrides[require_user] = lambda: "tester"
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
|
||||
@app.get("/loop-thread")
|
||||
async def loop_thread_probe():
|
||||
return {"thread": threading.get_ident()}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
# Context-manager client: one portal/event loop serves both requests, so
|
||||
# the probe and the POST are guaranteed to see the same loop thread.
|
||||
with TestClient(app) as client:
|
||||
loop_thread = client.get("/loop-thread").json()["thread"]
|
||||
resp = client.post(
|
||||
"/api/personal/add_directory", json={"directory": str(target)}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert record["index_thread"] != loop_thread, (
|
||||
"index_personal_documents ran on the event loop thread — every other "
|
||||
"request queues behind the indexing job (#5558)"
|
||||
)
|
||||
assert record["bookkeeping_thread"] != loop_thread, (
|
||||
"personal_docs_manager.add_directory (refresh_index) ran on the event "
|
||||
"loop thread"
|
||||
)
|
||||
|
||||
|
||||
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["indexed_count"] == 3
|
||||
assert body["failed_count"] == 0
|
||||
assert body["directory"] == os.path.realpath(str(target))
|
||||
assert record["bookkeeping_index_flag"] is False
|
||||
|
||||
|
||||
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
|
||||
"""Off-loop execution must not mean parallel index jobs: concurrent
|
||||
requests would race PersonalDocsManager's unsynchronized list mutations
|
||||
and file writes (save_directories/_save_excluded are plain open('w'))."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.2); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
for name in ("docs_a", "docs_b"):
|
||||
(tmp_path / name).mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} index jobs ran in parallel — concurrent "
|
||||
"add_directory requests must serialize"
|
||||
)
|
||||
|
||||
|
||||
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
target = tmp_path / "docs"
|
||||
target.mkdir()
|
||||
|
||||
def _fail(directory, owner=None):
|
||||
return {"success": False, "message": "boom"}
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
|
||||
assert resp.status_code == 500
|
||||
assert "boom" in resp.json()["detail"]
|
||||
|
||||
|
||||
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
|
||||
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
|
||||
running while an add job is in flight races PersonalDocsManager's
|
||||
unsynchronized list/index mutations — the inconsistent state the PR's
|
||||
'add/remove are serialized' guarantee claims to prevent."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_remove(self, directory):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
(tmp_path / "docs_b").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/remove critical sections overlapped — "
|
||||
"remove must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
|
||||
calls personal_docs_manager.add_directory — the same vector/tracking state
|
||||
add_directory mutates. It must hold the SAME job lock, or an upload landing
|
||||
mid-add interleaves two writers over unsynchronized state."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
# The test coroutine runs on the event loop, so this IS the loop thread.
|
||||
assert record["add_document_thread"] != threading.get_ident(), (
|
||||
"rag.add_document ran on the event loop thread — chunk writes block "
|
||||
"every other request for the duration of the upload"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/upload critical sections overlapped — "
|
||||
"upload must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
|
||||
"""A multi-file upload must retain at most one capped payload at a time."""
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
reads = []
|
||||
original_read = StarletteUploadFile.read
|
||||
|
||||
async def _recording_read(upload, size=-1):
|
||||
reads.append(upload.filename)
|
||||
return await original_read(upload, size)
|
||||
|
||||
def _record_first_index(self, chunk, metadata):
|
||||
self._record.setdefault("reads_at_first_index", len(reads))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
|
||||
files = [
|
||||
("files", ("a.txt", b"alpha", "text/plain")),
|
||||
("files", ("b.txt", b"bravo", "text/plain")),
|
||||
("files", ("c.txt", b"charlie", "text/plain")),
|
||||
]
|
||||
async with _async_client(app) as ac:
|
||||
response = await ac.post("/api/personal/upload", files=files)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
|
||||
assert reads == ["a.txt", "b.txt", "c.txt"]
|
||||
assert record["reads_at_first_index"] == 1, (
|
||||
"all upload bodies were retained before worker processing began"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
|
||||
calls personal_docs_manager.exclude_file. Both mutate state add_directory
|
||||
also touches, so the delete must hold the SAME job lock as add."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_delete(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
doomed = tmp_path / "doomed.txt"
|
||||
doomed.write_text("bye")
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert record["delete_thread"] != threading.get_ident(), (
|
||||
"rag.delete_by_source ran on the event loop thread"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/delete critical sections overlapped — "
|
||||
"delete must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
|
||||
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
|
||||
the same job lock so it cannot race an in-flight add job."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_refresh(self):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/reload"),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/reload critical sections overlapped — "
|
||||
"reload must hold the same index job lock as add"
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Windows execution contract for the agent Bash tool."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_tools import subprocess_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_uses_git_bash_with_structural_cwd(monkeypatch):
|
||||
captured = {}
|
||||
bash = r"C:\Program Files\Git\bin\bash.exe"
|
||||
workspace = r"D:\Workspaces\Project with spaces"
|
||||
process = object()
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: bash)
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
captured["argv"] = argv
|
||||
captured["kwargs"] = kwargs
|
||||
return process
|
||||
|
||||
async def fail_shell(*_args, **_kwargs):
|
||||
pytest.fail("native Windows Bash must not execute through cmd.exe")
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_shell)
|
||||
|
||||
result = await subprocess_tools._create_bash_subprocess(
|
||||
"pwd; cat package.json",
|
||||
cwd=workspace,
|
||||
env={"HOME": r"C:\Odysseus\data"},
|
||||
)
|
||||
|
||||
assert result is process
|
||||
assert captured["argv"] == (bash, "-c", "pwd; cat package.json")
|
||||
assert captured["kwargs"]["cwd"] == workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
|
||||
|
||||
async def fail_spawn(*_args, **_kwargs):
|
||||
pytest.fail("no subprocess should start without Git Bash")
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_spawn)
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fail_spawn)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Git Bash is required"):
|
||||
await subprocess_tools._create_bash_subprocess("pwd", cwd=r"C:\Work")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
{"subproc_env": {}, "session_id": None},
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "install Git for Windows" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
|
||||
captured = {}
|
||||
workspace = r"D:\Workspaces\Project with spaces"
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools.shutil,
|
||||
"which",
|
||||
lambda name: r"C:\msys64\usr\bin\tmux.exe",
|
||||
)
|
||||
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: workspace)
|
||||
|
||||
async def fail_tmux(*_args, **_kwargs):
|
||||
pytest.fail("native Windows must not enter the POSIX tmux path")
|
||||
|
||||
async def fake_create(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
return object()
|
||||
|
||||
async def fake_stream(_process, **_kwargs):
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
|
||||
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
{"subproc_env": {}, "session_id": "chat-1"},
|
||||
)
|
||||
|
||||
assert result == {"output": "ok", "exit_code": 0}
|
||||
assert captured["command"] == "pwd"
|
||||
assert captured["kwargs"]["cwd"] == workspace
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_posix_bash_keeps_existing_shell_path(monkeypatch):
|
||||
captured = {}
|
||||
process = object()
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", False)
|
||||
|
||||
async def fake_shell(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
return process
|
||||
|
||||
async def fail_exec(*_args, **_kwargs):
|
||||
pytest.fail("POSIX behavior must continue through create_subprocess_shell")
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", fake_shell)
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fail_exec)
|
||||
|
||||
result = await subprocess_tools._create_bash_subprocess("pwd", cwd="/tmp/work")
|
||||
|
||||
assert result is process
|
||||
assert captured == {"command": "pwd", "kwargs": {"cwd": "/tmp/work"}}
|
||||
@@ -76,7 +76,7 @@ def _load_webhook_routes_for_test(monkeypatch):
|
||||
module_name = "routes.webhook_routes_under_test"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_name,
|
||||
Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
|
||||
Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
+44
-6
@@ -1,17 +1,19 @@
|
||||
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
|
||||
|
||||
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
|
||||
Both write to a sibling ``.tmp.<pid>`` file, ``fsync`` it, then ``os.replace``
|
||||
into place so a crash mid-write leaves the previous good copy untouched rather
|
||||
than a truncated/empty file.
|
||||
Both write to a sibling ``.tmp.<random>`` file, ``fsync`` it, then
|
||||
``os.replace`` into place so a crash mid-write leaves the previous good copy
|
||||
untouched rather than a truncated/empty file.
|
||||
|
||||
These tests cover the happy path (round-trip, indent, parent-dir creation,
|
||||
full overwrite, no leftover tmp) and the two failure paths the implementation
|
||||
guarantees: the target file is preserved when serialization fails before the
|
||||
replace, and when ``os.replace`` itself fails.
|
||||
full overwrite, no leftover tmp), the two failure paths the implementation
|
||||
guarantees (the target file is preserved when serialization fails before the
|
||||
replace, and when ``os.replace`` itself fails), and that two concurrent
|
||||
writers to the same path don't collide on the same temp file.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -84,6 +86,42 @@ def test_atomic_write_json_leaves_no_tmp_file(tmp_path):
|
||||
assert _tmp_siblings(tmp_path, "data.json") == []
|
||||
|
||||
|
||||
def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
|
||||
# Both writers run in this same process, so a PID-based tmp suffix is
|
||||
# identical for both: whichever writer finishes first unlinks the tmp
|
||||
# file (via os.replace) out from under the other, which then raises
|
||||
# FileNotFoundError on its own os.replace instead of landing its write.
|
||||
target = tmp_path / "settings.json"
|
||||
orig_dump = json.dump
|
||||
barrier = threading.Barrier(2)
|
||||
errors = []
|
||||
|
||||
def slow_dump(obj, fp, **kwargs):
|
||||
orig_dump(obj, fp, **kwargs)
|
||||
fp.flush()
|
||||
barrier.wait()
|
||||
|
||||
def write(payload):
|
||||
try:
|
||||
atomic_write_json(str(target), payload)
|
||||
except Exception as exc: # noqa: BLE001 - captured for the assertion below
|
||||
errors.append(exc)
|
||||
|
||||
json.dump = slow_dump
|
||||
try:
|
||||
t1 = threading.Thread(target=write, args=({"writer": "A"},))
|
||||
t2 = threading.Thread(target=write, args=({"writer": "B"},))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
finally:
|
||||
json.dump = orig_dump
|
||||
|
||||
assert errors == []
|
||||
assert json.loads(target.read_text(encoding="utf-8"))["writer"] in ("A", "B")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# atomic_write_json — failure path: target preserved on serialization error.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,9 @@ def _setup(monkeypatch, store, user="alice"):
|
||||
|
||||
mem = MagicMock()
|
||||
mem.load_all.return_value = list(store)
|
||||
# import_data reads through the strict loader so a store it cannot read is
|
||||
# never overwritten (#5673); the double has to offer the same entry point.
|
||||
mem.load_all_for_update.return_value = list(store)
|
||||
saved = {}
|
||||
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
|
||||
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
"""Default calendar creation belongs to the caller's transaction.
|
||||
|
||||
Before this regression, ``_ensure_default_calendar`` committed independently.
|
||||
If event persistence then failed, the event rolled back but a new ``Personal``
|
||||
calendar remained (``calendar_count=1``, ``event_count=0``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.helpers.import_state import clear_fake_database_modules
|
||||
|
||||
clear_fake_database_modules()
|
||||
|
||||
import core.database as cdb # noqa: E402
|
||||
import routes.calendar_routes as calendar_routes # noqa: E402
|
||||
from core.database import CalendarCal, CalendarEvent # noqa: E402
|
||||
from routes.calendar_routes import EventCreate # noqa: E402
|
||||
from routes.calendar_routes import ( # noqa: E402
|
||||
_default_calendar_id,
|
||||
_ensure_default_calendar,
|
||||
)
|
||||
|
||||
|
||||
class _RejectEventCommit(Session):
|
||||
"""Reproduce an event commit failure after default-calendar creation."""
|
||||
|
||||
def commit(self):
|
||||
if any(isinstance(row, CalendarEvent) for row in self.new):
|
||||
raise RuntimeError("commit guard rejected event commit")
|
||||
return super().commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_factory(tmp_path, monkeypatch):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'calendar.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(
|
||||
bind=engine,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
class_=_RejectEventCommit,
|
||||
)
|
||||
monkeypatch.setattr(cdb, "SessionLocal", factory)
|
||||
monkeypatch.setattr(calendar_routes, "SessionLocal", factory)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _request():
|
||||
return SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||
|
||||
|
||||
def _endpoint(method, suffix):
|
||||
router = calendar_routes.setup_calendar_routes()
|
||||
for route in router.routes:
|
||||
if route.path.endswith(suffix) and method in route.methods:
|
||||
return route.endpoint
|
||||
raise RuntimeError(f"{method} *{suffix} not found")
|
||||
|
||||
|
||||
def _counts(factory):
|
||||
db = factory()
|
||||
try:
|
||||
return db.query(CalendarCal).count(), db.query(CalendarEvent).count()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def test_route_event_failure_rolls_back_new_default_calendar(session_factory):
|
||||
create_event = _endpoint("POST", "/events")
|
||||
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
await create_event(
|
||||
_request(),
|
||||
EventCreate(summary="Planning", dtstart="2126-07-20T09:00:00Z"),
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 500
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_route_event_validation_failure_rolls_back_new_default_calendar(
|
||||
session_factory,
|
||||
):
|
||||
create_event = _endpoint("POST", "/events")
|
||||
|
||||
with pytest.raises(HTTPException) as caught:
|
||||
await create_event(
|
||||
_request(),
|
||||
EventCreate(summary="Planning", dtstart="not-a-datetime"),
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 500
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_tool_event_failure_rolls_back_new_default_calendar(session_factory):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Planning",
|
||||
"dtstart": "2126-07-20T09:00:00Z",
|
||||
}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "commit guard rejected event commit" in result["error"]
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_tool_event_validation_failure_rolls_back_new_default_calendar(
|
||||
session_factory,
|
||||
):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Planning",
|
||||
"dtstart": "not-a-datetime",
|
||||
}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 1
|
||||
assert "Could not parse dtstart" in result["error"]
|
||||
assert _counts(session_factory) == (0, 0)
|
||||
|
||||
|
||||
async def test_route_list_calendars_persists_lazy_default(session_factory):
|
||||
list_calendars = _endpoint("GET", "/calendars")
|
||||
|
||||
result = await list_calendars(_request())
|
||||
|
||||
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
|
||||
assert _counts(session_factory) == (1, 0)
|
||||
|
||||
|
||||
async def test_tool_list_calendars_persists_lazy_default(session_factory):
|
||||
from src.tools.calendar import do_manage_calendar
|
||||
|
||||
result = await do_manage_calendar(
|
||||
json.dumps({"action": "list_calendars"}),
|
||||
owner="alice",
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert [calendar["name"] for calendar in result["calendars"]] == ["Personal"]
|
||||
assert _counts(session_factory) == (1, 0)
|
||||
|
||||
|
||||
def test_repeated_rename_and_reuse_uses_stable_fallback_ids(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'renamed-calendar.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
db = factory()
|
||||
try:
|
||||
first = _ensure_default_calendar(db, "alice")
|
||||
assert first.id == _default_calendar_id("alice")
|
||||
db.commit()
|
||||
|
||||
# The supported user-rename migration changes owner columns while
|
||||
# deliberately preserving durable row identifiers.
|
||||
first.owner = "bob"
|
||||
db.commit()
|
||||
|
||||
second = _ensure_default_calendar(db, "alice")
|
||||
assert second.id == _default_calendar_id("alice", 1)
|
||||
db.commit()
|
||||
|
||||
# Repeating the same lifecycle must advance deterministically instead
|
||||
# of failing or choosing a random identifier.
|
||||
second.owner = "carol"
|
||||
db.commit()
|
||||
|
||||
third = _ensure_default_calendar(db, "alice")
|
||||
assert third.id == _default_calendar_id("alice", 2)
|
||||
db.commit()
|
||||
|
||||
rows = db.query(CalendarCal).order_by(CalendarCal.owner).all()
|
||||
assert [(row.owner, row.id) for row in rows] == [
|
||||
("alice", _default_calendar_id("alice", 2)),
|
||||
("bob", _default_calendar_id("alice")),
|
||||
("carol", _default_calendar_id("alice", 1)),
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _assert_concurrent_first_use(tmp_path, occupied_owner=None):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'concurrent-calendar.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 10},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
expected_collision_index = 0
|
||||
if occupied_owner is not None:
|
||||
seed = factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner=occupied_owner,
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
expected_collision_index = 1
|
||||
finally:
|
||||
seed.close()
|
||||
first_staged = threading.Event()
|
||||
second_selected = threading.Event()
|
||||
errors = []
|
||||
|
||||
@event.listens_for(engine, "after_cursor_execute")
|
||||
def observe_second_gap(conn, cursor, statement, parameters, context, executemany):
|
||||
if (
|
||||
threading.current_thread().name == "calendar-worker-second"
|
||||
and statement.lstrip().upper().startswith("SELECT")
|
||||
and "FROM calendars" in statement
|
||||
):
|
||||
second_selected.set()
|
||||
|
||||
def create_default(worker, hold=False):
|
||||
db = factory()
|
||||
try:
|
||||
if not hold:
|
||||
assert first_staged.wait(5)
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
start = datetime(2126, 7, 20, 9 if hold else 10)
|
||||
db.add(CalendarEvent(
|
||||
uid=worker,
|
||||
calendar_id=cal.id,
|
||||
summary=f"Event {worker}",
|
||||
dtstart=start,
|
||||
dtend=start + timedelta(hours=1),
|
||||
))
|
||||
if hold:
|
||||
first_staged.set()
|
||||
# The second session has observed the uncommitted gap before
|
||||
# this transaction releases its writer reservation.
|
||||
assert second_selected.wait(5)
|
||||
db.commit()
|
||||
assert cal.id == _default_calendar_id("alice", expected_collision_index)
|
||||
except BaseException as exc: # pragma: no cover - asserted below
|
||||
errors.append((worker, exc))
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
first = threading.Thread(
|
||||
target=create_default,
|
||||
args=("first", True),
|
||||
name="calendar-worker-first",
|
||||
)
|
||||
second = threading.Thread(
|
||||
target=create_default,
|
||||
args=("second",),
|
||||
name="calendar-worker-second",
|
||||
)
|
||||
first.start()
|
||||
second.start()
|
||||
first.join(10)
|
||||
second.join(10)
|
||||
|
||||
try:
|
||||
assert not first.is_alive() and not second.is_alive()
|
||||
assert errors == []
|
||||
db = factory()
|
||||
try:
|
||||
rows = db.query(CalendarCal).filter(CalendarCal.owner == "alice").all()
|
||||
assert [(row.id, row.name) for row in rows] == [
|
||||
(_default_calendar_id("alice", expected_collision_index), "Personal")
|
||||
]
|
||||
assert db.query(CalendarEvent).count() == 2
|
||||
if occupied_owner is not None:
|
||||
occupied = db.query(CalendarCal).filter(
|
||||
CalendarCal.id == _default_calendar_id("alice"),
|
||||
).one()
|
||||
assert occupied.owner == occupied_owner
|
||||
finally:
|
||||
db.close()
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_concurrent_first_use_creates_one_sqlite_default(tmp_path):
|
||||
_assert_concurrent_first_use(tmp_path)
|
||||
|
||||
|
||||
def test_concurrent_first_use_after_rename_creates_one_fallback_default(tmp_path):
|
||||
_assert_concurrent_first_use(tmp_path, occupied_owner="bob")
|
||||
|
||||
|
||||
def test_sqlite_default_stays_in_callers_transaction(session_factory):
|
||||
db = session_factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "rollback-owner")
|
||||
assert cal.id == _default_calendar_id("rollback-owner")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = session_factory()
|
||||
try:
|
||||
assert (
|
||||
verify.query(CalendarCal)
|
||||
.filter(CalendarCal.owner == "rollback-owner")
|
||||
.count()
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
verify.close()
|
||||
|
||||
|
||||
def test_sqlite_fallback_default_stays_in_callers_transaction(session_factory):
|
||||
seed = session_factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
finally:
|
||||
seed.close()
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
assert cal.id == _default_calendar_id("alice", 1)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = session_factory()
|
||||
try:
|
||||
assert verify.query(CalendarCal).filter(CalendarCal.owner == "alice").count() == 0
|
||||
assert verify.query(CalendarCal).filter(CalendarCal.owner == "bob").count() == 1
|
||||
finally:
|
||||
verify.close()
|
||||
|
||||
|
||||
class _FakeDialect:
|
||||
name = "postgresql"
|
||||
|
||||
|
||||
class _FakeBind:
|
||||
dialect = _FakeDialect()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def filter(self, *conditions):
|
||||
return self
|
||||
|
||||
def with_for_update(self):
|
||||
self.session.locking_read = True
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
self.session.query_count += 1
|
||||
if self.session.query_count == 1:
|
||||
return None
|
||||
return self.session.winner
|
||||
|
||||
|
||||
class _GenericRaceSession:
|
||||
"""Minimal non-SQLite session that loses the deterministic-ID race."""
|
||||
|
||||
def __init__(self):
|
||||
self.query_count = 0
|
||||
self.nested_entries = 0
|
||||
self.locking_read = False
|
||||
self.candidate = None
|
||||
self.winner = CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="alice",
|
||||
name="Personal",
|
||||
source="local",
|
||||
)
|
||||
|
||||
def get_bind(self):
|
||||
return _FakeBind()
|
||||
|
||||
def query(self, model):
|
||||
assert model is CalendarCal
|
||||
return _FakeQuery(self)
|
||||
|
||||
@contextmanager
|
||||
def begin_nested(self):
|
||||
self.nested_entries += 1
|
||||
yield
|
||||
|
||||
def add(self, row):
|
||||
self.candidate = row
|
||||
|
||||
def flush(self):
|
||||
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
|
||||
|
||||
|
||||
def test_generic_backend_lost_race_recovers_inside_savepoint():
|
||||
db = _GenericRaceSession()
|
||||
|
||||
winner = _ensure_default_calendar(db, "alice")
|
||||
|
||||
assert winner is db.winner
|
||||
assert db.nested_entries == 1
|
||||
assert db.locking_read is True
|
||||
assert db.candidate.id == db.winner.id
|
||||
|
||||
|
||||
def test_generic_backend_unattributed_integrity_error_is_not_retried():
|
||||
db = _GenericRaceSession()
|
||||
db.winner = None
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
_ensure_default_calendar(db, "alice")
|
||||
|
||||
assert db.nested_entries == 1
|
||||
|
||||
|
||||
class _GenericRenamedSlotSession(_GenericRaceSession):
|
||||
"""A different owner occupies slot zero; slot one remains available."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.candidates = []
|
||||
self.winner = CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
)
|
||||
|
||||
def add(self, row):
|
||||
self.candidate = row
|
||||
self.candidates.append(row)
|
||||
|
||||
def flush(self):
|
||||
if len(self.candidates) == 1:
|
||||
raise IntegrityError("insert", {}, RuntimeError("duplicate primary key"))
|
||||
|
||||
|
||||
def test_generic_backend_renamed_slot_advances_inside_savepoint():
|
||||
db = _GenericRenamedSlotSession()
|
||||
|
||||
fallback = _ensure_default_calendar(db, "alice")
|
||||
|
||||
assert fallback is db.candidates[-1]
|
||||
assert fallback.id == _default_calendar_id("alice", 1)
|
||||
assert fallback.owner == "alice"
|
||||
assert db.nested_entries == 2
|
||||
assert db.locking_read is True
|
||||
assert db.winner.owner == "bob"
|
||||
|
||||
|
||||
def test_generic_backend_fallback_keeps_outer_transaction_usable(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'generic-savepoint-calendar.db'}",
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
# SQLite supplies a lightweight local SQL executor here; changing only the
|
||||
# dispatch name exercises the real Session/savepoint branch used by
|
||||
# PostgreSQL-style backends without pretending to validate their dialect.
|
||||
engine.dialect.name = "postgresql"
|
||||
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
seed = factory()
|
||||
try:
|
||||
seed.add(CalendarCal(
|
||||
id=_default_calendar_id("alice"),
|
||||
owner="bob",
|
||||
name="Personal",
|
||||
source="local",
|
||||
))
|
||||
seed.commit()
|
||||
finally:
|
||||
seed.close()
|
||||
|
||||
db = factory()
|
||||
try:
|
||||
cal = _ensure_default_calendar(db, "alice")
|
||||
start = datetime(2126, 7, 20, 9)
|
||||
db.add(CalendarEvent(
|
||||
uid="after-fallback",
|
||||
calendar_id=cal.id,
|
||||
summary="Atomic",
|
||||
dtstart=start,
|
||||
dtend=start + timedelta(hours=1),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
verify = factory()
|
||||
try:
|
||||
assert [
|
||||
(row.owner, row.id)
|
||||
for row in verify.query(CalendarCal).order_by(CalendarCal.owner).all()
|
||||
] == [
|
||||
("alice", _default_calendar_id("alice", 1)),
|
||||
("bob", _default_calendar_id("alice")),
|
||||
]
|
||||
assert verify.query(CalendarEvent).count() == 1
|
||||
finally:
|
||||
verify.close()
|
||||
engine.dispose()
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -13,7 +14,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
|
||||
assert "let _cancelThinkingTimer = () => {};" in outer_scope
|
||||
assert "let _removeThinkingSpinner = () => {};" in outer_scope
|
||||
|
||||
assert "_renderStream = () => {" in try_body
|
||||
assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
|
||||
assert "_cancelThinkingTimer = () => {" in try_body
|
||||
assert "_removeThinkingSpinner = () => {" in try_body
|
||||
assert "function _renderStream()" not in try_body
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Regression coverage for authoritative Python CI validation."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_WORKFLOW = (
|
||||
Path(__file__).resolve().parent.parent / ".github" / "workflows" / "ci.yml"
|
||||
)
|
||||
|
||||
|
||||
def _indented_block(text: str, heading: str, indent: int) -> str:
|
||||
pattern = re.compile(
|
||||
rf"(?ms)^{' ' * indent}{re.escape(heading)}:\n"
|
||||
rf"(?P<body>(?:(?:{' ' * (indent + 2)}.*|\s*)\n)*)"
|
||||
)
|
||||
match = pattern.search(text)
|
||||
assert match is not None, f"missing {heading!r} block"
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def test_ci_runs_on_integrated_dev_pushes():
|
||||
workflow = _WORKFLOW.read_text()
|
||||
push = _indented_block(workflow, "push", 2)
|
||||
|
||||
assert re.search(r"(?m)^ branches:\s*\[main,\s*dev\]\s*$", push)
|
||||
assert "paths-ignore:" not in push
|
||||
|
||||
|
||||
def test_python_tests_are_authoritative():
|
||||
workflow = _WORKFLOW.read_text()
|
||||
python_tests = _indented_block(workflow, "python-tests", 2)
|
||||
|
||||
assert "python -m pytest -q" in python_tests
|
||||
assert "continue-on-error:" not in python_tests
|
||||
@@ -306,3 +306,24 @@ def test_integration_recalls_from_chat_history_dom():
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
|
||||
|
||||
|
||||
def test_prompt_recall_is_not_duplicated_in_app_js():
|
||||
"""Only composerArrowUpRecall.js may own ArrowUp on #message (issue #5862).
|
||||
|
||||
static/app.js once carried a near-verbatim copy of this recall logic, wired
|
||||
as a second capture-phase listener on the same textarea. That copy lacked
|
||||
the draft guard here, and because it called stopImmediatePropagation it won
|
||||
regardless of registration order — so a typed multi-line prompt was replaced
|
||||
by the last sent one instead of the caret moving up a line.
|
||||
"""
|
||||
app_js = (_REPO / "static" / "app.js").read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
"_odysseusPromptRecallCapture",
|
||||
"_readComposerPromptHistory",
|
||||
"odysseusRecallIndex",
|
||||
):
|
||||
assert marker not in app_js, (
|
||||
f"static/app.js reintroduces prompt recall ({marker!r}); "
|
||||
"it belongs to static/js/composerArrowUpRecall.js alone"
|
||||
)
|
||||
|
||||
@@ -723,7 +723,12 @@ def test_local_windows_download_pid_tracks_inner_bash_and_stop_kills_tree():
|
||||
routes_src = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
running_src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbookRunning.js").read_text(encoding="utf-8")
|
||||
|
||||
assert 'printf \'%s\\\\n\' \\"$$\\" > {pp}' in routes_src
|
||||
# The Windows-local runner publishes Python's valid Win32 fallback before
|
||||
# allowing Git Bash to replace it with /proc/$$/winpid.
|
||||
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in routes_src
|
||||
assert "/proc/$$/winpid" in routes_src
|
||||
assert "pid_ready_path.touch()" in routes_src
|
||||
assert '\\"$$\\" > {pp}' not in routes_src
|
||||
assert "function Stop-Tree([int]$Id)" in running_src
|
||||
assert "('ParentProcessId = ' + $Id)" in running_src
|
||||
assert "Stop-Tree ([int]$p)" in running_src
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Behavioral regression coverage for Windows-local Cookbook PID recording."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from routes.cookbook_routes import _windows_local_pid_record_line
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COOKBOOK_ROUTES = ROOT / "routes" / "cookbook_routes.py"
|
||||
|
||||
|
||||
def _fake_cat(tmp_path: Path, body: str) -> Path:
|
||||
fake_bin = tmp_path / "bin"
|
||||
fake_bin.mkdir()
|
||||
cat = fake_bin / "cat"
|
||||
cat.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
|
||||
cat.chmod(0o755)
|
||||
return fake_bin
|
||||
|
||||
|
||||
def _env_for(fake_bin: Path, **extra: str) -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
|
||||
env.update(extra)
|
||||
return env
|
||||
|
||||
|
||||
def _run_pid_line(
|
||||
pid_path: Path,
|
||||
ready_path: Path,
|
||||
fake_bin: Path,
|
||||
**extra_env: str,
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", "-c", _windows_local_pid_record_line(pid_path, ready_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=_env_for(fake_bin, **extra_env),
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def test_windows_local_pid_line_records_numeric_winpid_after_fallback(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("11111", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
cat_arg = tmp_path / "cat-arg.txt"
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "%s\\n" "$1" > "$FAKE_CAT_ARG"\n'
|
||||
'printf "%s\\n" "$FAKE_WINPID"',
|
||||
)
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
FAKE_CAT_ARG=str(cat_arg),
|
||||
FAKE_WINPID="42324",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
|
||||
assert not ready_path.exists()
|
||||
|
||||
proc_path = cat_arg.read_text(encoding="utf-8").strip()
|
||||
parts = proc_path.strip("/").split("/")
|
||||
assert len(parts) == 3
|
||||
assert parts[0] == "proc"
|
||||
assert parts[1].isdigit()
|
||||
assert parts[2] == "winpid"
|
||||
|
||||
|
||||
def test_windows_local_pid_line_waits_for_python_fallback_before_replacing(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "%s\\n" "$FAKE_WINPID"',
|
||||
)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
_windows_local_pid_record_line(pid_path, ready_path),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
env=_env_for(fake_bin, FAKE_WINPID="42324"),
|
||||
)
|
||||
|
||||
# The inner shell has started, but Python has not published its fallback yet.
|
||||
time.sleep(0.05)
|
||||
assert proc.poll() is None
|
||||
assert not pid_path.exists()
|
||||
|
||||
# Simulate the post-Popen Python publication order.
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
|
||||
assert proc.returncode == 0, stderr or stdout
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "42324"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_windows_local_pid_line_preserves_outer_pid_when_mapping_missing(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
fake_bin = _fake_cat(tmp_path, "exit 1")
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_windows_local_pid_line_rejects_malformed_mapping(tmp_path):
|
||||
pid_path = tmp_path / "serve.pid"
|
||||
ready_path = tmp_path / "serve.pid.ready"
|
||||
|
||||
pid_path.write_text("31100", encoding="utf-8")
|
||||
ready_path.touch()
|
||||
|
||||
fake_bin = _fake_cat(
|
||||
tmp_path,
|
||||
'printf "not-a-win32-pid\\n"',
|
||||
)
|
||||
|
||||
result = _run_pid_line(
|
||||
pid_path,
|
||||
ready_path,
|
||||
fake_bin,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert pid_path.read_text(encoding="utf-8").strip() == "31100"
|
||||
assert not ready_path.exists()
|
||||
|
||||
|
||||
def test_local_windows_launcher_publishes_fallback_before_releasing_inner_runner():
|
||||
source = COOKBOOK_ROUTES.read_text(encoding="utf-8")
|
||||
start = source.index(" def _launch_local_detached(")
|
||||
end = source.index(
|
||||
' @router.post("/api/model/download")',
|
||||
start,
|
||||
)
|
||||
launcher = source[start:end]
|
||||
|
||||
assert "_windows_local_pid_record_line(pid_path, pid_ready_path)" in launcher
|
||||
assert "pid_ready_path.unlink(missing_ok=True)" in launcher
|
||||
|
||||
fallback = launcher.index(
|
||||
'pid_path.write_text(str(proc.pid), encoding="utf-8")'
|
||||
)
|
||||
release = launcher.index("pid_ready_path.touch()")
|
||||
|
||||
assert fallback < release
|
||||
|
||||
# Never write Git Bash's bare MSYS $$ to the session pid file.
|
||||
assert '\\"$$\\" > {pp}' not in launcher
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user