mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 06:42:20 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ce95049d | ||
|
|
7b4c83089b | ||
|
|
6bc6ee9c7e | ||
|
|
d190980f47 | ||
|
|
8670aed9ad | ||
|
|
c762efe1c9 | ||
|
|
7fe3bc6482 | ||
|
|
ab6f3fa70b | ||
|
|
69041b5f50 | ||
|
|
2ac72d5964 | ||
|
|
91764ad3e7 | ||
|
|
c7c1b7717e |
@@ -189,7 +189,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
# 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_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 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)
|
# Host Docker access (explicit opt-in)
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
**Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues)
|
||||||
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first.
|
||||||
Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md)
|
The [roadmap](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md) is directional rather than a complete backlog.
|
||||||
or an existing open issue will be closed as duplicates.
|
Feature requests that duplicate an existing issue or accepted proposal may be closed as duplicates.
|
||||||
|
|
||||||
If your idea needs community input before it becomes a concrete proposal,
|
If your idea needs community input before it becomes a concrete proposal,
|
||||||
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
||||||
|
|||||||
@@ -153,16 +153,6 @@ 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 ──────────────────────────
|
// ── Find existing bot comment to update in-place ──────────────────────────
|
||||||
const MARKER = '<!-- issue-description-check -->';
|
const MARKER = '<!-- issue-description-check -->';
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
@@ -170,6 +160,9 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
});
|
});
|
||||||
const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER));
|
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 (failures.length === 0) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: ci / issue description check
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened, edited, reopened, closed]
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# Odysseus
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
|
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
|
||||||
</p>
|
</p>
|
||||||
@@ -9,6 +11,8 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Quick Start</a> ·
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
<a href="docs/setup.md">Setup Guide</a> ·
|
<a href="docs/setup.md">Setup Guide</a> ·
|
||||||
|
<a href="docs/ARCHITECTURE.md">Architecture</a> ·
|
||||||
|
<a href="SECURITY.md">Security</a> ·
|
||||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||||
<a href="ROADMAP.md">Roadmap</a>
|
<a href="ROADMAP.md">Roadmap</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -51,15 +55,15 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
|
|||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
|
Explore the interface through the [interactive product tour](docs/index.html).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md).
|
Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, documentation, and small focused refactors. Read the [contributing guide](CONTRIBUTING.md), review the [public roadmap](ROADMAP.md), and browse the open [GitHub issues](https://github.com/odysseus-dev/odysseus/issues).
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. Deployment details are in the [setup guide](docs/setup.md#security-notes).
|
Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model or service ports publicly. Read the [security policy](SECURITY.md) and the [deployment security guidance](docs/setup.md#security-notes).
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
@@ -73,4 +77,4 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0-or-later -- see [LICENSE](LICENSE) and [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md).
|
Licensed under AGPL-3.0-or-later. See the [license](LICENSE) and [acknowledgments](ACKNOWLEDGMENTS.md).
|
||||||
|
|||||||
+43
-75
@@ -1,87 +1,55 @@
|
|||||||
# Roadmap / Help Wanted
|
# Roadmap
|
||||||
|
|
||||||
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
|
This document provides a high-level view of the areas Odysseus is currently improving.
|
||||||
|
|
||||||
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
|
It is directional rather than exhaustive. Priorities may change as the project evolves, defects are discovered, and maintainers learn more from implementation work and user feedback.
|
||||||
the codebase, you are probably right to stay away.
|
|
||||||
|
|
||||||
## High Priority
|
For current implementation work, see the open [GitHub issues](https://github.com/odysseus-dev/odysseus/issues). Accepted behaviour should be documented in the repository alongside the code.
|
||||||
|
|
||||||
- SQUASH BUGS
|
## Current priorities
|
||||||
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
|
|
||||||
and WSL all need coverage.
|
|
||||||
|
|
||||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
### Reliability and setup
|
||||||
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
|
||||||
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
|
||||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
|
||||||
NVIDIA/AMD hardware paths.
|
|
||||||
- Deep Research model presets by hardware. Recommend approved model/parameter
|
|
||||||
profiles for small, medium, and large local setups so people with different
|
|
||||||
hardware can use Deep Research without guessing. Surface this either in Deep
|
|
||||||
Research settings or as a Cookbook scan/dropdown suggestion.
|
|
||||||
- Cookbook model scan/download ranking. Prioritize newer architectures and
|
|
||||||
better hardware-fit models instead of scoring everything almost the same.
|
|
||||||
Ranking should account for architecture age, quant format, VRAM/RAM fit,
|
|
||||||
backend support, vision/mmproj requirements, and likely serve reliability.
|
|
||||||
- Cookbook error feedback and logging. Failed downloads, dependency installs,
|
|
||||||
preflights, and serve jobs should show the actual command/output/error in the
|
|
||||||
UI, with copyable logs and clear next steps instead of just "crashed".
|
|
||||||
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
|
|
||||||
tool schemas, skills, memory, documents, and instructions can eat the context
|
|
||||||
before the user request really starts. We need slimmer prompts, better tool
|
|
||||||
selection, smaller default tool sets, and clearer guidance for models with
|
|
||||||
4k/8k/16k context windows.
|
|
||||||
- Local model speculative decoding support. For Odysseus-tuned local models,
|
|
||||||
plan to ship or recommend a small same-tokenizer draft model when the serving
|
|
||||||
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
|
|
||||||
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
|
|
||||||
DSpark conversion performed poorly. Treat this as a supported draft-model lane
|
|
||||||
first; keep MTP-specific packaging as future work only when the architecture
|
|
||||||
and runtime support are real. Judge this by time-to-success, tool correctness,
|
|
||||||
grammar, and unchanged target output, not tokens/sec alone.
|
|
||||||
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
|
||||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
|
||||||
whether models follow malicious instructions from those surfaces.
|
|
||||||
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
|
|
||||||
- Email performance audit. Fetching, searching, opening, deleting, and sending
|
|
||||||
email can feel slow, especially over IMAP/SMTP providers with high latency.
|
|
||||||
Need someone who knows mail performance to profile the current flow, identify
|
|
||||||
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
|
|
||||||
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
|
|
||||||
propose safer caching/prefetch/batching without breaking multi-account state.
|
|
||||||
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
|
|
||||||
|
|
||||||
## Refactor Targets
|
- Improve fresh-install and smoke-test coverage across supported environments.
|
||||||
- CSS cleanup. `static/style.css` basically Calypso's island atm.
|
- Make provider setup, probing, and failure states more predictable.
|
||||||
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
|
- Improve Cookbook reliability across hardware, operating systems, drivers, shells, and serving backends.
|
||||||
- Modal/window positioning cleanup. Some window controls have improved, but the
|
- Improve degraded-state reporting and recovery guidance when optional services are unavailable.
|
||||||
underlying popup/dropdown/fixed-position behavior is still too fragile.
|
|
||||||
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
|
|
||||||
- Dead code pass for old routes, stale feature flags, and unused UI states.
|
|
||||||
|
|
||||||
## Frontend
|
### Local model workflows
|
||||||
|
|
||||||
- Expand the Editor for quicker, more robust everyday use. Better file/document
|
- Improve hardware-aware model recommendations and compatibility guidance.
|
||||||
handling, smoother window behavior, clearer save/export flows, stronger image
|
- Evaluate serving optimizations, including speculative decoding, through reproducible benchmarks.
|
||||||
editing affordances, and fewer brittle edge cases.
|
- Improve installation, preflight checks, logging, and error reporting for local model serving.
|
||||||
- Better AI integration for Notes and Todos. Notes should be easier for the
|
- Reduce prompt and context overhead for smaller local models.
|
||||||
agent to read, update, summarize, and turn into actions. Todos should be
|
|
||||||
assignable to an agent from the UI, possibly through a button, task action,
|
|
||||||
or dedicated skill/tool flow.
|
|
||||||
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
|
|
||||||
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
|
|
||||||
- Improve empty states and error messages on fresh installs.
|
|
||||||
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
|
|
||||||
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
|
|
||||||
|
|
||||||
## Backend
|
### Safety and resilience
|
||||||
|
|
||||||
- More tests around endpoint probing and provider setup.
|
- Continue hardening tool execution, filesystem access, credentials, networking, and destructive operations.
|
||||||
- Better task scheduler defaults and visibility.
|
- Treat content from documents, notes, memories, skills, and fetched pages as potentially untrusted.
|
||||||
- Backup/restore guide and helper flow for `data/`.
|
- Improve security-focused regression coverage and operational guidance.
|
||||||
- Security hardening around admin-only tools and clear docs for their risk.
|
- Review integrations that expand access to sensitive data or privileged operations.
|
||||||
|
|
||||||
## Not The Focus Right Now
|
### Product usability
|
||||||
|
|
||||||
I prob shouldnt add more themes.
|
- Improve first-run setup, onboarding, hints, and tours.
|
||||||
|
- Improve accessibility, keyboard navigation, focus behaviour, contrast, and reduced-motion support.
|
||||||
|
- Improve empty states, error messages, and recovery paths.
|
||||||
|
- Strengthen Notes, Todos, Editor, mobile, and everyday workspace flows.
|
||||||
|
|
||||||
|
### Architecture and maintainability
|
||||||
|
|
||||||
|
- Reduce duplication and technical debt through focused, reviewable refactors.
|
||||||
|
- Improve subsystem documentation as behaviour and architecture become stable.
|
||||||
|
- Remove stale code, obsolete feature flags, and unsupported integrations.
|
||||||
|
- Keep implementation decisions grounded in current code and verified behaviour.
|
||||||
|
|
||||||
|
## Tracking work
|
||||||
|
|
||||||
|
Concrete implementation tasks, defects, proposals, and technical investigations are tracked in:
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/odysseus-dev/odysseus/issues)
|
||||||
|
- [Contributing Guide](CONTRIBUTING.md)
|
||||||
|
|
||||||
|
Maintainers may use additional private coordination tools for ownership, planning, and unresolved decisions.
|
||||||
|
|
||||||
|
This roadmap is not a complete backlog or a guarantee that a particular item will be delivered.
|
||||||
|
|||||||
+3
-1
@@ -37,4 +37,6 @@ Only `.env.example`, docs, source, tests, and static assets should be committed.
|
|||||||
|
|
||||||
## Reporting
|
## Reporting
|
||||||
|
|
||||||
Please report vulnerabilities privately via GitHub security advisories if available, or by opening a minimal issue that does not disclose exploit details.
|
Report security vulnerabilities privately through [GitHub Security Advisories](https://github.com/odysseus-dev/odysseus/security/advisories/new).
|
||||||
|
|
||||||
|
Do not open a public issue or discussion, and do not disclose exploit details publicly.
|
||||||
|
|||||||
+5
-5
@@ -68,14 +68,14 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
|||||||
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` everywhere.
|
- `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` everywhere.
|
||||||
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
|
- **CSP:** nonce-based `script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net`. `style-src 'unsafe-inline'` is intentionally kept — `static/index.html` ships inline `<style>` blocks and JS modules set `style=""` attributes at runtime. Inline styles do not execute script so the risk is visual-only. Removing this requires templating the HTML files and auditing all JS-set style attributes.
|
||||||
|
|
||||||
|
## Token-Supplied Model Endpoints
|
||||||
|
|
||||||
|
Direct `/api/v1/chat` requests with a token-supplied `base_url` must use a public HTTP(S) endpoint. This restriction applies only to untrusted direct values; administrator-configured endpoints may intentionally use local or LAN URLs for private model providers.
|
||||||
|
|
||||||
## Known Gaps
|
## Known Gaps
|
||||||
|
|
||||||
These are open, acknowledged, and contributor help is welcome:
|
These are open, acknowledged, and contributor help is welcome:
|
||||||
|
|
||||||
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
|
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal.
|
||||||
|
|
||||||
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
|
2. **Token scopes are coarse.** There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either `chat` or `admin` scope with no per-capability granularity.
|
||||||
|
|
||||||
3. **`src/search/` partial consolidation.** `src.search.core` and `src.search.providers` correctly alias `services.search` via `sys.modules` replacement. `analytics`, `cache`, `content`, `query`, and `ranking` are still independent copies that can drift. The SSRF regression tests in `tests/test_webhook_ssrf_resilience.py` test `src.webhook_manager` directly (separate from search), so the safety net there is intact. See #1058.
|
|
||||||
|
|
||||||
4. **Token scopes are coarse.** There is no way to grant a session a subset of the owning user's privileges. Companion/mobile tokens carry either `chat` or `admin` scope with no per-capability granularity.
|
|
||||||
|
|||||||
@@ -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))
|
app.include_router(setup_history_routes(session_manager, upload_handler=upload_handler))
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
from routes.search.search_routes import setup_search_routes
|
from routes.search_routes import setup_search_routes
|
||||||
app.include_router(setup_search_routes(config))
|
app.include_router(setup_search_routes(config))
|
||||||
|
|
||||||
# Presets
|
# Presets
|
||||||
@@ -739,7 +739,7 @@ app.include_router(setup_stt_routes(stt_service))
|
|||||||
logger.info("STT service initialized (provider managed via settings)")
|
logger.info("STT service initialized (provider managed via settings)")
|
||||||
|
|
||||||
# Documents (artifacts/canvas)
|
# Documents (artifacts/canvas)
|
||||||
from routes.document.document_routes import setup_document_routes
|
from routes.document_routes import setup_document_routes
|
||||||
document_router = setup_document_routes(session_manager, upload_handler)
|
document_router = setup_document_routes(session_manager, upload_handler)
|
||||||
app.include_router(document_router)
|
app.include_router(document_router)
|
||||||
|
|
||||||
@@ -805,7 +805,7 @@ app.include_router(setup_font_routes())
|
|||||||
# MCP (Model Context Protocol)
|
# MCP (Model Context Protocol)
|
||||||
from src.mcp_manager import McpManager
|
from src.mcp_manager import McpManager
|
||||||
from src.agent_tools import set_mcp_manager
|
from src.agent_tools import set_mcp_manager
|
||||||
from routes.mcp.mcp_routes import setup_mcp_routes
|
from routes.mcp_routes import setup_mcp_routes
|
||||||
|
|
||||||
mcp_manager = McpManager()
|
mcp_manager = McpManager()
|
||||||
set_mcp_manager(mcp_manager)
|
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)")
|
logger.info("AI interaction tools initialized (session, memory, RAG, UI control)")
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
from routes.webhook.webhook_routes import setup_webhook_routes
|
from routes.webhook_routes import setup_webhook_routes
|
||||||
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
app.include_router(setup_webhook_routes(webhook_manager, auth_manager, session_manager, api_key_manager))
|
||||||
|
|
||||||
# API Tokens
|
# API Tokens
|
||||||
@@ -852,7 +852,7 @@ app.include_router(setup_codex_routes(
|
|||||||
))
|
))
|
||||||
app.include_router(setup_claude_routes())
|
app.include_router(setup_claude_routes())
|
||||||
|
|
||||||
from routes.vault.vault_routes import setup_vault_routes
|
from routes.vault_routes import setup_vault_routes
|
||||||
app.include_router(setup_vault_routes())
|
app.include_router(setup_vault_routes())
|
||||||
|
|
||||||
# Contacts (CardDAV)
|
# Contacts (CardDAV)
|
||||||
|
|||||||
+4
-8
@@ -15,21 +15,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
||||||
"""Atomically persist `data` as JSON at `path`.
|
"""Atomically persist `data` as JSON at `path`.
|
||||||
|
|
||||||
The temp file uses a random suffix so two concurrent writers saving the
|
The temp file uses the live PID as a suffix so two processes saving the
|
||||||
same file don't collide on the rename target. A PID suffix does not do
|
same file (e.g. unit tests) don't collide on the rename target.
|
||||||
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)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=indent)
|
json.dump(data, f, indent=indent)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -41,7 +37,7 @@ def atomic_write_text(path: str, text: str) -> None:
|
|||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise TypeError("atomic_write_text expects a string")
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
tmp = f"{path}.tmp.{os.getpid()}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|||||||
+12
-41
@@ -194,12 +194,7 @@ class SessionManager:
|
|||||||
is_important=getattr(db_session, 'is_important', False) or False,
|
is_important=getattr(db_session, 'is_important', False) or False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The rows just loaded are the whole transcript, so they — not the
|
session.message_count = getattr(db_session, 'message_count', len(history))
|
||||||
# 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
|
return session
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -403,50 +398,30 @@ class SessionManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_session(self, session_id: str) -> Session:
|
def get_session(self, session_id: str) -> Session:
|
||||||
"""Get a session by ID, loading complete DB history when needed.
|
"""Get a session by ID, loading from DB if needed.
|
||||||
|
|
||||||
Sessions seeded by ``load_sessions`` start with empty history, and a
|
Sessions seeded by `load_sessions` start with empty history. The
|
||||||
cached session can also become partially stale. Refresh metadata first,
|
first read here hydrates them with the message rows.
|
||||||
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:
|
if session_id not in self.sessions:
|
||||||
self._load_session_from_db(session_id)
|
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
|
# Keep model/endpoint metadata fresh. Endpoint deletion can clear the
|
||||||
# DB row while a session object is still cached in RAM. Refreshing first
|
# DB row while a session object is still cached in RAM.
|
||||||
# also exposes the authoritative message count before completeness is
|
|
||||||
# checked.
|
|
||||||
self.sync_session_metadata(session_id)
|
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
|
# Update last_accessed
|
||||||
self._touch_session(session_id)
|
self._touch_session(session_id)
|
||||||
|
|
||||||
return self.sessions[session_id]
|
return self.sessions[session_id]
|
||||||
|
|
||||||
def sync_session_metadata(self, session_id: str) -> bool:
|
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)
|
session = self.sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
return False
|
return False
|
||||||
@@ -469,11 +444,7 @@ class SessionManager:
|
|||||||
session.archived = db_session.archived
|
session.archived = db_session.archived
|
||||||
session.owner = getattr(db_session, "owner", None)
|
session.owner = getattr(db_session, "owner", None)
|
||||||
session.is_important = getattr(db_session, "is_important", False) or False
|
session.is_important = getattr(db_session, "is_important", False) or False
|
||||||
session.message_count = (
|
session.message_count = getattr(db_session, "message_count", session.message_count) or 0
|
||||||
db.query(DbChatMessage)
|
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
|
||||||
.count()
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
logger.error(f"Error syncing session metadata {session_id}: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Odysseus discovery maps
|
||||||
|
|
||||||
|
Compact, code-grounded discovery maps of cross-cutting systems in the checked-in Odysseus codebase. They preserve investigation context and open factual questions; they are not canonical subsystem specifications, a feature certification, or a substitute for normal testing.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Checked-in code is the source of truth for current behaviour. Mature subsystem specifications, where they exist, are the canonical documentation of accepted subsystem behaviour. Check code, tests, and configuration before reconciling a discovery finding. Discovery remains non-canonical.
|
||||||
|
|
||||||
|
## Explore the maps
|
||||||
|
|
||||||
|
| Document | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| [Current system map](system-map.md) | Records evidence locations, confirmed local observations, and factual open questions about subsystem boundaries. |
|
||||||
|
| [Safety boundaries](safety-boundaries.md) | Records evidence about broad authority, safeguards, confirmed risks or gaps, and unverified behaviour. |
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- **Trace the code first.** Confirm the current path in source before recording a claim.
|
||||||
|
- **Promote selectively.** When an owning mature specification exists, add a fact only when it is verified, useful, and not already represented there.
|
||||||
|
- **Record missing ownership.** When no owning specification exists, retain the verified finding in discovery and record missing documentation ownership as a follow-up.
|
||||||
|
- **Retain uncertainty here.** Keep unresolved questions and useful investigation context in discovery rather than treating them as canonical truth.
|
||||||
|
- **Keep specifications current-state only.** Do not record intentions, design direction, refactor plans, decision history, priority, ownership, or sequencing here.
|
||||||
|
- **Investigate with cause.** Do not exhaustively revalidate existing functionality without a report, visible failure, relevant change, or high-authority review need.
|
||||||
|
- **Review authority carefully.** Give execution, data access, external tools, credentials, destructive operations, and unattended work focused review.
|
||||||
|
- **Use stable locations.** Cite modules, routes, classes, and functions instead of fragile line ranges or generated evidence tables.
|
||||||
|
|
||||||
|
## Reconciliation flow
|
||||||
|
|
||||||
|
1. Start with the relevant map and trace the cited code.
|
||||||
|
2. Classify the finding against current source evidence and an owning mature specification where one exists.
|
||||||
|
3. Promote only verified, useful facts that are missing from an existing owning specification.
|
||||||
|
4. When no owning specification exists, retain the verified finding here and record missing documentation ownership as a follow-up; otherwise retain unresolved context here and correct stale wording.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This package intentionally contains no generator, validator, maturity scale, feature database, or parallel work tracker. The [architecture runtime inventory](./architecture-runtime-inventory.md) preserves dated structural metrics, investigation context, and historical planning as an explicitly non-canonical snapshot.
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
# Architecture runtime inventory
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This document is a dated structural snapshot, not a canonical runtime specification.
|
||||||
|
> Counts, paths, and implementation details may drift as the repository changes.
|
||||||
|
> Verify implementation-sensitive claims against the current code and tests.
|
||||||
|
|
||||||
|
- **Branch:** `discovery`
|
||||||
|
- **Commit:** `c762efe1c97a`
|
||||||
|
- **Generated:** `2026-07-28T05:38:14+01:00`
|
||||||
|
- **Historical context:** readability and refactor planning in [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
|
||||||
|
|
||||||
|
## Disposition
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Reviewed for documentation classification. Stable runtime structure and
|
||||||
|
> subsystem ownership have been transferred to the proposed canonical
|
||||||
|
> destination, [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md), for
|
||||||
|
> maintainer review.
|
||||||
|
>
|
||||||
|
> This document retains dated metrics, rankings, investigation context,
|
||||||
|
> refactor-sensitive observations, and historical planning. Those contents are
|
||||||
|
> non-canonical and belong under `discovery/`.
|
||||||
|
|
||||||
|
| Content | Authority and destination |
|
||||||
|
|---|---|
|
||||||
|
| Stable runtime structure | Proposed canonical destination: `docs/ARCHITECTURE.md` |
|
||||||
|
| Stable subsystem boundaries | Proposed canonical destination: `docs/ARCHITECTURE.md` |
|
||||||
|
| Frontend module organization | `static/js/MODULE_SUMMARY.md` |
|
||||||
|
| Counts, line totals, and rankings | This non-canonical inventory |
|
||||||
|
| Investigation context and open questions | `discovery/` |
|
||||||
|
| Refactor options and prioritization | Issues, Plane, or non-canonical discovery material |
|
||||||
|
|
||||||
|
The transfer preserves the stable facts without promoting generated metrics or
|
||||||
|
historical prioritization into canonical documentation.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This inventory provides a reviewable map of the current repository structure,
|
||||||
|
large runtime modules, major subsystem boundaries, and refactor-sensitive areas.
|
||||||
|
|
||||||
|
It does not:
|
||||||
|
|
||||||
|
- define accepted subsystem behaviour;
|
||||||
|
- certify runtime correctness;
|
||||||
|
- prescribe a committed refactor sequence;
|
||||||
|
- replace focused specifications, tests, or source review.
|
||||||
|
|
||||||
|
For cross-cutting implementation evidence, see the [discovery maps](./README.md).
|
||||||
|
|
||||||
|
## Top-level runtime structure
|
||||||
|
|
||||||
|
| Area | Role |
|
||||||
|
|---|---|
|
||||||
|
| `app.py` | FastAPI application composition and entry point |
|
||||||
|
| `launcher.py` | Application launch support |
|
||||||
|
| `setup.py` | Native setup workflow |
|
||||||
|
| `core/` | Authentication, middleware, persistence, sessions, and platform primitives |
|
||||||
|
| `routes/` | HTTP and API route handlers |
|
||||||
|
| `src/` | Application services, orchestration, tools, providers, and runtime helpers |
|
||||||
|
| `services/` | Domain-oriented service packages |
|
||||||
|
| `mcp_servers/` | Built-in MCP server implementations |
|
||||||
|
| `scripts/` | CLI tools, diagnostics, maintenance, and migration helpers |
|
||||||
|
| `static/` | No-build browser frontend and bundled assets |
|
||||||
|
| `tests/` | Automated test suite and supporting test infrastructure |
|
||||||
|
|
||||||
|
## Directory snapshot
|
||||||
|
|
||||||
|
| Directory | Tracked files | Tracked Python files | Direct subdirectories |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `src/` | 143 | 143 | `agent_tools/`, `model_capability_readers/`, `search/`, `tools/` |
|
||||||
|
| `routes/` | 73 | 73 | `admin_wipe/`, `cleanup/`, `compare/`, `contacts/`, `gallery/`, `history/`, `memory/`, `note/`, `research/` |
|
||||||
|
| `core/` | 11 | 11 | None |
|
||||||
|
| `services/` | 42 | 40 | `docs/`, `faces/`, `hwfit/`, `memory/`, `research/`, `search/`, `shell/`, `stt/`, `tts/`, `youtube/` |
|
||||||
|
| `mcp_servers/` | 5 | 5 | None |
|
||||||
|
| `scripts/` | 44 | 17 | `_completion/`, `_lib/`, `demo_email/` |
|
||||||
|
| `static/js/` | 154 | 0 | `calendar/`, `color/`, `compare/`, `editor/`, `emailLibrary/`, `markdown/`, `model/`, `research/`, `util/` |
|
||||||
|
| `tests/` | 768 | 758 | `cli/`, `helpers/`, `streaming/`, `tools/` |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Counts in this table use `git ls-files`, so generated caches, virtual
|
||||||
|
> environments, and other untracked local files are excluded.
|
||||||
|
|
||||||
|
## Largest backend modules
|
||||||
|
|
||||||
|
Large files are review signals, not proof that a module should be split.
|
||||||
|
Coupling, ownership, import compatibility, tests, and runtime authority matter more
|
||||||
|
than line count alone.
|
||||||
|
|
||||||
|
| Rank | File | Lines | Classes | Top-level functions | Review signal |
|
||||||
|
|---:|---|---:|---:|---:|---|
|
||||||
|
| 1 | `routes/email_routes.py` | 6032 | 1 | 58 | High |
|
||||||
|
| 2 | `src/agent_loop.py` | 5248 | 0 | 63 | High |
|
||||||
|
| 3 | `routes/cookbook_routes.py` | 4545 | 0 | 16 | High |
|
||||||
|
| 4 | `mcp_servers/email_server.py` | 2920 | 0 | 77 | High |
|
||||||
|
| 5 | `src/llm_core.py` | 2895 | 3 | 85 | High |
|
||||||
|
| 6 | `src/builtin_actions.py` | 2845 | 2 | 27 | High |
|
||||||
|
| 7 | `routes/model_routes.py` | 2743 | 0 | 65 | High |
|
||||||
|
| 8 | `src/task_scheduler.py` | 2627 | 1 | 8 | Medium |
|
||||||
|
| 9 | `core/database.py` | 2562 | 28 | 67 | High |
|
||||||
|
| 10 | `routes/gallery/gallery_routes.py` | 2325 | 0 | 16 | Medium |
|
||||||
|
| 11 | `routes/chat_routes.py` | 2063 | 0 | 18 | Medium |
|
||||||
|
| 12 | `routes/shell_routes.py` | 1971 | 1 | 21 | Medium |
|
||||||
|
| 13 | `src/visual_report.py` | 1933 | 0 | 11 | Medium |
|
||||||
|
| 14 | `routes/email_helpers.py` | 1888 | 3 | 48 | Medium |
|
||||||
|
| 15 | `routes/document_routes.py` | 1810 | 0 | 5 | Medium |
|
||||||
|
| 16 | `src/tools/cookbook.py` | 1705 | 0 | 34 | Medium |
|
||||||
|
| 17 | `routes/calendar_routes.py` | 1667 | 2 | 19 | Medium |
|
||||||
|
| 18 | `routes/skills_routes.py` | 1662 | 3 | 19 | Medium |
|
||||||
|
| 19 | `src/tool_schemas.py` | 1595 | 0 | 3 | Medium |
|
||||||
|
| 20 | `routes/email_pollers.py` | 1551 | 0 | 23 | Medium |
|
||||||
|
|
||||||
|
The largest current backend concentrations include:
|
||||||
|
|
||||||
|
- email routing and helper logic;
|
||||||
|
- agent-loop orchestration;
|
||||||
|
- Cookbook lifecycle and serving logic;
|
||||||
|
- provider and model routing;
|
||||||
|
- task scheduling;
|
||||||
|
- shared database models and persistence helpers.
|
||||||
|
|
||||||
|
These areas require focused ownership and compatibility analysis before structural
|
||||||
|
changes are attempted.
|
||||||
|
|
||||||
|
## Largest frontend modules
|
||||||
|
|
||||||
|
| Rank | File | Lines |
|
||||||
|
|---:|---|---:|
|
||||||
|
| 1 | `static/style.css` | 41132 |
|
||||||
|
| 2 | `static/js/document.js` | 11200 |
|
||||||
|
| 3 | `static/js/emailLibrary.js` | 8505 |
|
||||||
|
| 4 | `static/js/slashCommands.js` | 6520 |
|
||||||
|
| 5 | `static/js/chat.js` | 6001 |
|
||||||
|
| 6 | `static/js/settings.js` | 5819 |
|
||||||
|
| 7 | `static/js/notes.js` | 5365 |
|
||||||
|
| 8 | `static/app.js` | 4681 |
|
||||||
|
| 9 | `static/js/cookbookRunning.js` | 4433 |
|
||||||
|
| 10 | `static/js/galleryEditor.js` | 4386 |
|
||||||
|
| 11 | `static/js/cookbookServe.js` | 4305 |
|
||||||
|
| 12 | `static/js/calendar.js` | 3722 |
|
||||||
|
| 13 | `static/js/cookbook.js` | 3677 |
|
||||||
|
| 14 | `static/js/sessions.js` | 3665 |
|
||||||
|
| 15 | `static/js/documentLibrary.js` | 3422 |
|
||||||
|
| 16 | `static/js/tasks.js` | 3187 |
|
||||||
|
| 17 | `static/js/admin.js` | 3144 |
|
||||||
|
| 18 | `static/js/gallery.js` | 2958 |
|
||||||
|
| 19 | `static/js/cookbook-hwfit.js` | 2826 |
|
||||||
|
| 20 | `static/js/chatRenderer.js` | 2808 |
|
||||||
|
|
||||||
|
The browser frontend remains a no-build ES-module application. Its current source
|
||||||
|
tree is authoritative; the maintained structural summary is available in
|
||||||
|
[`static/js/MODULE_SUMMARY.md`](../static/js/MODULE_SUMMARY.md).
|
||||||
|
|
||||||
|
CSS modularization remains tracked separately in
|
||||||
|
[#2617](https://github.com/odysseus-dev/odysseus/issues/2617).
|
||||||
|
|
||||||
|
## Major subsystem boundaries
|
||||||
|
|
||||||
|
| Subsystem | Primary implementation locations |
|
||||||
|
|---|---|
|
||||||
|
| Application startup | `app.py`, `src/app_initializer.py`, `core/` |
|
||||||
|
| Authentication and sessions | `core/auth.py`, `core/middleware.py`, `core/session_manager.py`, `routes/auth_routes.py` |
|
||||||
|
| Chat and streaming | `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, `src/chat_processor.py`, `src/llm_core.py` |
|
||||||
|
| Agents and tools | `src/agent_loop.py`, `src/tool_execution.py`, `src/agent_tools/`, `src/tools/`, `src/tool_policy.py`, `src/tool_security.py` |
|
||||||
|
| Models and providers | `routes/model_routes.py`, `src/model_discovery.py`, `src/model_capabilities.py`, `src/endpoint_resolver.py`, `src/llm_core.py` |
|
||||||
|
| Cookbook and hardware fit | `routes/cookbook_routes.py`, `routes/cookbook_helpers.py`, `src/cookbook_serve_lifecycle.py`, `services/hwfit/` |
|
||||||
|
| Search and research | `routes/search_routes.py`, `services/search/`, `routes/research/`, `services/research/`, `src/deep_research.py` |
|
||||||
|
| Documents and retrieval | `routes/document_routes.py`, `src/document_processor.py`, `src/personal_docs.py`, `src/rag_manager.py`, `src/pdf_runtime.py` |
|
||||||
|
| Memory and skills | `routes/memory/`, `services/memory/`, `routes/skills_routes.py` |
|
||||||
|
| Email | `routes/email_routes.py`, `routes/email_helpers.py`, `routes/email_pollers.py`, `mcp_servers/email_server.py` |
|
||||||
|
| Calendar, contacts, notes, and tasks | `routes/calendar_routes.py`, `routes/contacts/`, `routes/note/`, `routes/task_routes.py`, `src/task_scheduler.py` |
|
||||||
|
| Media and speech | `routes/gallery/`, `routes/stt_routes.py`, `routes/tts_routes.py`, `services/stt/`, `services/tts/` |
|
||||||
|
| Persistence and operations | `core/database.py`, `src/runtime_paths.py`, `src/bg_jobs.py`, `routes/backup_routes.py`, `routes/cleanup/` |
|
||||||
|
|
||||||
|
For a broader evidence map, see
|
||||||
|
[`system-map.md`](./system-map.md).
|
||||||
|
|
||||||
|
## Refactor-sensitive areas
|
||||||
|
|
||||||
|
### Shared persistence
|
||||||
|
|
||||||
|
`core/database.py` is a central dependency containing models and shared persistence
|
||||||
|
helpers. Changes can affect routes, services, background work, tests, migrations,
|
||||||
|
and import compatibility.
|
||||||
|
|
||||||
|
A split should not begin from file size alone. It requires:
|
||||||
|
|
||||||
|
- an importer inventory;
|
||||||
|
- model and helper ownership decisions;
|
||||||
|
- migration compatibility checks;
|
||||||
|
- stable re-export or migration strategy;
|
||||||
|
- focused and full-suite validation.
|
||||||
|
|
||||||
|
### Agent orchestration
|
||||||
|
|
||||||
|
`src/agent_loop.py` coordinates model interaction, tool selection, policy decisions,
|
||||||
|
multi-round execution, and background behaviour. Extraction work must preserve tool
|
||||||
|
event semantics, policy enforcement, cancellation, and test patch points.
|
||||||
|
|
||||||
|
Historical agent-loop modularization discussion is tracked in
|
||||||
|
[#3266](https://github.com/odysseus-dev/odysseus/issues/3266).
|
||||||
|
|
||||||
|
### Tool implementation boundaries
|
||||||
|
|
||||||
|
Tool implementation is no longer represented by one proposed future package alone.
|
||||||
|
Current responsibilities are distributed across:
|
||||||
|
|
||||||
|
- `src/tool_execution.py`;
|
||||||
|
- `src/tool_schemas.py`;
|
||||||
|
- `src/tool_index.py`;
|
||||||
|
- `src/tool_policy.py`;
|
||||||
|
- `src/tool_security.py`;
|
||||||
|
- `src/agent_tools/`;
|
||||||
|
- `src/tools/`;
|
||||||
|
- remaining compatibility surfaces such as `src/tool_implementations.py`.
|
||||||
|
|
||||||
|
Historical tool modularization work is tracked in
|
||||||
|
[#3629](https://github.com/odysseus-dev/odysseus/issues/3629).
|
||||||
|
|
||||||
|
### Route ownership
|
||||||
|
|
||||||
|
`routes/` now contains both flat modules and domain packages. Existing package
|
||||||
|
boundaries should be extended only through focused changes. Broad mechanical route
|
||||||
|
movement would affect registration, imports, tests, monkeypatch targets, and
|
||||||
|
compatibility paths.
|
||||||
|
|
||||||
|
### Frontend concentration
|
||||||
|
|
||||||
|
The no-build frontend contains several large JavaScript modules and one central CSS
|
||||||
|
file. Refactors should preserve module load order, global compatibility exports,
|
||||||
|
DOM contracts, deep-link handling, and browser behaviour.
|
||||||
|
|
||||||
|
## Non-implemented architecture options
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The paths below are historical or possible design directions. They do not describe
|
||||||
|
> the current repository and are not approved implementation plans.
|
||||||
|
|
||||||
|
Earlier planning discussed:
|
||||||
|
|
||||||
|
- renaming `app.py` to `main.py`;
|
||||||
|
- moving agent orchestration into a new `src/agent/` package;
|
||||||
|
- introducing broad `src/domain/`, `src/infra/`, `src/api/`, or `src/pkg/` layers;
|
||||||
|
- moving all routes into domain subpackages;
|
||||||
|
- splitting database models into a new infrastructure hierarchy.
|
||||||
|
|
||||||
|
These options should be reconsidered against the current tree rather than copied
|
||||||
|
forward as assumed targets.
|
||||||
|
|
||||||
|
## Refactor guardrails
|
||||||
|
|
||||||
|
- Keep structural changes behaviour-preserving.
|
||||||
|
- Change one ownership boundary at a time.
|
||||||
|
- Do not mix file movement with unrelated feature work.
|
||||||
|
- Preserve existing import and monkeypatch paths where compatibility is required.
|
||||||
|
- Identify focused tests before modifying high-authority modules.
|
||||||
|
- Validate startup, imports, and affected runtime paths.
|
||||||
|
- Avoid repository-wide package reorganizations without maintainer agreement.
|
||||||
|
- Treat generated metrics as snapshots, not architectural decisions.
|
||||||
|
|
||||||
|
## Reproduce the snapshot
|
||||||
|
|
||||||
|
Run these commands from the repository root.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tracked directory totals
|
||||||
|
for dir in src routes core services mcp_servers scripts static/js tests; do
|
||||||
|
files="$(git ls-files "$dir" | wc -l)"
|
||||||
|
python_files="$(git ls-files "$dir" '*.py' | wc -l)"
|
||||||
|
|
||||||
|
printf '%-14s tracked=%-5s python=%-5s\n' \
|
||||||
|
"$dir" \
|
||||||
|
"$files" \
|
||||||
|
"$python_files"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Largest tracked backend files
|
||||||
|
git ls-files \
|
||||||
|
'app.py' \
|
||||||
|
'launcher.py' \
|
||||||
|
'setup.py' \
|
||||||
|
'core/*.py' \
|
||||||
|
'core/**/*.py' \
|
||||||
|
'routes/*.py' \
|
||||||
|
'routes/**/*.py' \
|
||||||
|
'services/*.py' \
|
||||||
|
'services/**/*.py' \
|
||||||
|
'src/*.py' \
|
||||||
|
'src/**/*.py' \
|
||||||
|
'mcp_servers/*.py' \
|
||||||
|
'scripts/*.py' \
|
||||||
|
'scripts/**/*.py' |
|
||||||
|
xargs wc -l |
|
||||||
|
sort -nr |
|
||||||
|
head -31
|
||||||
|
|
||||||
|
# Largest tracked frontend source files
|
||||||
|
git ls-files \
|
||||||
|
'static/*.js' \
|
||||||
|
'static/*.css' \
|
||||||
|
'static/*.html' \
|
||||||
|
'static/**/*.js' \
|
||||||
|
'static/**/*.css' \
|
||||||
|
'static/**/*.html' |
|
||||||
|
grep -vE '\.min\.js$' |
|
||||||
|
xargs wc -l |
|
||||||
|
sort -nr |
|
||||||
|
head -31
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation for architecture changes
|
||||||
|
|
||||||
|
Use the smallest relevant checks first, then expand according to risk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m compileall -q app.py core routes services src
|
||||||
|
venv/bin/python -m pytest tests/<focused-test-file>.py -q
|
||||||
|
venv/bin/python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Startup, browser, Docker, and integration checks may also be required depending on
|
||||||
|
the affected boundary.
|
||||||
|
|
||||||
|
## Related documentation
|
||||||
|
|
||||||
|
- [Documentation style](../docs/STYLE.md)
|
||||||
|
- [Discovery maps](../discovery/README.md)
|
||||||
|
- [Current system map](../discovery/system-map.md)
|
||||||
|
- [Safety boundaries](../discovery/safety-boundaries.md)
|
||||||
|
- [Frontend module summary](../static/js/MODULE_SUMMARY.md)
|
||||||
|
- [Testing standard](../tests/TESTING_STANDARD.md)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Safety boundaries
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This non-canonical discovery map records code-grounded safeguards, confirmed risks or gaps, and unverified behaviour. Broad authority does not by itself establish a vulnerability. Verify the cited source before relying on a finding. No destructive test, external connection, or real credential was used for this map.
|
||||||
|
|
||||||
|
## Navigate the boundaries
|
||||||
|
|
||||||
|
- [Shell and subprocess execution](#shell-and-subprocess-execution)
|
||||||
|
- [Filesystem access and workspace confinement](#filesystem-access-and-workspace-confinement)
|
||||||
|
- [Agent-controlled tool dispatch](#agent-controlled-tool-dispatch)
|
||||||
|
- [MCP and external tool servers](#mcp-and-external-tool-servers)
|
||||||
|
- [Outbound network requests and URL validation](#outbound-network-requests-and-url-validation)
|
||||||
|
- [Secrets, credentials, and vault sessions](#secrets-credentials-and-vault-sessions)
|
||||||
|
- [Authentication and privileged administration](#authentication-and-privileged-administration)
|
||||||
|
- [Deletion, wipe, backup, and restore](#deletion-wipe-backup-and-restore)
|
||||||
|
- [Background jobs and unattended task execution](#background-jobs-and-unattended-task-execution)
|
||||||
|
|
||||||
|
## Shell and subprocess execution
|
||||||
|
|
||||||
|
- **Boundary:** Shell routes, agent `bash` and `python` tools, local model serving, and detached background jobs.
|
||||||
|
|
||||||
|
- **Available authority:** Commands run as the application process user and can create child processes.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Direct shell requests, model-produced tool arguments, scheduled-task prompts, and model-serving configuration.
|
||||||
|
|
||||||
|
- **Current safeguards:** Agent dispatch applies owner/admin checks and tool policy; process helpers use timeouts or bounded background-job lifecycle where implemented.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Intentional authority with a confirmed gap: the agent shell starts in its workspace but is not sandboxed to it, and has no egress sandbox. This is documented in source and the threat model; it is not a newly demonstrated bypass.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Role-gate and disabled-tool outcomes, direct shell-route behaviour, and timeout, cancellation, and output handling for foreground and detached processes remain unverified.
|
||||||
|
|
||||||
|
## Filesystem access and workspace confinement
|
||||||
|
|
||||||
|
- **Boundary:** Agent read, write, patch, listing, glob, and grep tools.
|
||||||
|
|
||||||
|
- **Available authority:** Read and modify files within active workspace confinement or fallback allowlisted roots.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Tool paths, patches, file contents, search patterns, and workspace selection passed into the tool dispatcher.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`src/tool_execution.py`](../src/tool_execution.py) resolves paths, blocks sensitive subpaths, applies allowlist containment, and tightens paths to the active workspace when one is bound. File tools use those resolvers.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Intentional authority with safeguards. The file-tool policy does not sandbox the shell; treating a workspace as a whole-process containment boundary would be incorrect.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Traversal, symlink, sensitive-name, absolute-path, and workspace-switch behaviour remains unverified.
|
||||||
|
|
||||||
|
## Agent-controlled tool dispatch
|
||||||
|
|
||||||
|
- **Boundary:** Model output becomes native or parsed tool calls and is dispatched by the agent loop.
|
||||||
|
|
||||||
|
- **Available authority:** The authority of every enabled tool, including privileged built-ins and external tools.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Chat content, attached/retrieved content that may influence the model, tool arguments, per-request tool selection, and policy toggles.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`src/tool_security.py`](../src/tool_security.py) blocks protected tools for non-admin users and fails closed for malformed tool names; [`src/tool_policy.py`](../src/tool_policy.py) supports disabled and guide-only policy; prompt-security helpers label untrusted context.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Credible risk requiring verification: aliases, legacy text tools, native function calls, and MCP-qualified names must all reach the same policy outcome. The code has specific alias handling for email/MCP names, which makes this a sensitive compatibility seam.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** The current policy outcomes for owner role, request mode, disabled state, native versus parsed invocation, qualified aliases, and external-content entry points remain unverified.
|
||||||
|
|
||||||
|
## MCP and external tool servers
|
||||||
|
|
||||||
|
- **Boundary:** Configured MCP servers and their tools are exposed to the agent through the MCP manager and routes.
|
||||||
|
|
||||||
|
- **Available authority:** Depends on the server: external network access, local process access, messaging, or data mutation may be delegated outside the application.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Server configuration, remote OAuth completion, tool arguments, and model-selected MCP calls.
|
||||||
|
|
||||||
|
- **Current safeguards:** MCP routes are registered through [`routes/mcp_routes.py`](../routes/mcp_routes.py); MCP-qualified tools are denied to non-admin users by [`src/tool_security.py`](../src/tool_security.py). OAuth state and token persistence are handled in [`src/mcp_oauth.py`](../src/mcp_oauth.py).
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Credible risk requiring verification: an MCP server authority is broader than the application can infer from its tool name. This map does not establish a trust or approval model for server installation and individual tool invocation.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Server onboarding, credential storage, server-origin trust, OAuth callback deployment, tool disablement, and invocation audit behaviour remain unverified.
|
||||||
|
|
||||||
|
## Outbound network requests and URL validation
|
||||||
|
|
||||||
|
- **Boundary:** Search/content fetch, research, webhooks, skill import, provider endpoints, and other HTTP clients.
|
||||||
|
|
||||||
|
- **Available authority:** The application can make outbound requests from its network position.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Search/fetch URLs, imported skill URLs, webhook configuration, and some endpoint settings.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`src/url_security.py`](../src/url_security.py) validates untrusted public HTTP URLs and fails closed on unsuitable schemes or private addresses. [`services/search/content.py`](../services/search/content.py) resolves and rejects non-public hosts, pins resolved addresses for fetches, caps bodies, and limits redirects.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Intentional split: administrator-created model endpoints may target private providers, while untrusted URLs use public-address checks. That distinction is required for self-hosted deployments but needs explicit call-site review.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** The URL-source classification for outbound clients and the current handling of redirects and DNS changes remain unverified.
|
||||||
|
|
||||||
|
## Secrets, credentials, and vault sessions
|
||||||
|
|
||||||
|
- **Boundary:** Application-managed encrypted secrets, API keys, provider credentials, and Bitwarden/Vaultwarden CLI sessions.
|
||||||
|
|
||||||
|
- **Available authority:** Credentials unlock remote providers and connected personal services.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Administrative configuration, login/unlock requests, imported settings, and agent vault tool arguments.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`src/secret_storage.py`](../src/secret_storage.py) uses a locally stored Fernet key with restrictive permissions for supported database secrets. Vault routes require an administrator, avoid passing master passwords in command arguments, and set restrictive permissions on the vault-session file.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Confirmed current boundary: vault session data is persisted through the vault path, not through [`src/secret_storage.py`](../src/secret_storage.py). This is an unresolved question about current security semantics, not a confirmed exposure.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Current encryption-at-rest, owner scope, rotation, lock/logout, backup/restore, and log/tool-result exposure behaviour remains unverified.
|
||||||
|
|
||||||
|
## Authentication and privileged administration
|
||||||
|
|
||||||
|
- **Boundary:** Session authentication, API tokens, privileged routes, and internal tool loopback.
|
||||||
|
|
||||||
|
- **Available authority:** Administrative identity can access execution, settings, integrations, data deletion, and secrets.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Login/signup data, session cookies, API tokens, authentication configuration, and requests to privileged routes.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`core/auth.py`](../core/auth.py), [`core/middleware.py`](../core/middleware.py), and route-level checks establish identity and administrator gates. [`app.py`](../app.py) warns when localhost bypass is configured; [`SECURITY.md`](../SECURITY.md) documents deployment requirements.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Intentional authority with safeguards. Security depends on deployments keeping authentication enabled and internal services private; this map does not audit reverse-proxy or environment configuration.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Setup, anonymous, non-admin, admin, token, and internal-loopback behaviour, including privileged-route gate consistency, remains unverified.
|
||||||
|
|
||||||
|
## Deletion, wipe, backup, and restore
|
||||||
|
|
||||||
|
- **Boundary:** Administrative wipe, cleanup, backup import/export, and the backup restore command.
|
||||||
|
|
||||||
|
- **Available authority:** Delete or replace user data and credentials.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Administrative HTTP requests, cleanup choices, backup payloads, archive paths, and restore command options.
|
||||||
|
|
||||||
|
- **Current safeguards:** Administrative wipe routes use the administrative boundary. Cleanup exposes a preview route before mutation. The documented backup tool requires explicit restore confirmation, stages the old data directory, and validates archive members before extraction.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Intentional destructive authority. Backup archives contain secrets by design, as documented in [`docs/backup-restore.md`](../docs/backup-restore.md); this is an operator confidentiality responsibility, not a code defect established here.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Role-gate, confirmation, archive-rejection, staged-recovery, and owner-isolation behaviour remains unverified. No destructive runtime test was performed.
|
||||||
|
|
||||||
|
## Background jobs and unattended task execution
|
||||||
|
|
||||||
|
- **Boundary:** Scheduled tasks, background-job monitor, startup tasks, and notification/delivery work that continue without an active browser request.
|
||||||
|
|
||||||
|
- **Available authority:** Scheduled agent work can obtain model access and, for eligible owners, shell and file tools; task output can interact with connected services.
|
||||||
|
|
||||||
|
- **User-controlled inputs:** Stored task prompt, schedule, model/crew selection, enabled-tool configuration, output target, and prior persisted state.
|
||||||
|
|
||||||
|
- **Current safeguards:** [`src/task_scheduler.py`](../src/task_scheduler.py) serializes execution, records task runs, associates work with an owner, and applies the agent owner-based tool gate. [`src/bg_jobs.py`](../src/bg_jobs.py) keeps bounded state and can terminate overlong subprocess jobs.
|
||||||
|
|
||||||
|
- **Confirmed risks or gaps:** Credible risk requiring verification: authority is inherited and exercised later, so changes to roles, task configuration, and disabled tools must be checked at execution time rather than assumed from task creation.
|
||||||
|
|
||||||
|
- **Unverified behaviour:** Creation, editing, role-change, scheduling, cancellation, restart-recovery, and execution behaviour remains unverified, including whether current policy is re-evaluated before privileged action.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Current system map
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This non-canonical discovery map is an evidence guide, not an exhaustive feature catalog or runtime certification. Verify the cited source before relying on a finding. Each section records local implementation observations, evidence locations, confirmed current problems, and unresolved factual questions.
|
||||||
|
|
||||||
|
## Navigate the system
|
||||||
|
|
||||||
|
- [Startup and application composition](#startup-and-application-composition)
|
||||||
|
- [Frontend shell and browser interaction](#frontend-shell-and-browser-interaction)
|
||||||
|
- [Chat, sessions, and streaming](#chat-sessions-and-streaming)
|
||||||
|
- [Agents, tools, and execution](#agents-tools-and-execution)
|
||||||
|
- [Models, providers, and local serving](#models-providers-and-local-serving)
|
||||||
|
- [Search and research](#search-and-research)
|
||||||
|
- [Documents, retrieval, and personal knowledge](#documents-retrieval-and-personal-knowledge)
|
||||||
|
- [Memory and skills](#memory-and-skills)
|
||||||
|
- [Email, calendar, contacts, notes, and tasks](#email-calendar-contacts-notes-and-tasks)
|
||||||
|
- [Media, speech, and image work](#media-speech-and-image-work)
|
||||||
|
- [Authentication, secrets, and privileged administration](#authentication-secrets-and-privileged-administration)
|
||||||
|
- [Persistence, background work, and operations](#persistence-background-work-and-operations)
|
||||||
|
|
||||||
|
## Startup and application composition
|
||||||
|
|
||||||
|
- **How it works:** [`app.py`](../app.py) creates the application, mounts static assets, constructs shared services, registers route factories, and owns lifespan startup and shutdown. [`src/app_initializer.py`](../src/app_initializer.py) prepares application state; [`core/`](../core/) provides persistence, authentication, middleware, sessions, and platform helpers.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`app.py`](../app.py); [`src/app_initializer.py`](../src/app_initializer.py); [`core/database.py`](../core/database.py); [`core/auth.py`](../core/auth.py); [`core/middleware.py`](../core/middleware.py); [`routes/`](../routes/).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** Which component currently owns startup and shutdown for each long-lived service?
|
||||||
|
|
||||||
|
## Frontend shell and browser interaction
|
||||||
|
|
||||||
|
- **How it works:** [`static/index.html`](../static/index.html) is served by the root and SPA deep-link routes in [`app.py`](../app.py); [`static/app.js`](../static/app.js), [`static/style.css`](../static/style.css), and [`static/js/`](../static/js/) implement the client surface.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`static/index.html`](../static/index.html); [`static/app.js`](../static/app.js); [`static/js/`](../static/js/); [`static/style.css`](../static/style.css); [`app.py`](../app.py) deep-link handlers.
|
||||||
|
|
||||||
|
- **Known problems:** The `/backgrounds` route in [`app.py`](../app.py) calls `serve_html_with_nonce` for `static/backgrounds.html`, but that file is absent from [`static/`](../static/). This is a confirmed broken prototype route, not evidence about the rest of the frontend.
|
||||||
|
|
||||||
|
- **Open question:** Is `/backgrounds` currently an intentionally supported route or an obsolete prototype?
|
||||||
|
|
||||||
|
## Chat, sessions, and streaming
|
||||||
|
|
||||||
|
- **How it works:** [`routes/chat_routes.py`](../routes/chat_routes.py) and [`routes/chat_helpers.py`](../routes/chat_helpers.py) coordinate requests, session state, and SSE delivery. [`src/chat_handler.py`](../src/chat_handler.py), [`src/chat_processor.py`](../src/chat_processor.py), [`src/llm_core.py`](../src/llm_core.py), and [`src/session_actions.py`](../src/session_actions.py) provide message preparation, provider interaction, and session operations.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/chat_routes.py`](../routes/chat_routes.py); [`routes/chat_helpers.py`](../routes/chat_helpers.py); [`routes/session_routes.py`](../routes/session_routes.py); [`src/chat_handler.py`](../src/chat_handler.py); [`src/chat_processor.py`](../src/chat_processor.py); [`src/llm_core.py`](../src/llm_core.py); [`core/session_manager.py`](../core/session_manager.py).
|
||||||
|
|
||||||
|
- **Known problems:** [`src/agent_loop.py`](../src/agent_loop.py) annotates `_resolved_tool_event_name` with `Any` but imports no `Any` and does not enable postponed annotation evaluation. Python evaluates that annotation while importing the module, so this is an import-time defect at the checked baseline.
|
||||||
|
|
||||||
|
- **Open question:** No end-to-end provider or browser streaming run was performed for this map.
|
||||||
|
|
||||||
|
## Agents, tools, and execution
|
||||||
|
|
||||||
|
- **How it works:** [`src/agent_loop.py`](../src/agent_loop.py) drives multi-round tool use. [`src/tool_execution.py`](../src/tool_execution.py) dispatches calls and binds workspace context. [`src/agent_tools/`](../src/agent_tools/) contains individual implementations; [`src/tool_security.py`](../src/tool_security.py) and [`src/tool_policy.py`](../src/tool_policy.py) apply role and request policies. Long-running command work is represented by [`src/bg_jobs.py`](../src/bg_jobs.py).
|
||||||
|
|
||||||
|
- **Evidence locations:** [`src/agent_loop.py`](../src/agent_loop.py); [`src/tool_execution.py`](../src/tool_execution.py); [`src/agent_tools/`](../src/agent_tools/); [`src/tool_security.py`](../src/tool_security.py); [`src/tool_policy.py`](../src/tool_policy.py); [`src/tool_schemas.py`](../src/tool_schemas.py); [`src/bg_jobs.py`](../src/bg_jobs.py).
|
||||||
|
|
||||||
|
- **Known problems:** The import-time annotation defect above blocks the main agent/tool path. The shell is intentionally not a filesystem or network sandbox; that is an authority boundary, not by itself a vulnerability claim.
|
||||||
|
|
||||||
|
- **Open question:** Which native, legacy, and MCP-qualified invocation paths reach each policy gate?
|
||||||
|
|
||||||
|
## Models, providers, and local serving
|
||||||
|
|
||||||
|
- **How it works:** Model routes delegate to discovery, capabilities, endpoint resolution, and LLM core modules. Cookbook routes and hardware-fit services handle model lifecycle and local-serving support.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/model_routes.py`](../routes/model_routes.py); [`src/model_discovery.py`](../src/model_discovery.py); [`src/model_capabilities.py`](../src/model_capabilities.py); [`src/endpoint_resolver.py`](../src/endpoint_resolver.py); [`src/llm_core.py`](../src/llm_core.py); [`routes/cookbook_routes.py`](../routes/cookbook_routes.py); [`src/cookbook_serve_lifecycle.py`](../src/cookbook_serve_lifecycle.py); [`services/hwfit/`](../services/hwfit/).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** Which endpoint inputs are administrator-created and permitted to use private provider addresses?
|
||||||
|
|
||||||
|
## Search and research
|
||||||
|
|
||||||
|
- **How it works:** HTTP search routes use [`services/search/`](../services/search/); research is exposed through [`routes/research/`](../routes/research/) and implemented in [`services/research/`](../services/research/), [`src/deep_research.py`](../src/deep_research.py), and related helpers. [`src/search/`](../src/search/) remains an import-compatibility layer for callers not yet moved to `services.search`.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/search_routes.py`](../routes/search_routes.py); [`services/search/`](../services/search/); [`routes/research/research_routes.py`](../routes/research/research_routes.py); [`services/research/`](../services/research/); [`src/deep_research.py`](../src/deep_research.py); [`src/search/`](../src/search/).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** No live provider request was made; provider configuration and network access remain unverified.
|
||||||
|
|
||||||
|
## Documents, retrieval, and personal knowledge
|
||||||
|
|
||||||
|
- **How it works:** Document routes coordinate upload handling, document processing, and editor actions. Personal-document and RAG modules use Chroma and embedding clients. PDF viewing uses the optional-dependency loader in [`src/pdf_runtime.py`](../src/pdf_runtime.py); form extraction and filling live separately in [`src/pdf_forms.py`](../src/pdf_forms.py) and [`src/pdf_form_doc.py`](../src/pdf_form_doc.py).
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/document_routes.py`](../routes/document_routes.py); [`src/upload_handler.py`](../src/upload_handler.py); [`src/document_processor.py`](../src/document_processor.py); [`src/document_actions.py`](../src/document_actions.py); [`src/personal_docs.py`](../src/personal_docs.py); [`src/rag_manager.py`](../src/rag_manager.py); [`src/embeddings.py`](../src/embeddings.py); [`src/pdf_runtime.py`](../src/pdf_runtime.py); [`src/pdf_forms.py`](../src/pdf_forms.py); [`src/pdf_form_doc.py`](../src/pdf_form_doc.py).
|
||||||
|
|
||||||
|
- **Known problems:** PDF viewing/runtime loading and PDF form processing are separate implementations. That separation is confirmed and intentional in the source; it is not a defect without a reported behavioural failure.
|
||||||
|
|
||||||
|
- **Open question:** Optional PDF dependencies and representative uploaded documents were not exercised.
|
||||||
|
|
||||||
|
## Memory and skills
|
||||||
|
|
||||||
|
- **How it works:** Memory routes use [`services/memory/`](../services/memory/) and vector helpers. Skills are exposed through [`routes/skills_routes.py`](../routes/skills_routes.py), stored and managed in [`services/memory/skills.py`](../services/memory/skills.py), and may be imported through [`services/memory/skill_importer.py`](../services/memory/skill_importer.py).
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/memory/memory_routes.py`](../routes/memory/memory_routes.py); [`services/memory/`](../services/memory/); [`src/memory.py`](../src/memory.py); [`src/memory_vector.py`](../src/memory_vector.py); [`routes/skills_routes.py`](../routes/skills_routes.py); [`services/memory/skills.py`](../services/memory/skills.py); [`services/memory/skill_importer.py`](../services/memory/skill_importer.py).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** Which imported skill content can reach execution-capable paths, and which validation occurs before that point?
|
||||||
|
|
||||||
|
## Email, calendar, contacts, notes, and tasks
|
||||||
|
|
||||||
|
- **How it works:** Dedicated route modules own email, CalDAV calendar, CardDAV contacts, notes, and tasks. Supporting modules include email helpers and pollers, CalDAV sync and writeback, and the task scheduler.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/email_routes.py`](../routes/email_routes.py); [`routes/calendar_routes.py`](../routes/calendar_routes.py); [`routes/contacts/contacts_routes.py`](../routes/contacts/contacts_routes.py); [`routes/note/note_routes.py`](../routes/note/note_routes.py); [`routes/task_routes.py`](../routes/task_routes.py); [`routes/assistant_routes.py`](../routes/assistant_routes.py); [`src/caldav_sync.py`](../src/caldav_sync.py); [`src/caldav_writeback.py`](../src/caldav_writeback.py); [`src/task_scheduler.py`](../src/task_scheduler.py).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** External account behaviour, writeback, and delivery require controlled credentials and are not runtime-validated here.
|
||||||
|
|
||||||
|
## Media, speech, and image work
|
||||||
|
|
||||||
|
- **How it works:** Gallery and image routes coordinate media features. Service modules own speech and media integrations; [`src/generated_images.py`](../src/generated_images.py) and [`src/visual_report.py`](../src/visual_report.py) support artifact handling and presentation.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`routes/gallery/gallery_routes.py`](../routes/gallery/gallery_routes.py); [`routes/stt_routes.py`](../routes/stt_routes.py); [`routes/tts_routes.py`](../routes/tts_routes.py); [`src/generated_images.py`](../src/generated_images.py); [`services/stt/`](../services/stt/); [`services/tts/`](../services/tts/); [`services/faces/`](../services/faces/); [`src/visual_report.py`](../src/visual_report.py).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** Hardware- and provider-dependent media workflows were not exercised.
|
||||||
|
|
||||||
|
## Authentication, secrets, and privileged administration
|
||||||
|
|
||||||
|
- **How it works:** [`core/auth.py`](../core/auth.py) and [`core/middleware.py`](../core/middleware.py) provide identity and request gates. [`src/secret_storage.py`](../src/secret_storage.py) encrypts application-managed database secrets with a local Fernet key. Vault handling is separate: [`routes/vault_routes.py`](../routes/vault_routes.py) and [`src/tools/vault.py`](../src/tools/vault.py) invoke the Bitwarden CLI and persist its session data in the application data area.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`core/auth.py`](../core/auth.py); [`core/middleware.py`](../core/middleware.py); [`routes/auth_routes.py`](../routes/auth_routes.py); [`routes/api_token_routes.py`](../routes/api_token_routes.py); [`src/secret_storage.py`](../src/secret_storage.py); [`routes/vault_routes.py`](../routes/vault_routes.py); [`src/tools/vault.py`](../src/tools/vault.py); [`routes/admin_wipe/admin_wipe_routes.py`](../routes/admin_wipe/admin_wipe_routes.py).
|
||||||
|
|
||||||
|
- **Known problems:** Vault-command handling and local application secret storage are distinct paths with different storage mechanisms. This is a source-confirmed boundary, not evidence that either path is compromised.
|
||||||
|
|
||||||
|
- **Open question:** What are the current confidentiality, ownership, rotation, and backup semantics for vault session data?
|
||||||
|
|
||||||
|
## Persistence, background work, and operations
|
||||||
|
|
||||||
|
- **How it works:** SQLite models and persistence are centred in [`core/database.py`](../core/database.py); managers use application data paths. The scheduler and background-job monitor can continue work outside a live browser request. Operational routes cover cleanup, backup, and administrative wipe; the repository also provides a backup script and user documentation.
|
||||||
|
|
||||||
|
- **Evidence locations:** [`core/database.py`](../core/database.py); [`src/runtime_paths.py`](../src/runtime_paths.py); [`src/task_scheduler.py`](../src/task_scheduler.py); [`src/bg_jobs.py`](../src/bg_jobs.py); [`src/bg_monitor.py`](../src/bg_monitor.py); [`routes/backup_routes.py`](../routes/backup_routes.py); [`routes/cleanup/cleanup_routes.py`](../routes/cleanup/cleanup_routes.py); [`routes/admin_wipe/admin_wipe_routes.py`](../routes/admin_wipe/admin_wipe_routes.py); [`scripts/odysseus-backup`](../scripts/odysseus-backup); [`docs/backup-restore.md`](../docs/backup-restore.md).
|
||||||
|
|
||||||
|
- **Known problems:** None recorded by this mapping.
|
||||||
|
|
||||||
|
- **Open question:** What current behaviour applies to background execution, cancellation, retries, and authority inheritance?
|
||||||
@@ -67,7 +67,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- 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_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- 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_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ services:
|
|||||||
- ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=${ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES:-26214400}
|
- 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_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
- 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:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document is the proposed canonical destination for stable high-level
|
||||||
|
> architecture facts. It remains subject to maintainer review. Source code,
|
||||||
|
> tests, and configuration remain authoritative for implementation-sensitive
|
||||||
|
> behaviour.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document identifies the stable runtime boundaries and primary ownership
|
||||||
|
locations used to navigate and extend Odysseus.
|
||||||
|
|
||||||
|
It intentionally excludes generated metrics, file-size rankings, refactor
|
||||||
|
priorities, unresolved investigation findings, and proposed package layouts.
|
||||||
|
|
||||||
|
## Runtime structure
|
||||||
|
|
||||||
|
| Area | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `app.py` | FastAPI application composition and primary application entry point |
|
||||||
|
| `launcher.py` | Application launch support |
|
||||||
|
| `setup.py` | Native setup workflow |
|
||||||
|
| `core/` | Authentication, middleware, persistence, sessions, and platform primitives |
|
||||||
|
| `routes/` | HTTP and API route handlers |
|
||||||
|
| `src/` | Application orchestration, tools, providers, and runtime helpers |
|
||||||
|
| `services/` | Domain-oriented service implementations |
|
||||||
|
| `mcp_servers/` | Built-in MCP server implementations |
|
||||||
|
| `scripts/` | CLI tools, diagnostics, maintenance, and migration helpers |
|
||||||
|
| `static/` | No-build browser frontend and bundled assets |
|
||||||
|
| `tests/` | Automated tests and supporting test infrastructure |
|
||||||
|
|
||||||
|
## Subsystem boundaries
|
||||||
|
|
||||||
|
| Subsystem | Primary implementation locations |
|
||||||
|
|---|---|
|
||||||
|
| Application startup | `app.py`, `src/app_initializer.py`, `core/` |
|
||||||
|
| Authentication and sessions | `core/auth.py`, `core/middleware.py`, `core/session_manager.py`, `routes/auth_routes.py` |
|
||||||
|
| Chat and streaming | `routes/chat_routes.py`, `routes/chat_helpers.py`, `src/chat_handler.py`, `src/chat_processor.py`, `src/llm_core.py` |
|
||||||
|
| Agents and tools | `src/agent_loop.py`, `src/tool_execution.py`, `src/agent_tools/`, `src/tools/`, `src/tool_policy.py`, `src/tool_security.py` |
|
||||||
|
| Models and providers | `routes/model_routes.py`, `src/model_discovery.py`, `src/model_capabilities.py`, `src/endpoint_resolver.py`, `src/llm_core.py` |
|
||||||
|
| Cookbook and hardware fit | `routes/cookbook_routes.py`, `routes/cookbook_helpers.py`, `src/cookbook_serve_lifecycle.py`, `services/hwfit/` |
|
||||||
|
| Search and research | `routes/search_routes.py`, `services/search/`, `routes/research/`, `services/research/`, `src/deep_research.py` |
|
||||||
|
| Documents and retrieval | `routes/document_routes.py`, `src/document_processor.py`, `src/personal_docs.py`, `src/rag_manager.py`, `src/pdf_runtime.py` |
|
||||||
|
| Memory and skills | `routes/memory/`, `services/memory/`, `routes/skills_routes.py` |
|
||||||
|
| Email | `routes/email_routes.py`, `routes/email_helpers.py`, `routes/email_pollers.py`, `mcp_servers/email_server.py` |
|
||||||
|
| Calendar, contacts, notes, and tasks | `routes/calendar_routes.py`, `routes/contacts/`, `routes/note/`, `routes/task_routes.py`, `src/task_scheduler.py` |
|
||||||
|
| Media and speech | `routes/gallery/`, `routes/stt_routes.py`, `routes/tts_routes.py`, `services/stt/`, `services/tts/` |
|
||||||
|
| Persistence and operations | `core/database.py`, `src/runtime_paths.py`, `src/bg_jobs.py`, `routes/backup_routes.py`, `routes/cleanup/` |
|
||||||
|
|
||||||
|
## Architectural constraints
|
||||||
|
|
||||||
|
- Preserve established import and compatibility paths unless a focused change
|
||||||
|
explicitly migrates them.
|
||||||
|
- Keep HTTP concerns in route modules and reusable domain behaviour in runtime
|
||||||
|
or service modules.
|
||||||
|
- Treat shared persistence, agent orchestration, tool execution, and application
|
||||||
|
startup as high-authority boundaries.
|
||||||
|
- Change one ownership boundary at a time.
|
||||||
|
- Do not mix structural movement with unrelated feature behaviour.
|
||||||
|
- Validate affected imports, startup paths, compatibility surfaces, and tests.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
The browser frontend is a no-build ES-module application under `static/`.
|
||||||
|
|
||||||
|
Its maintained module-level structure is documented in
|
||||||
|
[`static/js/MODULE_SUMMARY.md`](../static/js/MODULE_SUMMARY.md).
|
||||||
|
|
||||||
|
## Investigation and snapshots
|
||||||
|
|
||||||
|
Non-canonical investigation material is maintained under [`discovery/`](../discovery/).
|
||||||
|
|
||||||
|
The following documents may contain dated observations, metrics, unresolved
|
||||||
|
questions, or historical planning and must not be treated as specifications:
|
||||||
|
|
||||||
|
- [`discovery/system-map.md`](../discovery/system-map.md)
|
||||||
|
- [`discovery/architecture-runtime-inventory.md`](../discovery/architecture-runtime-inventory.md)
|
||||||
|
|
||||||
|
## Documentation authority
|
||||||
|
|
||||||
|
- Code, tests, and configuration define implemented behaviour.
|
||||||
|
- Mature specifications define accepted subsystem behaviour where they exist.
|
||||||
|
- Following maintainer acceptance, this document will define the high-level
|
||||||
|
architecture map.
|
||||||
|
- Discovery documents preserve evidence and uncertainty but remain
|
||||||
|
non-canonical.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Documentation style
|
||||||
|
|
||||||
|
This guide defines the shared structure and writing conventions for Odysseus documentation.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Write for a clear audience and purpose.
|
||||||
|
- State whether a document is canonical, informational, a snapshot, or planning material.
|
||||||
|
- Prefer current behaviour over historical explanation.
|
||||||
|
- Link to source files, tests, issues, or other documentation when useful.
|
||||||
|
- Separate verified behaviour from assumptions, open questions, and future work.
|
||||||
|
- Keep headings descriptive and consistent.
|
||||||
|
- Use Markdown callouts where status or risk must be visible.
|
||||||
|
- Do not use emojis.
|
||||||
|
|
||||||
|
## Document status
|
||||||
|
|
||||||
|
Use a status callout near the top when the document is not normal canonical guidance.
|
||||||
|
|
||||||
|
### Canonical documentation
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This document describes accepted current behaviour. Verify implementation-sensitive details against the current code and tests.
|
||||||
|
|
||||||
|
### Discovery material
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is non-canonical discovery material. It records code-grounded observations and open questions.
|
||||||
|
|
||||||
|
### Snapshot or inventory
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This document is a dated snapshot. Counts, paths, and implementation details may drift as the codebase changes.
|
||||||
|
|
||||||
|
### Planning material
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document records planning context. It does not define current runtime behaviour or guarantee future implementation.
|
||||||
|
|
||||||
|
## Recommended structure
|
||||||
|
|
||||||
|
Use the following sections where relevant:
|
||||||
|
|
||||||
|
1. Title
|
||||||
|
2. Purpose or status callout
|
||||||
|
3. Scope
|
||||||
|
4. Current behaviour or guidance
|
||||||
|
5. Safety, limitations, or known gaps
|
||||||
|
6. Validation or evidence
|
||||||
|
7. Related documentation
|
||||||
|
|
||||||
|
Not every document needs every section.
|
||||||
|
|
||||||
|
## Writing style
|
||||||
|
|
||||||
|
- Use concise sentences.
|
||||||
|
- Prefer direct language.
|
||||||
|
- Avoid jokes, filler, and informal warnings.
|
||||||
|
- Avoid repeating the same guidance across several files.
|
||||||
|
- Link to the owning document instead of duplicating large sections.
|
||||||
|
- Use lists for procedures, requirements, and comparisons.
|
||||||
|
- Use tables only when they improve scanning.
|
||||||
|
- Use fenced code blocks with an appropriate language identifier.
|
||||||
|
- Use relative repository links for internal files.
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
|
||||||
|
The current code, tests, and configuration are the source of truth for implemented behaviour.
|
||||||
|
|
||||||
|
Canonical documentation describes accepted behaviour and supported workflows.
|
||||||
|
|
||||||
|
Discovery, inventory, and planning documents must identify themselves explicitly and must not silently become behavioural specifications.
|
||||||
+34
-2
@@ -1,7 +1,13 @@
|
|||||||
# Attachment References and Upload Storage
|
# Attachment References and Upload Storage
|
||||||
|
|
||||||
Odysseus stores uploaded bytes once under the configured upload directory and
|
> [!NOTE]
|
||||||
passes stable references through chat history, tools, and future artifact work.
|
> This document records the current attachment-reference and upload-lifecycle
|
||||||
|
> contract proposed for maintainer acceptance. Source code, tests, and
|
||||||
|
> configuration remain authoritative for implementation-sensitive behaviour.
|
||||||
|
|
||||||
|
Odysseus stores chat and document attachment bytes under the configured upload
|
||||||
|
directory and passes stable references through chat history, document flows, and
|
||||||
|
tool context.
|
||||||
The goal is to avoid duplicating large inline media payloads in
|
The goal is to avoid duplicating large inline media payloads in
|
||||||
`chat_messages.content` or the SQLite FTS index.
|
`chat_messages.content` or the SQLite FTS index.
|
||||||
|
|
||||||
@@ -54,6 +60,32 @@ External MCP/custom tools should treat the URI and attachment ID as the stable
|
|||||||
contract and request bytes through an owner-checked server path, not by assuming
|
contract and request bytes through an owner-checked server path, not by assuming
|
||||||
host filesystem layout.
|
host filesystem layout.
|
||||||
|
|
||||||
|
## Implementation evidence
|
||||||
|
|
||||||
|
The current contract is implemented primarily through:
|
||||||
|
|
||||||
|
- `src/upload_handler.py` for upload metadata, owner-aware resolution,
|
||||||
|
reservations, cleanup, and deletion;
|
||||||
|
- `src/attachment_refs.py` for compact persisted references and search-index
|
||||||
|
sanitization;
|
||||||
|
- `src/document_processor.py` for resolving attachments into chat/model context;
|
||||||
|
- `src/tool_execution.py` for attachment manifests exposed to tools;
|
||||||
|
- `routes/upload_routes.py` and `routes/document_helpers.py` for upload and
|
||||||
|
retrieval paths.
|
||||||
|
|
||||||
|
Focused regression coverage includes:
|
||||||
|
|
||||||
|
- `tests/test_attachment_refs.py`;
|
||||||
|
- `tests/test_upload_handler_cleanup.py`;
|
||||||
|
- `tests/test_replace_messages_upload_reservations.py`;
|
||||||
|
- `tests/test_resolve_upload_path_nondict.py`;
|
||||||
|
- `tests/test_chat_preprocess_tool_policy.py`;
|
||||||
|
- the upload, attachment, and PDF-marker cases in
|
||||||
|
`tests/test_security_regressions.py`.
|
||||||
|
|
||||||
|
These tests cover compact persistence, owner isolation, path containment,
|
||||||
|
cleanup safety, reservation-before-write behaviour, and traversal resistance.
|
||||||
|
|
||||||
## Retention and Deletion
|
## Retention and Deletion
|
||||||
|
|
||||||
Current retention behavior is conservative:
|
Current retention behavior is conservative:
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ Dirty, blocked, conflicting, and unknown merge states are shown as risk/caution
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m py_compile scripts/pr_blocker_audit.py tests/test_pr_blocker_audit.py
|
venv/bin/python -m py_compile scripts/pr_blocker_audit.py tests/test_pr_blocker_audit.py
|
||||||
python3 -m pytest tests/test_pr_blocker_audit.py -q
|
venv/bin/python -m pytest tests/test_pr_blocker_audit.py -q
|
||||||
python3 scripts/pr_blocker_audit.py --help
|
python3 scripts/pr_blocker_audit.py --help
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|||||||
+35
-16
@@ -1,6 +1,28 @@
|
|||||||
# Odysseus Setup Guide
|
# Odysseus Setup Guide
|
||||||
|
|
||||||
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
|
This guide covers installation, deployment, troubleshooting, and configuration.
|
||||||
|
For a minimal Docker installation, start with the
|
||||||
|
[repository README](../README.md#quick-start).
|
||||||
|
|
||||||
|
## On this page
|
||||||
|
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Docker](#docker-recommended)
|
||||||
|
- [Native Linux and macOS](#native-linux--macos)
|
||||||
|
- [Apple Silicon](#apple-silicon)
|
||||||
|
- [Native Windows](#native-windows)
|
||||||
|
- [Troubleshooting and advanced setup](#troubleshooting--advanced-setup)
|
||||||
|
- [Security notes](#security-notes)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Architecture](#architecture)
|
||||||
|
- [Data and backups](#data)
|
||||||
|
|
||||||
|
Related guidance:
|
||||||
|
|
||||||
|
- [Security policy](../SECURITY.md)
|
||||||
|
- [Architecture overview](ARCHITECTURE.md)
|
||||||
|
- [Backup and restore guide](backup-restore.md)
|
||||||
|
- [Contributing guide](../CONTRIBUTING.md)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -15,8 +37,8 @@ On first setup, Odysseus creates an admin account (`admin` unless
|
|||||||
For Docker installs, the same line is in `docker compose logs odysseus`.
|
For Docker installs, the same line is in `docker compose logs odysseus`.
|
||||||
Use that for the first login, then change it in **Settings**.
|
Use that for the first login, then change it in **Settings**.
|
||||||
|
|
||||||
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
|
Contributing? See the [contributing guide](../CONTRIBUTING.md) for development
|
||||||
pull request guidelines.
|
setup, testing, and pull request guidelines.
|
||||||
|
|
||||||
### Docker (recommended)
|
### Docker (recommended)
|
||||||
```bash
|
```bash
|
||||||
@@ -441,8 +463,8 @@ uv pip sync requirements.lock # reproduce it exactly la
|
|||||||
### Outlook / Office 365 email
|
### Outlook / Office 365 email
|
||||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||||
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
|
passwords will fail. See the [Outlook and Microsoft 365 email guide](email-outlook.md)
|
||||||
current limitation and the planned integration direction.
|
for the current limitation and planned integration direction.
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
Odysseus is a self-hosted workspace with powerful local tools: shell access, file uploads, model downloads, web research, email/calendar integrations, and API tokens. Treat it like an admin console.
|
||||||
@@ -530,19 +552,16 @@ npx -y @playwright/mcp@latest --version
|
|||||||
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
That installs `@playwright/mcp` plus Playwright (~300MB total). Restart Odysseus and the server will register at startup.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
```
|
|
||||||
app.py # FastAPI entry point
|
For stable high-level runtime structure, subsystem boundaries, and documentation
|
||||||
core/ auth, database, middleware, constants
|
authority, see the [architecture overview](ARCHITECTURE.md).
|
||||||
src/ llm_core, agent_loop, agent_tools, chat_processor, search/
|
|
||||||
routes/ chat, session, document, memory, model … endpoints
|
Source code, tests, and configuration remain authoritative for
|
||||||
services/ docs, memory, search, hwfit (Cookbook) …
|
implementation-sensitive behaviour.
|
||||||
static/ index.html + app.js + style.css + js/ (modular front-end)
|
|
||||||
docs/ landing page (index.html) + preview clips
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data
|
## Data
|
||||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||||
|
|
||||||
To back up or restore everything in `data/`, see the
|
To protect or recover this data, follow the
|
||||||
[Backup & Restore guide](backup-restore.md).
|
[backup and restore guide](backup-restore.md).
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ from mcp.types import Tool, TextContent
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
server = Server("memory")
|
server = Server("memory")
|
||||||
|
|
||||||
# Late-initialized managers (set during first tool call)
|
# Late-initialized managers (set during first tool call)
|
||||||
@@ -31,10 +29,6 @@ _OWNER_SCOPE_ERROR = (
|
|||||||
"Error: Memory MCP owner is not configured for an owner-scoped memory store. "
|
"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."
|
"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:
|
def _configured_owner() -> str | None:
|
||||||
@@ -57,21 +51,9 @@ def _owner_scoped_store(entries: list[dict]) -> bool:
|
|||||||
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
return any(_entry_owner(entry) for entry in entries if isinstance(entry, dict))
|
||||||
|
|
||||||
|
|
||||||
def _scope_entries(for_update: bool = False) -> tuple[str | None, list[dict], list[dict], str | None]:
|
def _scope_entries() -> tuple[str | None, list[dict], list[dict], str | None]:
|
||||||
"""Return configured owner, all entries, visible entries, and optional error.
|
"""Return configured owner, all entries, visible entries, and optional error."""
|
||||||
|
entries = _memory_manager.load_all()
|
||||||
``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()
|
owner = _configured_owner()
|
||||||
if owner is None and _owner_scoped_store(entries):
|
if owner is None and _owner_scoped_store(entries):
|
||||||
return None, entries, [], _OWNER_SCOPE_ERROR
|
return None, entries, [], _OWNER_SCOPE_ERROR
|
||||||
@@ -179,7 +161,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
category = arguments.get("category", "fact")
|
category = arguments.get("category", "fact")
|
||||||
if not text:
|
if not text:
|
||||||
return _text_result("Error: Memory text cannot be empty")
|
return _text_result("Error: Memory text cannot be empty")
|
||||||
owner, memories, _visible, scope_error = _scope_entries(for_update=True)
|
owner, memories, _visible, scope_error = _scope_entries()
|
||||||
if scope_error:
|
if scope_error:
|
||||||
return _text_result(scope_error)
|
return _text_result(scope_error)
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
|
|||||||
+1
-4
@@ -38,10 +38,7 @@ python-dateutil
|
|||||||
caldav
|
caldav
|
||||||
cryptography
|
cryptography
|
||||||
bcrypt
|
bcrypt
|
||||||
# Built-in servers use the v1 low-level Server decorator API. MCP SDK v2 is a
|
mcp
|
||||||
# breaking rewrite, so keep fresh installs on the maintained v1 line until the
|
|
||||||
# servers are migrated together.
|
|
||||||
mcp<2
|
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
croniter
|
croniter
|
||||||
|
|||||||
+1
-10
@@ -6,7 +6,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
from services.memory import MemoryStoreUnreadable
|
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.settings import load_settings, save_settings, load_features, save_features
|
from src.settings import load_settings, save_settings, load_features, save_features
|
||||||
|
|
||||||
@@ -77,15 +76,7 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
|
|
||||||
# ── Memories ──
|
# ── Memories ──
|
||||||
if "memories" in body and isinstance(body["memories"], list):
|
if "memories" in body and isinstance(body["memories"], list):
|
||||||
# Strict load: importing on top of an unreadable store would write
|
existing = memory_manager.load_all()
|
||||||
# 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
|
# Dedup against THIS user's own memories only. Using every tenant's
|
||||||
# rows (load_all) meant a memory whose text matched any other
|
# rows (load_all) meant a memory whose text matched any other
|
||||||
# user's was silently skipped, so the importing user lost their own
|
# user's was silently skipped, so the importing user lost their own
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
"""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
+239
-10
@@ -1,14 +1,243 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/document/document_helpers.py.
|
"""document_helpers.py — Pydantic models, doc serializers, owner gating, file-locator helpers shared with document_routes.py."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
"""Document routes — CRUD for living documents with version history."""
|
||||||
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 sys as _sys
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from routes.document import document_helpers as _canonical # noqa: F401
|
from fastapi import HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
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"
|
||||||
|
|||||||
+1806
-13
File diff suppressed because it is too large
Load Diff
@@ -247,7 +247,6 @@ import re as _re_reply
|
|||||||
_REPLY_OPEN_RE = _re_reply.compile(r"<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+", _re_reply.I)
|
_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_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)
|
_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:
|
def _extract_reply(text: str) -> str:
|
||||||
@@ -278,125 +277,6 @@ def _extract_reply(text: str) -> str:
|
|||||||
return _strip_think(t).strip()
|
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:
|
def _apply_email_style_mechanics(text: str) -> str:
|
||||||
"""Enforce deterministic writing-style mechanics that models often miss."""
|
"""Enforce deterministic writing-style mechanics that models often miss."""
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
+9
-23
@@ -40,7 +40,6 @@ from routes.email_helpers import (
|
|||||||
_pre_retrieve_context,
|
_pre_retrieve_context,
|
||||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||||
_generate_scheduled_email_summary, _email_summary_failure_log_detail,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -654,7 +653,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
no_msgid = 0
|
no_msgid = 0
|
||||||
examined = 0
|
examined = 0
|
||||||
_summaries_created = 0
|
_summaries_created = 0
|
||||||
_summary_failed = 0
|
|
||||||
_events_created = 0
|
_events_created = 0
|
||||||
_replies_drafted = 0
|
_replies_drafted = 0
|
||||||
_reply_failed = 0
|
_reply_failed = 0
|
||||||
@@ -787,17 +785,16 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
|
|
||||||
if need_sum:
|
if need_sum:
|
||||||
try:
|
try:
|
||||||
summary = await _generate_scheduled_email_summary(
|
summary = await task_llm_call_async(
|
||||||
url=url,
|
messages=[
|
||||||
model=model,
|
{"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."},
|
||||||
sender=sender,
|
{"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>>>."},
|
||||||
subject=subject,
|
],
|
||||||
body_for_llm=body_for_llm,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
headers=req_headers,
|
|
||||||
owner=account_owner or None,
|
owner=account_owner or None,
|
||||||
max_tokens=16384,
|
temperature=0.3, max_tokens=16384, timeout=240,
|
||||||
timeout=240,
|
|
||||||
)
|
)
|
||||||
|
summary = _extract_reply((summary or "").strip())
|
||||||
if summary:
|
if summary:
|
||||||
_c = _sql3.connect(SCHEDULED_DB)
|
_c = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_c.execute("""
|
||||||
@@ -811,19 +808,10 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
_summaries_created += 1
|
_summaries_created += 1
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_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)'}")
|
_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:
|
except Exception as e:
|
||||||
_summary_failed += 1
|
|
||||||
_uid_text = uid.decode() if isinstance(uid, bytes) else str(uid)
|
_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)'}")
|
_detail_lines.append(f"summary failed · {_folder}#{_uid_text} · {subject or '(no subject)'} — {sender or '(unknown sender)'}")
|
||||||
logger.warning(
|
logger.warning(f"Auto-summary {uid} failed: {e}")
|
||||||
"Auto-summary uid=%s failed %s",
|
|
||||||
_uid_text,
|
|
||||||
_email_summary_failure_log_detail(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
if need_reply:
|
if need_reply:
|
||||||
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
await _emit_progress(progress_cb, f"Drafting reply {processed + 1}/{_max_process} · checked {examined}/{len(uid_list)}")
|
||||||
@@ -1332,8 +1320,6 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
parts.append(f"processed {processed} new")
|
parts.append(f"processed {processed} new")
|
||||||
if auto_sum:
|
if auto_sum:
|
||||||
parts.append(f"summarized {_summaries_created}")
|
parts.append(f"summarized {_summaries_created}")
|
||||||
if _summary_failed:
|
|
||||||
parts.append(f"{_summary_failed} summary failed")
|
|
||||||
if auto_reply_draft:
|
if auto_reply_draft:
|
||||||
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
parts.append(f"drafted {_replies_drafted} repl" + ("y" if _replies_drafted == 1 else "ies"))
|
||||||
if _reply_failed:
|
if _reply_failed:
|
||||||
|
|||||||
+85
-144
@@ -57,8 +57,7 @@ from routes.email_helpers import (
|
|||||||
_extract_attachment_to_disk, _extract_html, _extract_text,
|
_extract_attachment_to_disk, _extract_html, _extract_text,
|
||||||
_fetch_sender_thread_context, _pre_retrieve_context,
|
_fetch_sender_thread_context, _pre_retrieve_context,
|
||||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||||
_friendly_email_auth_error, _email_summary_failure_log_detail,
|
_friendly_email_auth_error,
|
||||||
_generate_email_summary, EMAIL_SUMMARY_ERROR_CODE, EMAIL_SUMMARY_ERROR_MESSAGE,
|
|
||||||
SendEmailRequest, ExtractStyleRequest,
|
SendEmailRequest, ExtractStyleRequest,
|
||||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||||
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
attachment_extract_dir, _email_cache_owner_clause, email_translation_body_hash,
|
||||||
@@ -2861,22 +2860,13 @@ def setup_email_routes():
|
|||||||
return indexed_response
|
return indexed_response
|
||||||
return {"emails": [], "total": 0, "error": "Mail operation failed"}
|
return {"emails": [], "total": 0, "error": "Mail operation failed"}
|
||||||
|
|
||||||
def _read_email_sync(uid, folder, account_id, owner, mark_seen=False, full=False):
|
def _read_email_sync(uid, folder, account_id, owner, mark_seen=True, full=False):
|
||||||
"""Sync IMAP read — wrapped in to_thread by the async handler.
|
"""Sync IMAP read — wrapped in to_thread by the async handler.
|
||||||
|
|
||||||
The normal reader path fetches the headers plus a bounded body prefix.
|
The normal reader path fetches the headers plus a bounded body prefix.
|
||||||
That avoids downloading multi-megabyte attachments just to open a
|
That avoids downloading multi-megabyte attachments just to open a
|
||||||
message. Full-message fetch remains available for flows that need
|
message. Full-message fetch remains available for flows that need
|
||||||
attachment metadata immediately, such as forwarding.
|
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
|
import time as _t
|
||||||
_t0 = _t.monotonic()
|
_t0 = _t.monotonic()
|
||||||
@@ -2884,28 +2874,9 @@ def setup_email_routes():
|
|||||||
preview_bytes = 384 * 1024
|
preview_bytes = 384 * 1024
|
||||||
_t_select = 0.0
|
_t_select = 0.0
|
||||||
_t_fetch = 0.0
|
_t_fetch = 0.0
|
||||||
mark_seen_failed = False
|
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
# A foreground open owns both the body fetch and the \Seen
|
conn.select(_q(folder), readonly=True)
|
||||||
# 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
|
_t_select = _t.monotonic() - _t0
|
||||||
fetch_query = "(BODY.PEEK[])" if full else f"(BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0.{preview_bytes}>)"
|
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)
|
status, msg_data = _imap_uid_fetch(conn, uid, fetch_query)
|
||||||
@@ -2931,44 +2902,22 @@ def setup_email_routes():
|
|||||||
header_part = msg_data[0][1] or b""
|
header_part = msg_data[0][1] or b""
|
||||||
raw = header_part + b"\r\n" + text_part
|
raw = header_part + b"\r\n" + text_part
|
||||||
|
|
||||||
# Parse the fetched payload before mutating provider state. If
|
msg = email_mod.message_from_bytes(raw)
|
||||||
# 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)"))
|
subject = _decode_header(msg.get("Subject", "(no subject)"))
|
||||||
sender = _decode_header(msg.get("From", "unknown"))
|
sender = _decode_header(msg.get("From", "unknown"))
|
||||||
to = _decode_header(msg.get("To", ""))
|
to = _decode_header(msg.get("To", ""))
|
||||||
cc = _decode_header(msg.get("Cc", ""))
|
cc = _decode_header(msg.get("Cc", ""))
|
||||||
date_str = msg.get("Date", "")
|
date_str = msg.get("Date", "")
|
||||||
message_id = msg.get("Message-ID", "")
|
message_id = msg.get("Message-ID", "")
|
||||||
in_reply_to = msg.get("In-Reply-To", "")
|
in_reply_to = msg.get("In-Reply-To", "")
|
||||||
references = msg.get("References", "")
|
references = msg.get("References", "")
|
||||||
body = _extract_text(msg)
|
body = _extract_text(msg)
|
||||||
body_html = _extract_html(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 = []
|
related_attachments = []
|
||||||
if full and not _has_visible_attachments(msg):
|
if full and not _has_visible_attachments(msg):
|
||||||
related_attachments = _related_thread_attachments_sync(
|
related_attachments = _related_thread_attachments_sync(
|
||||||
@@ -3089,29 +3038,20 @@ def setup_email_routes():
|
|||||||
"boundaries": cached_boundaries,
|
"boundaries": cached_boundaries,
|
||||||
"thread_turns": cached_turns,
|
"thread_turns": cached_turns,
|
||||||
"sender_signature": cached_sender_sig,
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to read email {uid}: {e}")
|
logger.error(f"Failed to read email {uid}: {e}")
|
||||||
return {"error": "Mail operation failed"}
|
return {"error": "Mail operation failed"}
|
||||||
|
|
||||||
def _mark_email_seen_sync(uid, folder, account_id, owner):
|
def _mark_email_seen_sync(uid, folder, account_id, owner):
|
||||||
"""Synchronously mark a cached email seen and report success."""
|
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
conn.select(_q(folder), readonly=False)
|
conn.select(_q(folder))
|
||||||
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
|
conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Seen")
|
||||||
if status != "OK":
|
|
||||||
return False
|
|
||||||
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
|
||||||
_update_list_cache_seen(account_id, folder, uid, True)
|
_update_list_cache_seen(account_id, folder, uid, True)
|
||||||
return True
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
|
logger.debug(f"mark-seen after cached read failed uid={uid}: {e}")
|
||||||
return False
|
|
||||||
|
|
||||||
@router.get("/read/{uid}")
|
@router.get("/read/{uid}")
|
||||||
async def read_email_by_uid(
|
async def read_email_by_uid(
|
||||||
@@ -3137,32 +3077,32 @@ def setup_email_routes():
|
|||||||
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
|
if cached.get("attachment_version") != EMAIL_READ_ATTACHMENT_VERSION:
|
||||||
cached = None
|
cached = None
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
# A cache hit already holds a complete, valid message. Await the
|
if mark_seen:
|
||||||
# STORE so the response reports the real flag state, but never let
|
try:
|
||||||
# a failed STORE withhold a body we are holding in memory.
|
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
||||||
if mark_seen and not await _asyncio.to_thread(
|
except RuntimeError:
|
||||||
_mark_email_seen_sync, uid, folder, account_id, owner
|
pass
|
||||||
):
|
|
||||||
return {**cached, "mark_seen_failed": True}
|
|
||||||
return cached
|
return cached
|
||||||
if not full:
|
if not full:
|
||||||
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
|
persisted = _email_preview_cache_get(owner, account_id, folder, uid)
|
||||||
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
|
if persisted and persisted.get("attachment_version") == EMAIL_READ_ATTACHMENT_VERSION:
|
||||||
_read_cache_put(ck, persisted)
|
_read_cache_put(ck, persisted)
|
||||||
if mark_seen and not await _asyncio.to_thread(
|
if mark_seen:
|
||||||
_mark_email_seen_sync, uid, folder, account_id, owner
|
try:
|
||||||
):
|
_asyncio.create_task(_asyncio.to_thread(_mark_email_seen_sync, uid, folder, account_id, owner))
|
||||||
return {**persisted, "mark_seen_failed": True}
|
except RuntimeError:
|
||||||
|
pass
|
||||||
return persisted
|
return persisted
|
||||||
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
|
result = await _asyncio.to_thread(_read_email_sync, uid, folder, account_id, owner, mark_seen, full)
|
||||||
if result and not result.get("error"):
|
if result and not result.get("error"):
|
||||||
# `mark_seen_failed` describes this request, not the message, so it
|
_read_cache_put(ck, result)
|
||||||
# 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:
|
if not full:
|
||||||
_email_preview_cache_put(owner, account_id, folder, uid, cacheable)
|
_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
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
|
def _schedule_recent_email_warm(emails: list, folder: str, account_id: str | None, owner: str):
|
||||||
@@ -4826,6 +4766,8 @@ def setup_email_routes():
|
|||||||
"""Generate a quick AI summary of an email body."""
|
"""Generate a quick AI summary of an email body."""
|
||||||
try:
|
try:
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
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", "")
|
body = data.get("body", "")
|
||||||
subject = data.get("subject", "")
|
subject = data.get("subject", "")
|
||||||
@@ -4836,11 +4778,7 @@ def setup_email_routes():
|
|||||||
if account_id:
|
if account_id:
|
||||||
_assert_owns_account(account_id, owner)
|
_assert_owns_account(account_id, owner)
|
||||||
if not body:
|
if not body:
|
||||||
return {
|
return {"success": False, "error": "No body provided"}
|
||||||
"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
|
# If we know which UID this is, fetch the raw message and pull
|
||||||
# attachment text so the summary can reference invoice totals,
|
# attachment text so the summary can reference invoice totals,
|
||||||
@@ -4869,43 +4807,53 @@ def setup_email_routes():
|
|||||||
if not url:
|
if not url:
|
||||||
url, model, headers = resolve_endpoint("default", owner=owner)
|
url, model, headers = resolve_endpoint("default", owner=owner)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
return {
|
return {"success": False, "error": "No LLM endpoint configured"}
|
||||||
"success": False,
|
|
||||||
"error": "No model configured for email summaries",
|
|
||||||
"error_code": "email_summary_not_configured",
|
|
||||||
}
|
|
||||||
|
|
||||||
req_headers = {"Content-Type": "application/json"}
|
req_headers = {"Content-Type": "application/json"}
|
||||||
if headers:
|
if headers:
|
||||||
req_headers.update(headers)
|
req_headers.update(headers)
|
||||||
try:
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
content = await _generate_email_summary(
|
payload = {
|
||||||
url=url,
|
"model": model,
|
||||||
model=model,
|
"messages": [
|
||||||
sender=sender,
|
{"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."},
|
||||||
subject=subject,
|
{"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>>>."},
|
||||||
body_for_llm=body_for_llm,
|
],
|
||||||
headers=req_headers,
|
tok_key: 8192,
|
||||||
max_tokens=8192,
|
"temperature": 0.3,
|
||||||
timeout=180,
|
"stream": False,
|
||||||
)
|
}
|
||||||
except Exception as e:
|
# Reasoning models (o1/o3/o4/gpt-5) reject an explicit temperature.
|
||||||
logger.warning(
|
if _restricts_temperature(model):
|
||||||
"Email summary LLM call failed %s",
|
payload.pop("temperature", None)
|
||||||
_email_summary_failure_log_detail(e),
|
resp = await asyncio.to_thread(
|
||||||
)
|
_req.post, url, json=payload, headers=req_headers, timeout=180
|
||||||
return {
|
)
|
||||||
"success": False,
|
if not resp.ok:
|
||||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
return {"success": False, "error": f"LLM HTTP {resp.status_code}"}
|
||||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
rdata = resp.json()
|
||||||
}
|
msg = (rdata.get("choices") or [{}])[0].get("message", {})
|
||||||
|
content = (msg.get("content") or "").strip()
|
||||||
|
content = _extract_reply(content)
|
||||||
|
|
||||||
if not content:
|
if not content:
|
||||||
return {
|
# Model put everything in reasoning_content — extract bullet points
|
||||||
"success": False,
|
rc = (msg.get("reasoning_content") or "").strip()
|
||||||
"error": "The model returned an empty summary",
|
# Find bullet-point style output (lines starting with -, •, *, or numbered)
|
||||||
"error_code": "email_summary_empty",
|
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"}
|
||||||
|
|
||||||
# Cache the summary if we have a message_id
|
# Cache the summary if we have a message_id
|
||||||
mid = data.get("message_id", "")
|
mid = data.get("message_id", "")
|
||||||
@@ -4928,15 +4876,8 @@ def setup_email_routes():
|
|||||||
|
|
||||||
return {"success": True, "summary": content, "model_used": model}
|
return {"success": True, "summary": content, "model_used": model}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"Failed to summarize: {e}")
|
||||||
"Email summary route failed %s",
|
return {"success": False, "error": "Mail operation failed"}
|
||||||
_email_summary_failure_log_detail(e),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": EMAIL_SUMMARY_ERROR_MESSAGE,
|
|
||||||
"error_code": EMAIL_SUMMARY_ERROR_CODE,
|
|
||||||
}
|
|
||||||
|
|
||||||
@router.post("/translate")
|
@router.post("/translate")
|
||||||
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
async def translate_email(data: dict, owner: str = Depends(require_owner)):
|
||||||
@@ -5954,7 +5895,7 @@ def setup_email_routes():
|
|||||||
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
raise HTTPException(400, "GOOGLE_OAUTH_CLIENT_ID not set — add it to .env")
|
||||||
redirect_uri = (
|
redirect_uri = (
|
||||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||||
)
|
)
|
||||||
state = make_oauth_state(account_id, owner)
|
state = make_oauth_state(account_id, owner)
|
||||||
params = urllib.parse.urlencode({
|
params = urllib.parse.urlencode({
|
||||||
@@ -5991,7 +5932,7 @@ def setup_email_routes():
|
|||||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||||
redirect_uri = (
|
redirect_uri = (
|
||||||
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
os.environ.get("GOOGLE_OAUTH_REDIRECT_URI")
|
||||||
or f"{request.url.scheme}://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
or f"http://{request.headers.get('host', 'localhost:7000')}/api/email/oauth/google/callback"
|
||||||
)
|
)
|
||||||
import httpx as _httpx
|
import httpx as _httpx
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -127,25 +127,6 @@ def _load_grounding_backend():
|
|||||||
return cached
|
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):
|
def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
||||||
query = (text or "").strip()
|
query = (text or "").strip()
|
||||||
if not query:
|
if not query:
|
||||||
@@ -161,7 +142,10 @@ def _ground_text_to_box(image, text: str, *, threshold: float = 0.05):
|
|||||||
labels.append(f"a photo of {query}")
|
labels.append(f"a photo of {query}")
|
||||||
try:
|
try:
|
||||||
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
inputs = processor(text=[labels], images=image, return_tensors="pt")
|
||||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
model_inputs = {
|
||||||
|
k: (v.to(device) if hasattr(v, "to") else v)
|
||||||
|
for k, v in inputs.items()
|
||||||
|
}
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
outputs = model(**model_inputs)
|
outputs = model(**model_inputs)
|
||||||
target_sizes = torch.tensor([[image.height, image.width]])
|
target_sizes = torch.tensor([[image.height, image.width]])
|
||||||
@@ -1885,7 +1869,10 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
inputs = processor(image, **kwargs)
|
inputs = processor(image, **kwargs)
|
||||||
model_inputs = _model_inputs_to_device(inputs, device, torch)
|
model_inputs = {
|
||||||
|
k: (v.to(device) if hasattr(v, "to") else v)
|
||||||
|
for k, v in inputs.items()
|
||||||
|
}
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
outputs = model(**model_inputs)
|
outputs = model(**model_inputs)
|
||||||
masks = processor.image_processor.post_process_masks(
|
masks = processor.image_processor.post_process_masks(
|
||||||
|
|||||||
@@ -137,6 +137,44 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
entry["metadata"] = meta
|
entry["metadata"] = meta
|
||||||
return entry
|
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}")
|
@router.get("/api/history/{session_id}")
|
||||||
async def get_session_history(
|
async def get_session_history(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -160,8 +198,6 @@ 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 = int(offset) if offset is not None else max(total - page_limit, 0)
|
||||||
page_offset = max(0, min(page_offset, total))
|
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 = (
|
rows = (
|
||||||
db.query(DbChatMessage)
|
db.query(DbChatMessage)
|
||||||
.filter(DbChatMessage.session_id == session_id)
|
.filter(DbChatMessage.session_id == session_id)
|
||||||
@@ -170,6 +206,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
.limit(page_limit)
|
.limit(page_limit)
|
||||||
.all()
|
.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 = [
|
history_dict = [
|
||||||
entry for entry in (_db_history_entry(m) for m in rows)
|
entry for entry in (_db_history_entry(m) for m in rows)
|
||||||
if not (entry.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
@@ -214,10 +258,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
entry["metadata"] = msg["metadata"]
|
entry["metadata"] = msg["metadata"]
|
||||||
history_dict.append(entry)
|
history_dict.append(entry)
|
||||||
|
|
||||||
# Fallback: load from DB if in-memory renders empty. Display only —
|
# Fallback: load from DB if in-memory is empty
|
||||||
# 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:
|
if not history_dict:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -227,10 +268,17 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
.order_by(DbChatMessage.timestamp)
|
.order_by(DbChatMessage.timestamp)
|
||||||
.all()
|
.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.
|
# Response excludes hidden messages, matching the in-memory path.
|
||||||
history_dict = [
|
history_dict = [
|
||||||
entry for entry in (_db_history_entry(m) for m in db_messages)
|
m for m in db_history
|
||||||
if not (entry.get("metadata") or {}).get("hidden")
|
if not (m.get("metadata") or {}).get("hidden")
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"DB fallback failed for {session_id}: {e}")
|
logger.error(f"DB fallback failed for {session_id}: {e}")
|
||||||
@@ -597,14 +645,8 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
body = await request.json()
|
body = await request.json()
|
||||||
keep_count = body.get("keep_count", 0)
|
keep_count = body.get("keep_count", 0)
|
||||||
|
|
||||||
# Get the source session. keep_count indexes into source.history,
|
# Get the source session
|
||||||
# so this must go through get_session — reading the cache directly
|
source = session_manager.sessions.get(session_id)
|
||||||
# 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:
|
if not source:
|
||||||
raise HTTPException(404, "Session not found")
|
raise HTTPException(404, "Session not found")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,697 +0,0 @@
|
|||||||
# 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>"""
|
|
||||||
+693
-14
@@ -1,18 +1,697 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/mcp/mcp_routes.py.
|
# 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
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
from core.database import McpServer, SessionLocal
|
||||||
that ``import routes.mcp_routes``, ``from routes.mcp_routes import X``,
|
from core.middleware import require_admin
|
||||||
``importlib.import_module("routes.mcp_routes")``, the
|
from src.constants import DATA_DIR, MCP_OAUTH_DIR
|
||||||
``sys.modules.pop("routes.mcp_routes")`` + re-import pattern in
|
from src.mcp_manager import McpManager
|
||||||
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).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys as _sys
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from routes.mcp import mcp_routes as _canonical # noqa: F401
|
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
|
||||||
|
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>"""
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def _strip_list_prefix(text: str) -> str:
|
|||||||
return text
|
return text
|
||||||
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
return _LIST_PREFIX_RE.sub("", text, count=1).strip()
|
||||||
|
|
||||||
from services.memory import MemoryManager, MemoryStoreUnreadable
|
from services.memory import MemoryManager
|
||||||
from core.session_manager import SessionManager
|
from core.session_manager import SessionManager
|
||||||
from src.request_models import MemoryAddRequest
|
from src.request_models import MemoryAddRequest
|
||||||
from core.database import SessionLocal
|
from core.database import SessionLocal
|
||||||
@@ -35,22 +35,6 @@ from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||||
"""Set up memory-related routes."""
|
"""Set up memory-related routes."""
|
||||||
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
router = APIRouter(prefix="/api/memory", tags=["memory"])
|
||||||
@@ -132,7 +116,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)
|
new_entry = memory_manager.add_entry(text, memory_data.source, memory_data.category, owner=user)
|
||||||
if memory_data.session_id:
|
if memory_data.session_id:
|
||||||
new_entry["session_id"] = memory_data.session_id
|
new_entry["session_id"] = memory_data.session_id
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
all_mem.append(new_entry)
|
all_mem.append(new_entry)
|
||||||
memory_manager.save(all_mem)
|
memory_manager.save(all_mem)
|
||||||
# Sync vector index
|
# Sync vector index
|
||||||
@@ -503,7 +487,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
def pin_memory(request: Request, memory_id: str, pinned: bool = Form(True)):
|
||||||
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
"""Pin or unpin a memory. Pinned memories are always included in context."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -528,7 +512,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)):
|
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."""
|
"""Update an existing memory item with new text and optional category."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
for i, memory in enumerate(all_mem):
|
for i, memory in enumerate(all_mem):
|
||||||
if memory["id"] == memory_id:
|
if memory["id"] == memory_id:
|
||||||
_verify_memory_owner(memory, user)
|
_verify_memory_owner(memory, user)
|
||||||
@@ -550,7 +534,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
def delete_memory(request: Request, memory_id: str):
|
def delete_memory(request: Request, memory_id: str):
|
||||||
"""Delete a memory item by its ID."""
|
"""Delete a memory item by its ID."""
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
all_mem = _load_for_update(memory_manager)
|
all_mem = memory_manager.load_all()
|
||||||
|
|
||||||
# Find and verify ownership before deleting
|
# Find and verify ownership before deleting
|
||||||
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
target = next((m for m in all_mem if m["id"] == memory_id), None)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""User preferences API — per-user key/value store backed by a JSON file."""
|
"""User preferences API — per-user key/value store backed by a JSON file."""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from core.atomic_io import atomic_write_json
|
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import USER_PREFS_FILE
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
@@ -20,7 +20,13 @@ def _load():
|
|||||||
|
|
||||||
|
|
||||||
def _save(prefs):
|
def _save(prefs):
|
||||||
atomic_write_json(PREFS_FILE, prefs, indent=2)
|
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)
|
||||||
|
|
||||||
|
|
||||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
"""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
|
|
||||||
+107
-9
@@ -1,13 +1,111 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/search/search_routes.py.
|
"""Search routes — /api/search/config GET, /api/search POST."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
import logging
|
||||||
that ``import routes.search_routes`` and ``from routes.search_routes import X``
|
from typing import Dict, Any
|
||||||
keep resolving to the canonical module. Keeps existing import paths working
|
|
||||||
after slice 2j (#4082/#4071).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys as _sys
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
from routes.search import search_routes as _canonical # noqa: F401
|
import time
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
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
|
||||||
|
|||||||
@@ -801,6 +801,15 @@ def setup_session_routes(
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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")
|
@router.get("/session/{sid}/export")
|
||||||
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
def export_session(request: Request, sid: str, fmt: str = "md", filename: str = ""):
|
||||||
"""Export conversation history as a downloadable file.
|
"""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
|
# Prefer the configured DEFAULT (→ Utility) model — not the current chat
|
||||||
# session's model. Fall back to the caller's session model only if unset.
|
# session's model. Fall back to the caller's session model only if unset.
|
||||||
url, model, headers = resolve_endpoint("utility", owner=user)
|
url, model, headers = resolve_endpoint("default", owner=user)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||||
model = model or ((body.get("model") or "").strip() or None)
|
model = model or ((body.get("model") or "").strip() or None)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
+237
-9
@@ -1,14 +1,242 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/vault/vault_routes.py.
|
"""
|
||||||
|
vault_routes.py
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
||||||
that ``import routes.vault_routes``, ``from routes.vault_routes import X``,
|
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
||||||
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 sys as _sys
|
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 routes.vault import vault_routes as _canonical # noqa: F401
|
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
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
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
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
"""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.
|
|
||||||
"""
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
"""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
|
|
||||||
+391
-12
@@ -1,16 +1,395 @@
|
|||||||
"""Backward-compat shim — canonical location is routes/webhook/webhook_routes.py.
|
"""Webhook, API Token, and sync chat routes."""
|
||||||
|
|
||||||
This module is replaced in ``sys.modules`` by the canonical module object so
|
import uuid
|
||||||
that ``import routes.webhook_routes``, ``from routes.webhook_routes import X``,
|
import logging
|
||||||
``importlib.import_module("routes.webhook_routes")``, and the
|
from typing import Optional
|
||||||
``__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 sys as _sys
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Form
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from routes.webhook import webhook_routes as _canonical # noqa: F401
|
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
|
||||||
|
|
||||||
_sys.modules[__name__] = _canonical
|
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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""Memory service — persistent memory storage and retrieval."""
|
"""Memory service — persistent memory storage and retrieval."""
|
||||||
|
|
||||||
from .service import MemoryService, Memory, MemorySearchResult
|
from .service import MemoryService, Memory, MemorySearchResult
|
||||||
from .memory import MemoryManager, MemoryStoreUnreadable
|
from .memory import MemoryManager
|
||||||
from .memory_vector import MemoryVectorStore
|
from .memory_vector import MemoryVectorStore
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -10,6 +10,5 @@ __all__ = [
|
|||||||
"Memory",
|
"Memory",
|
||||||
"MemorySearchResult",
|
"MemorySearchResult",
|
||||||
"MemoryManager",
|
"MemoryManager",
|
||||||
"MemoryStoreUnreadable",
|
|
||||||
"MemoryVectorStore",
|
"MemoryVectorStore",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,16 +5,6 @@ application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
|||||||
parallel implementation here risks silent drift between import paths.
|
parallel implementation here risks silent drift between import paths.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from src.memory import (
|
from src.memory import MemoryManager, get_text_similarity, tokenize
|
||||||
MemoryManager,
|
|
||||||
MemoryStoreUnreadable,
|
|
||||||
get_text_similarity,
|
|
||||||
tokenize,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
||||||
"MemoryManager",
|
|
||||||
"MemoryStoreUnreadable",
|
|
||||||
"get_text_similarity",
|
|
||||||
"tokenize",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import os
|
|||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -389,13 +387,7 @@ async def extract_and_store(
|
|||||||
# Get owner from session
|
# Get owner from session
|
||||||
_owner = getattr(session, 'owner', None)
|
_owner = getattr(session, 'owner', None)
|
||||||
|
|
||||||
# Strict load: this is a read-modify-write. Degrading to [] here would
|
existing = memory_manager.load_all()
|
||||||
# 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
|
added = 0
|
||||||
|
|
||||||
for fact in facts:
|
for fact in facts:
|
||||||
@@ -634,18 +626,7 @@ async def audit_memories(
|
|||||||
|
|
||||||
# Merge audited entries back with other users' entries
|
# Merge audited entries back with other users' entries
|
||||||
if owner:
|
if owner:
|
||||||
# Strict load: the merge below reconstructs the whole file. If this
|
all_entries = memory_manager.load_all()
|
||||||
# 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}
|
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)]
|
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
|
# Also keep legacy entries that weren't part of this audit
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -100,18 +100,6 @@ def _parse_scalar(raw: str) -> Any:
|
|||||||
if raw.lower() in ("null", "none", "~"):
|
if raw.lower() in ("null", "none", "~"):
|
||||||
return None
|
return None
|
||||||
if (raw[0] == raw[-1]) and raw[0] in ("'", '"'):
|
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]
|
return raw[1:-1]
|
||||||
# Try number
|
# Try number
|
||||||
try:
|
try:
|
||||||
@@ -183,26 +171,6 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|||||||
return fm, body
|
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:
|
def _emit_scalar(v: Any) -> str:
|
||||||
if v is None:
|
if v is None:
|
||||||
return "null"
|
return "null"
|
||||||
@@ -213,15 +181,8 @@ def _emit_scalar(v: Any) -> str:
|
|||||||
if isinstance(v, list):
|
if isinstance(v, list):
|
||||||
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
return "[" + ", ".join(_emit_scalar(x) for x in v) + "]"
|
||||||
s = str(v)
|
s = str(v)
|
||||||
if any(c in s for c in _FM_MUST_QUOTE):
|
if any(c in s for c in (":", "#", "\n", "[", "]", "{", "}", ",", "&", "*", "!", "|", ">", "'", '"', "%", "@")):
|
||||||
# ensure_ascii=False keeps non-ASCII text as itself. SKILL.md is UTF-8 at
|
return json.dumps(s)
|
||||||
# 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
|
return s
|
||||||
|
|
||||||
|
|
||||||
@@ -480,4 +441,4 @@ class Skill:
|
|||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
"""Multi-provider TTS service — dispatches to local Kokoro, OpenAI-compatible API, or browser."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import wave
|
import wave
|
||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -42,11 +41,6 @@ class TTSService:
|
|||||||
self.cache_dir = Path(cache_dir)
|
self.cache_dir = Path(cache_dir)
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self._kokoro = None # lazy-init
|
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 ──
|
# ── Settings ──
|
||||||
|
|
||||||
@@ -95,53 +89,6 @@ class TTSService:
|
|||||||
ext = ".mp3" if (len(data) >= 3 and (data[:3] == b'ID3' or (data[0] == 0xff and (data[1] & 0xe0) == 0xe0))) else ".wav"
|
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.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):
|
def clear_cache(self):
|
||||||
count = 0
|
count = 0
|
||||||
for f in self.cache_dir.glob("*.*"):
|
for f in self.cache_dir.glob("*.*"):
|
||||||
|
|||||||
@@ -1,412 +1,31 @@
|
|||||||
# Architecture Runtime Inventory
|
# Architecture runtime inventory disposition
|
||||||
|
|
||||||
> **Purpose**: Phase 0 planning baseline for codebase readability improvements (#4071).
|
> [!NOTE]
|
||||||
> **Parent issue**: [#4082](https://github.com/odysseus-dev/odysseus/issues/4082)
|
> The dated architecture runtime inventory has been classified and transferred
|
||||||
> **Last updated**: dev@b58af42 | 2026-06-16
|
> to [`discovery/architecture-runtime-inventory.md`](../discovery/architecture-runtime-inventory.md)
|
||||||
> **Status**: Draft — to be reviewed before follow-up slices open.
|
> after verification of its stable facts.
|
||||||
> **Snapshot basis**: Importer / file / import-line counts are refreshed to `dev@b58af42` (2026-06-16) and are recomputable via the commands in §3.4. **Line counts** in §2.1 / §2.2 are a snapshot from an earlier baseline and drift as `dev` moves — recompute any of them with `wc -l <file>`. This inventory tracks structure and risk, not live metrics.
|
>
|
||||||
|
> Stable runtime structure and subsystem boundaries were transferred to the
|
||||||
This document maps the current runtime module structure, identifies high-risk boundaries, and recommends safe first refactor slices. It does **not** move files, change imports, or alter runtime behavior.
|
> proposed canonical destination,
|
||||||
|
> [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md), for maintainer review.
|
||||||
---
|
|
||||||
|
## Classification
|
||||||
## 1. Current Structure Overview
|
|
||||||
|
| Content | Disposition |
|
||||||
### 1.1 Top-Level Layout
|
|---|---|
|
||||||
|
| Stable runtime structure | Proposed canonical architecture document |
|
||||||
```
|
| Stable subsystem boundaries | Proposed canonical architecture document |
|
||||||
odysseus/
|
| Frontend module organization | `static/js/MODULE_SUMMARY.md` |
|
||||||
├── app.py # FastAPI app entrypoint (1,145 lines)
|
| Counts, rankings, and generated metrics | Non-canonical discovery inventory |
|
||||||
├── conf/ # Configuration (config.py, settings.py, settings_scrub.py)
|
| Investigation context and unresolved questions | Non-canonical discovery material |
|
||||||
├── src/ # 95 flat .py files + 2 subdirectories
|
| Refactor options and prioritization | Issues, Plane, or discovery material |
|
||||||
│ ├── agent_tools/ # Tool helpers: document, filesystem, subprocess, web
|
|
||||||
│ └── search/ # Search subsystem
|
No dated metrics, prioritization, or investigation findings remain in this
|
||||||
├── routes/ # 54 flat .py files — HTTP route handlers
|
specification path.
|
||||||
├── core/ # 10 files — database models, auth, middleware, session
|
|
||||||
├── mcp_servers/ # 5 files — MCP server implementations
|
## Review status
|
||||||
├── scripts/ # CLI tools and one-shot scripts
|
|
||||||
├── static/ # Frontend HTML/CSS/JS
|
- Findings and scope have been validated.
|
||||||
├── tests/ # 583 test files (~54,800 lines)
|
- Transfer verification has completed.
|
||||||
└── services/ # (exists as needed)
|
- The final canonical status of `docs/ARCHITECTURE.md` requires maintainer review.
|
||||||
```
|
- No implementation behaviour, test taxonomy, or runtime files were changed.
|
||||||
|
|
||||||
### 1.2 Directory Flatness Metric
|
|
||||||
|
|
||||||
| Directory | Flat `.py` Files | Subdirectories | Concern |
|
|
||||||
|-----------|-----------------|----------------|---------|
|
|
||||||
| `src/` | **95** | 2 (`agent_tools/`, `search/`) | No domain grouping; 95 files in one directory |
|
|
||||||
| `routes/` | **54** | 0 | All route handlers in one flat directory |
|
|
||||||
| `core/` | 10 | 0 | Manageable, but `database.py` is oversized |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Largest Runtime Modules
|
|
||||||
|
|
||||||
### 2.1 Python Backend
|
|
||||||
|
|
||||||
| Rank | File | Lines | Classes | Functions | Risk |
|
|
||||||
|------|------|-------|---------|-----------|------|
|
|
||||||
| 1 | `src/tool_implementations.py` | **4,032** | 0 | ~48 | **HIGH** |
|
|
||||||
| 2 | `routes/email_routes.py` | **3,245** | — | — | **MEDIUM** |
|
|
||||||
| 3 | `routes/cookbook_routes.py` | **2,969** | — | — | **MEDIUM** |
|
|
||||||
| 4 | `src/agent_loop.py` | **2,961** | 0 | ~24 | **HIGH** |
|
|
||||||
| 5 | `src/task_scheduler.py` | **2,330** | — | 5 | MEDIUM |
|
|
||||||
| 6 | `routes/model_routes.py` | **2,266** | — | — | MEDIUM |
|
|
||||||
| 7 | `core/database.py` | **2,265** | 28 | ~59 helpers | **HIGH** |
|
|
||||||
| 8 | `src/builtin_actions.py` | **2,262** | 2 | ~24 | MEDIUM |
|
|
||||||
| 9 | `src/llm_core.py` | **2,164** | — | — | MEDIUM |
|
|
||||||
| 10 | `mcp_servers/email_server.py` | 2,197 | — | — | LOW (separate process) |
|
|
||||||
| 11 | `src/visual_report.py` | 1,918 | — | — | LOW |
|
|
||||||
| 12 | `routes/gallery_routes.py` | 1,896 | — | — | LOW |
|
|
||||||
| 13 | `src/ai_interaction.py` | 1,846 | — | — | MEDIUM |
|
|
||||||
| 14 | `routes/document_routes.py` | 1,717 | — | — | LOW |
|
|
||||||
| 15 | `routes/skills_routes.py` | 1,648 | — | — | LOW |
|
|
||||||
|
|
||||||
**Heuristic**: Files > 2,000 lines with 20+ public symbols and many importers are the highest-risk splits. Files 1,000–2,000 lines are medium-risk if tightly coupled.
|
|
||||||
|
|
||||||
### 2.2 Frontend
|
|
||||||
|
|
||||||
| File | Lines | Concern |
|
|
||||||
|------|-------|---------|
|
|
||||||
| `static/style.css` | **36,653** | Entire app CSS in one file (tracked separately in #2617) |
|
|
||||||
| `static/js/document.js` | **9,776** | Single JS file for document functionality |
|
|
||||||
| `static/js/slashCommands.js` | 6,498 | |
|
|
||||||
| `static/js/settings.js` | 5,266 | |
|
|
||||||
| `static/js/emailLibrary.js` | 5,217 | |
|
|
||||||
| `static/js/notes.js` | 5,124 | |
|
|
||||||
| `static/js/chat.js` | 4,985 | |
|
|
||||||
| `static/app.js` | 4,090 | |
|
|
||||||
|
|
||||||
**Note**: Frontend modularization is tracked separately in #2617 (CSS) and is not the focus of this Phase 0 inventory. Frontend is listed here for completeness but follow-up slices should target Python backend boundaries first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Import Dependency Graph
|
|
||||||
|
|
||||||
### 3.1 Who Depends on `core/database.py`
|
|
||||||
|
|
||||||
**102 files** import from `core.database` — this is the most depended-upon module:
|
|
||||||
|
|
||||||
- All route handlers (`routes/*.py`)
|
|
||||||
- Most `src/*.py` files
|
|
||||||
- `core/session_manager.py`, `core/auth.py`
|
|
||||||
- Multiple test files
|
|
||||||
|
|
||||||
**Implication**: Any split of `core/database.py` is the highest-risk refactor. It should be tackled **last**, never first.
|
|
||||||
|
|
||||||
### 3.2 Who Depends on `src/tool_implementations.py`
|
|
||||||
|
|
||||||
**17 files** import from `src.tool_implementations`:
|
|
||||||
- `src/agent_loop.py`, `src/builtin_actions.py`, `src/tool_index.py`
|
|
||||||
- `src/task_scheduler.py`, `src/tool_policy.py`
|
|
||||||
- Various tests
|
|
||||||
|
|
||||||
### 3.3 Who Depends on `src/agent_loop.py`
|
|
||||||
|
|
||||||
**22 files** import from `src.agent_loop`:
|
|
||||||
|
|
||||||
- `src/tool_policy.py`, `src/teacher_escalation.py`, `src/bg_monitor.py`
|
|
||||||
- `src/task_scheduler.py`
|
|
||||||
- Multiple test files
|
|
||||||
|
|
||||||
### 3.4 Cross-Layer Import Violations
|
|
||||||
|
|
||||||
**`src/` importing from `routes/`** (backwards dependency — domain logic depending on HTTP layer):
|
|
||||||
|
|
||||||
```
|
|
||||||
src/tool_implementations.py ──→ routes/calendar_routes.py
|
|
||||||
src/tool_implementations.py ──→ routes/cookbook_helpers.py
|
|
||||||
src/tool_implementations.py ──→ routes/email_helpers.py
|
|
||||||
src/tool_implementations.py ──→ routes/email_pollers.py
|
|
||||||
src/tool_implementations.py ──→ routes/email_routes.py
|
|
||||||
src/tool_implementations.py ──→ routes/model_routes.py
|
|
||||||
src/tool_implementations.py ──→ routes/note_routes.py
|
|
||||||
src/tool_implementations.py ──→ routes/prefs_routes.py
|
|
||||||
```
|
|
||||||
|
|
||||||
> These are **runtime imports** (inside function bodies, not at module top), which mitigates circular import risk but indicates fuzzy layer boundaries. Function-level inline imports from the HTTP layer into business logic are a code smell.
|
|
||||||
|
|
||||||
**Import counts (top-level)**:
|
|
||||||
| Direction | Count | Notes |
|
|
||||||
|-----------|-------|-------|
|
|
||||||
| `routes/` → `src/` | **374** | Expected: HTTP handlers call domain logic |
|
|
||||||
| `routes/` → `core/` | **126** | Expected: handlers access DB models |
|
|
||||||
| `src/` → `routes/` | **31** | **Unexpected**: domain logic reaching into HTTP layer (direct grep of import lines referencing `routes/`) |
|
|
||||||
| `src/` → `core/` | **106** | Acceptable but could be reduced with a data-access layer |
|
|
||||||
|
|
||||||
> **How the metrics in this document are computed** — recompute against current `dev` before treating any count as authoritative (the tree drifts; these numbers are a snapshot, not a live value):
|
|
||||||
> - `src/` flat `.py` files: `find src -maxdepth 1 -name '*.py' | wc -l`
|
|
||||||
> - `tests/` test files: `find tests -name 'test_*.py' | wc -l`
|
|
||||||
> - `core.database` importers: `grep -rlE '(from|import) +core\.database' --include='*.py' . | grep -v core/database.py | wc -l`
|
|
||||||
> - `src.agent_loop` importers: `grep -rlE '(from|import) +src\.agent_loop' --include='*.py' . | grep -v src/agent_loop.py | wc -l`
|
|
||||||
> - Cross-layer import lines: `grep -rhE '(from|import) +<pkg>' --include='*.py' <dir>/ | wc -l` (e.g. `(from|import) +routes` over `src/`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Route Ownership Map
|
|
||||||
|
|
||||||
Routes can be grouped into logical feature domains. Current flat structure obscures these boundaries:
|
|
||||||
|
|
||||||
| Domain | Route Files | Total Lines | Review Complexity |
|
|
||||||
|--------|-------------|-------------|-------------------|
|
|
||||||
| **Email** | `email_routes.py`, `email_helpers.py`, `email_pollers.py` | 5,936 | HIGH — most complex domain |
|
|
||||||
| **Chat / Agent** | `chat_routes.py`, `chat_helpers.py`, `shell_routes.py`, `codex_routes.py`, `skills_routes.py` | 6,365 | HIGH — core interaction surface |
|
|
||||||
| **Cookbook** | `cookbook_routes.py`, `cookbook_helpers.py`, `cookbook_output.py` | 4,110 | MEDIUM |
|
|
||||||
| **Model / LLM** | `model_routes.py`, `assistant_routes.py`, `copilot_routes.py` | 2,764 | MEDIUM |
|
|
||||||
| **Calendar / Contacts** | `calendar_routes.py`, `contacts_routes.py` | 2,336 | MEDIUM |
|
|
||||||
| **Documents** | `document_routes.py`, `document_helpers.py` | 1,954 | LOW |
|
|
||||||
| **Auth** | `auth_routes.py`, `api_token_routes.py`, `device_flow.py` | 1,171 | LOW |
|
|
||||||
| **Tasks** | `task_routes.py` (standalone) | 1,157 | LOW |
|
|
||||||
| **Session** | `session_routes.py` (standalone) | 1,287 | LOW |
|
|
||||||
| **Gallery** | `gallery_routes.py`, `gallery_helpers.py` | 1,896 | LOW |
|
|
||||||
| **Memory** | `memory_routes.py` | — | LOW |
|
|
||||||
| **Research** | `research_routes.py` | — | LOW |
|
|
||||||
| **MCP** | `mcp_routes.py` | — | LOW |
|
|
||||||
| **Notes** | `note_routes.py` | — | LOW |
|
|
||||||
| **Other** | `prefs_routes.py`, `upload_routes.py`, `vault_routes.py`, `webhook_routes.py`, `workspace_routes.py`, `search_routes.py`, `history_routes.py`, `hwfit_routes.py`, `preset_routes.py`, `signature_routes.py`, `backup_routes.py`, `cleanup_routes.py`, `diagnostics_routes.py`, `embedding_routes.py`, `emoji_routes.py`, `font_routes.py`, `stt_routes.py`, `tts_routes.py`, `compare_routes.py`, `personal_routes.py`, `editor_draft_routes.py`, `admin_wipe_routes.py`, `chatgpt_subscription_routes.py` | 2,000+ | LOW individual, HIGH cumulative |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Tool Registry & Implementation Boundaries
|
|
||||||
|
|
||||||
### 5.1 Current Tool Architecture
|
|
||||||
|
|
||||||
| Component | File | Lines | Role |
|
|
||||||
|-----------|------|-------|------|
|
|
||||||
| Tool schemas | `src/tool_schemas.py` | 1,392 | JSON Schema tool definitions (Duck-TypedDict) |
|
|
||||||
| Tool index | `src/tool_index.py` | 542 | RAG-based tool retrieval from ChromaDB |
|
|
||||||
| Tool implementations | `src/tool_implementations.py` | 4,032 | 33 `do_*` functions — all tool execution logic |
|
|
||||||
| Tool security | `src/tool_security.py` | — | Owner-scoped tool blocking |
|
|
||||||
| Tool policy | `src/tool_policy.py` | — | Guide-only directive, plan-mode disabled tools |
|
|
||||||
| Tool utils | `src/tool_utils.py` | — | Shared tool helpers |
|
|
||||||
|
|
||||||
### 5.2 Tool Implementation Categories
|
|
||||||
|
|
||||||
The 33 `do_*` functions in `tool_implementations.py` fall into natural domain groups — the basis for slice 1's split in §6.2:
|
|
||||||
|
|
||||||
| Category | `do_*` functions | Count |
|
|
||||||
|----------|------------------|-------|
|
|
||||||
| **System / config** | `do_manage_skills`, `do_manage_tasks`, `do_manage_endpoints`, `do_manage_mcp`, `do_manage_webhooks`, `do_manage_tokens`, `do_manage_settings`, `do_api_call`, `do_app_api` | 9 |
|
|
||||||
| **Cookbook / model serving** | `do_download_model`, `do_serve_model`, `do_list_served_models`, `do_stop_served_model`, `do_tail_serve_output`, `do_list_downloads`, `do_cancel_download`, `do_search_hf_models`, `do_adopt_served_model`, `do_list_cookbook_servers`, `do_list_serve_presets`, `do_serve_preset`, `do_list_cached_models` | 13 |
|
|
||||||
| **Notes** | `do_manage_notes` | 1 |
|
|
||||||
| **Calendar** | `do_manage_calendar` | 1 |
|
|
||||||
| **Search** | `do_search_chats` | 1 |
|
|
||||||
| **Research** | `do_manage_research`, `do_trigger_research` | 2 |
|
|
||||||
| **Contacts** | `do_resolve_contact`, `do_manage_contact` | 2 |
|
|
||||||
| **Vault** | `do_vault_search`, `do_vault_get`, `do_vault_unlock` | 3 |
|
|
||||||
| **Image** | `do_edit_image` | 1 |
|
|
||||||
| | **Total** | **33** |
|
|
||||||
|
|
||||||
> Low-level tools (filesystem, subprocess, web fetch, document parsing) live in `src/agent_tools/`, **not** in `tool_implementations.py` — out of scope for this split.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Risk Assessment & Candidate Slice Ranking
|
|
||||||
|
|
||||||
> **Candidate proposals, not a committed plan.** The rankings, package shapes (e.g. `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/`), split ordering, and route-grouping strategy below are **options for maintainer discussion**. Per #4082/#4071, slice ownership and order are settled by maintainers before any follow-up PR. §1–§3 above are the factual current-state inventory.
|
|
||||||
|
|
||||||
### 6.1 Risk Scale
|
|
||||||
|
|
||||||
| Level | Criteria |
|
|
||||||
|-------|----------|
|
|
||||||
| **LOW** | File has ≤3 importers AND ≤500 lines, OR is a pure refactor with clear boundaries |
|
|
||||||
| **MEDIUM** | File has 4–15 importers OR 500–1,500 lines |
|
|
||||||
| **HIGH** | File has 16+ importers OR >2,000 lines, OR has cross-layer import violations |
|
|
||||||
|
|
||||||
### 6.2 Ranked Split Candidates
|
|
||||||
|
|
||||||
| Priority | Target | Risk | Rationale |
|
|
||||||
|----------|--------|------|-----------|
|
|
||||||
| **1** | `src/tool_implementations.py` → `src/tools/*.py` | **MEDIUM** | 4,032 lines → ~10 files by tool category. Already has natural boundaries. 17 importers, tracked in #3629. Use `__init__.py` shim to keep existing imports working. |
|
|
||||||
| **2** | `routes/` → domain subdirectories (one domain per PR) | **MEDIUM** | 54 flat files. Done **one domain at a time** (e.g. a standalone PR for the email domain, then chat, …), not a broad reorganization — route modules carry helper imports, registration assumptions, and test import paths. |
|
|
||||||
| **3** | `src/agent_loop.py` → `src/agent/loop.py` + submodules | **MEDIUM-HIGH** | 2,961 lines, 24 functions. Can extract prompt building, classification, verification, and runaway detection. Tracked in #3266. |
|
|
||||||
| **4** | `src/` → `src/pkg/`, `src/domain/`, `src/infra/`, `src/api/` | **MEDIUM** | Structural reorganization. Split flat `src/` into layered packages. Must come after routes and tools are stable. |
|
|
||||||
| **5** | `routes/email_*.py` consolidation | **LOW** | Already grouped by filename prefix. Low-risk cleanup within the email domain. |
|
|
||||||
| **6** | `core/database.py` → `src/infra/database/models/*.py` | **HIGH** | 28 classes, 102 importers. Highest-risk split. Must be **last** in any sequence. Requires careful import shim strategy. |
|
|
||||||
| **7** | Frontend CSS modularization | **MEDIUM** | 36,653 lines. Tracked in #2617. Separate timeline from backend work. |
|
|
||||||
| **8** | Frontend JS modularization | **MEDIUM** | 9,776 lines in `document.js`. Introduce ES modules at minimum. |
|
|
||||||
|
|
||||||
### 6.3 Candidate First 3 Behavior-Preserving Slices
|
|
||||||
|
|
||||||
**Slice 1: Split `tool_implementations.py`** (Lowest-risk high-impact)
|
|
||||||
|
|
||||||
- Create `src/tools/` package with one file per tool category
|
|
||||||
- Add `src/tools/__init__.py` re-exporting all symbols with current names
|
|
||||||
- Update 17 importers to use new paths (can be deferred via shim)
|
|
||||||
- Validation: `python -m pytest tests/ -x -q` + manual smoke test of tool execution
|
|
||||||
- Reference: #3629
|
|
||||||
|
|
||||||
**Slice 2: Group `routes/` by domain** (one domain per PR, not a broad sweep)
|
|
||||||
|
|
||||||
Route modules carry helper imports, router registration assumptions, and test import paths, so this must be done **one domain at a time** rather than as a single reorganization PR. Example sequence (each its own PR):
|
|
||||||
|
|
||||||
- PR 2a: move the **email** domain (`email_routes.py`, `email_helpers.py`, `email_pollers.py`) → `routes/email/` + shim
|
|
||||||
- PR 2b: move the **chat/agent** domain → `routes/chat/` + shim
|
|
||||||
- PR 2c: move the **cookbook** domain → `routes/cookbook/` + shim
|
|
||||||
- …and so on per domain from §4
|
|
||||||
|
|
||||||
Each PR: add `__init__.py` re-exporting old names, update `app.py` router imports, validation `python app.py` starts clean. **No behavior change** — pure file reorganization.
|
|
||||||
|
|
||||||
**Slice 3: Extract `agent_loop.py` submodules** (Improve reviewability)
|
|
||||||
|
|
||||||
- Move prompt assembly → `src/agent/prompt.py`
|
|
||||||
- Move request classification → `src/agent/classifier.py`
|
|
||||||
- Move sub-agent verification → `src/agent/verifier.py`
|
|
||||||
- Move runaway detection → `src/agent/runaway.py`
|
|
||||||
- Move context management → `src/agent/context.py`
|
|
||||||
- Keep `src/agent/loop.py` as the main orchestration module
|
|
||||||
- Validation: `python -m pytest tests/test_agent_loop.py tests/test_loop_breaker_runaway.py -v`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Safety Guardrails for Follow-Up Work
|
|
||||||
|
|
||||||
Per maintainer guidance in #4082 and #4071:
|
|
||||||
|
|
||||||
- [ ] **One domain/slice per PR** — never mix multiple reorganizations
|
|
||||||
- [ ] **No behavior changes** mixed with file moves — pure reorganization only
|
|
||||||
- [ ] **Keep compatibility shims** — `__init__.py` re-exports for all existing import paths
|
|
||||||
- [ ] **Add or identify focused tests** before risky splits
|
|
||||||
- [ ] **Do not start with `core/database.py`** or broad route movement unless this inventory shows a safe boundary
|
|
||||||
- [ ] **Prefer small, reviewable slices** over large restructures
|
|
||||||
- [ ] **No packaging/runtime/tooling migration** mixed into file moves
|
|
||||||
- [ ] **No frontend framework migration** inside this stabilization lane
|
|
||||||
- [ ] **Validate with `python -m compileall`** — every PR must pass CI checks
|
|
||||||
- [ ] **Validate with `pytest`** — run the full test suite before opening each PR
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Validation Commands
|
|
||||||
|
|
||||||
Each follow-up PR should be verifiable with these commands before submission:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Syntax check — must pass with zero errors
|
|
||||||
python -m compileall src/ routes/ core/ conf/
|
|
||||||
|
|
||||||
# Full test suite — must match baseline pass rate
|
|
||||||
python -m pytest tests/ -x -q
|
|
||||||
|
|
||||||
# Import shim verification — existing import paths must still work
|
|
||||||
python -c "from src.tool_implementations import do_search_chats; print('OK')"
|
|
||||||
|
|
||||||
# App startup smoke test (if backend touched)
|
|
||||||
timeout 5 python app.py 2>&1 | head -5 || true
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Open Questions
|
|
||||||
|
|
||||||
1. Is `#2538` (specs ground truth) the canonical behavior map baseline, and should this inventory be kept in sync with those specs once merged?
|
|
||||||
2. Should route grouping follow the domain map proposed here, or is there a different taxonomy preferred by maintainers?
|
|
||||||
3. For the `tool_implementations.py` split (#3629), is the tool categorization in §5.2 acceptable, or should it follow a different grouping?
|
|
||||||
4. Should compatibility shims (`__init__.py`) be temporary (removed in a follow-up wave) or permanent?
|
|
||||||
5. Should an ADR (Architecture Decision Record) document be started to track decisions made during this process?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Future Direction (NOT current state)
|
|
||||||
|
|
||||||
The following are **future refactor targets** (candidate directions **pending maintainer agreement**, not committed), recorded here so this inventory does not imply they exist today. None of them are present in the current `dev` tree:
|
|
||||||
|
|
||||||
- `main.py` — proposed rename of the `app.py` entrypoint. Today the app boots via `app.py`.
|
|
||||||
- `src/agent/` — proposed package to hold `agent_loop.py` submodules (prompt/classifier/verifier/runaway/context). Today `agent_loop.py` is a single flat file in `src/`.
|
|
||||||
- `src/infra/`, `src/domain/`, `src/pkg/`, `src/api/` — proposed layered reorganization of the flat `src/` directory (slice 4 in §6).
|
|
||||||
|
|
||||||
These become real only when the corresponding slices land.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix A: File Listing
|
|
||||||
|
|
||||||
### `src/` (95 files — 61 shown; run `ls src/*.py` for the full list)
|
|
||||||
|
|
||||||
```
|
|
||||||
agent_loop.py tool_implementations.py tool_schemas.py
|
|
||||||
tool_index.py tool_security.py tool_policy.py
|
|
||||||
tool_utils.py builtin_actions.py task_scheduler.py
|
|
||||||
llm_core.py model_context.py model_discovery.py
|
|
||||||
session_search.py context_budget.py context_compactor.py
|
|
||||||
ai_interaction.py action_intents.py agent_runs.py
|
|
||||||
app_helpers.py app_initializer.py config.py
|
|
||||||
database.py memory.py memory_provider.py
|
|
||||||
secret_storage.py prompt_security.py url_security.py
|
|
||||||
url_safety.py rate_limiter.py cleanup_service.py
|
|
||||||
readiness.py service_health.py exceptions.py
|
|
||||||
request_models.py assistant_log.py bg_monitor.py
|
|
||||||
builtin_mcp.py chat_helpers.py chroma_client.py
|
|
||||||
document_processor.py embedding_lanes.py deep_research.py
|
|
||||||
research_handler.py research_utils.py personal_docs.py
|
|
||||||
rag_manager.py rag_singleton.py topic_analyzer.py
|
|
||||||
visual_report.py youtube_handler.py pdf_forms.py
|
|
||||||
pdf_form_doc.py pdf_runtime.py caldav_writeback.py
|
|
||||||
email_thread_parser.py text_helpers.py user_time.py
|
|
||||||
teacher_escalation.py cookbook_serve_lifecycle.py
|
|
||||||
chatgpt_subscription.py mcp_manager.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### `routes/` (54 files)
|
|
||||||
|
|
||||||
```
|
|
||||||
__init__.py _validators.py
|
|
||||||
auth_routes.py api_token_routes.py device_flow.py
|
|
||||||
chat_routes.py chat_helpers.py shell_routes.py
|
|
||||||
codex_routes.py skills_routes.py
|
|
||||||
email_routes.py email_helpers.py email_pollers.py
|
|
||||||
cookbook_routes.py cookbook_helpers.py cookbook_output.py
|
|
||||||
model_routes.py assistant_routes.py copilot_routes.py
|
|
||||||
calendar_routes.py contacts_routes.py
|
|
||||||
document_routes.py document_helpers.py
|
|
||||||
gallery_routes.py gallery_helpers.py
|
|
||||||
task_routes.py session_routes.py
|
|
||||||
note_routes.py memory_routes.py research_routes.py
|
|
||||||
mcp_routes.py search_routes.py history_routes.py
|
|
||||||
webhook_routes.py workspace_routes.py upload_routes.py
|
|
||||||
vault_routes.py prefs_routes.py preset_routes.py
|
|
||||||
signature_routes.py personal_routes.py hwfit_routes.py
|
|
||||||
backup_routes.py cleanup_routes.py diagnostics_routes.py
|
|
||||||
embedding_routes.py emoji_routes.py font_routes.py
|
|
||||||
stt_routes.py tts_routes.py compare_routes.py
|
|
||||||
editor_draft_routes.py chatgpt_subscription_routes.py admin_wipe_routes.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### `core/` (10 files)
|
|
||||||
|
|
||||||
```
|
|
||||||
__init__.py constants.py database.py models.py
|
|
||||||
auth.py middleware.py session_manager.py exceptions.py
|
|
||||||
atomic_io.py platform_compat.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Appendix B: Key Import Relationships
|
|
||||||
|
|
||||||
```
|
|
||||||
core/database.py ←── 102 importers (routes/*, src/*, core/*, tests/*)
|
|
||||||
↑
|
|
||||||
├── routes/auth_routes.py
|
|
||||||
├── routes/email_routes.py
|
|
||||||
├── src/builtin_actions.py
|
|
||||||
├── src/task_scheduler.py
|
|
||||||
├── src/tool_implementations.py (inline)
|
|
||||||
└── ...97 more
|
|
||||||
|
|
||||||
src/tool_implementations.py ←── 17 importers
|
|
||||||
↑
|
|
||||||
├── src/agent_loop.py
|
|
||||||
├── src/builtin_actions.py
|
|
||||||
├── src/tool_index.py
|
|
||||||
├── src/task_scheduler.py
|
|
||||||
├── src/tool_policy.py
|
|
||||||
└── ...12 more (mostly tests)
|
|
||||||
|
|
||||||
src/agent_loop.py ←── 22 importers
|
|
||||||
↑
|
|
||||||
├── src/tool_policy.py
|
|
||||||
├── src/teacher_escalation.py
|
|
||||||
├── src/bg_monitor.py
|
|
||||||
├── src/task_scheduler.py
|
|
||||||
└── 18 more (incl. tests)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import collections
|
import collections
|
||||||
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
from typing import Optional, Callable, Awaitable, Tuple, Dict
|
||||||
from core.platform_compat import IS_WINDOWS, find_bash
|
|
||||||
from src.constants import MAX_OUTPUT_CHARS
|
from src.constants import MAX_OUTPUT_CHARS
|
||||||
|
|
||||||
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
DEFAULT_BASH_TIMEOUT = 60 * 60 # 1 hour
|
||||||
@@ -17,27 +16,6 @@ PROGRESS_TAIL_LINES = 12
|
|||||||
TMUX_CAPTURE_LINES = 2000
|
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:
|
def _tmux_session_name(session_id: Optional[str]) -> str:
|
||||||
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-")
|
||||||
return f"ody-agent-{raw[:80] or 'default'}"
|
return f"ody-agent-{raw[:80] or 'default'}"
|
||||||
@@ -302,10 +280,7 @@ class BashTool:
|
|||||||
progress_cb = ctx.get("progress_cb")
|
progress_cb = ctx.get("progress_cb")
|
||||||
_subproc_env = ctx.get("subproc_env")
|
_subproc_env = ctx.get("subproc_env")
|
||||||
session_id = ctx.get("session_id")
|
session_id = ctx.get("session_id")
|
||||||
# tmux is a POSIX persistence path. A stray MSYS/Cygwin tmux.exe on
|
if session_id and shutil.which("tmux"):
|
||||||
# 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(
|
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||||
content,
|
content,
|
||||||
session_id=str(session_id),
|
session_id=str(session_id),
|
||||||
@@ -332,16 +307,13 @@ class BashTool:
|
|||||||
"tmux_session": _tmux_session_name(str(session_id)),
|
"tmux_session": _tmux_session_name(str(session_id)),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
proc = await asyncio.create_subprocess_shell(
|
||||||
proc = await _create_bash_subprocess(
|
content,
|
||||||
content,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
env=_subproc_env,
|
||||||
env=_subproc_env,
|
cwd=agent_cwd(),
|
||||||
cwd=agent_cwd(),
|
)
|
||||||
)
|
|
||||||
except RuntimeError as e:
|
|
||||||
return {"error": f"bash: {e}", "exit_code": 1}
|
|
||||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||||
proc,
|
proc,
|
||||||
timeout=DEFAULT_BASH_TIMEOUT,
|
timeout=DEFAULT_BASH_TIMEOUT,
|
||||||
|
|||||||
+1
-10
@@ -22,7 +22,6 @@ import time
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||||
|
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.memory import MemoryStoreUnreadable
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -385,15 +384,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
return {"error": "Memory text cannot be empty"}
|
return {"error": "Memory text cannot be empty"}
|
||||||
|
|
||||||
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
entry = _memory_manager.add_entry(text, source="ai_agent", category=category, owner=owner)
|
||||||
# Strict load: this is a read-modify-write, and it is the path an
|
memories = _memory_manager.load_all()
|
||||||
# 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)
|
memories.append(entry)
|
||||||
_memory_manager.save(memories)
|
_memory_manager.save(memories)
|
||||||
|
|
||||||
|
|||||||
+3
-172
@@ -1,14 +1,11 @@
|
|||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
from urllib.parse import urljoin, urlparse, urlunparse
|
from urllib.parse import urljoin, urlparse, urlunparse
|
||||||
|
|
||||||
import httpcore
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -357,152 +354,6 @@ def _find_integration(identifier: str) -> Optional[Dict[str, Any]]:
|
|||||||
return None
|
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(
|
async def execute_api_call(
|
||||||
integration_id: str,
|
integration_id: str,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -558,31 +409,13 @@ async def execute_api_call(
|
|||||||
# loopback for locked-down deployments. Private stays allowed by default
|
# loopback for locked-down deployments. Private stays allowed by default
|
||||||
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
# because LAN integrations (Home Assistant, Miniflux, ntfy) are the
|
||||||
# primary use case.
|
# primary use case.
|
||||||
from src.url_safety import check_outbound_url, _default_resolver
|
from src.url_safety import check_outbound_url
|
||||||
block_private = os.getenv(
|
block_private = os.getenv(
|
||||||
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
"INTEGRATION_API_BLOCK_PRIVATE_IPS", "false"
|
||||||
).lower() == "true"
|
).lower() == "true"
|
||||||
# Resolve the host exactly once and remember the IPs the guard validated so
|
ok, reason = check_outbound_url(url, block_private=block_private)
|
||||||
# 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:
|
if not ok:
|
||||||
return {"error": f"URL rejected: {reason}", "exit_code": 1}
|
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()
|
method = method.upper()
|
||||||
|
|
||||||
@@ -622,9 +455,7 @@ async def execute_api_call(
|
|||||||
auth = httpx.BasicAuth(parts[0], parts[1])
|
auth = httpx.BasicAuth(parts[0], parts[1])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
timeout=30.0, transport=_PinnedAsyncTransport(pinned_ips)
|
|
||||||
) as client:
|
|
||||||
response = await client.request(
|
response = await client.request(
|
||||||
method,
|
method,
|
||||||
url,
|
url,
|
||||||
|
|||||||
+12
-89
@@ -1,7 +1,6 @@
|
|||||||
# src/llm_core.py
|
# src/llm_core.py
|
||||||
import httpx
|
import httpx
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -645,7 +644,7 @@ def _build_ollama_payload(
|
|||||||
if options:
|
if options:
|
||||||
payload["options"] = options
|
payload["options"] = options
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = _alias_harmony_tools(tools, model)
|
payload["tools"] = tools
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -1056,57 +1055,6 @@ def _model_disallows_reasoning_effort_with_chat_tools(model: str) -> bool:
|
|||||||
return bool(re.match(r"^(?:openai/)?gpt-5(?:[.\-]\d+)?(?:[-_:].*)?$", m))
|
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:
|
def _scrub_openai_chat_tool_reasoning(payload: Dict, target_url: str, model: str) -> None:
|
||||||
if not payload.get("tools"):
|
if not payload.get("tools"):
|
||||||
return
|
return
|
||||||
@@ -1289,27 +1237,15 @@ def _anthropic_rejects_temperature(model: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
# `(?<![a-z])` anchors "opus" to a word boundary so a substring match like
|
# `(?<![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
|
# `oct-opus`/`octopus-4-8` can't be read as Opus (it would otherwise strip
|
||||||
# temperature). Both version components are capped at 1-2 digits and forbid a
|
# temperature). Cap the minor at 1-2 digits and forbid a trailing digit so a
|
||||||
# trailing digit, so an 8-digit date can never be read as a version number:
|
# dated id like `claude-opus-4-20250514` (Opus 4.0) parses as major-only (no
|
||||||
# `claude-opus-4-20250514` (Opus 4.0) parses as major-only rather than reading
|
# minor match, kept) instead of reading the date `20250514` as a giant minor
|
||||||
# `20250514` as a giant minor, and `claude-3-opus-20240229` (legacy Claude 3
|
# that would falsely test >= 4.7. Dated 4.7+ snapshots (`claude-opus-4-7-
|
||||||
# Opus, date directly after "opus-") fails to match at all rather than reading
|
# 20260201`) keep their explicit minor and are still matched.
|
||||||
# the date as a giant major. Dated 4.7+ snapshots (`claude-opus-4-7-20260201`)
|
match = re.search(r"(?<![a-z])opus[-_]?(\d+)[-_.](\d{1,2})(?!\d)", model.lower())
|
||||||
# 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:
|
if not match:
|
||||||
return False
|
return False
|
||||||
major = int(match.group(1))
|
return (int(match.group(1)), int(match.group(2))) >= (4, 7)
|
||||||
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
|
# Reasoning effort level sent to Mistral thinking-capable models. Mistral's
|
||||||
# API accepts "high", "medium", "low", "none" — see
|
# API accepts "high", "medium", "low", "none" — see
|
||||||
@@ -2161,17 +2097,7 @@ async def llm_call_async(
|
|||||||
response = _parse_ollama_response(data)
|
response = _parse_ollama_response(data)
|
||||||
else:
|
else:
|
||||||
msg = data["choices"][0]["message"]
|
msg = data["choices"][0]["message"]
|
||||||
content = msg.get("content")
|
response = msg.get("content") or msg.get("reasoning_content") or ""
|
||||||
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)
|
_set_cached_response(cache_key, response)
|
||||||
return response
|
return response
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2288,7 +2214,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"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = _alias_harmony_tools(tools, model)
|
payload["tools"] = tools
|
||||||
elif tool_choice_none:
|
elif tool_choice_none:
|
||||||
payload["tool_choice"] = "none"
|
payload["tool_choice"] = "none"
|
||||||
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
# Mistral thinking-capable models — send reasoning_effort so Mistral
|
||||||
@@ -2422,7 +2348,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
if fn.get("name"):
|
if fn.get("name"):
|
||||||
_ollama_tool_calls.append({
|
_ollama_tool_calls.append({
|
||||||
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
"id": tc.get("id") or f"call_{len(_ollama_tool_calls)}",
|
||||||
"name": _unalias_harmony_tool_name(fn.get("name") or "", model),
|
"name": fn.get("name") or "",
|
||||||
"arguments": json.dumps(fn.get("arguments") or {}),
|
"arguments": json.dumps(fn.get("arguments") or {}),
|
||||||
})
|
})
|
||||||
if j.get("done"):
|
if j.get("done"):
|
||||||
@@ -2802,10 +2728,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
if tc.get("extra_content"):
|
if tc.get("extra_content"):
|
||||||
_tc_acc[idx]["extra_content"] = tc["extra_content"]
|
_tc_acc[idx]["extra_content"] = tc["extra_content"]
|
||||||
if func.get("name"):
|
if func.get("name"):
|
||||||
# Map harmony aliases back to real
|
_tc_acc[idx]["name"] = func["name"]
|
||||||
# tool names before anything
|
|
||||||
# downstream sees them.
|
|
||||||
_tc_acc[idx]["name"] = _unalias_harmony_tool_name(func["name"], model)
|
|
||||||
if "arguments" in func:
|
if "arguments" in func:
|
||||||
# Guard against a null arguments delta: `func` can be
|
# Guard against a null arguments delta: `func` can be
|
||||||
# {"arguments": None} (JSON null), and a raw `+= None`
|
# {"arguments": None} (JSON null), and a raw `+= None`
|
||||||
|
|||||||
+9
-79
@@ -10,18 +10,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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]:
|
def tokenize(text: str) -> List[str]:
|
||||||
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
||||||
return [word.strip('.,!?";') for word in text.split()]
|
return [word.strip('.,!?";') for word in text.split()]
|
||||||
@@ -122,69 +110,21 @@ class MemoryManager:
|
|||||||
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump([], f, ensure_ascii=False, indent=2)
|
json.dump([], f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
def _read_entries(self) -> List[Dict]:
|
def load_all(self) -> List[Dict]:
|
||||||
"""Parse the store, or raise :class:`MemoryStoreUnreadable`.
|
"""Load all memory entries from JSON file (unfiltered)."""
|
||||||
|
|
||||||
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):
|
if not os.path.exists(self.memory_file):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.memory_file, "r", encoding="utf-8") as f:
|
with open(self.memory_file, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
except OSError as e:
|
if isinstance(data, list):
|
||||||
# PermissionError is an OSError (a scanner holding the file, a
|
return self._validate_entries(data)
|
||||||
# permissions problem, bad media).
|
except (json.JSONDecodeError, PermissionError) as e:
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
logger.error("Error loading memory.json: %s", e)
|
||||||
return []
|
return self._migrate_from_legacy()
|
||||||
|
|
||||||
def load_all_for_update(self) -> List[Dict]:
|
return []
|
||||||
"""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]:
|
def load(self, owner: str = None) -> List[Dict]:
|
||||||
"""Load memory entries, optionally filtered by owner."""
|
"""Load memory entries, optionally filtered by owner."""
|
||||||
@@ -195,12 +135,7 @@ class MemoryManager:
|
|||||||
|
|
||||||
def claim_ownerless(self, owner: str):
|
def claim_ownerless(self, owner: str):
|
||||||
"""Assign all ownerless memory entries to the given owner."""
|
"""Assign all ownerless memory entries to the given owner."""
|
||||||
try:
|
entries = self.load_all()
|
||||||
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
|
changed = False
|
||||||
claimed = 0
|
claimed = 0
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -300,12 +235,7 @@ class MemoryManager:
|
|||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
id_set = set(ids)
|
id_set = set(ids)
|
||||||
try:
|
entries = self.load_all()
|
||||||
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
|
changed = False
|
||||||
for e in entries:
|
for e in entries:
|
||||||
if e.get("id") in id_set:
|
if e.get("id") in id_set:
|
||||||
|
|||||||
@@ -157,11 +157,7 @@ class NativeMemoryProvider(MemoryProvider):
|
|||||||
if metadata:
|
if metadata:
|
||||||
entry["metadata"] = dict(metadata)
|
entry["metadata"] = dict(metadata)
|
||||||
|
|
||||||
# Strict load: read-modify-write. `load_all` degrades an unreadable
|
memories = self.memory_manager.load_all()
|
||||||
# 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)
|
memories.append(entry)
|
||||||
self.memory_manager.save(memories)
|
self.memory_manager.save(memories)
|
||||||
|
|
||||||
@@ -227,10 +223,7 @@ class NativeMemoryProvider(MemoryProvider):
|
|||||||
]
|
]
|
||||||
|
|
||||||
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
||||||
# Strict load for the same reason: `remaining` is derived from this
|
memories = self.memory_manager.load_all()
|
||||||
# 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 = []
|
remaining = []
|
||||||
deleted_id = None
|
deleted_id = None
|
||||||
|
|
||||||
|
|||||||
+4
-69
@@ -187,13 +187,9 @@ _FUNCTION_MODEL_NAME_RE = re.compile(
|
|||||||
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
_FUNCTION_MODEL_PARAMS_OPEN_RE = re.compile(r"<parameters>\s*", re.IGNORECASE)
|
||||||
_FUNCTION_MODEL_PARAMS_CLOSE_RE = re.compile(r"</parameters>", 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)
|
_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(
|
_QWEN_BARE_MARKER_RE = re.compile(
|
||||||
r"(?:^|[\t\r\n ])(?:/?\|end\||\|end|end\|)(?=[\t\r\n ]|$)|"
|
r"(?:^|[\t\r\n ])(?:\|?end\|?|/?\|end\|)(?=[\t\r\n ]|$)|"
|
||||||
r"(?:^|[\r\n])[ \t]*assistan(?:t)?[ \t]*(?=[\r\n]|$)",
|
r"(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -929,46 +925,6 @@ def _parse_xml_direct_tool(name, body) -> Optional[ToolBlock]:
|
|||||||
return function_call_to_tool_block(mapped, json.dumps(params))
|
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):
|
def _iter_stepfun_tool_calls(text: str):
|
||||||
"""Yield StepFun native tool-call token bodies without regex backtracking."""
|
"""Yield StepFun native tool-call token bodies without regex backtracking."""
|
||||||
pos = 0
|
pos = 0
|
||||||
@@ -1370,21 +1326,10 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
if blocks:
|
if blocks:
|
||||||
return blocks
|
return blocks
|
||||||
# Try wrapped: <tool_call><invoke ...>...</invoke></tool_call>
|
# 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(
|
for _ms, inner_start, inner_end, _me in _iter_delimited(
|
||||||
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
text, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE
|
||||||
):
|
):
|
||||||
body = text[inner_start:inner_end]
|
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):
|
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||||
block = _parse_xml_invoke(inv_name, inv_body)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
@@ -1399,13 +1344,6 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
if not blocks:
|
if not blocks:
|
||||||
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
for m in _XML_OPEN_TOOL_CALL_RE.finditer(text):
|
||||||
body = m.group(1)
|
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):
|
for inv_name, inv_body in _iter_xml_invoke(body):
|
||||||
block = _parse_xml_invoke(inv_name, inv_body)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
@@ -1416,11 +1354,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
block = _parse_xml_direct_tool(d_name, d_body)
|
block = _parse_xml_direct_tool(d_name, d_body)
|
||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
# Try bare <invoke> without wrapper. Skipped when a JSON wrapper body
|
# Try bare <invoke> without wrapper
|
||||||
# was seen but produced no block: this rescan covers the full text,
|
if not blocks:
|
||||||
# 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):
|
for inv_name, inv_body in _iter_xml_invoke(text):
|
||||||
block = _parse_xml_invoke(inv_name, inv_body)
|
block = _parse_xml_invoke(inv_name, inv_body)
|
||||||
if block:
|
if block:
|
||||||
|
|||||||
+2
-4
@@ -46,9 +46,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||||
|
|
||||||
action = (args.get("action") or "").strip().lower()
|
action = (args.get("action") or "").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.skills import SkillsManager
|
||||||
from services.memory.skill_format import Skill, slugify
|
from services.memory.skill_format import Skill, slugify
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
@@ -57,7 +55,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
# Accept legacy `skill_id` as an alias for `name`.
|
# Accept legacy `skill_id` as an alias for `name`.
|
||||||
name = (args.get("name") or args.get("skill_id") or "").strip()
|
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)
|
all_skills = sm.load(owner=owner)
|
||||||
if not all_skills:
|
if not all_skills:
|
||||||
return {"results": "No skills yet. Create one with action='add'."}
|
return {"results": "No skills yet. Create one with action='add'."}
|
||||||
|
|||||||
+147
-50
@@ -10,25 +10,18 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
|
|||||||
import ragModule from './js/rag.js';
|
import ragModule from './js/rag.js';
|
||||||
import presetsModule from './js/presets.js';
|
import presetsModule from './js/presets.js';
|
||||||
import searchModule from './js/search.js';
|
import searchModule from './js/search.js';
|
||||||
import chatModule from './js/chat.js?v=20260801fix1';
|
import chatModule from './js/chat.js?v=20260722ctxheader4';
|
||||||
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
import compareModule from './js/compare/index.js?v=20260723compareicon2';
|
||||||
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
import documentModule from './js/document.js?v=20260722emailfastindex1';
|
||||||
import searchChatModule from './js/search-chat.js';
|
import searchChatModule from './js/search-chat.js';
|
||||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||||
import {
|
|
||||||
revealApplicationShellAfterPaint,
|
|
||||||
runDeferredRouteOpener,
|
|
||||||
deferRouteOpener,
|
|
||||||
settleSessionHydration
|
|
||||||
} from './js/startupShell.js';
|
|
||||||
import markdownModule from './js/markdown.js';
|
import markdownModule from './js/markdown.js';
|
||||||
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
import chatRenderer from './js/chatRenderer.js?v=20260722emailfastindex1';
|
||||||
import sessionModule from './js/sessions.js';
|
import sessionModule from './js/sessions.js?v=20260722ctxheader4';
|
||||||
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
import memoryModule from './js/memory.js?v=20260722memoryloading1';
|
||||||
import voiceRecorderModule from './js/voiceRecorder.js';
|
import voiceRecorderModule from './js/voiceRecorder.js';
|
||||||
import censorModule from './js/censor.js';
|
import censorModule from './js/censor.js';
|
||||||
import galleryModule from './js/gallery.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 tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
|
||||||
import calendarModule from './js/calendar.js';
|
import calendarModule from './js/calendar.js';
|
||||||
import notesModule from './js/notes.js';
|
import notesModule from './js/notes.js';
|
||||||
@@ -1224,13 +1217,12 @@ function initializeEventListeners() {
|
|||||||
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
'/library': () => sessionModule && sessionModule.openLibrary && sessionModule.openLibrary(),
|
||||||
};
|
};
|
||||||
const _opener = _routeOpen[urlPath];
|
const _opener = _routeOpen[urlPath];
|
||||||
// Defer the opener — at this point in init, the modules whose handlers we
|
// Defer the opener — at this point in init, the modules whose handlers
|
||||||
// trigger (#rail-new-session click handler, the email-section header click
|
// we trigger (#rail-new-session click handler, the email-section header
|
||||||
// handler in emailInbox, sessionModule) are still being wired up further
|
// click handler in emailInbox, sessionModule's loaded session list) are
|
||||||
// down in this same function. startupShell decides when it can run: as soon
|
// still being wired up further down in this same function. Stash the
|
||||||
// as wiring completes, or — for the routes that read the session list —
|
// opener so it runs from sessionModule.loadSessions().finally() below.
|
||||||
// once /api/sessions has settled.
|
if (_opener) window._odysseusRouteOpener = _opener;
|
||||||
deferRouteOpener(urlPath, _opener);
|
|
||||||
|
|
||||||
// Archive browser tool button
|
// Archive browser tool button
|
||||||
const toolLibraryBtn = el('tool-library-btn');
|
const toolLibraryBtn = el('tool-library-btn');
|
||||||
@@ -1697,20 +1689,12 @@ function initializeEventListeners() {
|
|||||||
|
|
||||||
const newMemoryInput = el('new-memory-input');
|
const newMemoryInput = el('new-memory-input');
|
||||||
if (newMemoryInput) {
|
if (newMemoryInput) {
|
||||||
// keydown, not the deprecated keypress: keypress is not guaranteed to
|
newMemoryInput.addEventListener('keypress', (e) => {
|
||||||
// fire for Enter everywhere, which left the Add Memory form with no
|
if (e.key === 'Enter') {
|
||||||
// working submit path (#5828).
|
|
||||||
newMemoryInput.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.isComposing) {
|
|
||||||
e.preventDefault();
|
|
||||||
memoryModule.addNewMemory();
|
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)
|
// Voice recording is handled by the dual-purpose send/mic button (see below)
|
||||||
|
|
||||||
@@ -2726,6 +2710,46 @@ function initializeEventListeners() {
|
|||||||
// ── UI Visibility (Customize UI modal) ──
|
// ── UI Visibility (Customize UI modal) ──
|
||||||
const UI_VIS_KEY = 'odysseus-ui-visibility';
|
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)
|
// Keys that need admin to toggle off (reserved for future use)
|
||||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||||
|
|
||||||
@@ -2738,14 +2762,14 @@ function initializeEventListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyUIVis(state) {
|
function applyUIVis(state) {
|
||||||
// resolveVisibility computes selector→visible (pure; ui_visibility.js),
|
Object.entries(UI_VIS_MAP).forEach(([key, selector]) => {
|
||||||
// including the tools-section parent rule that hides every tool rail
|
// section-drag-reorder uses a body class instead of inline styles
|
||||||
// launcher when Tools is off. Apply the result to the DOM here.
|
if (key === 'section-drag-reorder') return;
|
||||||
for (const [selector, visible] of Object.entries(resolveVisibility(state))) {
|
const visible = key in state ? state[key] !== false : !UI_VIS_DEFAULT_OFF.has(key);
|
||||||
document.querySelectorAll(selector).forEach(el => {
|
document.querySelectorAll(selector).forEach(el => {
|
||||||
el.style.display = visible ? '' : 'none';
|
el.style.display = visible ? '' : 'none';
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
// Drag reorder: use body class so dynamically created handles are covered
|
// Drag reorder: use body class so dynamically created handles are covered
|
||||||
const dragEnabled = state['section-drag-reorder'] === true;
|
const dragEnabled = state['section-drag-reorder'] === true;
|
||||||
document.body.classList.toggle('rearrange-mode', dragEnabled);
|
document.body.classList.toggle('rearrange-mode', dragEnabled);
|
||||||
@@ -3884,10 +3908,85 @@ function startOdysseusApp() {
|
|||||||
const messageInput = el('message');
|
const messageInput = el('message');
|
||||||
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
const modelPickerWrap = document.getElementById('model-picker-wrap');
|
||||||
|
|
||||||
// ArrowUp/ArrowDown prompt recall on #message lives in
|
function _readComposerPromptHistory() {
|
||||||
// static/js/composerArrowUpRecall.js (wired from chat.js). Do not re-add a
|
const chatBox = document.getElementById('chat-history');
|
||||||
// copy here: two capture-phase listeners on the same textarea meant the one
|
if (!chatBox) return [];
|
||||||
// without the draft guard won and ate unsent multi-line prompts (#5862).
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
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 _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>';
|
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>';
|
||||||
@@ -4283,10 +4382,6 @@ function startOdysseusApp() {
|
|||||||
// Load initial data
|
// Load initial data
|
||||||
presetsModule.loadPresets(uiModule.showError);
|
presetsModule.loadPresets(uiModule.showError);
|
||||||
|
|
||||||
// Core wiring is complete for this turn — reveal the shell independently of
|
|
||||||
// the session-list request.
|
|
||||||
revealApplicationShellAfterPaint();
|
|
||||||
|
|
||||||
if (sessionModule) {
|
if (sessionModule) {
|
||||||
sessionModule.initDependencies({
|
sessionModule.initDependencies({
|
||||||
API_BASE: API_BASE,
|
API_BASE: API_BASE,
|
||||||
@@ -4298,19 +4393,21 @@ function startOdysseusApp() {
|
|||||||
scrollHistory: uiModule.scrollHistoryInstant
|
scrollHistory: uiModule.scrollHistoryInstant
|
||||||
});
|
});
|
||||||
|
|
||||||
// sessionModule is now wired, so every route opener has the modules it
|
// Load sessions first (critical path) — remove loader when done
|
||||||
// drives. The ones that read no session data open here rather than
|
sessionModule.loadSessions()
|
||||||
// queueing behind /api/sessions.
|
.catch(e => console.warn('loadSessions error:', e))
|
||||||
runDeferredRouteOpener();
|
.finally(() => {
|
||||||
|
const loader = document.getElementById('app-loader');
|
||||||
// The shell is already usable at this point; session hydration is
|
if (loader) { loader.style.opacity = '0'; setTimeout(() => loader.remove(), 300); }
|
||||||
// sidebar-local and settles on its own schedule.
|
// Fire any URL route opener now that sessions + module wiring are
|
||||||
settleSessionHydration(() => sessionModule.loadSessions());
|
// ready. Deferred from up top of init for exactly this reason.
|
||||||
|
if (window._odysseusRouteOpener) {
|
||||||
|
try { window._odysseusRouteOpener(); } catch (_) {}
|
||||||
|
window._odysseusRouteOpener = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('Session module not loaded!');
|
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) => {
|
const runNonCriticalStartup = (fn, delay = 4000) => {
|
||||||
|
|||||||
+10
-32
@@ -248,20 +248,11 @@
|
|||||||
}, { once: true });
|
}, { once: true });
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<!-- Preload the two faces first paint actually uses: Fira Code 400 and 600,
|
<link rel="stylesheet" href="/static/style.css?v=20260723tasksbulkfeedback1">
|
||||||
the app font and the weight the sidebar and header text render at. They
|
<link rel="modulepreload" href="/static/app.js?v=20260723tasksbulkfeedback1">
|
||||||
are declared in style.css, so without a hint they are only discovered
|
<link rel="modulepreload" href="/static/js/chat.js?v=20260722ctxheader4">
|
||||||
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/ui.js">
|
||||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
<link rel="modulepreload" href="/static/js/sessions.js?v=20260722ctxheader4">
|
||||||
<link rel="modulepreload" href="/static/js/markdown.js">
|
<link rel="modulepreload" href="/static/js/markdown.js">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -295,13 +286,7 @@
|
|||||||
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
if(!document.getElementById('app-loader')){clearInterval(iv);return}
|
||||||
render();
|
render();
|
||||||
},150);
|
},150);
|
||||||
// startupShell.js hides the loader as soon as the shell is wired; it calls
|
setTimeout(function(){var l=document.getElementById('app-loader');if(l){l.style.opacity='0';setTimeout(function(){l.remove()},300)}},5000);
|
||||||
// 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>
|
</script>
|
||||||
<!-- Memory Management Modal -->
|
<!-- Memory Management Modal -->
|
||||||
@@ -380,7 +365,6 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
|
<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>
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -828,13 +812,7 @@
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
<div id="session-list" role="listbox">
|
<div id="session-list" role="listbox"></div>
|
||||||
<!-- 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>
|
</div>
|
||||||
<!-- Hidden dropdown for session actions -->
|
<!-- Hidden dropdown for session actions -->
|
||||||
<div id="session-actions-dropdown" class="dropdown hidden">
|
<div id="session-actions-dropdown" class="dropdown hidden">
|
||||||
@@ -1027,7 +1005,7 @@
|
|||||||
var tips = mobile ? phone : desktop;
|
var tips = mobile ? phone : desktop;
|
||||||
var el = document.getElementById('welcome-tip');
|
var el = document.getElementById('welcome-tip');
|
||||||
if (el) {
|
if (el) {
|
||||||
el.textContent = tips[Math.floor(Math.random() * tips.length)];
|
el.textContent = 'Pick a model if you want, or just type.';
|
||||||
}
|
}
|
||||||
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
fetch('/api/version').then(function(r){return r.json()}).then(function(d){
|
||||||
if (d.version) window._appVersion = d.version;
|
if (d.version) window._appVersion = d.version;
|
||||||
@@ -2526,7 +2504,7 @@
|
|||||||
<script type="module" src="/static/js/ui.js"></script>
|
<script type="module" src="/static/js/ui.js"></script>
|
||||||
<script type="module" src="/static/js/markdown.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/dragSort.js"></script>
|
||||||
<script type="module" src="/static/js/sessions.js"></script>
|
<script type="module" src="/static/js/sessions.js?v=20260722ctxheader4"></script>
|
||||||
<script type="module" src="/static/js/memory.js?v=20260722memoryloading1"></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/skills.js"></script>
|
||||||
<script type="module" src="/static/js/tourHints.js"></script>
|
<script type="module" src="/static/js/tourHints.js"></script>
|
||||||
@@ -2544,7 +2522,7 @@
|
|||||||
<script type="module" src="/static/js/chatRenderer.js?v=20260722emailfastindex1"></script>
|
<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/codeRunner.js"></script>
|
||||||
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script>
|
||||||
<script type="module" src="/static/js/chat.js?v=20260801fix1"></script>
|
<script type="module" src="/static/js/chat.js?v=20260722ctxheader4"></script>
|
||||||
<script type="module" src="/static/js/cookbook.js"></script>
|
<script type="module" src="/static/js/cookbook.js"></script>
|
||||||
<script src="/static/js/cookbookSchedule.js"></script>
|
<script src="/static/js/cookbookSchedule.js"></script>
|
||||||
<script type="module" src="/static/js/search-chat.js"></script>
|
<script type="module" src="/static/js/search-chat.js"></script>
|
||||||
@@ -2552,7 +2530,7 @@
|
|||||||
<script type="module" src="/static/js/censor.js"></script>
|
<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/settings.js?v=20260723compareicon1"></script>
|
||||||
<script type="module" src="/static/js/assistant.js"></script>
|
<script type="module" src="/static/js/assistant.js"></script>
|
||||||
<script type="module" src="/static/app.js?v=20260808startupshell1"></script> <!-- app.js must be LAST -->
|
<script type="module" src="/static/app.js?v=20260723tasksbulkfeedback1"></script> <!-- app.js must be LAST -->
|
||||||
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
|
||||||
<script type="module" src="/static/js/a11y.js"></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>
|
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ 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. |
|
| **`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. |
|
| **`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`. |
|
| **`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`. |
|
| **`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. |
|
| **`slashAutocomplete.js`** | Composer autocomplete popup for `/` commands. |
|
||||||
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
|
| **`composerArrowUpRecall.js`** | Recall last user message with `↑` on an empty composer. |
|
||||||
|
|||||||
+233
-522
File diff suppressed because it is too large
Load Diff
@@ -478,10 +478,7 @@ 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 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 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_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
|
||||||
// Keep in sync with _QWEN_BARE_MARKER_RE in src/tool_parsing.py. At least one
|
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
|
||||||
// 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)
|
// 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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowUp walks older prompts. An unmatched draft already returned above,
|
// ArrowUp owns prompt history in the chat composer. If the current text
|
||||||
// so reaching here means the composer is empty or holds a recalled prompt
|
// is not already a recalled prompt, start from newest instead of letting
|
||||||
// — the caret-navigation case is never hijacked.
|
// the browser move the caret inside the textarea.
|
||||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||||
const recalled = history[nextIndex];
|
const recalled = history[nextIndex];
|
||||||
if (!recalled) {
|
if (!recalled) {
|
||||||
|
|||||||
+10
-49
@@ -149,7 +149,6 @@ let _loading = false;
|
|||||||
let _expanded = false;
|
let _expanded = false;
|
||||||
let _docModule = null;
|
let _docModule = null;
|
||||||
let _listSpinner = null;
|
let _listSpinner = null;
|
||||||
let _openEmailRequestSeq = 0;
|
|
||||||
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
let _senderFilter = null; // email address (lowercased) to filter by, or null
|
||||||
let _senderFilterLabel = null; // display label for the active filter chip
|
let _senderFilterLabel = null; // display label for the active filter chip
|
||||||
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
let _showEmailTags = localStorage.getItem('odysseus.email.showTags') !== '0';
|
||||||
@@ -188,7 +187,7 @@ export function init(documentModule) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (opts.compose) { _composeNew(); return; }
|
if (opts.compose) { _composeNew(); return; }
|
||||||
if (opts.email) {
|
if (opts.email) {
|
||||||
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '', '', opts.mailboxContext || null);
|
await _openEmail(opts.email, null, opts.emailData, opts.mode || 'reply', opts.noteHint || '');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -752,21 +751,7 @@ function _createEmailItem(em) {
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '', mailboxContext = null) {
|
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||||
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 aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
||||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||||
@@ -795,10 +780,9 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
let data = preloadedData;
|
let data = preloadedData;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
const fullQS = mode === 'forward' ? '&full=1' : '';
|
const fullQS = mode === 'forward' ? '&full=1' : '';
|
||||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true${fullQS}`);
|
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(_currentFolder)}${_acct()}${fullQS}`);
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
}
|
}
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.error('Failed to read email:', data.error);
|
console.error('Failed to read email:', data.error);
|
||||||
return;
|
return;
|
||||||
@@ -824,7 +808,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
message_id: _fallback(data.message_id, em.message_id),
|
message_id: _fallback(data.message_id, em.message_id),
|
||||||
};
|
};
|
||||||
if (wantsAiReply) {
|
if (wantsAiReply) {
|
||||||
const activeReplyAccount = data.account_id || em.account_id || accountAtStart;
|
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
|
||||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||||
} else {
|
} else {
|
||||||
@@ -850,7 +834,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
session_id: currentSessionId,
|
session_id: currentSessionId,
|
||||||
message_id: data.message_id || '',
|
message_id: data.message_id || '',
|
||||||
uid: String(em.uid || ''),
|
uid: String(em.uid || ''),
|
||||||
folder: folderAtStart,
|
folder: _currentFolder,
|
||||||
account_id: activeReplyAccount,
|
account_id: activeReplyAccount,
|
||||||
fast: true,
|
fast: true,
|
||||||
user_hint: (noteHint || '').trim() || undefined,
|
user_hint: (noteHint || '').trim() || undefined,
|
||||||
@@ -858,7 +842,6 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (result.success && result.reply) {
|
if (result.success && result.reply) {
|
||||||
aiSuggestedBody = _cleanAiReplyText(result.reply);
|
aiSuggestedBody = _cleanAiReplyText(result.reply);
|
||||||
} else {
|
} else {
|
||||||
@@ -872,7 +855,6 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (draftToastTimer) clearTimeout(draftToastTimer);
|
if (draftToastTimer) clearTimeout(draftToastTimer);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
console.error('AI reply generation failed:', e);
|
console.error('AI reply generation failed:', e);
|
||||||
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@@ -880,12 +862,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isCurrentOpen()) return;
|
em.is_read = true;
|
||||||
// Only claim the message is read when the provider accepted the \Seen
|
if (itemEl) itemEl.classList.remove('email-unread');
|
||||||
// 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
|
// Addresses to exclude from Reply All. Prefer the full set of configured
|
||||||
// accounts (so a multi-account user's other mailboxes are excluded too),
|
// accounts (so a multi-account user's other mailboxes are excluded too),
|
||||||
@@ -933,7 +911,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 += `\nIn-Reply-To: ${data.message_id}`;
|
||||||
if (mode !== 'forward' && data.message_id) content += `\nReferences: ${data.references ? data.references + ' ' + data.message_id : 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-UID: ${em.uid}`;
|
||||||
content += `\nX-Source-Folder: ${folderAtStart}`;
|
content += `\nX-Source-Folder: ${_currentFolder}`;
|
||||||
if (data.attachments && data.attachments.length > 0) {
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
const attStr = data.attachments.map(a => `${a.index}:${a.filename}:${a.size}`).join('|');
|
||||||
content += `\nX-Attachments: ${attStr}`;
|
content += `\nX-Attachments: ${attStr}`;
|
||||||
@@ -1002,27 +980,21 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
// and block Send on long threads.
|
// and block Send on long threads.
|
||||||
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
||||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||||
? _docModule.findEmailDocId(em.uid, folderAtStart)
|
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
||||||
: null;
|
: null;
|
||||||
if (existingDocId) {
|
if (existingDocId) {
|
||||||
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
||||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
await _docModule.loadDocument(existingDocId);
|
await _docModule.loadDocument(existingDocId);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
||||||
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
}
|
}
|
||||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
}
|
}
|
||||||
_bringEmailReplyDraftToFrontOnMobile();
|
_bringEmailReplyDraftToFrontOnMobile();
|
||||||
} else {
|
} else {
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
let activeSid = await _createEmailChat(data, { forceNew: true });
|
let activeSid = await _createEmailChat(data, { forceNew: true });
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (!activeSid) {
|
if (!activeSid) {
|
||||||
console.error('reply: could not obtain a session_id');
|
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(() => {});
|
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||||
@@ -1040,20 +1012,13 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
let docRes = await createReplyDoc(activeSid);
|
let docRes = await createReplyDoc(activeSid);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (docRes.status === 404) {
|
if (docRes.status === 404) {
|
||||||
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
activeSid = await _createEmailChat(data, { forceNew: true });
|
activeSid = await _createEmailChat(data, { forceNew: true });
|
||||||
if (!isCurrentOpen()) return;
|
if (activeSid) docRes = await createReplyDoc(activeSid);
|
||||||
if (activeSid) {
|
|
||||||
docRes = await createReplyDoc(activeSid);
|
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!docRes.ok) {
|
if (!docRes.ok) {
|
||||||
const errText = await docRes.text();
|
const errText = await docRes.text();
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
||||||
// uiModule isn't statically imported here — use the dynamic
|
// uiModule isn't statically imported here — use the dynamic
|
||||||
// import pattern the rest of this file uses. (Previously this
|
// import pattern the rest of this file uses. (Previously this
|
||||||
@@ -1063,12 +1028,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doc = await docRes.json();
|
const doc = await docRes.json();
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
if (doc.id) {
|
if (doc.id) {
|
||||||
const wasOpen = _docModule.isPanelOpen();
|
const wasOpen = _docModule.isPanelOpen();
|
||||||
if (!wasOpen) _docModule.openPanel();
|
if (!wasOpen) _docModule.openPanel();
|
||||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
// Use the doc dict from the POST directly — avoids a 404 race
|
// 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
|
// when the GET fires before the new row is visible to the read
|
||||||
// connection (or when caching is interfering). loadDocument's
|
// connection (or when caching is interfering). loadDocument's
|
||||||
@@ -1077,14 +1040,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
|||||||
_docModule.injectFreshDoc(doc);
|
_docModule.injectFreshDoc(doc);
|
||||||
} else {
|
} else {
|
||||||
await _docModule.loadDocument(doc.id);
|
await _docModule.loadDocument(doc.id);
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
}
|
}
|
||||||
_bringEmailReplyDraftToFrontOnMobile();
|
_bringEmailReplyDraftToFrontOnMobile();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isCurrentOpen()) return;
|
|
||||||
console.error('Failed to open email:', e);
|
console.error('Failed to open email:', e);
|
||||||
// Surface the failure so a silent throw in the reply flow doesn't
|
// Surface the failure so a silent throw in the reply flow doesn't
|
||||||
// look like "nothing happened". Dynamic import — uiModule isn't a
|
// look like "nothing happened". Dynamic import — uiModule isn't a
|
||||||
|
|||||||
+159
-425
@@ -13,7 +13,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
|||||||
import {
|
import {
|
||||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||||
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
_formatBubbleDate, _formatRecipients, _senderColor, _initials,
|
||||||
_sanitizeHtml, _renderEmailSummaryError,
|
_sanitizeHtml,
|
||||||
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
_TALON_WROTE, _TALON_FROM, _TALON_SENT, _TALON_SUBJ, _TALON_TO,
|
||||||
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
_TALON_ORIG_RE, _SIG_BLOAT_MIN_CHARS,
|
||||||
} from './emailLibrary/utils.js';
|
} from './emailLibrary/utils.js';
|
||||||
@@ -30,10 +30,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
|||||||
const API_BASE = window.location.origin;
|
const API_BASE = window.location.origin;
|
||||||
let _emailUnreadChipClickWired = false;
|
let _emailUnreadChipClickWired = false;
|
||||||
let _libLoadSeq = 0;
|
let _libLoadSeq = 0;
|
||||||
let _emailMailboxGeneration = 0;
|
|
||||||
let _emailCardOpenSeq = 0;
|
|
||||||
let _emailReadMutationSeq = 0;
|
|
||||||
const _emailReadMutations = new Map();
|
|
||||||
let _libFolderSeq = 0;
|
let _libFolderSeq = 0;
|
||||||
let _libSearchSeq = 0;
|
let _libSearchSeq = 0;
|
||||||
let _libSearchHadResults = false;
|
let _libSearchHadResults = false;
|
||||||
@@ -841,41 +837,14 @@ document.addEventListener('keydown', (e) => {
|
|||||||
e.stopImmediatePropagation?.();
|
e.stopImmediatePropagation?.();
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
function _emailReadContextKey(context) {
|
function _syncEmailReadState(uid, isRead = true) {
|
||||||
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;
|
if (uid == null) return;
|
||||||
const uidStr = String(uid);
|
const uidStr = String(uid);
|
||||||
const read = !!isRead;
|
const read = !!isRead;
|
||||||
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
|
const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
|
||||||
const match = (state._libEmails || []).find(x => (
|
|
||||||
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
|
|
||||||
));
|
|
||||||
if (match) match.is_read = read;
|
if (match) match.is_read = read;
|
||||||
|
|
||||||
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
|
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);
|
card.classList.toggle('email-card-unread', !read);
|
||||||
const titleRow = card.querySelector('.email-card-titlerow');
|
const titleRow = card.querySelector('.email-card-titlerow');
|
||||||
if (read) {
|
if (read) {
|
||||||
@@ -1793,18 +1762,11 @@ function _rememberedEmailAccountId() {
|
|||||||
// results and __scheduled__ are deliberately not cached.
|
// results and __scheduled__ are deliberately not cached.
|
||||||
const _libListCache = new Map();
|
const _libListCache = new Map();
|
||||||
const _LIB_CACHE_MAX = 24;
|
const _LIB_CACHE_MAX = 24;
|
||||||
const _LIB_INITIAL_PAGE_SIZE = 100;
|
|
||||||
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
const _LIB_SESSION_CACHE_PREFIX = 'odysseus.email.list.';
|
||||||
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
const _LIB_SESSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
const _LIB_LAST_ACCOUNT_KEY = 'odysseus.email.lastAccountId';
|
||||||
const _LIB_PREWARM_COOLDOWN_MS = 5 * 60 * 1000;
|
let _libPrewarmTimer = null;
|
||||||
let _libPrewarmDelayTimer = null;
|
|
||||||
let _libPrewarmIdleHandle = null;
|
|
||||||
let _libPrewarmPromise = null;
|
let _libPrewarmPromise = null;
|
||||||
let _libPrewarmResolve = null;
|
|
||||||
let _libPrewarmAbortController = null;
|
|
||||||
let _libPrewarmDetachPriorityListeners = null;
|
|
||||||
let _libPrewarmGeneration = 0;
|
|
||||||
let _libLastPrewarmAt = 0;
|
let _libLastPrewarmAt = 0;
|
||||||
let _libUnreadPrewarmKey = '';
|
let _libUnreadPrewarmKey = '';
|
||||||
let _libUnreadPrewarmAt = 0;
|
let _libUnreadPrewarmAt = 0;
|
||||||
@@ -1946,7 +1908,6 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
|
|||||||
_exitEmailReaderModeForList();
|
_exitEmailReaderModeForList();
|
||||||
_resetBulkSelectionForContextChange();
|
_resetBulkSelectionForContextChange();
|
||||||
state._libOffset = 0;
|
state._libOffset = 0;
|
||||||
_emailMailboxGeneration += 1;
|
|
||||||
_libLoadSeq += 1;
|
_libLoadSeq += 1;
|
||||||
const ck = _libCacheKey();
|
const ck = _libCacheKey();
|
||||||
const cached = useCache ? _libCacheGet(ck) : null;
|
const cached = useCache ? _libCacheGet(ck) : null;
|
||||||
@@ -2115,319 +2076,162 @@ function _isChatInteractionBusy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _canRunEmailPrewarm() {
|
function _loadEmailsWhenChatIdle({ delay = 50, retries = 180, options = {} } = {}) {
|
||||||
if (state._libOpen || state._libLoading || _libSearchInFlight) return false;
|
const run = () => {
|
||||||
if (document.visibilityState && document.visibilityState !== 'visible') return false;
|
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
|
||||||
return !_isChatInteractionBusy();
|
if (_isChatInteractionBusy() && retries > 0) {
|
||||||
}
|
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
|
||||||
|
|
||||||
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;
|
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 } = {}) {
|
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
|
||||||
if (_libPrewarmPromise) return _libPrewarmPromise;
|
if (_libPrewarmTimer || _libPrewarmPromise) return;
|
||||||
const elapsed = Date.now() - _libLastPrewarmAt;
|
const elapsed = Date.now() - _libLastPrewarmAt;
|
||||||
if (elapsed >= 0 && elapsed < _LIB_PREWARM_COOLDOWN_MS) return Promise.resolve(false);
|
if (elapsed >= 0 && elapsed < 5 * 60 * 1000) return;
|
||||||
return _scheduleEmailPrewarm(_prewarmEmailViews, { delay });
|
_libPrewarmTimer = setTimeout(() => {
|
||||||
|
_libPrewarmTimer = null;
|
||||||
|
_libPrewarmPromise = _prewarmEmailViews()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { _libPrewarmPromise = null; });
|
||||||
|
}, Math.max(0, Number(delay) || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _chooseEmailPrewarmAccountId(accounts) {
|
async function _ensureEmailAccountsForPrewarm() {
|
||||||
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;
|
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
|
||||||
if (!(Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh)) {
|
if (Array.isArray(state._libAccounts) && state._libAccounts.length && accountsFresh) {
|
||||||
try {
|
if (!state._libAccountId) {
|
||||||
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, {
|
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||||
credentials: 'same-origin',
|
state._libAccountId = def?.id || null;
|
||||||
signal,
|
_publishActiveAccount();
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
const accountId = _chooseEmailPrewarmAccountId(state._libAccounts);
|
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal)) return null;
|
if (!accountsRes.ok) return;
|
||||||
if (!accountId) return null;
|
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||||
if (accountId && state._libAccountId !== accountId) {
|
if (Array.isArray(accountsData.accounts)) {
|
||||||
state._libAccountId = accountId;
|
state._libAccounts = accountsData.accounts;
|
||||||
_publishActiveAccount();
|
_libAccountsLoadedAt = Date.now();
|
||||||
}
|
if (!state._libAccountId && state._libAccounts.length) {
|
||||||
return accountId;
|
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
|
||||||
|
state._libAccountId = def?.id || null;
|
||||||
|
_publishActiveAccount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
export async function prewarmUnreadEmails({ limit = 8, maxUid = 0 } = {}) {
|
||||||
return _scheduleEmailPrewarm(
|
if (state._libOpen) return;
|
||||||
context => _prewarmUnreadEmailsNow({ limit, maxUid }, context),
|
await _ensureEmailAccountsForPrewarm();
|
||||||
{ delay: 0 }
|
if (state._libOpen) return;
|
||||||
);
|
const accountId = state._libAccountId || '';
|
||||||
}
|
|
||||||
|
|
||||||
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 n = Math.max(1, Math.min(20, Number(limit) || 8));
|
||||||
const key = `${accountId}|${maxUid || 0}|${n}`;
|
const key = `${accountId}|${maxUid || 0}|${n}`;
|
||||||
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return true;
|
if (_libUnreadPrewarmKey === key && (Date.now() - _libUnreadPrewarmAt) < 60 * 1000) return;
|
||||||
|
_libUnreadPrewarmKey = key;
|
||||||
|
_libUnreadPrewarmAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
const res = await fetch(emailApiUrl('/api/email/list', {
|
||||||
folder,
|
folder,
|
||||||
limit: n,
|
limit: n,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
filter: 'unread',
|
filter: 'unread',
|
||||||
account_id: accountId || undefined,
|
account_id: accountId || undefined,
|
||||||
}), {
|
}), { credentials: 'same-origin' });
|
||||||
credentials: 'same-origin',
|
if (state._libOpen) return;
|
||||||
signal,
|
if (!res.ok) return;
|
||||||
});
|
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
|
||||||
const data = await res.json().catch(() => null);
|
const data = await res.json().catch(() => null);
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return;
|
||||||
if (!data || data.error || !Array.isArray(data.emails) || !data.emails.length) return false;
|
|
||||||
const sync = data.sync || {};
|
const sync = data.sync || {};
|
||||||
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
_libCachePut(_libCacheKeyFor(accountId, folder, 'unread', false), {
|
||||||
emails: data.emails,
|
emails: data.emails,
|
||||||
total: data.total || data.emails.length,
|
total: data.total || data.emails.length,
|
||||||
sync,
|
sync,
|
||||||
});
|
});
|
||||||
_libUnreadPrewarmKey = key;
|
} catch (_) {}
|
||||||
_libUnreadPrewarmAt = Date.now();
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _prewarmEmailViews({ signal, generation } = {}) {
|
function _sleep(ms) {
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _prewarmEmailViews() {
|
||||||
|
if (state._libOpen) return;
|
||||||
|
_libLastPrewarmAt = Date.now();
|
||||||
_setEmailSyncStatus({ warming: true });
|
_setEmailSyncStatus({ warming: true });
|
||||||
const folder = 'INBOX';
|
const folder = 'INBOX';
|
||||||
const filter = 'all';
|
const filter = 'all';
|
||||||
try {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One optional first-page request only. Folder metadata, unread state, and
|
// The accounts request is cheap and warms the account strip for first open.
|
||||||
// other accounts remain demand-driven so startup cannot fan out into IMAP.
|
// Then folder/list requests warm both the client cache and the backend
|
||||||
const res = await fetch(emailApiUrl('/api/email/list', {
|
// IMAP/read caches. Failure stays silent: no configured mail should not nag.
|
||||||
folder,
|
try {
|
||||||
limit: _LIB_INITIAL_PAGE_SIZE,
|
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
|
||||||
offset: 0,
|
if (accountsRes.ok) {
|
||||||
filter,
|
const accountsData = await accountsRes.json().catch(() => ({}));
|
||||||
account_id: accountId || undefined,
|
if (Array.isArray(accountsData.accounts)) {
|
||||||
}), {
|
state._libAccounts = accountsData.accounts;
|
||||||
credentials: 'same-origin',
|
_libAccountsLoadedAt = Date.now();
|
||||||
signal,
|
}
|
||||||
});
|
}
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal) || !res.ok) return false;
|
} catch (_) {}
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
if (!_isEmailPrewarmCurrent(generation, signal)) return false;
|
const accounts = Array.isArray(state._libAccounts) ? state._libAccounts.filter(a => a && a.enabled !== false) : [];
|
||||||
if (!data || data.error || !Array.isArray(data.emails)) return false;
|
const preferred = state._libAccountId
|
||||||
const sync = data.sync || {};
|
|| (accounts.find(a => a.is_default)?.id)
|
||||||
_libCachePut(ck, {
|
|| (accounts[0]?.id)
|
||||||
emails: data.emails,
|
|| '';
|
||||||
total: data.total || 0,
|
if (!state._libAccountId && preferred) {
|
||||||
sync,
|
state._libAccountId = preferred;
|
||||||
});
|
_publishActiveAccount();
|
||||||
_libLastPrewarmAt = Date.now();
|
}
|
||||||
_setEmailSyncStatus({
|
const orderedAccountIds = [
|
||||||
updatedAt: sync.updated_at || new Date().toISOString(),
|
preferred,
|
||||||
source: sync.source || '',
|
...accounts.map(a => a.id).filter(id => id && id !== preferred),
|
||||||
warming: true,
|
].filter((id, idx, arr) => arr.indexOf(id) === idx);
|
||||||
});
|
if (!orderedAccountIds.length) orderedAccountIds.push('');
|
||||||
return true;
|
|
||||||
} catch (_) {
|
try {
|
||||||
return false;
|
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);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_setEmailSyncStatus({ warming: false });
|
_setEmailSyncStatus({ warming: false });
|
||||||
}
|
}
|
||||||
@@ -2482,34 +2286,16 @@ function _publishActiveAccount() {
|
|||||||
|
|
||||||
export function initEmailLibrary(config) {
|
export function initEmailLibrary(config) {
|
||||||
state._docModule = config.documentModule;
|
state._docModule = config.documentModule;
|
||||||
const onEmailClick = config.onEmailClick;
|
state._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 isOpen() { return state._libOpen; }
|
||||||
|
|
||||||
export function openEmailLibrary(opts = {}) {
|
export function openEmailLibrary(opts = {}) {
|
||||||
// Foreground email always wins: cancel a delayed/idle callback and abort the
|
if (_libPrewarmTimer) {
|
||||||
// one optional request if it has already started. Generation checks make a
|
clearTimeout(_libPrewarmTimer);
|
||||||
// non-abortable response harmless if it races this transition.
|
_libPrewarmTimer = null;
|
||||||
_cancelEmailPrewarm();
|
}
|
||||||
// Force-clean any stale state from previous attempts
|
// Force-clean any stale state from previous attempts
|
||||||
const existing = document.getElementById('email-lib-modal');
|
const existing = document.getElementById('email-lib-modal');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
@@ -2517,7 +2303,6 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
document.removeEventListener('keydown', state._libEscHandler, true);
|
document.removeEventListener('keydown', state._libEscHandler, true);
|
||||||
state._libEscHandler = null;
|
state._libEscHandler = null;
|
||||||
}
|
}
|
||||||
_emailMailboxGeneration += 1;
|
|
||||||
state._libOpen = true;
|
state._libOpen = true;
|
||||||
// On mobile the sidebar overlays content — close it so the email view isn't
|
// On mobile the sidebar overlays content — close it so the email view isn't
|
||||||
// opened behind it (same pattern as session-switch/delete).
|
// opened behind it (same pattern as session-switch/delete).
|
||||||
@@ -3141,7 +2926,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
}
|
}
|
||||||
const fastAccountAtOpen = state._libAccountId || '';
|
const fastAccountAtOpen = state._libAccountId || '';
|
||||||
if (fastAccountAtOpen) {
|
if (fastAccountAtOpen) {
|
||||||
_loadEmails({ useCache: true });
|
_loadEmailsWhenChatIdle({ delay: 0 });
|
||||||
}
|
}
|
||||||
// If we already know the previous/default account, paint that inbox first
|
// If we already know the previous/default account, paint that inbox first
|
||||||
// from the durable index and validate accounts in parallel. Cold refreshes
|
// from the durable index and validate accounts in parallel. Cold refreshes
|
||||||
@@ -3151,7 +2936,7 @@ export function openEmailLibrary(opts = {}) {
|
|||||||
_loadFolders();
|
_loadFolders();
|
||||||
_loadEmailReminderBellVisibility();
|
_loadEmailReminderBellVisibility();
|
||||||
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
if (!fastAccountAtOpen || fastAccountAtOpen !== (state._libAccountId || '')) {
|
||||||
_loadEmails({ useCache: true });
|
_loadEmailsWhenChatIdle();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -3336,7 +3121,6 @@ export async function openEmailLibrarySettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function closeEmailLibrary() {
|
export function closeEmailLibrary() {
|
||||||
_cancelEmailPrewarm();
|
|
||||||
const modal = document.getElementById('email-lib-modal');
|
const modal = document.getElementById('email-lib-modal');
|
||||||
if (modal) modal.remove();
|
if (modal) modal.remove();
|
||||||
if (_libSyncTicker) {
|
if (_libSyncTicker) {
|
||||||
@@ -4770,7 +4554,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 450);
|
const timer = setTimeout(() => ctrl.abort(), 450);
|
||||||
try {
|
try {
|
||||||
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`, {
|
const fastRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}&cached_only=1`, {
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
const fastData = await fastRes.json().catch(() => null);
|
const fastData = await fastRes.json().catch(() => null);
|
||||||
@@ -4797,7 +4581,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
|||||||
// opens omit it so rapid close/reopen returns instantly; the
|
// opens omit it so rapid close/reopen returns instantly; the
|
||||||
// Refresh button passes `force: true` to add it back.
|
// Refresh button passes `force: true` to add it back.
|
||||||
const buster = force ? `&_=${Date.now()}` : '';
|
const buster = force ? `&_=${Date.now()}` : '';
|
||||||
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 res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(folderAtStart)}${accountQS}&limit=100&offset=${offsetAtStart}&filter=${filterAtStart}${attQS}${buster}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
if (seq !== _libLoadSeq || accountAtStart !== (state._libAccountId || '')) return;
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
@@ -5052,8 +4836,6 @@ function _createCard(em) {
|
|||||||
else if (!em.is_read) cls += ' email-card-unread';
|
else if (!em.is_read) cls += ' email-card-unread';
|
||||||
card.className = cls;
|
card.className = cls;
|
||||||
card.dataset.uid = String(em.uid);
|
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');
|
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
|
||||||
|
|
||||||
// Checkbox in select mode
|
// Checkbox in select mode
|
||||||
@@ -5380,25 +5162,6 @@ async function _toggleCardPreview(card, em) {
|
|||||||
// currently-selected folder for normal inbox cards.
|
// currently-selected folder for normal inbox cards.
|
||||||
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
||||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
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 grid = card.closest('.doclib-grid');
|
||||||
const gridRect = grid?.getBoundingClientRect?.();
|
const gridRect = grid?.getBoundingClientRect?.();
|
||||||
const modal = document.getElementById('email-lib-modal');
|
const modal = document.getElementById('email-lib-modal');
|
||||||
@@ -5423,30 +5186,6 @@ async function _toggleCardPreview(card, em) {
|
|||||||
return;
|
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
|
// Collapse any other expanded card
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
||||||
@@ -5468,10 +5207,10 @@ async function _toggleCardPreview(card, em) {
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
||||||
});
|
});
|
||||||
if (!wasReadAtStart) {
|
if (!em.is_read) {
|
||||||
// Keep the current optimistic visual update, but let the read request below
|
_syncEmailReadState(em.uid, true);
|
||||||
// own the provider-side \Seen transition. A failure restores unread state.
|
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
|
||||||
_syncEmailReadState(uidAtStart, true, readContext);
|
.catch(err => console.error('Failed to mark email read:', err));
|
||||||
}
|
}
|
||||||
// Class hook on the modal so the header-hide / padding rules work on
|
// Class hook on the modal so the header-hide / padding rules work on
|
||||||
// browsers without :has() support (Firefox mobile) — the :has() versions
|
// browsers without :has() support (Firefox mobile) — the :has() versions
|
||||||
@@ -5500,28 +5239,25 @@ async function _toggleCardPreview(card, em) {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
};
|
};
|
||||||
|
|
||||||
let authoritativeReadSucceeded = false;
|
|
||||||
try {
|
try {
|
||||||
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||||
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (
|
||||||
restoreUnreadState();
|
accountAtStart !== (state._libAccountId || '') ||
|
||||||
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
|
libraryFolderAtStart !== (state._libFolder || 'INBOX') ||
|
||||||
|
uidAtStart !== String(card?.dataset?.uid || '') ||
|
||||||
|
!card.isConnected ||
|
||||||
|
!card.classList.contains('email-card-expanded')
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data.mark_seen_failed) {
|
if (data.error) {
|
||||||
// The body is authoritative even when the provider refused the \Seen
|
showFailedReader(`Failed to load email: ${data.error}`);
|
||||||
// transition. Render the message and roll the unread marker back so the
|
return;
|
||||||
// list keeps telling the truth, rather than refusing to open a message
|
|
||||||
// we successfully read.
|
|
||||||
restoreUnreadState();
|
|
||||||
} else {
|
|
||||||
authoritativeReadSucceeded = true;
|
|
||||||
commitReadState();
|
|
||||||
}
|
}
|
||||||
if (!isCurrentOpen()) return;
|
// Mark as read locally
|
||||||
|
_syncEmailReadState(em.uid, true);
|
||||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||||
|
|
||||||
// Build the attachments wrap using the shared helper so the signature-
|
// Build the attachments wrap using the shared helper so the signature-
|
||||||
@@ -5703,10 +5439,7 @@ async function _toggleCardPreview(card, em) {
|
|||||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!authoritativeReadSucceeded) restoreUnreadState();
|
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||||
if (isCurrentOpen()) {
|
|
||||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7526,11 +7259,12 @@ async function _generateSummary(reader, data, btn) {
|
|||||||
if (label) label.textContent = 'Summary';
|
if (label) label.textContent = 'Summary';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_renderEmailSummaryError(content, result);
|
content.innerHTML = `<span style="color:var(--red)">${_esc(result.error || 'Failed to summarize')}</span>`;
|
||||||
|
panel.remove();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
sp.destroy();
|
sp.destroy();
|
||||||
_renderEmailSummaryError(content, null);
|
panel.remove();
|
||||||
if (uiModule) uiModule.showError?.('Failed to summarize');
|
if (uiModule) uiModule.showError?.('Failed to summarize');
|
||||||
} finally {
|
} finally {
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
|
|||||||
@@ -30,25 +30,6 @@ export function _esc(text) {
|
|||||||
return div.innerHTML;
|
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) {
|
function _attrEsc(text) {
|
||||||
return String(text ?? '')
|
return String(text ?? '')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
// 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;
|
|
||||||
+7
-13
@@ -758,36 +758,30 @@ export function mdToHtml(src, opts) {
|
|||||||
// Remove empty paragraphs
|
// Remove empty paragraphs
|
||||||
s = s.replace(/<p><\/p>/g, '');
|
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
|
// CRITICAL: Restore allowed HTML blocks first
|
||||||
allowedHtmlBlocks.forEach((block, index) => {
|
allowedHtmlBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___ALLOWED_HTML_${index}___`, () => block);
|
s = s.replace(`___ALLOWED_HTML_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore math blocks
|
// Restore math blocks
|
||||||
mathBlocks.forEach((block, index) => {
|
mathBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___MATH_BLOCK_${index}___`, () => block);
|
s = s.replace(`___MATH_BLOCK_${index}___`, block);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore mermaid diagram blocks
|
// Restore mermaid diagram blocks
|
||||||
mermaidBlocks.forEach((block, index) => {
|
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
|
// CRITICAL: Restore code blocks at the end
|
||||||
codeBlocks.forEach((block, index) => {
|
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
|
// Restore inline code spans last, so placeholders carried inside restored
|
||||||
// <a>/allowed-HTML blocks are resolved too.
|
// <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.
|
||||||
inlineCodeBlocks.forEach((block, index) => {
|
inlineCodeBlocks.forEach((block, index) => {
|
||||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-25
@@ -1683,21 +1683,8 @@ export async function loadSessions() {
|
|||||||
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(url);
|
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();
|
fetched = await res.json();
|
||||||
}
|
}
|
||||||
if (!Array.isArray(fetched)) {
|
|
||||||
throw new Error('Session request returned an invalid response');
|
|
||||||
}
|
|
||||||
sessions = _normalizeSessionsList(fetched);
|
sessions = _normalizeSessionsList(fetched);
|
||||||
renderSessionList();
|
renderSessionList();
|
||||||
|
|
||||||
@@ -1820,15 +1807,9 @@ export async function loadSessions() {
|
|||||||
_autoCreateInProgress = false;
|
_autoCreateInProgress = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in loadSessions:', error);
|
console.error('Error in loadSessions:', error);
|
||||||
// app.js's global fetch wrapper owns expired-auth navigation. Avoid
|
uiModule.showError('Failed to load sessions: ' + error.message);
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1866,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||||||
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
||||||
if (!_isTransientChat) {
|
if (!_isTransientChat) {
|
||||||
Storage.set('lastSessionId', id);
|
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
|
// Restore character preset for persistent chats
|
||||||
try {
|
try {
|
||||||
@@ -2336,7 +2313,6 @@ export async function materializePendingSession() {
|
|||||||
currentSessionId = payload.id;
|
currentSessionId = payload.id;
|
||||||
if (!isIncognito) {
|
if (!isIncognito) {
|
||||||
Storage.set('lastSessionId', payload.id);
|
Storage.set('lastSessionId', payload.id);
|
||||||
history.replaceState(null, '', '#' + payload.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||||
|
|||||||
+22
-25
@@ -3031,14 +3031,12 @@ async function initEmailAccountsSettings() {
|
|||||||
const body = {
|
const body = {
|
||||||
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||||
from_address: 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_host: el('eaf-imap-host').value.trim(),
|
||||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||||
imap_user: el('eaf-imap-user').value.trim(),
|
imap_user: el('eaf-imap-user').value.trim(),
|
||||||
imap_starttls: el('eaf-imap-starttls').checked,
|
imap_starttls: el('eaf-imap-starttls').checked,
|
||||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||||
smtp_security: el('eaf-smtp-security').value,
|
|
||||||
smtp_user: el('eaf-imap-user').value.trim(),
|
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; }
|
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||||
@@ -5790,30 +5788,29 @@ export function close() {
|
|||||||
window.history.replaceState(null, '', clean);
|
window.history.replaceState(null, '', clean);
|
||||||
const success = sp.has('email_oauth_success');
|
const success = sp.has('email_oauth_success');
|
||||||
const errMsg = sp.get('email_oauth_error') || '';
|
const errMsg = sp.get('email_oauth_error') || '';
|
||||||
// Open settings → integrations once the document is ready. This module owns
|
// Open settings → integrations after the app has initialised.
|
||||||
// the open() API, so it does not need to wait for a window-level alias.
|
function _tryOpen() {
|
||||||
function _showResult() {
|
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
||||||
open('integrations');
|
window.settingsModule.open('integrations');
|
||||||
// Brief toast-style banner.
|
// Brief toast-style banner.
|
||||||
const banner = document.createElement('div');
|
const banner = document.createElement('div');
|
||||||
banner.textContent = success
|
banner.textContent = success
|
||||||
? 'Google account connected — email is ready'
|
? '✓ Google account connected — email is ready'
|
||||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||||
Object.assign(banner.style, {
|
Object.assign(banner.style, {
|
||||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||||
});
|
});
|
||||||
document.body.appendChild(banner);
|
document.body.appendChild(banner);
|
||||||
setTimeout(() => banner.remove(), 4000);
|
setTimeout(() => banner.remove(), 4000);
|
||||||
}
|
} else {
|
||||||
if (document.readyState === 'loading') {
|
setTimeout(_tryOpen, 100);
|
||||||
document.addEventListener('DOMContentLoaded', _showResult, { once: true });
|
}
|
||||||
} else {
|
|
||||||
_showResult();
|
|
||||||
}
|
}
|
||||||
|
_tryOpen();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||||
|
|||||||
+5
-3
@@ -83,9 +83,11 @@ export async function loadSkills(cascade = false) {
|
|||||||
// Play the domino-in entrance on this load (set when the tab is opened,
|
// Play the domino-in entrance on this load (set when the tab is opened,
|
||||||
// not for the silent re-loads after an edit/delete).
|
// not for the silent re-loads after an edit/delete).
|
||||||
if (cascade) _cascadeNext = true;
|
if (cascade) _cascadeNext = true;
|
||||||
// Always re-fetch when the tab is explicitly opened — the cascade
|
if (cascade && loaded && !_loadPromise && _playSkillsCascade()) {
|
||||||
// animation is handled inside renderSkillsList() via _cascadeNext.
|
_cascadeNext = false;
|
||||||
// Skipping the fetch here caused stale data on panel close/reopen (#5870).
|
updateCount();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_loadPromise) return _loadPromise;
|
if (_loadPromise) return _loadPromise;
|
||||||
_loadPromise = (async () => {
|
_loadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+11
-84
@@ -4,13 +4,6 @@
|
|||||||
* ASCII Spinner Module for AI thinking/processing status
|
* 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 {
|
class Spinner {
|
||||||
constructor(message = "AI is processing", style = "right", animation = "spinner") {
|
constructor(message = "AI is processing", style = "right", animation = "spinner") {
|
||||||
// Different animation frames
|
// Different animation frames
|
||||||
@@ -28,9 +21,6 @@ class Spinner {
|
|||||||
this.intervalId = null;
|
this.intervalId = null;
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
this.element = null;
|
this.element = null;
|
||||||
this._wpWasConnected = false;
|
|
||||||
this._wpUnattachedSince = null;
|
|
||||||
this._visHandler = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,7 +74,6 @@ class Spinner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_drawSineWave() {
|
_drawSineWave() {
|
||||||
if (!this.isRunning) return;
|
|
||||||
const ctx = this._ctx;
|
const ctx = this._ctx;
|
||||||
const W = this._canvas.width;
|
const W = this._canvas.width;
|
||||||
const H = this._canvas.height;
|
const H = this._canvas.height;
|
||||||
@@ -131,7 +120,9 @@ class Spinner {
|
|||||||
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
|
ctx.fillStyle = 'rgba(156, 222, 242, 0.9)';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
if (this.isRunning) this._requestFrame();
|
if (this.isRunning) {
|
||||||
|
this.rafId = requestAnimationFrame(() => this._drawSineWave());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_createWhirlpoolElement() {
|
_createWhirlpoolElement() {
|
||||||
@@ -167,7 +158,6 @@ class Spinner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_drawWhirlpool() {
|
_drawWhirlpool() {
|
||||||
if (!this.isRunning) return;
|
|
||||||
const ctx = this._wpCtx;
|
const ctx = this._wpCtx;
|
||||||
const W = this._wpCanvas.width;
|
const W = this._wpCanvas.width;
|
||||||
const H = this._wpCanvas.height;
|
const H = this._wpCanvas.height;
|
||||||
@@ -239,79 +229,20 @@ class Spinner {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
// Leak-safe self-terminate. "Nobody can see this spinner" has two shapes
|
if (!this.isRunning) return;
|
||||||
// and we have to catch both:
|
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||||
// 1. the element WAS in the DOM and then got removed (a loading row
|
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||||
// replaced by results);
|
// before it's first appended — start() runs synchronously, before the
|
||||||
// 2. the element was NEVER inserted, and the grace window for inserting
|
// caller inserts the element, so it isn't connected on frame 1.
|
||||||
// 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);
|
const connected = !!(this.element && this.element.isConnected);
|
||||||
if (connected) {
|
if (connected) this._wpWasConnected = true;
|
||||||
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) {
|
if (connected || !this._wpWasConnected) {
|
||||||
this._requestFrame();
|
this.rafId = requestAnimationFrame(() => this._drawWhirlpool());
|
||||||
} else {
|
} else {
|
||||||
this.stop();
|
this.isRunning = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the spinner display
|
* Update the spinner display
|
||||||
*/
|
*/
|
||||||
@@ -341,15 +272,12 @@ class Spinner {
|
|||||||
|
|
||||||
if (this.animation === 'sinewave') {
|
if (this.animation === 'sinewave') {
|
||||||
this._wavePrev = performance.now();
|
this._wavePrev = performance.now();
|
||||||
this._armVisibilityPause();
|
|
||||||
this._drawSineWave();
|
this._drawSineWave();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.animation === 'whirlpool') {
|
if (this.animation === 'whirlpool') {
|
||||||
this._wpStartedAt = performance.now();
|
this._wpStartedAt = performance.now();
|
||||||
this._wpUnattachedSince = null;
|
|
||||||
this._armVisibilityPause();
|
|
||||||
this._drawWhirlpool();
|
this._drawWhirlpool();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -374,7 +302,6 @@ class Spinner {
|
|||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
this.rafId = null;
|
this.rafId = null;
|
||||||
}
|
}
|
||||||
this._disarmVisibilityPause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// 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,12 +38015,6 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 5px;
|
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 {
|
#email-lib-grid .date-section-header {
|
||||||
padding: 10px 5px 3px;
|
padding: 10px 5px 3px;
|
||||||
}
|
}
|
||||||
|
|||||||
+142
-175
@@ -1,202 +1,169 @@
|
|||||||
# Test Layout Inventory
|
# Test layout inventory
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This document is a completed implementation snapshot for the first low-risk
|
||||||
|
> test-directory reorganization. It does not propose additional file moves and
|
||||||
|
> is not the canonical definition of the current test taxonomy.
|
||||||
|
|
||||||
|
- **Original inventory:** #3712
|
||||||
|
- **Parent tracker:** #2523
|
||||||
|
- **Implemented by:** #3842
|
||||||
|
- **Status:** completed historical snapshot
|
||||||
|
|
||||||
|
- **Durable testing policy:** [`TESTING_STANDARD.md`](./TESTING_STANDARD.md)
|
||||||
|
- **Current testing mechanics:** [`README.md`](./README.md)
|
||||||
|
|
||||||
|
## Disposition
|
||||||
|
|
||||||
|
This document is retained as a non-canonical historical record of the completed
|
||||||
|
CLI test relocation.
|
||||||
|
|
||||||
|
Reusable testing rules are maintained in `TESTING_STANDARD.md`; current helper,
|
||||||
|
taxonomy-runner, and execution mechanics are maintained in `README.md`, the test
|
||||||
|
suite, and its supporting code.
|
||||||
|
|
||||||
|
Counts, residual-file observations, and path listings in this document are
|
||||||
|
snapshot evidence only. They must not be maintained as durable repository truth.
|
||||||
|
|
||||||
|
Any additional test relocation requires a separately approved issue or work item.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Inventory for the first low-risk split of the flat `tests/` directory
|
This document preserves the reasoning, scope, and validation contract for the
|
||||||
(issue #3712, parent #2523). This document only records *what* should move
|
first mechanical reorganization of the formerly flat `tests/` directory.
|
||||||
first and *why*; it moves nothing. The actual move is a separate, mechanical
|
|
||||||
PR that relocates the listed files verbatim and changes no test content.
|
|
||||||
|
|
||||||
The target layout and category definitions come from
|
The original change moved 28 tests classified as `area_cli` into `tests/cli/`
|
||||||
[`TESTING_STANDARD.md`](./TESTING_STANDARD.md); the collection-time markers
|
without changing their assertions, helpers, taxonomy, or runtime behaviour.
|
||||||
come from [`_taxonomy.py`](./_taxonomy.py), which classifies by **filename
|
|
||||||
tokens only** (paths are ignored, except the `tests/helpers/` rule). A file
|
|
||||||
keeps its `area_*`/`sub_*` markers when moved into a subdirectory, and
|
|
||||||
`conftest.py` discovers marker names recursively (`rglob`), so a move does not
|
|
||||||
disturb marker registration or focused selection.
|
|
||||||
|
|
||||||
## Current low-risk candidate groups
|
Current testing guidance and classification rules are maintained in:
|
||||||
|
|
||||||
Groups whose tests need no route/app setup and no real DB/session setup:
|
- [`README.md`](./README.md)
|
||||||
|
- [`TESTING_STANDARD.md`](./TESTING_STANDARD.md)
|
||||||
|
- [`_taxonomy.py`](./_taxonomy.py)
|
||||||
|
|
||||||
1. **CLI / script tests** (`area_cli`, 28 files) - load `scripts/` entry
|
The current repository and test suite remain authoritative when this snapshot
|
||||||
points via `tests.helpers.cli_loader.load_script`; DB access is stubbed
|
and the implementation differ.
|
||||||
with `tests.helpers.db_stubs` (`SessionLocal` is a plain stub attribute).
|
|
||||||
No `TestClient`, no FastAPI app import, no SQLite files.
|
|
||||||
2. **Helper self-tests** (`area_helpers`) - e.g. `test_helpers_import_state.py`,
|
|
||||||
`test_db_stubs_helper.py`. Safe but tiny (two files), and they test the
|
|
||||||
shared helpers from the #3685 audit (merged) that the rest of the suite
|
|
||||||
depends on; little payoff as a first slice.
|
|
||||||
3. **Pure unit / parsing tests** (`area_unit`) - `*_nonstring.py`,
|
|
||||||
`*_nondict.py`, parsing tests. Large and heterogeneous; some touch
|
|
||||||
provider/session modules, so the boundary is less crisp.
|
|
||||||
4. **Static checks** - e.g. `test_readme_ascii_fenced.py`,
|
|
||||||
`test_docs_no_orphan_images.py`. Safe but tiny and `uncategorized` in the
|
|
||||||
taxonomy, so a move buys little and matches no existing marker.
|
|
||||||
|
|
||||||
Not candidates for the first move (per #3712 guidance): security/owner-scope
|
## Why CLI tests were selected
|
||||||
tests, route/API tests, DB/session-heavy tests, auth/session concurrency
|
|
||||||
tests, and the taxonomy/runner infrastructure tests that changed recently
|
|
||||||
(#3491, #3556, #3659, #3711).
|
|
||||||
|
|
||||||
## Recommended first move
|
The CLI and script tests were selected as the first low-risk group because they
|
||||||
|
formed a narrow, mechanically identifiable boundary:
|
||||||
|
|
||||||
**CLI / script tests → `tests/cli/`**
|
- script entry points were loaded through shared CLI helpers;
|
||||||
|
- database access used controlled stubs rather than persistent sessions;
|
||||||
|
- the tests did not require FastAPI application or route setup;
|
||||||
|
- the tests did not require a real database;
|
||||||
|
- taxonomy classification could be derived consistently from filenames;
|
||||||
|
- absolute helper imports remained valid after relocation.
|
||||||
|
|
||||||
Why this group over the alternatives:
|
This reduced the risk relative to route, security, session, provider, and
|
||||||
|
heterogeneous unit-test groups.
|
||||||
|
|
||||||
- Lowest coupling: every file imports only the script under test (via
|
## Completed implementation
|
||||||
`cli_loader`) plus `tests.helpers` stubs - no app, no routes, no real DB.
|
|
||||||
- Crisp, machine-checkable boundary: the set is exactly the files classified
|
|
||||||
`area_cli` by `_taxonomy.py`, so before/after selection counts can be
|
|
||||||
compared mechanically.
|
|
||||||
- Already the planned target dir for this category in `TESTING_STANDARD.md`
|
|
||||||
(`tests/cli/`).
|
|
||||||
- Absolute imports (`from tests.helpers...`) and unique basenames mean no
|
|
||||||
import-order or module-name collisions after the move.
|
|
||||||
- Lower risk than helper self-tests (tiny group, little payoff), unit tests
|
|
||||||
(fuzzy boundary), or anything security/route/session-shaped.
|
|
||||||
|
|
||||||
## Files included in the first move
|
Repository history records the implementation as:
|
||||||
|
|
||||||
The 28 files classified `area_cli` (verified against `_taxonomy.py`):
|
- `83af3ca test: move area_cli tests into cli directory (#3842)`
|
||||||
|
|
||||||
Note: this inventory was refreshed against current `dev` after `tests/test_research_cli_status.py` was added to the `area_cli` set.
|
The following 28 files were included in that completed move:
|
||||||
|
|
||||||
- `tests/test_calendar_cli_name.py`
|
- `tests/cli/test_calendar_cli_name.py`
|
||||||
- `tests/test_contacts_cli_rows.py`
|
- `tests/cli/test_contacts_cli_rows.py`
|
||||||
- `tests/test_cookbook_cli_state.py`
|
- `tests/cli/test_cookbook_cli_state.py`
|
||||||
- `tests/test_docs_cli_content_length.py`
|
- `tests/cli/test_docs_cli_content_length.py`
|
||||||
- `tests/test_gallery_cli_album_count.py`
|
- `tests/cli/test_gallery_cli_album_count.py`
|
||||||
- `tests/test_gallery_cli_preview.py`
|
- `tests/cli/test_gallery_cli_preview.py`
|
||||||
- `tests/test_logs_cli_resolve_nonstring.py`
|
- `tests/cli/test_logs_cli_resolve_nonstring.py`
|
||||||
- `tests/test_mail_cli_read_empty_fetch.py`
|
- `tests/cli/test_mail_cli_read_empty_fetch.py`
|
||||||
- `tests/test_mail_cli_recipients.py`
|
- `tests/cli/test_mail_cli_recipients.py`
|
||||||
- `tests/test_mcp_cli_env_serialize.py`
|
- `tests/cli/test_mcp_cli_env_serialize.py`
|
||||||
- `tests/test_mcp_cli_json.py`
|
- `tests/cli/test_mcp_cli_json.py`
|
||||||
- `tests/test_memory_cli_rows.py`
|
- `tests/cli/test_memory_cli_rows.py`
|
||||||
- `tests/test_notes_cli_items.py`
|
- `tests/cli/test_notes_cli_items.py`
|
||||||
- `tests/test_personal_cli_rows.py`
|
- `tests/cli/test_personal_cli_rows.py`
|
||||||
- `tests/test_preset_cli_invalid_entries.py`
|
- `tests/cli/test_preset_cli_invalid_entries.py`
|
||||||
- `tests/test_preset_cli_set_corrupt_entry.py`
|
- `tests/cli/test_preset_cli_set_corrupt_entry.py`
|
||||||
- `tests/test_preset_cli_store.py`
|
- `tests/cli/test_preset_cli_store.py`
|
||||||
- `tests/test_research_cli_preview.py`
|
- `tests/cli/test_research_cli_preview.py`
|
||||||
- `tests/test_research_cli_status_filter.py`
|
- `tests/cli/test_research_cli_status.py`
|
||||||
- `tests/test_research_cli_status.py`
|
- `tests/cli/test_research_cli_status_filter.py`
|
||||||
- `tests/test_research_cli_store.py`
|
- `tests/cli/test_research_cli_store.py`
|
||||||
- `tests/test_sessions_cli.py`
|
- `tests/cli/test_sessions_cli.py`
|
||||||
- `tests/test_signature_cli_export.py`
|
- `tests/cli/test_signature_cli_export.py`
|
||||||
- `tests/test_skills_cli_preview.py`
|
- `tests/cli/test_skills_cli_preview.py`
|
||||||
- `tests/test_skills_cli_rows.py`
|
- `tests/cli/test_skills_cli_rows.py`
|
||||||
- `tests/test_tasks_cli_preview.py`
|
- `tests/cli/test_tasks_cli_preview.py`
|
||||||
- `tests/test_theme_cli_store.py`
|
- `tests/cli/test_theme_cli_store.py`
|
||||||
- `tests/test_webhook_cli_mask.py`
|
- `tests/cli/test_webhook_cli_mask.py`
|
||||||
|
|
||||||
## Files intentionally excluded
|
## Original exclusions
|
||||||
|
|
||||||
- `tests/test_backup_cli_security.py` - classifies as `area_security`
|
The original inventory deliberately excluded:
|
||||||
(security outranks cli in the taxonomy); moving it into `tests/cli/` would
|
|
||||||
make the directory disagree with its marker. It belongs with the security
|
|
||||||
group in a later phase.
|
|
||||||
- `tests/test_run_focus.py`, `tests/test_taxonomy.py` - taxonomy/runner
|
|
||||||
infrastructure tests, recently changed (#3556, #3659); they also pin
|
|
||||||
flat-layout paths (e.g. `tests/test_auth_config_lock_concurrency.py` in
|
|
||||||
`test_run_focus.py`), so they stay put.
|
|
||||||
- Script-like but `uncategorized` files - `test_pr_blocker_audit.py`,
|
|
||||||
`test_update_database_script.py`, `test_windows_update_script.py`,
|
|
||||||
`test_setup_admin_user.py`, `test_amd_gpu_check_args.py`, `test_hwfit_*.py`.
|
|
||||||
They exercise `scripts/` too, but moving them would make `tests/cli/`
|
|
||||||
diverge from the `area_cli` marker set. Reclassify or move them in a later,
|
|
||||||
separate slice.
|
|
||||||
- Everything else (security, routes, services, unit, js, helpers) - out of
|
|
||||||
scope for the first move by design.
|
|
||||||
|
|
||||||
## How this was verified
|
- `tests/test_backup_cli_security.py`, because its security classification
|
||||||
|
outranked the CLI token in its filename;
|
||||||
|
- taxonomy and focused-runner infrastructure, including
|
||||||
|
`tests/test_taxonomy.py` and `tests/test_run_focus.py`;
|
||||||
|
- script-oriented tests not classified as `area_cli`;
|
||||||
|
- route, service, security, session, JavaScript, helper, and broad unit-test
|
||||||
|
groups.
|
||||||
|
|
||||||
Read-only checks, run from the repo root on this branch. Note the real API is
|
These exclusions kept the implementation mechanical and prevented it from
|
||||||
`classify_test_path` (there is no `classify_test_file`).
|
mixing taxonomy changes, assertion changes, helper extraction, or unrelated
|
||||||
|
test-suite restructuring.
|
||||||
|
|
||||||
|
## Current repository observation
|
||||||
|
|
||||||
|
The current taxonomy classifies 30 files as `area_cli`:
|
||||||
|
|
||||||
|
| Location | Files |
|
||||||
|
|---|---:|
|
||||||
|
| `tests/cli/` | 28 |
|
||||||
|
| Flat `tests/` directory | 2 |
|
||||||
|
|
||||||
|
The two currently classified CLI tests outside `tests/cli/` are:
|
||||||
|
|
||||||
|
- `tests/test_calendar_cli_overlap.py`
|
||||||
|
- `tests/test_memory_cli_add_nondict.py`
|
||||||
|
|
||||||
|
These files were not part of the original 28-file implementation.
|
||||||
|
|
||||||
|
Their presence is recorded solely to keep this snapshot accurate. This
|
||||||
|
documentation update does not recommend moving them, changing their taxonomy,
|
||||||
|
or opening implementation work. Any future test-layout change requires separate
|
||||||
|
maintainer agreement, scope, and validation.
|
||||||
|
|
||||||
|
## Validation contract preserved by the original move
|
||||||
|
|
||||||
|
The completed implementation was designed to preserve:
|
||||||
|
|
||||||
|
- identical `area_cli` selection before and after relocation;
|
||||||
|
- successful direct execution of the relocated tests;
|
||||||
|
- successful whole-suite collection;
|
||||||
|
- unchanged taxonomy and focused-runner behaviour;
|
||||||
|
- no stale references to the former flat paths;
|
||||||
|
- no production-code changes.
|
||||||
|
|
||||||
|
Relevant current verification commands include:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Compute the area_cli set and confirm test_backup_cli_security.py is
|
venv/bin/python tests/run_focus.py --dry-run --area cli
|
||||||
# area_security. Expected: 28 files, then "security".
|
venv/bin/python -m pytest -m area_cli -q
|
||||||
./venv/bin/python - <<'PY'
|
venv/bin/python -m pytest tests/cli/ -q
|
||||||
from pathlib import Path
|
venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
|
||||||
from tests._taxonomy import classify_test_path
|
venv/bin/python -m pytest --collect-only -q
|
||||||
|
|
||||||
cli = [p for p in sorted(Path("tests").glob("test_*.py"))
|
|
||||||
if classify_test_path(p).area == "cli"]
|
|
||||||
print(len(cli))
|
|
||||||
for p in cli:
|
|
||||||
print(p)
|
|
||||||
print(classify_test_path("tests/test_backup_cli_security.py").area)
|
|
||||||
PY
|
|
||||||
|
|
||||||
# Coupling check across the CLI files. Expected: the only hits are
|
|
||||||
# "SessionLocal" as stub attribute names passed to tests.helpers.db_stubs;
|
|
||||||
# no TestClient, FastAPI, create_app, sqlite, or dependency_overrides.
|
|
||||||
rg -n "TestClient|FastAPI|create_app|SessionLocal|sqlite|dependency_overrides" \
|
|
||||||
tests/test_*cli*.py tests/test_sessions_cli.py
|
|
||||||
|
|
||||||
# Hard-coded flat paths to the exact CLI files outside tests/. Expected: no matches.
|
|
||||||
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
|
||||||
from pathlib import Path
|
|
||||||
from tests._taxonomy import classify_test_path
|
|
||||||
|
|
||||||
for path in sorted(Path("tests").glob("test_*.py")):
|
|
||||||
if classify_test_path(path).area == "cli":
|
|
||||||
print(path)
|
|
||||||
PY2
|
|
||||||
|
|
||||||
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
|
|
||||||
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Also checked by reading the code: `tests/conftest.py` registers sub-markers
|
These commands describe validation coverage. This snapshot does not claim that
|
||||||
from a recursive `rglob` scan, and `tests/_taxonomy.py` classifies by filename
|
they were executed as part of the present documentation update.
|
||||||
tokens only (plus the `tests/helpers/` directory rule), so the markers of the
|
|
||||||
28 files do not change when they move into `tests/cli/`.
|
|
||||||
|
|
||||||
## Validation for the future move PR
|
|
||||||
|
|
||||||
Run with the project venv (`./venv/bin/python`); system `python3` may miss
|
|
||||||
pinned deps. Before the move, record the baseline; after, compare:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Selection must match the 28 files before and after the move.
|
|
||||||
./venv/bin/python tests/run_focus.py --dry-run --area cli
|
|
||||||
./venv/bin/python -m pytest -m area_cli -q
|
|
||||||
|
|
||||||
# Moved files pass when targeted directly.
|
|
||||||
./venv/bin/python -m pytest tests/cli/ -q
|
|
||||||
|
|
||||||
# Whole-suite collection still succeeds (catches import/path breakage).
|
|
||||||
./venv/bin/python -m pytest --collect-only -q
|
|
||||||
|
|
||||||
# Taxonomy/runner infrastructure is unaffected.
|
|
||||||
./venv/bin/python -m pytest tests/test_taxonomy.py tests/test_run_focus.py -q
|
|
||||||
|
|
||||||
# No stale flat-path references to the moved files. Expected: no matches
|
|
||||||
# outside tests/cli/ itself.
|
|
||||||
./venv/bin/python - <<'PY2' > /tmp/area_cli_paths.txt
|
|
||||||
from pathlib import Path
|
|
||||||
from tests._taxonomy import classify_test_path
|
|
||||||
|
|
||||||
for path in sorted(Path("tests").glob("test_*.py")):
|
|
||||||
if classify_test_path(path).area == "cli":
|
|
||||||
print(path)
|
|
||||||
PY2
|
|
||||||
|
|
||||||
rg -n -F -f /tmp/area_cli_paths.txt .github scripts docs \
|
|
||||||
tests/README.md tests/TESTING_STANDARD.md pyproject.toml 2>/dev/null || true
|
|
||||||
```
|
|
||||||
|
|
||||||
Pass criteria: identical test counts for `-m area_cli` before/after, zero
|
|
||||||
collection errors, and no changes outside the moved files.
|
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- No file moves, renames, or deletions in this PR.
|
This document does not:
|
||||||
- No changes to `conftest.py`, `_taxonomy.py`, `run_focus.py`, helpers,
|
|
||||||
markers, CI workflows, or production code.
|
- propose moving or renaming tests;
|
||||||
- No recommendation to split the whole suite at once; later groups get their
|
- redefine taxonomy classifications;
|
||||||
own inventory-then-move slices.
|
- change test behaviour or assertions;
|
||||||
|
- prescribe the next test-suite refactor;
|
||||||
|
- replace the testing standard;
|
||||||
|
- create implementation work from the two current residual files.
|
||||||
|
|||||||
+181
-148
@@ -1,5 +1,13 @@
|
|||||||
# Oversized Test File Split Plan
|
# Oversized Test File Split Plan
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> This is generated planning material based on the current test tree.
|
||||||
|
>
|
||||||
|
> It is non-canonical and disposable. Durable testing policy belongs in
|
||||||
|
> `tests/TESTING_STANDARD.md`; active split selection and prioritization
|
||||||
|
> belong in approved issues or the project tracker.
|
||||||
|
> It does not define runtime behaviour or approve any test-file split.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This document plans future oversized test-file splits using current repo data.
|
This document plans future oversized test-file splits using current repo data.
|
||||||
@@ -26,8 +34,8 @@ Future split PRs must still inspect each file manually before editing.
|
|||||||
|
|
||||||
## Current summary
|
## Current summary
|
||||||
|
|
||||||
- test files scanned: 583
|
- test files scanned: 746
|
||||||
- collected pytest items counted: 3586
|
- collected pytest items counted: 4764
|
||||||
- large-file threshold: 300 lines
|
- large-file threshold: 300 lines
|
||||||
- large-collected threshold: 20 collected items
|
- large-collected threshold: 20 collected items
|
||||||
|
|
||||||
@@ -35,123 +43,123 @@ Area distribution:
|
|||||||
|
|
||||||
| Value | Files |
|
| Value | Files |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| cli | 28 |
|
| cli | 30 |
|
||||||
| helpers | 1 |
|
| helpers | 1 |
|
||||||
| js | 39 |
|
| js | 49 |
|
||||||
| routes | 23 |
|
| routes | 35 |
|
||||||
| security | 77 |
|
| security | 88 |
|
||||||
| services | 144 |
|
| services | 197 |
|
||||||
| uncategorized | 234 |
|
| uncategorized | 306 |
|
||||||
| unit | 37 |
|
| unit | 40 |
|
||||||
|
|
||||||
Sub-area distribution:
|
Sub-area distribution:
|
||||||
|
|
||||||
| Value | Files |
|
| Value | Files |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| api | 6 |
|
| api | 8 |
|
||||||
| atomic | 3 |
|
| atomic | 3 |
|
||||||
| auth | 9 |
|
| auth | 11 |
|
||||||
| calendar | 10 |
|
| calendar | 12 |
|
||||||
| cli | 28 |
|
| cli | 30 |
|
||||||
| confinement | 7 |
|
| confinement | 8 |
|
||||||
| cookbook | 13 |
|
| cookbook | 20 |
|
||||||
| document | 11 |
|
| document | 13 |
|
||||||
| email | 12 |
|
| email | 23 |
|
||||||
| embedding | 3 |
|
| embedding | 6 |
|
||||||
| gallery | 5 |
|
| gallery | 6 |
|
||||||
| history | 3 |
|
| history | 3 |
|
||||||
| js | 39 |
|
| js | 49 |
|
||||||
| llm | 16 |
|
| llm | 21 |
|
||||||
| mcp | 8 |
|
| mcp | 12 |
|
||||||
| memory | 15 |
|
| memory | 17 |
|
||||||
| nondict | 7 |
|
| nondict | 7 |
|
||||||
| nonstring | 22 |
|
| nonstring | 23 |
|
||||||
| owner | 14 |
|
| owner | 15 |
|
||||||
| owner_scope | 23 |
|
| owner_scope | 27 |
|
||||||
| parse | 4 |
|
| parse | 5 |
|
||||||
| provider | 6 |
|
| provider | 16 |
|
||||||
| research | 16 |
|
| research | 17 |
|
||||||
| route | 6 |
|
| route | 6 |
|
||||||
| routes | 9 |
|
| routes | 19 |
|
||||||
| scheduler | 3 |
|
| scheduler | 4 |
|
||||||
| scope | 5 |
|
| scope | 5 |
|
||||||
| security | 9 |
|
| security | 9 |
|
||||||
| session | 16 |
|
| session | 18 |
|
||||||
| ssrf | 3 |
|
| ssrf | 6 |
|
||||||
| webhook | 3 |
|
| webhook | 4 |
|
||||||
| xss | 5 |
|
| xss | 5 |
|
||||||
|
|
||||||
Values below 2 files: 244 values covering 244 files.
|
Values below 2 files: 318 values covering 318 files.
|
||||||
|
|
||||||
## Top files by collected pytest items
|
## Top files by collected pytest items
|
||||||
|
|
||||||
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||||
|---|---:|---:|---:|---:|---|---|---|
|
|---|---:|---:|---:|---:|---|---|---|
|
||||||
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
| `tests/test_model_routes.py` | 2099 | 163 | 134 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||||
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
| `tests/test_security_regressions.py` | 1515 | 98 | 74 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||||
| `tests/test_provider_classification.py` | 188 | 67 | 21 | 4 | services | provider | - |
|
| `tests/test_cookbook_helpers.py` | 958 | 69 | 69 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
||||||
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
| `tests/test_shell_routes.py` | 538 | 69 | 53 | 9 | routes | routes | route/api, import-state, filesystem |
|
||||||
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
|
| `tests/test_endpoint_probing.py` | 497 | 61 | 36 | 7 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||||
| `tests/test_provider_endpoints.py` | 241 | 58 | 18 | 1 | services | provider | subprocess/script |
|
| `tests/test_research_routes_path_confinement.py` | 565 | 58 | 26 | 0 | security | confinement | route/api, security, filesystem, async/threading |
|
||||||
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
|
| `tests/test_run_focus.py` | 492 | 55 | 52 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||||
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
| `tests/test_agent_loop.py` | 500 | 54 | 54 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||||
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
| `tests/test_manage_mcp_command_allowlist.py` | 168 | 45 | 10 | 0 | services | mcp | db/session, subprocess/script, async/threading, ui/static |
|
||||||
| `tests/test_llm_core_temperature.py` | 196 | 41 | 17 | 0 | services | llm | - |
|
| `tests/test_provider_classification.py` | 115 | 42 | 9 | 2 | services | provider | - |
|
||||||
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
| `tests/test_review_regressions.py` | 1429 | 41 | 41 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_chat_helpers.py` | 582 | 37 | 24 | 0 | uncategorized | chat_helpers | route/api, filesystem, async/threading |
|
||||||
|
| `tests/test_model_context.py` | 314 | 36 | 34 | 4 | uncategorized | model_context | db/session, import-state |
|
||||||
|
| `tests/test_email_oauth.py` | 798 | 34 | 29 | 0 | services | email | route/api, db/session, security, async/threading |
|
||||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | 6 | 0 | services | llm | db/session |
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | 6 | 0 | services | llm | db/session |
|
||||||
| `tests/test_chat_helpers.py` | 264 | 31 | 18 | 0 | uncategorized | chat_helpers | route/api |
|
| `tests/test_redos_verdict_continuation.py` | 80 | 30 | 5 | 0 | uncategorized | redos_verdict_continuation | route/api |
|
||||||
| `tests/test_provider_detection.py` | 148 | 31 | 31 | 5 | services | provider | - |
|
|
||||||
| `tests/test_model_context.py` | 251 | 30 | 30 | 4 | uncategorized | model_context | db/session, import-state |
|
|
||||||
| `tests/test_endpoint_resolver.py` | 148 | 30 | 30 | 6 | uncategorized | endpoint_resolver | - |
|
|
||||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
|
||||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | 5 | 0 | uncategorized | upload_limits_centralized | import-state, filesystem |
|
| `tests/test_upload_limits_centralized.py` | 110 | 29 | 5 | 0 | uncategorized | upload_limits_centralized | import-state, filesystem |
|
||||||
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
|
| `tests/test_cookbook_docker_access.py` | 339 | 28 | 13 | 0 | services | cookbook | route/api, filesystem, async/threading |
|
||||||
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
| `tests/test_rename_user_owner_sync.py` | 740 | 27 | 27 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
| `tests/test_taxonomy.py` | 151 | 27 | 17 | 0 | uncategorized | taxonomy | security, ui/static |
|
||||||
|
| `tests/test_endpoint_resolver_urls.py` | 95 | 27 | 23 | 3 | uncategorized | endpoint_resolver_urls | - |
|
||||||
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||||
| `tests/test_taxonomy.py` | 145 | 26 | 16 | 0 | uncategorized | taxonomy | security, ui/static |
|
| `tests/test_workspace_confine.py` | 527 | 25 | 25 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
|
||||||
| `tests/test_tool_path_confinement.py` | 282 | 24 | 24 | 0 | security | confinement | import-state, filesystem, async/threading |
|
| `tests/test_chat_route_tool_policy.py` | 344 | 25 | 23 | 0 | routes | route | filesystem, ui/static |
|
||||||
| `tests/test_copilot.py` | 170 | 23 | 16 | 0 | uncategorized | copilot | - |
|
| `tests/test_tool_path_confinement.py` | 303 | 25 | 25 | 0 | security | confinement | import-state, filesystem, async/threading |
|
||||||
| `tests/test_research_utils.py` | 97 | 23 | 23 | 2 | services | research | - |
|
| `tests/test_copilot.py` | 182 | 25 | 18 | 0 | uncategorized | copilot | - |
|
||||||
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
| `tests/test_llm_core_temperature_reasoning.py` | 133 | 25 | 9 | 0 | services | llm | - |
|
||||||
| `tests/test_tool_support_heuristic.py` | 166 | 22 | 22 | 3 | uncategorized | tool_support_heuristic | - |
|
| `tests/test_provider_endpoints_url_building.py` | 86 | 25 | 3 | 0 | services | provider | - |
|
||||||
| `tests/test_platform_compat.py` | 318 | 21 | 21 | 0 | uncategorized | platform_compat | import-state, filesystem, subprocess/script |
|
| `tests/test_bg_job_tools.py` | 174 | 23 | 16 | 0 | uncategorized | bg_job_tools | filesystem, async/threading |
|
||||||
|
|
||||||
## Top files by physical line count
|
## Top files by physical line count
|
||||||
|
|
||||||
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
| File | Lines | Collected tests | Test defs | Test classes | Area | Sub-area | Signals |
|
||||||
|---|---:|---:|---:|---:|---|---|---|
|
|---|---:|---:|---:|---:|---|---|---|
|
||||||
| `tests/test_model_routes.py` | 1778 | 139 | 116 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
| `tests/test_model_routes.py` | 2099 | 163 | 134 | 10 | routes | routes | route/api, db/session, import-state, async/threading |
|
||||||
| `tests/test_security_regressions.py` | 1224 | 92 | 68 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
| `tests/test_security_regressions.py` | 1515 | 98 | 74 | 0 | security | security | route/api, db/session, import-state, security, filesystem, async/threading, ui/static |
|
||||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | 29 | 0 | services | embedding | filesystem |
|
| `tests/test_review_regressions.py` | 1429 | 41 | 41 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | 58 | 0 | uncategorized | pr_blocker_audit | import-state, security, filesystem |
|
||||||
| `tests/test_review_regressions.py` | 930 | 26 | 26 | 0 | uncategorized | review_regressions | route/api, db/session, import-state, filesystem, async/threading |
|
| `tests/test_cookbook_helpers.py` | 958 | 69 | 69 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
||||||
| `tests/test_cookbook_helpers.py` | 912 | 65 | 65 | 0 | services | cookbook | route/api, filesystem, subprocess/script, async/threading, ui/static |
|
| `tests/test_upload_handler_cleanup.py` | 831 | 13 | 13 | 0 | uncategorized | upload_handler_cleanup | route/api, db/session, filesystem, async/threading |
|
||||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | 26 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
| `tests/test_email_oauth.py` | 798 | 34 | 29 | 0 | services | email | route/api, db/session, security, async/threading |
|
||||||
| `tests/test_email_oauth.py` | 580 | 28 | 25 | 0 | services | email | route/api, db/session, security, async/threading |
|
| `tests/test_rename_user_owner_sync.py` | 740 | 27 | 27 | 0 | security | owner | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
|
| `tests/test_email_owner_scope.py` | 656 | 13 | 13 | 0 | security | owner_scope | route/api, db/session, filesystem, async/threading |
|
||||||
|
| `tests/test_model_capability_readers.py` | 646 | 20 | 20 | 0 | uncategorized | model_capability_readers | - |
|
||||||
|
| `tests/test_chat_helpers.py` | 582 | 37 | 24 | 0 | uncategorized | chat_helpers | route/api, filesystem, async/threading |
|
||||||
| `tests/test_api_token_routes.py` | 578 | 17 | 17 | 0 | routes | api_routes | route/api, db/session, import-state, async/threading |
|
| `tests/test_api_token_routes.py` | 578 | 17 | 17 | 0 | routes | api_routes | route/api, db/session, import-state, async/threading |
|
||||||
| `tests/test_shell_routes.py` | 481 | 63 | 48 | 8 | routes | routes | route/api, import-state, filesystem |
|
| `tests/test_research_routes_path_confinement.py` | 565 | 58 | 26 | 0 | security | confinement | route/api, security, filesystem, async/threading |
|
||||||
| `tests/test_email_owner_scope.py` | 474 | 9 | 9 | 0 | security | owner_scope | route/api, db/session, filesystem, async/threading |
|
| `tests/test_shell_routes.py` | 538 | 69 | 53 | 9 | routes | routes | route/api, import-state, filesystem |
|
||||||
| `tests/test_service_health.py` | 472 | 47 | 42 | 0 | uncategorized | service_health | async/threading |
|
| `tests/test_workspace_confine.py` | 527 | 25 | 25 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
|
||||||
| `tests/test_agent_loop.py` | 469 | 52 | 52 | 5 | uncategorized | agent_loop | db/session, import-state |
|
| `tests/test_agent_loop.py` | 500 | 54 | 54 | 5 | uncategorized | agent_loop | db/session, import-state |
|
||||||
|
| `tests/test_endpoint_probing.py` | 497 | 61 | 36 | 7 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
||||||
|
| `tests/test_run_focus.py` | 492 | 55 | 52 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
||||||
|
| `tests/test_tool_policy.py` | 475 | 18 | 18 | 0 | uncategorized | tool_policy | import-state, async/threading |
|
||||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | 8 | 0 | uncategorized | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading |
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | 8 | 0 | uncategorized | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading |
|
||||||
|
| `tests/test_email_test_connection_oauth.py` | 436 | 9 | 7 | 0 | services | email | route/api, db/session, async/threading |
|
||||||
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
| `tests/test_helpers_import_state.py` | 426 | 26 | 26 | 0 | helpers | helpers | route/api, db/session, import-state |
|
||||||
| `tests/test_endpoint_owner_scope_followup.py` | 414 | 11 | 11 | 0 | security | owner_scope | route/api, db/session, filesystem |
|
| `tests/test_endpoint_owner_scope_followup.py` | 414 | 11 | 11 | 0 | security | owner_scope | route/api, db/session, filesystem |
|
||||||
| `tests/test_endpoint_probing.py` | 411 | 34 | 30 | 6 | uncategorized | endpoint_probing | route/api, db/session, import-state |
|
| `tests/test_api_chat_security.py` | 404 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
| `tests/test_imap_leak_fixes.py` | 404 | 15 | 15 | 0 | uncategorized | imap_leak_fixes | route/api, db/session, security, filesystem |
|
| `tests/test_imap_leak_fixes.py` | 404 | 15 | 15 | 0 | uncategorized | imap_leak_fixes | route/api, db/session, security, filesystem |
|
||||||
| `tests/test_companion_readonly.py` | 402 | 17 | 17 | 0 | uncategorized | companion_readonly | db/session, import-state |
|
| `tests/test_companion_readonly.py` | 402 | 17 | 17 | 0 | uncategorized | companion_readonly | db/session, import-state |
|
||||||
| `tests/test_api_chat_security.py` | 401 | 22 | 8 | 0 | security | security | route/api, db/session, import-state, filesystem, async/threading |
|
|
||||||
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | 9 | 0 | uncategorized | upload_handler_atomicity | filesystem, async/threading |
|
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | 9 | 0 | uncategorized | upload_handler_atomicity | filesystem, async/threading |
|
||||||
| `tests/test_run_focus.py` | 399 | 47 | 44 | 0 | uncategorized | run_focus | security, filesystem, subprocess/script, ui/static |
|
| `tests/test_auth_regressions.py` | 370 | 15 | 15 | 0 | security | auth | route/api, db/session, import-state, async/threading |
|
||||||
| `tests/test_auth_regressions.py` | 375 | 15 | 15 | 0 | security | auth | route/api, db/session, import-state, async/threading |
|
| `tests/test_calendar_recurrence.py` | 356 | 20 | 20 | 0 | services | calendar | - |
|
||||||
| `tests/test_calendar_owner_scope.py` | 345 | 7 | 7 | 0 | security | owner_scope | route/api, db/session, import-state, filesystem, async/threading, ui/static |
|
| `tests/test_task_cookbook_admin_gate.py` | 350 | 10 | 10 | 0 | services | cookbook | route/api, db/session, import-state, filesystem, async/threading |
|
||||||
| `tests/test_null_owner_gates.py` | 342 | 20 | 20 | 0 | security | owner | route/api, db/session, import-state |
|
|
||||||
| `tests/test_agent_migration_manifest.py` | 340 | 15 | 15 | 0 | uncategorized | agent_migration_manifest | import-state, filesystem |
|
|
||||||
| `tests/test_calendar_recurrence.py` | 338 | 19 | 19 | 0 | services | calendar | - |
|
|
||||||
| `tests/test_tool_policy.py` | 330 | 13 | 13 | 0 | uncategorized | tool_policy | import-state, async/threading |
|
|
||||||
| `tests/test_workspace_confine.py` | 328 | 18 | 18 | 0 | uncategorized | workspace_confine | route/api, filesystem, subprocess/script, async/threading |
|
|
||||||
| `tests/test_diffusion_server_security.py` | 325 | 14 | 14 | 0 | security | security | route/api, import-state, security, filesystem, async/threading, ui/static |
|
|
||||||
|
|
||||||
## Split planning candidates
|
## Split planning candidates
|
||||||
|
|
||||||
@@ -165,53 +173,69 @@ These are planning candidates only. A later split PR still needs a focused manua
|
|||||||
|
|
||||||
| File | Why included | Setup/risk signals | Suggested handling |
|
| File | Why included | Setup/risk signals | Suggested handling |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `tests/test_model_routes.py` | 1778 lines, 139 collected tests | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_model_routes.py` | 2099 lines, 163 collected tests | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_security_regressions.py` | 1224 lines, 92 collected tests | route/api, db/session, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_security_regressions.py` | 1515 lines, 98 collected tests | route/api, db/session, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_provider_classification.py` | 67 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_cookbook_helpers.py` | 958 lines, 69 collected tests | route/api, filesystem, subprocess/script, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_cookbook_helpers.py` | 912 lines, 65 collected tests | route/api, filesystem, subprocess/script, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_shell_routes.py` | 538 lines, 69 collected tests | route/api, import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_shell_routes.py` | 481 lines, 63 collected tests | route/api, import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_endpoint_probing.py` | 497 lines, 61 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_pr_blocker_audit.py` | 964 lines, 58 collected tests | import-state, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_pr_blocker_audit.py` | 964 lines, 58 collected tests | import-state, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_provider_endpoints.py` | 58 collected tests | subprocess/script | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_research_routes_path_confinement.py` | 565 lines, 58 collected tests | route/api, security, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_agent_loop.py` | 469 lines, 52 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_run_focus.py` | 492 lines, 55 collected tests | security, filesystem, subprocess/script, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_service_health.py` | 472 lines, 47 collected tests | async/threading | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_agent_loop.py` | 500 lines, 54 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_run_focus.py` | 399 lines, 47 collected tests | security, filesystem, subprocess/script, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_manage_mcp_command_allowlist.py` | 45 collected tests | db/session, subprocess/script, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_llm_core_temperature.py` | 41 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_provider_classification.py` | 42 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_endpoint_probing.py` | 411 lines, 34 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_review_regressions.py` | 1429 lines, 41 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_chat_helpers.py` | 582 lines, 37 collected tests | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_model_context.py` | 314 lines, 36 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_email_oauth.py` | 798 lines, 34 collected tests | route/api, db/session, security, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 32 collected tests | db/session | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 32 collected tests | db/session | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_chat_helpers.py` | 31 collected tests | route/api | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_redos_verdict_continuation.py` | 30 collected tests | route/api | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_provider_detection.py` | 31 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
|
||||||
| `tests/test_model_context.py` | 30 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
|
||||||
| `tests/test_endpoint_resolver.py` | 30 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
|
||||||
| `tests/test_embedding_lanes.py` | 1104 lines, 29 collected tests | filesystem | Good first manual-review candidate if test themes are cohesive. |
|
|
||||||
| `tests/test_upload_limits_centralized.py` | 29 collected tests | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_upload_limits_centralized.py` | 29 collected tests | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_email_oauth.py` | 580 lines, 28 collected tests | route/api, db/session, security, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_cookbook_docker_access.py` | 339 lines, 28 collected tests | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_review_regressions.py` | 930 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_rename_user_owner_sync.py` | 740 lines, 27 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_rename_user_owner_sync.py` | 686 lines, 26 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_taxonomy.py` | 27 collected tests | security, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_endpoint_resolver_urls.py` | 27 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_helpers_import_state.py` | 426 lines, 26 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_helpers_import_state.py` | 426 lines, 26 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_taxonomy.py` | 26 collected tests | security, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_workspace_confine.py` | 527 lines, 25 collected tests | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_tool_path_confinement.py` | 24 collected tests | import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_chat_route_tool_policy.py` | 344 lines, 25 collected tests | filesystem, ui/static | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_copilot.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_tool_path_confinement.py` | 303 lines, 25 collected tests | import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_copilot.py` | 25 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_llm_core_temperature_reasoning.py` | 25 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_provider_endpoints_url_building.py` | 25 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_bg_job_tools.py` | 23 collected tests | filesystem, async/threading | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_research_utils.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_research_utils.py` | 23 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_api_chat_security.py` | 401 lines, 22 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_api_chat_security.py` | 404 lines, 22 collected tests | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_tool_support_heuristic.py` | 22 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_tool_support_heuristic.py` | 22 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_platform_compat.py` | 318 lines, 21 collected tests | import-state, filesystem, subprocess/script | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_platform_compat.py` | 322 lines, 21 collected tests | import-state, filesystem, subprocess/script | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_context_compactor.py` | 21 collected tests | db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_context_compactor.py` | 21 collected tests | db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_prompt_security.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
| `tests/test_prompt_security.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
| `tests/test_null_owner_gates.py` | 342 lines, 20 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_fenced_inline_args.py` | 21 collected tests | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_cache_affinity_local_only.py` | 21 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_model_capability_readers.py` | 646 lines, 20 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_calendar_recurrence.py` | 356 lines, 20 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_null_owner_gates.py` | 332 lines, 20 collected tests | route/api, db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_gallery_endpoint_hardening.py` | 326 lines, 20 collected tests | route/api, security, filesystem, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_redos_llm_parsers.py` | 20 collected tests | No obvious setup signals from static scan. | Good first manual-review candidate if test themes are cohesive. |
|
||||||
|
| `tests/test_code_nav_tools.py` | 20 collected tests | db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_youtube_handler_consolidation.py` | 20 collected tests | route/api, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_youtube_handler_consolidation.py` | 20 collected tests | route/api, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_calendar_recurrence.py` | 338 lines | No obvious setup signals from static scan. | Plan split boundaries before editing. |
|
| `tests/test_tool_policy.py` | 475 lines | import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_workspace_confine.py` | 328 lines | route/api, filesystem, subprocess/script, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_fenced_example_not_executed_for_native_models.py` | 345 lines | async/threading | Plan split boundaries before editing. |
|
||||||
| `tests/test_api_token_routes.py` | 578 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_api_token_routes.py` | 578 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_companion_readonly.py` | 402 lines | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_companion_readonly.py` | 402 lines | db/session, import-state | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_set_admin.py` | 317 lines | route/api, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_set_admin.py` | 317 lines | route/api, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_llm_core_ollama.py` | 324 lines | async/threading | Plan split boundaries before editing. |
|
||||||
| `tests/test_imap_leak_fixes.py` | 404 lines | route/api, db/session, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_imap_leak_fixes.py` | 404 lines | route/api, db/session, security, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_auth_regressions.py` | 375 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_auth_regressions.py` | 370 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_agent_migration_manifest.py` | 340 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_agent_migration_manifest.py` | 340 lines | import-state, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_mcp_email_decode_header_spaces.py` | 327 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_diffusion_server_security.py` | 325 lines | route/api, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_diffusion_server_security.py` | 325 lines | route/api, import-state, security, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_tool_policy.py` | 330 lines | import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_upload_handler_cleanup.py` | 831 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_email_owner_scope.py` | 656 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_upload_routes_owner_scope.py` | 344 lines | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_endpoint_owner_scope_followup.py` | 414 lines | route/api, db/session, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_endpoint_owner_scope_followup.py` | 414 lines | route/api, db/session, filesystem | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_upload_routes_owner_scope.py` | 315 lines | route/api, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_composer_arrow_up_recall_js.py` | 308 lines | filesystem, subprocess/script, ui/static | Plan split boundaries before editing. |
|
||||||
| `tests/test_email_owner_scope.py` | 474 lines | route/api, db/session, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_task_cookbook_admin_gate.py` | 350 lines | route/api, db/session, import-state, filesystem, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
|
| `tests/test_email_test_connection_oauth.py` | 436 lines | route/api, db/session, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_upload_handler_atomicity.py` | 401 lines | filesystem, async/threading | Plan split boundaries before editing. |
|
| `tests/test_upload_handler_atomicity.py` | 401 lines | filesystem, async/threading | Plan split boundaries before editing. |
|
||||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 lines | route/api, db/session, import-state, async/threading | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
| `tests/test_calendar_owner_scope.py` | 345 lines | route/api, db/session, import-state, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
| `tests/test_calendar_owner_scope.py` | 345 lines | route/api, db/session, import-state, filesystem, async/threading, ui/static | Defer mechanical split until setup/risk boundaries are mapped. |
|
||||||
@@ -226,28 +250,35 @@ Before using any of these files as a split target, first decide whether the taxo
|
|||||||
|
|
||||||
| File | Lines | Collected tests | Sub-area | Signals | Suggested follow-up |
|
| File | Lines | Collected tests | Sub-area | Signals | Suggested follow-up |
|
||||||
|---|---:|---:|---|---|---|
|
|---|---:|---:|---|---|---|
|
||||||
|
| `tests/test_endpoint_probing.py` | 497 | 61 | endpoint_probing | route/api, db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | pr_blocker_audit | import-state, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | pr_blocker_audit | import-state, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_agent_loop.py` | 469 | 52 | agent_loop | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_run_focus.py` | 492 | 55 | run_focus | security, filesystem, subprocess/script, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_service_health.py` | 472 | 47 | service_health | async/threading | Review taxonomy mapping before using as a split target. |
|
| `tests/test_agent_loop.py` | 500 | 54 | agent_loop | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_run_focus.py` | 399 | 47 | run_focus | security, filesystem, subprocess/script, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_review_regressions.py` | 1429 | 41 | review_regressions | route/api, db/session, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_endpoint_probing.py` | 411 | 34 | endpoint_probing | route/api, db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_chat_helpers.py` | 582 | 37 | chat_helpers | route/api, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_chat_helpers.py` | 264 | 31 | chat_helpers | route/api | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_model_context.py` | 314 | 36 | model_context | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_model_context.py` | 251 | 30 | model_context | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_redos_verdict_continuation.py` | 80 | 30 | redos_verdict_continuation | route/api | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_endpoint_resolver.py` | 148 | 30 | endpoint_resolver | - | Review taxonomy mapping before using as a split target. |
|
|
||||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | upload_limits_centralized | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_upload_limits_centralized.py` | 110 | 29 | upload_limits_centralized | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_review_regressions.py` | 930 | 26 | review_regressions | route/api, db/session, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_taxonomy.py` | 151 | 27 | taxonomy | security, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_taxonomy.py` | 145 | 26 | taxonomy | security, ui/static | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_endpoint_resolver_urls.py` | 95 | 27 | endpoint_resolver_urls | - | Review taxonomy mapping before using as a split target. |
|
||||||
| `tests/test_copilot.py` | 170 | 23 | copilot | - | Review taxonomy mapping before using as a split target. |
|
| `tests/test_workspace_confine.py` | 527 | 25 | workspace_confine | route/api, filesystem, subprocess/script, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_copilot.py` | 182 | 25 | copilot | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_bg_job_tools.py` | 174 | 23 | bg_job_tools | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
|
||||||
| `tests/test_tool_support_heuristic.py` | 166 | 22 | tool_support_heuristic | - | Review taxonomy mapping before using as a split target. |
|
| `tests/test_tool_support_heuristic.py` | 166 | 22 | tool_support_heuristic | - | Review taxonomy mapping before using as a split target. |
|
||||||
| `tests/test_platform_compat.py` | 318 | 21 | platform_compat | import-state, filesystem, subprocess/script | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_platform_compat.py` | 322 | 21 | platform_compat | import-state, filesystem, subprocess/script | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_context_compactor.py` | 233 | 21 | context_compactor | db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_context_compactor.py` | 233 | 21 | context_compactor | db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_fenced_inline_args.py` | 186 | 21 | fenced_inline_args | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_cache_affinity_local_only.py` | 104 | 21 | cache_affinity_local_only | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_model_capability_readers.py` | 646 | 20 | model_capability_readers | - | Review taxonomy mapping before using as a split target. |
|
||||||
|
| `tests/test_code_nav_tools.py` | 198 | 20 | code_nav_tools | db/session, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_youtube_handler_consolidation.py` | 104 | 20 | youtube_handler_consolidation | route/api, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_youtube_handler_consolidation.py` | 104 | 20 | youtube_handler_consolidation | route/api, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_workspace_confine.py` | 328 | 18 | workspace_confine | route/api, filesystem, subprocess/script, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_tool_policy.py` | 475 | 18 | tool_policy | import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
| `tests/test_fenced_example_not_executed_for_native_models.py` | 345 | 18 | fenced_example_not_executed_for_native_models | async/threading | Review taxonomy mapping before using as a split target. |
|
||||||
| `tests/test_companion_readonly.py` | 402 | 17 | companion_readonly | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_companion_readonly.py` | 402 | 17 | companion_readonly | db/session, import-state | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_set_admin.py` | 317 | 17 | set_admin | route/api, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_set_admin.py` | 317 | 17 | set_admin | route/api, import-state, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_imap_leak_fixes.py` | 404 | 15 | imap_leak_fixes | route/api, db/session, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_imap_leak_fixes.py` | 404 | 15 | imap_leak_fixes | route/api, db/session, security, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_agent_migration_manifest.py` | 340 | 15 | agent_migration_manifest | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_agent_migration_manifest.py` | 340 | 15 | agent_migration_manifest | import-state, filesystem | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_tool_policy.py` | 330 | 13 | tool_policy | import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_upload_handler_cleanup.py` | 831 | 13 | upload_handler_cleanup | route/api, db/session, filesystem, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | upload_handler_atomicity | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
|
| `tests/test_upload_handler_atomicity.py` | 401 | 9 | upload_handler_atomicity | filesystem, async/threading | Review taxonomy mapping before using as a split target. |
|
||||||
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
| `tests/test_kv_cache_invalidation_2927.py` | 463 | 8 | kv_cache_invalidation_2927 | route/api, db/session, import-state, async/threading | Review taxonomy and setup/risk boundaries before any split. |
|
||||||
|
|
||||||
@@ -259,14 +290,16 @@ Files still in the `uncategorized` taxonomy area are listed separately below so
|
|||||||
|
|
||||||
| File | Lines | Collected tests | Area | Sub-area | Signals | Why this is a candidate |
|
| File | Lines | Collected tests | Area | Sub-area | Signals | Why this is a candidate |
|
||||||
|---|---:|---:|---|---|---|---|
|
|---|---:|---:|---|---|---|---|
|
||||||
| `tests/test_provider_classification.py` | 188 | 67 | services | provider | - | 67 collected tests |
|
| `tests/test_provider_classification.py` | 115 | 42 | services | provider | - | 42 collected tests |
|
||||||
| `tests/test_provider_endpoints.py` | 241 | 58 | services | provider | subprocess/script | 58 collected tests |
|
| `tests/test_chat_route_tool_policy.py` | 344 | 25 | routes | route | filesystem, ui/static | 344 lines, 25 collected tests |
|
||||||
| `tests/test_llm_core_temperature.py` | 196 | 41 | services | llm | - | 41 collected tests |
|
| `tests/test_llm_core_temperature_reasoning.py` | 133 | 25 | services | llm | - | 25 collected tests |
|
||||||
| `tests/test_provider_detection.py` | 148 | 31 | services | provider | - | 31 collected tests |
|
| `tests/test_provider_endpoints_url_building.py` | 86 | 25 | services | provider | - | 25 collected tests |
|
||||||
| `tests/test_embedding_lanes.py` | 1104 | 29 | services | embedding | filesystem | 1104 lines, 29 collected tests |
|
|
||||||
| `tests/test_research_utils.py` | 97 | 23 | services | research | - | 23 collected tests |
|
| `tests/test_research_utils.py` | 97 | 23 | services | research | - | 23 collected tests |
|
||||||
| `tests/test_prompt_security.py` | 203 | 21 | security | security | - | 21 collected tests |
|
| `tests/test_prompt_security.py` | 203 | 21 | security | security | - | 21 collected tests |
|
||||||
| `tests/test_calendar_recurrence.py` | 338 | 19 | services | calendar | - | 338 lines |
|
| `tests/test_calendar_recurrence.py` | 356 | 20 | services | calendar | - | 356 lines, 20 collected tests |
|
||||||
|
| `tests/test_redos_llm_parsers.py` | 201 | 20 | services | llm | - | 20 collected tests |
|
||||||
|
| `tests/test_llm_core_ollama.py` | 324 | 16 | services | llm | async/threading | 324 lines |
|
||||||
|
| `tests/test_composer_arrow_up_recall_js.py` | 308 | 11 | js | js | filesystem, subprocess/script, ui/static | 308 lines |
|
||||||
|
|
||||||
## High-risk candidates to defer first
|
## High-risk candidates to defer first
|
||||||
|
|
||||||
@@ -274,21 +307,21 @@ These files may still be split later, but not as the first implementation slice
|
|||||||
|
|
||||||
| File | Lines | Collected tests | High-risk signals |
|
| File | Lines | Collected tests | High-risk signals |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| `tests/test_model_routes.py` | 1778 | 139 | db/session, import-state, route/api |
|
| `tests/test_model_routes.py` | 2099 | 163 | db/session, import-state, route/api |
|
||||||
| `tests/test_security_regressions.py` | 1224 | 92 | db/session, import-state, route/api, security |
|
| `tests/test_security_regressions.py` | 1515 | 98 | db/session, import-state, route/api, security |
|
||||||
| `tests/test_cookbook_helpers.py` | 912 | 65 | route/api |
|
| `tests/test_cookbook_helpers.py` | 958 | 69 | route/api |
|
||||||
| `tests/test_shell_routes.py` | 481 | 63 | import-state, route/api |
|
| `tests/test_shell_routes.py` | 538 | 69 | import-state, route/api |
|
||||||
|
| `tests/test_endpoint_probing.py` | 497 | 61 | db/session, import-state, route/api |
|
||||||
| `tests/test_pr_blocker_audit.py` | 964 | 58 | import-state, security |
|
| `tests/test_pr_blocker_audit.py` | 964 | 58 | import-state, security |
|
||||||
| `tests/test_agent_loop.py` | 469 | 52 | db/session, import-state |
|
| `tests/test_research_routes_path_confinement.py` | 565 | 58 | route/api, security |
|
||||||
| `tests/test_run_focus.py` | 399 | 47 | security |
|
| `tests/test_run_focus.py` | 492 | 55 | security |
|
||||||
| `tests/test_endpoint_probing.py` | 411 | 34 | db/session, import-state, route/api |
|
| `tests/test_agent_loop.py` | 500 | 54 | db/session, import-state |
|
||||||
|
| `tests/test_manage_mcp_command_allowlist.py` | 168 | 45 | db/session |
|
||||||
|
| `tests/test_review_regressions.py` | 1429 | 41 | db/session, import-state, route/api |
|
||||||
|
| `tests/test_chat_helpers.py` | 582 | 37 | route/api |
|
||||||
|
| `tests/test_model_context.py` | 314 | 36 | db/session, import-state |
|
||||||
|
| `tests/test_email_oauth.py` | 798 | 34 | db/session, route/api, security |
|
||||||
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | db/session |
|
| `tests/test_llm_core_anthropic_temp_omit.py` | 94 | 32 | db/session |
|
||||||
| `tests/test_chat_helpers.py` | 264 | 31 | route/api |
|
|
||||||
| `tests/test_model_context.py` | 251 | 30 | db/session, import-state |
|
|
||||||
| `tests/test_upload_limits_centralized.py` | 110 | 29 | import-state |
|
|
||||||
| `tests/test_email_oauth.py` | 580 | 28 | db/session, route/api, security |
|
|
||||||
| `tests/test_review_regressions.py` | 930 | 26 | db/session, import-state, route/api |
|
|
||||||
| `tests/test_rename_user_owner_sync.py` | 686 | 26 | db/session, import-state, route/api |
|
|
||||||
|
|
||||||
## Rules for future split PRs
|
## Rules for future split PRs
|
||||||
|
|
||||||
@@ -313,7 +346,7 @@ Do not start a split PR from this planning issue alone if the file's boundaries
|
|||||||
This document was generated with:
|
This document was generated with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Freshness check
|
## Freshness check
|
||||||
@@ -321,6 +354,6 @@ This document was generated with:
|
|||||||
After editing the builder or rebasing the branch, regenerate the plan and confirm no unexpected plan drift:
|
After editing the builder or rebasing the branch, regenerate the plan and confirm no unexpected plan drift:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
venv/bin/python tests/tools/build_oversized_test_split_plan.py
|
||||||
git diff --exit-code -- tests/OVERSIZED_TEST_SPLIT_PLAN.md
|
git diff --exit-code -- tests/OVERSIZED_TEST_SPLIT_PLAN.md
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
// 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, ['']);
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
"""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"
|
module_name = "routes.webhook_routes_under_test"
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
module_name,
|
module_name,
|
||||||
Path(__file__).resolve().parent.parent / "routes" / "webhook" / "webhook_routes.py",
|
Path(__file__).resolve().parent.parent / "routes" / "webhook_routes.py",
|
||||||
)
|
)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
|
|||||||
+6
-44
@@ -1,19 +1,17 @@
|
|||||||
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
|
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
|
||||||
|
|
||||||
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
|
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
|
||||||
Both write to a sibling ``.tmp.<random>`` file, ``fsync`` it, then
|
Both write to a sibling ``.tmp.<pid>`` file, ``fsync`` it, then ``os.replace``
|
||||||
``os.replace`` into place so a crash mid-write leaves the previous good copy
|
into place so a crash mid-write leaves the previous good copy untouched rather
|
||||||
untouched rather than a truncated/empty file.
|
than a truncated/empty file.
|
||||||
|
|
||||||
These tests cover the happy path (round-trip, indent, parent-dir creation,
|
These tests cover the happy path (round-trip, indent, parent-dir creation,
|
||||||
full overwrite, no leftover tmp), the two failure paths the implementation
|
full overwrite, no leftover tmp) and the two failure paths the implementation
|
||||||
guarantees (the target file is preserved when serialization fails before the
|
guarantees: the target file is preserved when serialization fails before the
|
||||||
replace, and when ``os.replace`` itself fails), and that two concurrent
|
replace, and when ``os.replace`` itself fails.
|
||||||
writers to the same path don't collide on the same temp file.
|
|
||||||
"""
|
"""
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -86,42 +84,6 @@ def test_atomic_write_json_leaves_no_tmp_file(tmp_path):
|
|||||||
assert _tmp_siblings(tmp_path, "data.json") == []
|
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.
|
# atomic_write_json — failure path: target preserved on serialization error.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ def _setup(monkeypatch, store, user="alice"):
|
|||||||
|
|
||||||
mem = MagicMock()
|
mem = MagicMock()
|
||||||
mem.load_all.return_value = list(store)
|
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 = {}
|
saved = {}
|
||||||
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
|
mem.save.side_effect = lambda entries: saved.__setitem__("entries", entries)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ def test_stream_render_helpers_are_visible_to_catch_block():
|
|||||||
assert "let _cancelThinkingTimer = () => {};" in outer_scope
|
assert "let _cancelThinkingTimer = () => {};" in outer_scope
|
||||||
assert "let _removeThinkingSpinner = () => {};" in outer_scope
|
assert "let _removeThinkingSpinner = () => {};" in outer_scope
|
||||||
|
|
||||||
assert re.search(r"(?m)^\s*_renderStream\s*=", try_body)
|
assert "_renderStream = () => {" in try_body
|
||||||
assert "_cancelThinkingTimer = () => {" in try_body
|
assert "_cancelThinkingTimer = () => {" in try_body
|
||||||
assert "_removeThinkingSpinner = () => {" in try_body
|
assert "_removeThinkingSpinner = () => {" in try_body
|
||||||
assert "function _renderStream()" not in try_body
|
assert "function _renderStream()" not in try_body
|
||||||
|
|||||||
@@ -306,24 +306,3 @@ def test_integration_recalls_from_chat_history_dom():
|
|||||||
)
|
)
|
||||||
assert proc.returncode == 0, proc.stderr
|
assert proc.returncode == 0, proc.stderr
|
||||||
assert json.loads(proc.stdout.strip()) == {"value": "stored prompt", "prevented": True}
|
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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Regression test for the document route shim (slice 2m, #4082/#4071).
|
|
||||||
|
|
||||||
The backward-compat shims at ``routes/document_routes.py`` and
|
|
||||||
``routes/document_helpers.py`` use ``sys.modules`` replacement so the legacy
|
|
||||||
import paths and the canonical ``routes.document.*`` paths resolve to the
|
|
||||||
*same* module objects. This is required because multiple tests do
|
|
||||||
``import routes.document_routes as droutes`` followed by
|
|
||||||
``droutes.SessionLocal = ...`` / ``monkeypatch.setattr(droutes, ...)`` and
|
|
||||||
``sys.modules.pop("routes.document_helpers")`` + re-import — for those to
|
|
||||||
take effect at runtime, the legacy and canonical module objects must be
|
|
||||||
identical.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import routes.document_routes as _shim_routes # noqa: F401
|
|
||||||
import routes.document_helpers as _shim_helpers # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_and_canonical_routes_are_same_object():
|
|
||||||
legacy = importlib.import_module("routes.document_routes")
|
|
||||||
canonical = importlib.import_module("routes.document.document_routes")
|
|
||||||
assert legacy is canonical
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_and_canonical_helpers_are_same_object():
|
|
||||||
legacy = importlib.import_module("routes.document_helpers")
|
|
||||||
canonical = importlib.import_module("routes.document.document_helpers")
|
|
||||||
assert legacy is canonical
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js"
|
|
||||||
|
|
||||||
|
|
||||||
def _source() -> str:
|
|
||||||
return _EMAIL_LIBRARY.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _function_source(name: str) -> str:
|
|
||||||
"""Return one top-level JS function using balanced braces."""
|
|
||||||
text = _source()
|
|
||||||
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
|
|
||||||
starts = [text.find(marker) for marker in markers]
|
|
||||||
starts = [start for start in starts if start >= 0]
|
|
||||||
assert starts, f"missing function {name}"
|
|
||||||
start = min(starts)
|
|
||||||
paren = text.index("(", start)
|
|
||||||
paren_depth = 0
|
|
||||||
quote = None
|
|
||||||
escaped = False
|
|
||||||
for index in range(paren, len(text)):
|
|
||||||
char = text[index]
|
|
||||||
if quote:
|
|
||||||
if escaped:
|
|
||||||
escaped = False
|
|
||||||
elif char == "\\":
|
|
||||||
escaped = True
|
|
||||||
elif char == quote:
|
|
||||||
quote = None
|
|
||||||
continue
|
|
||||||
if char in ("'", '"', "`"):
|
|
||||||
quote = char
|
|
||||||
elif char == "(":
|
|
||||||
paren_depth += 1
|
|
||||||
elif char == ")":
|
|
||||||
paren_depth -= 1
|
|
||||||
if paren_depth == 0:
|
|
||||||
brace = text.index("{", index)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise AssertionError(f"unterminated signature {name}")
|
|
||||||
depth = 0
|
|
||||||
quote = None
|
|
||||||
escaped = False
|
|
||||||
template_depth = 0
|
|
||||||
for index in range(brace, len(text)):
|
|
||||||
char = text[index]
|
|
||||||
if quote:
|
|
||||||
if escaped:
|
|
||||||
escaped = False
|
|
||||||
elif char == "\\":
|
|
||||||
escaped = True
|
|
||||||
elif char == quote and template_depth == 0:
|
|
||||||
quote = None
|
|
||||||
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
|
|
||||||
template_depth += 1
|
|
||||||
elif quote == "`" and char == "}" and template_depth:
|
|
||||||
template_depth -= 1
|
|
||||||
continue
|
|
||||||
if char in ("'", '"', "`"):
|
|
||||||
quote = char
|
|
||||||
elif char == "{":
|
|
||||||
depth += 1
|
|
||||||
elif char == "}":
|
|
||||||
depth -= 1
|
|
||||||
if depth == 0:
|
|
||||||
return text[start:index + 1]
|
|
||||||
raise AssertionError(f"unterminated function {name}")
|
|
||||||
|
|
||||||
|
|
||||||
def _run_scheduler_scenario(scenario: str):
|
|
||||||
node = shutil.which("node")
|
|
||||||
if not node:
|
|
||||||
pytest.skip("node not on PATH")
|
|
||||||
functions = "\n".join(
|
|
||||||
_function_source(name)
|
|
||||||
for name in (
|
|
||||||
"_isChatInteractionBusy",
|
|
||||||
"_canRunEmailPrewarm",
|
|
||||||
"_isEmailPrewarmTemporarilyBlocked",
|
|
||||||
"_settleEmailPrewarm",
|
|
||||||
"_cancelEmailPrewarm",
|
|
||||||
"_scheduleEmailPrewarm",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
script = f"""
|
|
||||||
let now = 0;
|
|
||||||
Date.now = () => now;
|
|
||||||
const state = {{ _libOpen: false, _libLoading: false }};
|
|
||||||
let _libSearchInFlight = false;
|
|
||||||
let _libPrewarmDelayTimer = null;
|
|
||||||
let _libPrewarmIdleHandle = null;
|
|
||||||
let _libPrewarmPromise = null;
|
|
||||||
let _libPrewarmResolve = null;
|
|
||||||
let _libPrewarmAbortController = null;
|
|
||||||
let _libPrewarmDetachPriorityListeners = null;
|
|
||||||
let _libPrewarmGeneration = 0;
|
|
||||||
let nextHandle = 1;
|
|
||||||
const timers = new Map();
|
|
||||||
const idleCallbacks = new Map();
|
|
||||||
let idleRequestCount = 0;
|
|
||||||
function eventTarget(target) {{
|
|
||||||
const listeners = new Map();
|
|
||||||
target.addEventListener = (type, callback) => {{
|
|
||||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
|
||||||
listeners.get(type).add(callback);
|
|
||||||
}};
|
|
||||||
target.removeEventListener = (type, callback) => listeners.get(type)?.delete(callback);
|
|
||||||
target.dispatchEvent = (event) => {{
|
|
||||||
for (const callback of [...(listeners.get(event.type) || [])]) callback(event);
|
|
||||||
}};
|
|
||||||
target.listenerCount = (type) => listeners.get(type)?.size || 0;
|
|
||||||
return target;
|
|
||||||
}}
|
|
||||||
const document = eventTarget({{ visibilityState: 'visible' }});
|
|
||||||
const window = {{
|
|
||||||
__odysseusChatBusy: false,
|
|
||||||
__odysseusChatBusyUntil: 0,
|
|
||||||
requestIdleCallback(callback) {{
|
|
||||||
const handle = nextHandle++;
|
|
||||||
idleRequestCount += 1;
|
|
||||||
idleCallbacks.set(handle, callback);
|
|
||||||
return handle;
|
|
||||||
}},
|
|
||||||
cancelIdleCallback(handle) {{ idleCallbacks.delete(handle); }},
|
|
||||||
}};
|
|
||||||
eventTarget(window);
|
|
||||||
function setTimeout(callback, delay) {{
|
|
||||||
const handle = nextHandle++;
|
|
||||||
timers.set(handle, {{ callback, at: now + Number(delay || 0) }});
|
|
||||||
return handle;
|
|
||||||
}}
|
|
||||||
function clearTimeout(handle) {{ timers.delete(handle); }}
|
|
||||||
async function flushMicrotasks() {{
|
|
||||||
for (let i = 0; i < 6; i += 1) await Promise.resolve();
|
|
||||||
}}
|
|
||||||
async function advanceTo(target) {{
|
|
||||||
while (true) {{
|
|
||||||
const pending = [...timers.entries()]
|
|
||||||
.filter(([, timer]) => timer.at <= target)
|
|
||||||
.sort((a, b) => a[1].at - b[1].at)[0];
|
|
||||||
if (!pending) break;
|
|
||||||
const [handle, timer] = pending;
|
|
||||||
timers.delete(handle);
|
|
||||||
now = timer.at;
|
|
||||||
timer.callback();
|
|
||||||
await flushMicrotasks();
|
|
||||||
}}
|
|
||||||
now = target;
|
|
||||||
await flushMicrotasks();
|
|
||||||
}}
|
|
||||||
async function fireNextIdle(budget = 5) {{
|
|
||||||
const pending = idleCallbacks.entries().next().value;
|
|
||||||
if (!pending) throw new Error('no idle callback pending');
|
|
||||||
const [handle, callback] = pending;
|
|
||||||
idleCallbacks.delete(handle);
|
|
||||||
callback({{ didTimeout: false, timeRemaining: () => budget }});
|
|
||||||
await flushMicrotasks();
|
|
||||||
}}
|
|
||||||
{functions}
|
|
||||||
{scenario}
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
[node, "--input-type=module"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
return json.loads(proc.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def test_prewarm_is_genuine_idle_only_and_single_flight():
|
|
||||||
scheduler = _function_source("_scheduleEmailPrewarm")
|
|
||||||
|
|
||||||
assert "if (_libPrewarmPromise) return _libPrewarmPromise;" in scheduler
|
|
||||||
assert "typeof window.requestIdleCallback !== 'function'" in scheduler
|
|
||||||
assert "return Promise.resolve(false);" in scheduler
|
|
||||||
assert "window.requestIdleCallback((deadline)" in scheduler
|
|
||||||
assert "!deadline.didTimeout" in scheduler
|
|
||||||
assert "deadline.timeRemaining() > 0" in scheduler
|
|
||||||
|
|
||||||
idle_callback = scheduler.index("window.requestIdleCallback((deadline)")
|
|
||||||
assert "Promise.resolve()" in scheduler
|
|
||||||
task_start = scheduler.index("task({ signal: controller.signal, generation })")
|
|
||||||
assert idle_callback < task_start, "network work must only be reachable from the idle callback"
|
|
||||||
|
|
||||||
|
|
||||||
def test_temporary_chat_priority_retries_one_single_flight_until_idle():
|
|
||||||
out = _run_scheduler_scenario("""
|
|
||||||
window.__odysseusChatBusyUntil = 10000;
|
|
||||||
let taskCalls = 0;
|
|
||||||
const task = async () => { taskCalls += 1; return true; };
|
|
||||||
const first = _scheduleEmailPrewarm(task, { delay: 1800 });
|
|
||||||
const joined = _scheduleEmailPrewarm(task, { delay: 0 });
|
|
||||||
const samePromise = first === joined;
|
|
||||||
await advanceTo(1800);
|
|
||||||
await fireNextIdle(7);
|
|
||||||
const callsWhileBusy = taskCalls;
|
|
||||||
while (now < 10300) {
|
|
||||||
await advanceTo(now + 500);
|
|
||||||
await fireNextIdle(7);
|
|
||||||
}
|
|
||||||
const result = await first;
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
result, samePromise, callsWhileBusy, taskCalls, idleRequestCount,
|
|
||||||
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
|
||||||
}));
|
|
||||||
""")
|
|
||||||
assert out == {
|
|
||||||
"result": True,
|
|
||||||
"samePromise": True,
|
|
||||||
"callsWhileBusy": 0,
|
|
||||||
"taskCalls": 1,
|
|
||||||
"idleRequestCount": 18,
|
|
||||||
"timers": 0,
|
|
||||||
"idleCallbacks": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_cancelled_prewarm_cannot_issue_a_delayed_duplicate():
|
|
||||||
out = _run_scheduler_scenario("""
|
|
||||||
let taskCalls = 0;
|
|
||||||
const pending = _scheduleEmailPrewarm(async () => { taskCalls += 1; return true; }, { delay: 1800 });
|
|
||||||
await advanceTo(1400);
|
|
||||||
_cancelEmailPrewarm();
|
|
||||||
await advanceTo(12000);
|
|
||||||
const result = await pending;
|
|
||||||
console.log(JSON.stringify({
|
|
||||||
result, taskCalls, idleRequestCount,
|
|
||||||
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
|
||||||
}));
|
|
||||||
""")
|
|
||||||
assert out == {
|
|
||||||
"result": False,
|
|
||||||
"taskCalls": 0,
|
|
||||||
"idleRequestCount": 0,
|
|
||||||
"timers": 0,
|
|
||||||
"idleCallbacks": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("transition", ["busy", "hidden"])
|
|
||||||
def test_active_prewarm_is_aborted_and_retried_once_after_priority_transition(transition):
|
|
||||||
block = (
|
|
||||||
"window.__odysseusChatBusy = true; "
|
|
||||||
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
|
||||||
if transition == "busy"
|
|
||||||
else "document.visibilityState = 'hidden'; document.dispatchEvent({ type: 'visibilitychange' });"
|
|
||||||
)
|
|
||||||
unblock = (
|
|
||||||
"window.__odysseusChatBusy = false; window.__odysseusChatBusyUntil = now; "
|
|
||||||
"window.dispatchEvent({ type: 'odysseus:chat-busy-change' });"
|
|
||||||
if transition == "busy"
|
|
||||||
else "document.visibilityState = 'visible'; document.dispatchEvent({ type: 'visibilitychange' });"
|
|
||||||
)
|
|
||||||
out = _run_scheduler_scenario(f"""
|
|
||||||
let taskCalls = 0;
|
|
||||||
let firstSignal = null;
|
|
||||||
let finishFirst;
|
|
||||||
const firstAttempt = new Promise(resolve => {{ finishFirst = resolve; }});
|
|
||||||
const pending = _scheduleEmailPrewarm(async ({{ signal }}) => {{
|
|
||||||
taskCalls += 1;
|
|
||||||
if (taskCalls === 1) {{ firstSignal = signal; return firstAttempt; }}
|
|
||||||
return true;
|
|
||||||
}});
|
|
||||||
await fireNextIdle(7);
|
|
||||||
{block}
|
|
||||||
const aborted = firstSignal.aborted;
|
|
||||||
{unblock}
|
|
||||||
const callsBeforeLateResult = taskCalls;
|
|
||||||
finishFirst(true);
|
|
||||||
await flushMicrotasks();
|
|
||||||
const stillPendingAfterLateResult = _libPrewarmPromise === pending;
|
|
||||||
await advanceTo(now + 500);
|
|
||||||
await fireNextIdle(7);
|
|
||||||
const result = await pending;
|
|
||||||
console.log(JSON.stringify({{
|
|
||||||
result, aborted, callsBeforeLateResult, taskCalls,
|
|
||||||
stillPendingAfterLateResult,
|
|
||||||
timers: timers.size, idleCallbacks: idleCallbacks.size,
|
|
||||||
chatListeners: window.listenerCount('odysseus:chat-busy-change'),
|
|
||||||
visibilityListeners: document.listenerCount('visibilitychange'),
|
|
||||||
}}));
|
|
||||||
""")
|
|
||||||
assert out == {
|
|
||||||
"result": True,
|
|
||||||
"aborted": True,
|
|
||||||
"callsBeforeLateResult": 1,
|
|
||||||
"taskCalls": 2,
|
|
||||||
"stillPendingAfterLateResult": True,
|
|
||||||
"timers": 0,
|
|
||||||
"idleCallbacks": 0,
|
|
||||||
"chatListeners": 0,
|
|
||||||
"visibilityListeners": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_prewarm_skips_hidden_and_foreground_work():
|
|
||||||
guard = _function_source("_canRunEmailPrewarm")
|
|
||||||
|
|
||||||
assert "state._libOpen" in guard
|
|
||||||
assert "state._libLoading" in guard
|
|
||||||
assert "_libSearchInFlight" in guard
|
|
||||||
assert "document.visibilityState !== 'visible'" in guard
|
|
||||||
assert "!_isChatInteractionBusy()" in guard
|
|
||||||
|
|
||||||
|
|
||||||
def test_prewarm_selects_only_last_used_or_default_account():
|
|
||||||
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
|
||||||
prewarm = _function_source("_prewarmEmailViews")
|
|
||||||
|
|
||||||
assert "_rememberedEmailAccountId()" in chooser
|
|
||||||
assert "a.enabled !== false" in chooser
|
|
||||||
assert "a.is_default" in chooser
|
|
||||||
assert "enabled[0]" in chooser
|
|
||||||
|
|
||||||
assert "for (" not in prewarm
|
|
||||||
assert "orderedAccountIds" not in prewarm
|
|
||||||
assert "slice(0, 4)" not in prewarm
|
|
||||||
assert "/api/email/folders" not in prewarm
|
|
||||||
assert "/api/email/unread-state" not in prewarm
|
|
||||||
assert prewarm.count("/api/email/list") == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_prewarm_account_chooser_rejects_disabled_or_empty_authoritative_inventory():
|
|
||||||
node = shutil.which("node")
|
|
||||||
if not node:
|
|
||||||
pytest.skip("node not on PATH")
|
|
||||||
chooser = _function_source("_chooseEmailPrewarmAccountId")
|
|
||||||
script = f"""
|
|
||||||
const state = {{ _libAccountId: 'disabled-current' }};
|
|
||||||
function _rememberedEmailAccountId() {{ return 'disabled-remembered'; }}
|
|
||||||
{chooser}
|
|
||||||
const onlyDisabled = _chooseEmailPrewarmAccountId([
|
|
||||||
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
|
||||||
{{ id: 'disabled-current', enabled: false }},
|
|
||||||
]);
|
|
||||||
const empty = _chooseEmailPrewarmAccountId([]);
|
|
||||||
const mixed = _chooseEmailPrewarmAccountId([
|
|
||||||
{{ id: 'disabled-remembered', enabled: false, is_default: true }},
|
|
||||||
{{ id: 'enabled-default', enabled: true, is_default: true }},
|
|
||||||
]);
|
|
||||||
console.log(JSON.stringify({{ onlyDisabled, empty, mixed }}));
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
[node, "--input-type=module"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, proc.stderr
|
|
||||||
assert json.loads(proc.stdout.strip()) == {
|
|
||||||
"onlyDisabled": "",
|
|
||||||
"empty": "",
|
|
||||||
"mixed": "enabled-default",
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_accounts = _function_source("_ensureEmailAccountsForPrewarm")
|
|
||||||
assert "if (!accountId) return null;" in ensure_accounts
|
|
||||||
assert ensure_accounts.index("if (!accountId) return null;") < ensure_accounts.index("_publishActiveAccount();")
|
|
||||||
|
|
||||||
|
|
||||||
def test_prewarm_is_bounded_to_the_interactive_initial_page_size():
|
|
||||||
text = _source()
|
|
||||||
prewarm = _function_source("_prewarmEmailViews")
|
|
||||||
|
|
||||||
assert "const _LIB_INITIAL_PAGE_SIZE = 100;" in text
|
|
||||||
assert "limit: _LIB_INITIAL_PAGE_SIZE" in prewarm
|
|
||||||
assert text.count("limit=${_LIB_INITIAL_PAGE_SIZE}&offset=${offsetAtStart}") == 2
|
|
||||||
assert "limit: 100" not in prewarm
|
|
||||||
|
|
||||||
|
|
||||||
def test_open_cancels_scheduled_or_inflight_prewarm_first():
|
|
||||||
text = _source()
|
|
||||||
cancel = _function_source("_cancelEmailPrewarm")
|
|
||||||
open_library = _function_source("openEmailLibrary")
|
|
||||||
|
|
||||||
assert "clearTimeout(_libPrewarmDelayTimer)" in cancel
|
|
||||||
assert "window.cancelIdleCallback(_libPrewarmIdleHandle)" in cancel
|
|
||||||
assert "_libPrewarmAbortController?.abort()" in cancel
|
|
||||||
assert "_libPrewarmGeneration += 1" in cancel
|
|
||||||
assert open_library.index("_cancelEmailPrewarm();") < open_library.index("state._libOpen = true;")
|
|
||||||
assert "_loadEmailsWhenChatIdle" not in text
|
|
||||||
assert text.count("_loadEmails({ useCache: true });") >= 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_close_cancels_pending_prewarm_cleanup():
|
|
||||||
close_library = _function_source("closeEmailLibrary")
|
|
||||||
|
|
||||||
assert close_library.index("_cancelEmailPrewarm();") < close_library.index("state._libOpen = false;")
|
|
||||||
|
|
||||||
|
|
||||||
def test_unread_warm_joins_the_same_idle_single_flight_gate():
|
|
||||||
unread_entry = _function_source("prewarmUnreadEmails")
|
|
||||||
unread_work = _function_source("_prewarmUnreadEmailsNow")
|
|
||||||
|
|
||||||
assert "_scheduleEmailPrewarm(" in unread_entry
|
|
||||||
assert "fetch(" not in unread_entry
|
|
||||||
assert "_ensureEmailAccountsForPrewarm({ signal, generation })" in unread_work
|
|
||||||
assert "signal" in unread_work
|
|
||||||
assert "Math.min(20" in unread_work
|
|
||||||
+2
-122
@@ -29,7 +29,6 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import unittest.mock as mock
|
import unittest.mock as mock
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -273,14 +272,8 @@ def _callback_endpoint():
|
|||||||
|
|
||||||
|
|
||||||
class _FakeRequest:
|
class _FakeRequest:
|
||||||
"""Minimal stand-in for starlette Request — the callback reads the Host header
|
"""Minimal stand-in for starlette Request — the callback only reads headers."""
|
||||||
and the request scheme. Behind a TLS terminator uvicorn's proxy-headers
|
headers = {"host": "localhost:7000"}
|
||||||
middleware rewrites the scheme from `X-Forwarded-Proto`, so the route sees
|
|
||||||
`https` there and `http` on a plain origin."""
|
|
||||||
|
|
||||||
def __init__(self, scheme="http", host="localhost:7000"):
|
|
||||||
self.headers = {"host": host}
|
|
||||||
self.url = SimpleNamespace(scheme=scheme)
|
|
||||||
|
|
||||||
|
|
||||||
def _location(resp):
|
def _location(resp):
|
||||||
@@ -422,119 +415,6 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
|||||||
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
||||||
|
|
||||||
|
|
||||||
# ── Redirect URI scheme ───────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Google rejects the token exchange unless the callback's `redirect_uri` is
|
|
||||||
# byte-identical to the one the authorize step sent, so both routes have to
|
|
||||||
# agree — including on the scheme. Deriving it from the request keeps HTTPS
|
|
||||||
# deployments working without pinning GOOGLE_OAUTH_REDIRECT_URI by hand;
|
|
||||||
# hardcoding `http://` produced an unusable redirect behind any TLS front.
|
|
||||||
|
|
||||||
def _authorize_endpoint():
|
|
||||||
"""Return the live google_oauth_authorize endpoint from the email router."""
|
|
||||||
from routes.email_routes import setup_email_routes
|
|
||||||
router = setup_email_routes()
|
|
||||||
for route in router.routes:
|
|
||||||
if route.path == "/api/email/oauth/google/authorize" and "GET" in getattr(route, "methods", set()):
|
|
||||||
return route.endpoint
|
|
||||||
raise AssertionError("google_oauth_authorize route not found")
|
|
||||||
|
|
||||||
|
|
||||||
def _posted_redirect_uri(mock_post):
|
|
||||||
"""Pull `redirect_uri` out of the mocked Google token-exchange POST."""
|
|
||||||
return mock_post.call_args.kwargs["data"]["redirect_uri"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("scheme", ("http", "https"))
|
|
||||||
async def test_callback_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
|
|
||||||
"""The token exchange must echo the scheme the request actually arrived on —
|
|
||||||
`https` behind a TLS terminator, `http` on a plain origin."""
|
|
||||||
from routes.email_helpers import make_oauth_state
|
|
||||||
|
|
||||||
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
|
|
||||||
|
|
||||||
db, Factory = _make_db()
|
|
||||||
_make_account(db, account_id="acct-s", owner="alice", imap_user="alice@example.com")
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
token_resp = mock.MagicMock()
|
|
||||||
token_resp.raise_for_status = mock.MagicMock()
|
|
||||||
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
|
|
||||||
userinfo_resp = mock.MagicMock()
|
|
||||||
userinfo_resp.is_success = True
|
|
||||||
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
|
|
||||||
|
|
||||||
state = make_oauth_state("acct-s", "alice")
|
|
||||||
|
|
||||||
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
|
|
||||||
mock.patch("httpx.get", return_value=userinfo_resp), \
|
|
||||||
mock.patch("core.database.SessionLocal", Factory):
|
|
||||||
callback = _callback_endpoint()
|
|
||||||
await callback(
|
|
||||||
code="4/code", state=state, error=None,
|
|
||||||
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _posted_redirect_uri(mock_post) == (
|
|
||||||
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_callback_redirect_uri_env_override_still_wins(monkeypatch):
|
|
||||||
"""An explicit GOOGLE_OAUTH_REDIRECT_URI is used verbatim — deriving the
|
|
||||||
scheme must not override a value the operator pinned by hand."""
|
|
||||||
from routes.email_helpers import make_oauth_state
|
|
||||||
|
|
||||||
pinned = "https://mail.example.com/api/email/oauth/google/callback"
|
|
||||||
monkeypatch.setenv("GOOGLE_OAUTH_REDIRECT_URI", pinned)
|
|
||||||
|
|
||||||
db, Factory = _make_db()
|
|
||||||
_make_account(db, account_id="acct-p", owner="alice", imap_user="alice@example.com")
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
token_resp = mock.MagicMock()
|
|
||||||
token_resp.raise_for_status = mock.MagicMock()
|
|
||||||
token_resp.json.return_value = {"access_token": "ya29.t", "refresh_token": "1//r", "expires_in": 3600}
|
|
||||||
userinfo_resp = mock.MagicMock()
|
|
||||||
userinfo_resp.is_success = True
|
|
||||||
userinfo_resp.json.return_value = {"email": "alice@example.com", "name": "Alice"}
|
|
||||||
|
|
||||||
state = make_oauth_state("acct-p", "alice")
|
|
||||||
|
|
||||||
with mock.patch("httpx.post", return_value=token_resp) as mock_post, \
|
|
||||||
mock.patch("httpx.get", return_value=userinfo_resp), \
|
|
||||||
mock.patch("core.database.SessionLocal", Factory):
|
|
||||||
callback = _callback_endpoint()
|
|
||||||
await callback(code="4/code", state=state, error=None, request=_FakeRequest(scheme="http"))
|
|
||||||
|
|
||||||
assert _posted_redirect_uri(mock_post) == pinned
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("scheme", ("http", "https"))
|
|
||||||
async def test_authorize_redirect_uri_follows_the_request_scheme(scheme, monkeypatch):
|
|
||||||
"""The authorize step builds the same redirect_uri the callback will send.
|
|
||||||
`owner=""` is the unconfigured / single-user case, so no DB is touched."""
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
monkeypatch.delenv("GOOGLE_OAUTH_REDIRECT_URI", raising=False)
|
|
||||||
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "client-id.apps.googleusercontent.com")
|
|
||||||
|
|
||||||
authorize = _authorize_endpoint()
|
|
||||||
resp = await authorize(
|
|
||||||
account_id="acct-a",
|
|
||||||
request=_FakeRequest(scheme=scheme, host="odysseus.example.ts.net:7443"),
|
|
||||||
owner="",
|
|
||||||
)
|
|
||||||
|
|
||||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(resp.headers["location"]).query)
|
|
||||||
assert query["redirect_uri"] == [
|
|
||||||
f"{scheme}://odysseus.example.ts.net:7443/api/email/oauth/google/callback"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_callback_rejects_token_for_a_different_mailbox_identity():
|
async def test_callback_rejects_token_for_a_different_mailbox_identity():
|
||||||
"""Reconnecting with another Google identity must not replace the token
|
"""Reconnecting with another Google identity must not replace the token
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
"""Regression coverage for SMTP security saved before Google OAuth."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_email_tab_oauth_connect_persists_selected_smtp_security():
|
|
||||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
|
||||||
start = source.index("el('eaf-oauth-btn').addEventListener")
|
|
||||||
handler_body = source[start:source.index("if (!body.name)", start)]
|
|
||||||
|
|
||||||
assert "smtp_security: el('eaf-smtp-security').value" in handler_body
|
|
||||||
assert "display_name: el('eaf-display-name').value.trim()" in handler_body
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Regression coverage for the settings UI after Google OAuth redirects."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_oauth_redirect_uses_the_module_local_settings_api():
|
|
||||||
source = (_REPO / "static" / "js" / "settings.js").read_text(encoding="utf-8")
|
|
||||||
handler = source[
|
|
||||||
source.index("(function _handleOauthRedirect"):
|
|
||||||
source.index("const settingsModule =")
|
|
||||||
]
|
|
||||||
|
|
||||||
assert "open('integrations');" in handler
|
|
||||||
assert "window.settingsModule" not in handler
|
|
||||||
assert "window.__odysseusAppStarted" not in handler
|
|
||||||
assert "document.addEventListener('DOMContentLoaded', _showResult, { once: true })" in handler
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
"""Focused browser-side regression coverage for authoritative email opens."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parent.parent
|
|
||||||
_INBOX_JS = _REPO / "static" / "js" / "emailInbox.js"
|
|
||||||
_LIBRARY_JS = _REPO / "static" / "js" / "emailLibrary.js"
|
|
||||||
_HAS_NODE = shutil.which("node") is not None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_between(source: str, signature: str, next_marker: str) -> str:
|
|
||||||
start = source.index(signature)
|
|
||||||
end = source.index(next_marker, start)
|
|
||||||
return source[start:end].rstrip()
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
|
|
||||||
source = _LIBRARY_JS.read_text(encoding="utf-8")
|
|
||||||
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
|
||||||
|
|
||||||
assert function.count("/api/email/read/") == 1
|
|
||||||
assert "/api/email/mark-read/" not in function
|
|
||||||
assert "&mark_seen=true" in function
|
|
||||||
assert "_syncEmailReadState(uidAtStart, true, readContext)" in function
|
|
||||||
assert "_syncEmailReadState(uidAtStart, false, readContext)" in function
|
|
||||||
assert "openGeneration === _emailCardOpenSeq" in function
|
|
||||||
assert "_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation" in function
|
|
||||||
assert "authoritativeReadSucceeded = true;" in function
|
|
||||||
assert "if (!authoritativeReadSucceeded) restoreUnreadState();" in function
|
|
||||||
assert "if (!isCurrentOpen()) return" in function
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
|
|
||||||
source = _LIBRARY_JS.read_text(encoding="utf-8")
|
|
||||||
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
|
||||||
settlements = _extract_between(
|
|
||||||
function,
|
|
||||||
" const restoreUnreadState = () => {",
|
|
||||||
"\n\n // Collapse any other expanded card",
|
|
||||||
)
|
|
||||||
|
|
||||||
harness = f"""
|
|
||||||
const _emailReadMutations = new Map();
|
|
||||||
const readContextKey = 'same-mailbox-message';
|
|
||||||
const uidAtStart = '1';
|
|
||||||
const readContext = {{ accountId: 'acct-a', folder: 'INBOX', uid: '1' }};
|
|
||||||
const readUpdates = [];
|
|
||||||
function _syncEmailReadState(uid, isRead, context) {{
|
|
||||||
readUpdates.push({{ uid, isRead, context }});
|
|
||||||
}}
|
|
||||||
function createSettlers(readMutation) {{
|
|
||||||
{settlements}
|
|
||||||
return {{ restoreUnreadState, commitReadState }};
|
|
||||||
}}
|
|
||||||
function runRace(successFirst) {{
|
|
||||||
_emailReadMutations.clear();
|
|
||||||
readUpdates.length = 0;
|
|
||||||
const mutationA = {{ generation: 1, rollbackUnread: true }};
|
|
||||||
_emailReadMutations.set(readContextKey, mutationA);
|
|
||||||
const settlersA = createSettlers(mutationA);
|
|
||||||
const mutationB = {{ generation: 2, rollbackUnread: true }};
|
|
||||||
_emailReadMutations.set(readContextKey, mutationB);
|
|
||||||
const settlersB = createSettlers(mutationB);
|
|
||||||
if (successFirst) {{
|
|
||||||
settlersA.commitReadState();
|
|
||||||
settlersB.restoreUnreadState();
|
|
||||||
}} else {{
|
|
||||||
settlersB.restoreUnreadState();
|
|
||||||
settlersA.commitReadState();
|
|
||||||
}}
|
|
||||||
return {{
|
|
||||||
hasMutation: _emailReadMutations.has(readContextKey),
|
|
||||||
readUpdates: readUpdates.map(update => update.isRead),
|
|
||||||
}};
|
|
||||||
}}
|
|
||||||
console.log(JSON.stringify({{
|
|
||||||
successFirst: runRace(true),
|
|
||||||
failureFirst: runRace(false),
|
|
||||||
}}));
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "--input-type=module"],
|
|
||||||
input=harness,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
||||||
assert json.loads(proc.stdout.strip()) == {
|
|
||||||
"successFirst": {"hasMutation": False, "readUpdates": [True]},
|
|
||||||
"failureFirst": {"hasMutation": False, "readUpdates": [False, True]},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_reply_open_carries_immutable_mailbox_context():
|
|
||||||
library_source = _LIBRARY_JS.read_text(encoding="utf-8")
|
|
||||||
inbox_source = _INBOX_JS.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
assert "const mailboxGeneration = _emailMailboxGeneration;" in library_source
|
|
||||||
assert "messageFolder = String(options.email?.folder || libraryFolder)" in library_source
|
|
||||||
assert "return onEmailClick({ ...options, mailboxContext });" in library_source
|
|
||||||
assert "mailboxContext?.messageFolder || _currentFolder" in inbox_source
|
|
||||||
assert "mailboxContextIsCurrent()" in inbox_source
|
|
||||||
assert "if (!isCurrentOpen()) return;\n let activeSid = await _createEmailChat" in inbox_source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
def test_inbox_late_read_response_cannot_apply_after_newer_open():
|
|
||||||
source = _INBOX_JS.read_text(encoding="utf-8")
|
|
||||||
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
|
||||||
assert "let _openEmailRequestSeq = 0;" in source
|
|
||||||
|
|
||||||
harness = f"""
|
|
||||||
const realLog = console.log;
|
|
||||||
console.error = () => {{}};
|
|
||||||
const API_BASE = 'https://odysseus.invalid';
|
|
||||||
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
|
||||||
let _currentFolder = 'INBOX';
|
|
||||||
const _acct = () => '&account_id=acct-a';
|
|
||||||
let _openEmailRequestSeq = 0;
|
|
||||||
let _docModule = null;
|
|
||||||
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
|
||||||
const sessionModule = null;
|
|
||||||
let firstResolve;
|
|
||||||
const calls = [];
|
|
||||||
async function fetch(url) {{
|
|
||||||
calls.push(String(url));
|
|
||||||
if (calls.length === 1) {{
|
|
||||||
return await new Promise((resolve) => {{
|
|
||||||
firstResolve = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
return {{ json: async () => ({{ error: 'newer open completed test' }}) }};
|
|
||||||
}}
|
|
||||||
{function}
|
|
||||||
const oldEmail = {{ uid: '1', is_read: false }};
|
|
||||||
const newerEmail = {{ uid: '2', is_read: false }};
|
|
||||||
const first = _openEmail(oldEmail, null);
|
|
||||||
await Promise.resolve();
|
|
||||||
const second = _openEmail(newerEmail, null);
|
|
||||||
await second;
|
|
||||||
firstResolve();
|
|
||||||
await first;
|
|
||||||
realLog(JSON.stringify({{ calls, oldRead: oldEmail.is_read, newerRead: newerEmail.is_read }}));
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "--input-type=module"],
|
|
||||||
input=harness,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
||||||
result = json.loads(proc.stdout.strip())
|
|
||||||
assert len(result["calls"]) == 2
|
|
||||||
assert all("mark_seen=true" in url for url in result["calls"])
|
|
||||||
assert result["oldRead"] is False
|
|
||||||
assert result["newerRead"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
|
||||||
@pytest.mark.parametrize("context_change", ["account", "folder", "library"])
|
|
||||||
def test_inbox_late_read_response_cannot_apply_after_mailbox_switch(context_change):
|
|
||||||
source = _INBOX_JS.read_text(encoding="utf-8")
|
|
||||||
function = _extract_between(source, "async function _openEmail", "\nfunction _showEmailMenu")
|
|
||||||
|
|
||||||
changes = {
|
|
||||||
"account": "window.__odysseusActiveEmailAccount = 'acct-b';",
|
|
||||||
"folder": "_currentFolder = 'Archive';",
|
|
||||||
"library": "libraryCurrent = false;",
|
|
||||||
}
|
|
||||||
change = changes[context_change]
|
|
||||||
open_call = (
|
|
||||||
"_openEmail(email, null, null, 'reply', '', '', mailboxContext)"
|
|
||||||
if context_change == "library"
|
|
||||||
else "_openEmail(email, null)"
|
|
||||||
)
|
|
||||||
harness = f"""
|
|
||||||
const realLog = console.log;
|
|
||||||
console.error = () => {{}};
|
|
||||||
const API_BASE = 'https://odysseus.invalid';
|
|
||||||
const window = {{ __odysseusActiveEmailAccount: 'acct-a' }};
|
|
||||||
let _currentFolder = 'INBOX';
|
|
||||||
const _acct = () => '&account_id=acct-a';
|
|
||||||
let _openEmailRequestSeq = 0;
|
|
||||||
let libraryCurrent = true;
|
|
||||||
const mailboxContext = {{
|
|
||||||
accountId: 'acct-a',
|
|
||||||
messageFolder: 'Archive',
|
|
||||||
isCurrent: () => libraryCurrent,
|
|
||||||
}};
|
|
||||||
let createCalls = 0;
|
|
||||||
let _docModule = {{}};
|
|
||||||
async function _createEmailChat() {{ createCalls += 1; return 'stale-session'; }}
|
|
||||||
const spinnerModule = {{ createWhirlpool() {{ throw new Error('spinner should not run'); }} }};
|
|
||||||
const sessionModule = null;
|
|
||||||
let resolveRead;
|
|
||||||
async function fetch() {{
|
|
||||||
return await new Promise((resolve) => {{
|
|
||||||
resolveRead = () => resolve({{ json: async () => ({{ uid: '1', subject: 'old' }}) }});
|
|
||||||
}});
|
|
||||||
}}
|
|
||||||
{function}
|
|
||||||
const email = {{ uid: '1', is_read: false }};
|
|
||||||
const pending = {open_call};
|
|
||||||
await Promise.resolve();
|
|
||||||
{change}
|
|
||||||
resolveRead();
|
|
||||||
await pending;
|
|
||||||
realLog(JSON.stringify({{ createCalls, isRead: email.is_read }}));
|
|
||||||
"""
|
|
||||||
proc = subprocess.run(
|
|
||||||
["node", "--input-type=module"],
|
|
||||||
input=harness,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
cwd=str(_REPO),
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{harness}"
|
|
||||||
result = json.loads(proc.stdout.strip())
|
|
||||||
assert result == {"createCalls": 0, "isRead": False}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
RAW_EMAIL = (
|
|
||||||
b"From: Sender <sender@example.com>\r\n"
|
|
||||||
b"To: Alice <alice@example.com>\r\n"
|
|
||||||
b"Subject: Single authoritative open\r\n"
|
|
||||||
b"Message-ID: <single-open@example.com>\r\n"
|
|
||||||
b"Date: Tue, 04 Aug 2026 12:00:00 +0000\r\n"
|
|
||||||
b"Content-Type: text/plain; charset=utf-8\r\n"
|
|
||||||
b"\r\n"
|
|
||||||
b"Body"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _route_endpoint(router, path: str, method: str):
|
|
||||||
method = method.upper()
|
|
||||||
for route in router.routes:
|
|
||||||
if route.path == path and method in getattr(route, "methods", set()):
|
|
||||||
return route.endpoint
|
|
||||||
raise AssertionError(f"route not found: {method} {path}")
|
|
||||||
|
|
||||||
|
|
||||||
class FakeImap:
|
|
||||||
def __init__(self, store_status="OK", readonly_mailbox=False):
|
|
||||||
self.store_status = store_status
|
|
||||||
# Shared archives and some provider folders reject a read-write SELECT.
|
|
||||||
self.readonly_mailbox = readonly_mailbox
|
|
||||||
self.selects = []
|
|
||||||
self.commands = []
|
|
||||||
|
|
||||||
def select(self, mailbox, readonly=False):
|
|
||||||
self.selects.append((mailbox, readonly))
|
|
||||||
if self.readonly_mailbox and not readonly:
|
|
||||||
raise OSError("[READ-ONLY] Mailbox is read-only")
|
|
||||||
return "OK", [b"1"]
|
|
||||||
|
|
||||||
def uid(self, command, uid, *args):
|
|
||||||
self.commands.append((command, uid, *args))
|
|
||||||
if command == "FETCH":
|
|
||||||
header, body = RAW_EMAIL.split(b"\r\n\r\n", 1)
|
|
||||||
return "OK", [
|
|
||||||
(b"1 (UID 42 BODY[HEADER])", header + b"\r\n\r\n"),
|
|
||||||
(b"1 (UID 42 BODY[TEXT]<0>)", body),
|
|
||||||
]
|
|
||||||
if command == "STORE":
|
|
||||||
# RFC 3501 STORE takes a parenthesized flag-list. GreenMail rejects
|
|
||||||
# the formerly emitted bare ``\Seen`` atom with BAD, so keep the
|
|
||||||
# fake strict enough to catch that provider-compatibility failure.
|
|
||||||
if args != ("+FLAGS", "(\\Seen)"):
|
|
||||||
return "BAD", [b"Expected:'(' found:'\\'"]
|
|
||||||
return self.store_status, []
|
|
||||||
raise AssertionError(f"unexpected IMAP command: {command}")
|
|
||||||
|
|
||||||
|
|
||||||
def _install_fakes(monkeypatch, tmp_path, *, store_status="OK", readonly_mailbox=False):
|
|
||||||
import routes.email_helpers as email_helpers
|
|
||||||
import routes.email_routes as email_routes
|
|
||||||
|
|
||||||
db_path = tmp_path / "email.db"
|
|
||||||
monkeypatch.setattr(email_helpers, "SCHEDULED_DB", db_path)
|
|
||||||
monkeypatch.setattr(email_routes, "SCHEDULED_DB", db_path)
|
|
||||||
email_helpers._init_scheduled_db()
|
|
||||||
|
|
||||||
connections = []
|
|
||||||
indexed_updates = []
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def fake_imap(account_id=None, owner=""):
|
|
||||||
conn = FakeImap(store_status=store_status, readonly_mailbox=readonly_mailbox)
|
|
||||||
connections.append(conn)
|
|
||||||
yield conn
|
|
||||||
|
|
||||||
monkeypatch.setattr(email_routes, "_start_poller", lambda: None)
|
|
||||||
monkeypatch.setattr(email_routes, "_imap", fake_imap)
|
|
||||||
monkeypatch.setattr(email_routes, "_email_preview_cache_get", lambda *_args, **_kwargs: None)
|
|
||||||
monkeypatch.setattr(email_routes, "_email_preview_cache_put", lambda *_args, **_kwargs: None)
|
|
||||||
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_get", lambda *_args, **_kwargs: None)
|
|
||||||
monkeypatch.setattr(email_routes, "_email_attachment_meta_cache_put", lambda *_args, **_kwargs: None)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
email_routes,
|
|
||||||
"_email_index_update_flags",
|
|
||||||
lambda *args, **_kwargs: indexed_updates.append(args),
|
|
||||||
)
|
|
||||||
return email_routes, connections, indexed_updates
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("mark_seen", [True, False])
|
|
||||||
async def test_read_email_seen_contract_uses_one_imap_connection(monkeypatch, tmp_path, mark_seen):
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
result = await read_email(
|
|
||||||
"42",
|
|
||||||
folder="INBOX",
|
|
||||||
account_id="acct-a",
|
|
||||||
mark_seen=mark_seen,
|
|
||||||
full=False,
|
|
||||||
owner="alice",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["uid"] == "42"
|
|
||||||
assert len(connections) == 1
|
|
||||||
conn = connections[0]
|
|
||||||
assert conn.selects == [(conn.selects[0][0], not mark_seen)]
|
|
||||||
assert [command[0] for command in conn.commands] == (
|
|
||||||
["FETCH", "STORE"] if mark_seen else ["FETCH"]
|
|
||||||
)
|
|
||||||
assert "BODY.PEEK[HEADER]" in conn.commands[0][2]
|
|
||||||
if mark_seen:
|
|
||||||
assert conn.commands[1][2:] == ("+FLAGS", "(\\Seen)")
|
|
||||||
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
|
|
||||||
else:
|
|
||||||
assert indexed_updates == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cached_read_awaits_one_seen_store_without_refetch(monkeypatch, tmp_path):
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
first = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
asyncio,
|
|
||||||
"create_task",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
|
||||||
AssertionError("cached mark-seen must be awaited, not scheduled")
|
|
||||||
),
|
|
||||||
)
|
|
||||||
second = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first["message_id"] == second["message_id"]
|
|
||||||
assert len(connections) == 2
|
|
||||||
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
|
||||||
assert [command[0] for command in connections[1].commands] == ["STORE"]
|
|
||||||
assert connections[1].commands[0][2:] == ("+FLAGS", "(\\Seen)")
|
|
||||||
assert connections[1].selects[0][1] is False
|
|
||||||
assert indexed_updates == [("alice", "acct-a", "INBOX", "42", "\\Seen", True)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_seen_store_failure_returns_the_body_and_reports_the_failure(monkeypatch, tmp_path):
|
|
||||||
"""A failed STORE must not cost the reader the message.
|
|
||||||
|
|
||||||
The body was fetched successfully before the flag update was attempted, so
|
|
||||||
the response stays a normal read and carries `mark_seen_failed` for the
|
|
||||||
client to roll its optimistic unread marker back.
|
|
||||||
"""
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(
|
|
||||||
monkeypatch, tmp_path, store_status="NO"
|
|
||||||
)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
result = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "error" not in result
|
|
||||||
assert result["uid"] == "42"
|
|
||||||
assert result["mark_seen_failed"] is True
|
|
||||||
assert len(connections) == 1
|
|
||||||
assert [command[0] for command in connections[0].commands] == ["FETCH", "STORE"]
|
|
||||||
# The local index must not claim a transition the provider rejected.
|
|
||||||
assert indexed_updates == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_only_mailbox_serves_the_message_without_marking_seen(monkeypatch, tmp_path):
|
|
||||||
"""A mailbox that refuses a read-write SELECT is still readable.
|
|
||||||
|
|
||||||
Opening the message is the user's actual goal; the \\Seen transition is a
|
|
||||||
side effect of it. A folder that cannot accept flag changes must therefore
|
|
||||||
fall back to a read-only selection rather than failing the open.
|
|
||||||
"""
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(
|
|
||||||
monkeypatch, tmp_path, readonly_mailbox=True
|
|
||||||
)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
result = await read_email(
|
|
||||||
"42", folder="Archive", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "error" not in result
|
|
||||||
assert result["uid"] == "42"
|
|
||||||
assert result["mark_seen_failed"] is True
|
|
||||||
# Read-write attempt first, then the read-only retry on the same connection.
|
|
||||||
assert [readonly for _mailbox, readonly in connections[0].selects] == [False, True]
|
|
||||||
# No STORE is attempted once the mailbox is known to be read-only.
|
|
||||||
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
|
||||||
assert indexed_updates == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_failed_seen_state_is_not_replayed_from_cache(monkeypatch, tmp_path):
|
|
||||||
"""`mark_seen_failed` describes one request, not the stored message.
|
|
||||||
|
|
||||||
A second read that does not ask to mark seen must come back clean, or every
|
|
||||||
later reader would inherit a STORE failure it never issued.
|
|
||||||
"""
|
|
||||||
email_routes, connections, _ = _install_fakes(monkeypatch, tmp_path, store_status="NO")
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
failed = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
replayed = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert failed["mark_seen_failed"] is True
|
|
||||||
assert replayed.get("mark_seen_failed", False) is False
|
|
||||||
assert replayed["uid"] == "42"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unparseable_read_does_not_mark_seen(monkeypatch, tmp_path):
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(monkeypatch, tmp_path)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
email_routes.email_mod,
|
|
||||||
"message_from_bytes",
|
|
||||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("malformed message")),
|
|
||||||
)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
result = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"error": "Mail operation failed"}
|
|
||||||
assert len(connections) == 1
|
|
||||||
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
|
||||||
assert indexed_updates == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cached_seen_store_failure_returns_the_cached_body(monkeypatch, tmp_path):
|
|
||||||
"""A cache hit already holds a complete message; a failed STORE cannot take it away.
|
|
||||||
|
|
||||||
This is the path where withholding the body would be least defensible — the
|
|
||||||
response is served from memory and needed no network at all.
|
|
||||||
"""
|
|
||||||
email_routes, connections, indexed_updates = _install_fakes(
|
|
||||||
monkeypatch, tmp_path, store_status="NO"
|
|
||||||
)
|
|
||||||
router = email_routes.setup_email_routes()
|
|
||||||
read_email = _route_endpoint(router, "/api/email/read/{uid}", "GET")
|
|
||||||
|
|
||||||
first = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=False, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
second = await read_email(
|
|
||||||
"42", folder="INBOX", account_id="acct-a", mark_seen=True, full=False, owner="alice"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first["uid"] == "42"
|
|
||||||
assert "error" not in second
|
|
||||||
assert second["uid"] == "42"
|
|
||||||
assert second["body"] == first["body"]
|
|
||||||
assert second["mark_seen_failed"] is True
|
|
||||||
assert len(connections) == 2
|
|
||||||
assert [command[0] for command in connections[0].commands] == ["FETCH"]
|
|
||||||
assert [command[0] for command in connections[1].commands] == ["STORE"]
|
|
||||||
assert indexed_updates == []
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user