mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 19:02:22 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3819e3cbf | ||
|
|
6fceb6ccd3 | ||
|
|
501059805f | ||
|
|
353ea00736 | ||
|
|
0c56527d12 | ||
|
|
c23b1be3f8 | ||
|
|
86b886c611 | ||
|
|
f902be18f7 | ||
|
|
1cc56cb9d7 | ||
|
|
b5b76fe46d | ||
|
|
0e2c08300e | ||
|
|
afecd44ddf | ||
|
|
8feb50e894 | ||
|
|
a8c147b238 | ||
|
|
ec5bcd2b9a | ||
|
|
137daab16e | ||
|
|
41cace1337 | ||
|
|
10377f0c95 | ||
|
|
2c8e00ca05 |
@@ -10,6 +10,10 @@
|
|||||||
entrypoint.sh text eol=lf
|
entrypoint.sh text eol=lf
|
||||||
docker/entrypoint.sh text eol=lf
|
docker/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
|
# Maintained frontend modules and this lane's byte-identical deployment output.
|
||||||
|
frontend/src/** text eol=lf
|
||||||
|
static/js/actionMenuOrder.js text eol=lf
|
||||||
|
|
||||||
# Windows-native scripts stay CRLF.
|
# Windows-native scripts stay CRLF.
|
||||||
*.ps1 text eol=crlf
|
*.ps1 text eol=crlf
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
- run: python -m compileall -q app.py core routes src services scripts tests
|
- run: python -m compileall -q app.py core routes src services scripts tests
|
||||||
|
- name: Check committed frontend deployment modules
|
||||||
|
run: python scripts/build_frontend.py --check
|
||||||
|
|
||||||
node-syntax:
|
node-syntax:
|
||||||
name: JS syntax (node --check)
|
name: JS syntax (node --check)
|
||||||
@@ -92,13 +94,19 @@ jobs:
|
|||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
- name: Check frontend architecture
|
||||||
|
run: node scripts/check_frontend.mjs
|
||||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||||
- name: node --check
|
- name: node --check
|
||||||
run: |
|
run: |
|
||||||
shopt -s globstar nullglob
|
shopt -s globstar nullglob
|
||||||
for f in static/app.js static/js/**/*.js; do
|
for f in static/app.js static/js/**/*.js frontend/src/**/*.js; do
|
||||||
node --check "$f"
|
node --check "$f"
|
||||||
done
|
done
|
||||||
|
node --check scripts/check_frontend.mjs
|
||||||
|
- name: Test frontend architecture gates and stateless proof
|
||||||
|
run: node --test tests/frontendArchitecture.test.mjs
|
||||||
|
|
||||||
python-tests:
|
python-tests:
|
||||||
name: Python tests (pytest ${{ matrix.shard }})
|
name: Python tests (pytest ${{ matrix.shard }})
|
||||||
@@ -147,8 +155,15 @@ jobs:
|
|||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
cache: pip
|
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||||
- run: pip install -r requirements.txt
|
# cache keeps each shard from re-downloading the whole requirement set.
|
||||||
|
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: requirements.txt
|
||||||
|
prune-cache: false
|
||||||
|
- run: uv pip install --system -r requirements.txt
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
@@ -157,15 +172,25 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
|
# Browser binaries are keyed on the lockfile's Playwright version. On a
|
||||||
|
# hit, --with-deps still installs the system libraries but skips the
|
||||||
|
# browser downloads.
|
||||||
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||||
- run: npx playwright install --with-deps chromium
|
- run: npx playwright install --with-deps chromium
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
- name: Install FFmpeg for media integration tests
|
# One apt pass for FFmpeg (media integration tests) and bubblewrap
|
||||||
|
# (containment, verified in the next step).
|
||||||
|
- name: Install FFmpeg and bubblewrap
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
|
||||||
command -v ffmpeg
|
command -v ffmpeg
|
||||||
ffmpeg -version | head -n 1
|
ffmpeg -version | head -n 1
|
||||||
|
|
||||||
@@ -174,8 +199,6 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y --no-install-recommends bubblewrap
|
|
||||||
bwrap --version
|
bwrap --version
|
||||||
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
|
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
|
||||||
kernel.apparmor_restrict_unprivileged_userns
|
kernel.apparmor_restrict_unprivileged_userns
|
||||||
@@ -223,4 +246,4 @@ jobs:
|
|||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
env:
|
env:
|
||||||
PYTEST_SHARD: ${{ matrix.shard }}
|
PYTEST_SHARD: ${{ matrix.shard }}
|
||||||
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
|
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||||
|
|||||||
@@ -67,3 +67,24 @@ SAN-191 reconciles references, font preferences, catalogs, tests and packaging.
|
|||||||
The service-worker cache version changes so activation deletes prior app caches.
|
The service-worker cache version changes so activation deletes prior app caches.
|
||||||
Omission is not a finding of infringement and does not grant permission to
|
Omission is not a finding of infringement and does not grant permission to
|
||||||
restore the removed originals.
|
restore the removed originals.
|
||||||
|
|
||||||
|
## Post-publication restoration review
|
||||||
|
|
||||||
|
The historical SAN entries recorded above reflect the conservative posture adopted during publication migration. As noted in SAN-191, omission was not a finding of infringement. A subsequent maintainer review traced these 14 project-origin assets through repository history, recorded their repository provenance, and restored their byte-identical historical versions previously distributed by the project.
|
||||||
|
|
||||||
|
The original SHA-256 hashes are recorded below. Ambiguous third-party assets (such as vendor marks and third-party libraries) remain deliberately omitted.
|
||||||
|
|
||||||
|
- RESTORE-001: `assets/branding/odysseus-browser.jpg` (SHA-256: `96bc894113d3db1a8e19535047e85fef7bfe7e643d45973d6effb945dce667df`): README interface screenshot introduced by pewdiepie-archdaemon in commit "Refresh README screenshot".
|
||||||
|
- RESTORE-002: `assets/branding/odysseus-wordmark.png` (SHA-256: `5e20708f9709735c66692c0cffc74734d61cfa256d48f9095c6870bcd53f4552`): README wordmark artwork introduced by pewdiepie-archdaemon in commit "Refresh README presentation".
|
||||||
|
- RESTORE-003: `assets/branding/odysseus.jpg` (SHA-256: `cd5e87b12f1e9e7baca6a6c5e95012130c9e26c6e31526e864eaf17096fcd05e`): Project branding image from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon, used by build-macos-app.sh for launcher icon generation.
|
||||||
|
- RESTORE-004: `website/bg.webm` (SHA-256: `157f732539a6072d55164da23d65afa9423782728dd067154867fcebb176fb35`): Landing page background video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-005: `website/chat.webm` (SHA-256: `d69f319192cec2cac1831e08ee99ae902398f1b163021e42b3fda05a0d7ab4d5`): Chat demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-006: `website/compare.webm` (SHA-256: `90d8c68dbc2ac3029b258598a7e2c2c5c8f5d6d702b9d31f0c729e5517093f51`): Model comparison demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-007: `website/document.webm` (SHA-256: `754e4ae071e9def5151749a03ad284cd41385354620fc8afea3c5c4f50f64a0d`): Document editor demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-008: `website/gallery.webm` (SHA-256: `b3bbe99c55b5cd1de32cb8e84fa31044761b7724a76a4bae4d4b0acaed7f3500`): Image gallery demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-009: `website/notes.webm` (SHA-256: `2a82b501271c4b60927e575bb22f8c986e3d1a4089a5518f84b46e534dea37d7`): Notes demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-010: `website/research.webm` (SHA-256: `2495a1a6cc1ea037f7460d065e3af6aacf091b240edebc436ce4bca5e83dab87`): Deep research demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-011: `website/theme.webm` (SHA-256: `6bd5a4140e9eeb9e06bee217319d331e9c8c873763fcb9040425fb38878b8410`): Theme demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||||
|
- RESTORE-012: `static/icons/icon-192.png` (SHA-256: `d9f54e07d2a8dca6302125d84436176a26a056745c5adb29ef521345c161e359`): Project PWA icon (192x192) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||||
|
- RESTORE-013: `static/icons/icon-512.png` (SHA-256: `785872b140da58087f23539ed187fe0f69de6eb9690abf995b10c93b5b518388`): Project PWA icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||||
|
- RESTORE-014: `static/icons/icon-maskable-512.png` (SHA-256: `7ed567fe0de6b6b451eec35c8fb2b465d84a3b23945135c19c4fa388ac976449`): Project PWA maskable icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<h1 align="center">Odysseus</h1>
|
<p align="center">
|
||||||
|
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
||||||
@@ -15,6 +17,10 @@
|
|||||||
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -53,7 +59,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
|
|||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
The [Odysseus landing page](https://odysseus-dev.github.io/odysseus/) gives a text-only overview of each feature. Its source lives under [`website/`](website/).
|
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -516,28 +516,69 @@ app.mount("/static", _RevalidatingStatic(directory=STATIC_DIR), name="static")
|
|||||||
@app.get("/api/generated-image/{filename}")
|
@app.get("/api/generated-image/{filename}")
|
||||||
async def serve_generated_image(filename: str, request: Request):
|
async def serve_generated_image(filename: str, request: Request):
|
||||||
"""Serve generated images from the data directory."""
|
"""Serve generated images from the data directory."""
|
||||||
img_path = resolve_generated_image_path(filename)
|
# 1. Validate filename syntax upfront, rejecting malformed requests (400)
|
||||||
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
# without probing the filesystem or database.
|
||||||
# 12-hex content hash could pull another user's image bytes. Require
|
from src.generated_images import GENERATED_IMAGE_RE
|
||||||
# auth and verify ownership via the gallery row (when one exists).
|
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
|
||||||
try:
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
from src.auth_helpers import get_current_user
|
|
||||||
from core.database import SessionLocal as _SL, GalleryImage as _GI
|
# 2. Ownership verification:
|
||||||
_user = get_current_user(request)
|
# If auth is enabled, verify caller identity and ownership BEFORE probing disk,
|
||||||
if _user:
|
# preventing existence oracles during outages or unauthorized requests.
|
||||||
_db = _SL()
|
from src.auth_helpers import auth_disabled, effective_user
|
||||||
|
if not auth_disabled():
|
||||||
|
_user = effective_user(request)
|
||||||
|
if not _user:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
from src.generated_images import get_registered_image_owner
|
||||||
|
_reg_owner = get_registered_image_owner(filename)
|
||||||
|
if _reg_owner is not None:
|
||||||
|
if _reg_owner != _user:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
_row = _db.query(_GI).filter(_GI.filename == filename).first()
|
from core.database import (
|
||||||
# Generated-but-not-yet-imported images have no row → allow.
|
SessionLocal as _SL,
|
||||||
# Row exists with a different owner → 404 (don't confirm existence).
|
GalleryImage as _GI,
|
||||||
if _row is not None and _row.owner and _row.owner != _user:
|
Session as _DbSession,
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
ChatMessage as _CM,
|
||||||
finally:
|
)
|
||||||
_db.close()
|
_db = _SL()
|
||||||
except HTTPException:
|
try:
|
||||||
raise
|
_row = _db.query(_GI).filter(_GI.filename == filename).first()
|
||||||
except Exception as _e:
|
if _row is not None:
|
||||||
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
|
if _row.owner:
|
||||||
|
if _row.owner != _user:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
elif _row.session_id:
|
||||||
|
_sess = _db.query(_DbSession).filter(_DbSession.id == _row.session_id).first()
|
||||||
|
if not _sess or not _sess.owner or _sess.owner != _user:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
else:
|
||||||
|
# Gallery record has no owner or session link -> fail closed
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
else:
|
||||||
|
# Rowless image: check if referenced in a chat session owned by this user
|
||||||
|
_chat_ref = (
|
||||||
|
_db.query(_CM.id)
|
||||||
|
.join(_DbSession, _CM.session_id == _DbSession.id)
|
||||||
|
.filter(_DbSession.owner == _user, _CM.meta_data.like(f"%{filename}%"))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not _chat_ref:
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
finally:
|
||||||
|
_db.close()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("Image ownership verification failed for %r", filename, exc_info=_e)
|
||||||
|
raise HTTPException(status_code=503, detail="Ownership verification unavailable")
|
||||||
|
|
||||||
|
# 3. Resolve confined path and verify existence on disk
|
||||||
|
img_path = resolve_generated_image_path(filename)
|
||||||
|
|
||||||
ext = filename.rsplit('.', 1)[-1].lower()
|
ext = filename.rsplit('.', 1)[-1].lower()
|
||||||
mime = {
|
mime = {
|
||||||
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
|
||||||
@@ -545,10 +586,6 @@ async def serve_generated_image(filename: str, request: Request):
|
|||||||
"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
|
"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm",
|
||||||
"mkv": "video/x-matroska", "m4v": "video/mp4",
|
"mkv": "video/x-matroska", "m4v": "video/mp4",
|
||||||
}.get(ext, "application/octet-stream")
|
}.get(ext, "application/octet-stream")
|
||||||
# Generated-image filenames are content hashes → the bytes for a given
|
|
||||||
# filename never change. Cache them hard so the gallery doesn't
|
|
||||||
# re-download every full-size image each time it's opened. `immutable`
|
|
||||||
# tells the browser it never needs to revalidate within the max-age.
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
str(img_path),
|
str(img_path),
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
+18
-2
@@ -27,8 +27,23 @@ echo " port: $PORT"
|
|||||||
rm -rf "$APP"
|
rm -rf "$APP"
|
||||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||||
|
|
||||||
# Use the macOS default application icon; no branding-derived artwork is bundled.
|
# ── Icon (best effort) -- center-crop the branding image to a square .icns ──
|
||||||
echo " icon: macOS default"
|
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
|
||||||
|
TMPIMG="$(mktemp -d)"
|
||||||
|
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
|
||||||
|
# let sips emit the .icns directly: more robust across macOS versions than
|
||||||
|
# building an .iconset by hand.
|
||||||
|
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
|
||||||
|
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
|
||||||
|
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
|
||||||
|
echo " icon: odysseus.icns"
|
||||||
|
else
|
||||||
|
echo " icon: (skipped - conversion failed)"
|
||||||
|
fi
|
||||||
|
rm -rf "$TMPIMG"
|
||||||
|
else
|
||||||
|
echo " icon: (skipped - no assets/branding/odysseus.jpg)"
|
||||||
|
fi
|
||||||
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
|
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
|
||||||
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
|
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
|
||||||
|
|
||||||
@@ -45,6 +60,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|||||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||||
<key>CFBundlePackageType</key> <string>APPL</string>
|
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||||
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
|
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
|
||||||
|
<key>CFBundleIconFile</key> <string>odysseus</string>
|
||||||
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
||||||
<key>NSHighResolutionCapable</key> <true/>
|
<key>NSHighResolutionCapable</key> <true/>
|
||||||
<key>LSUIElement</key> <false/>
|
<key>LSUIElement</key> <false/>
|
||||||
|
|||||||
@@ -131,7 +131,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- chromadb-data:/chroma/chroma
|
# The image runs `chroma run /config.yaml`, whose persist_path is "/data".
|
||||||
|
# It declares no VOLUME, so anything written outside a mount lives in the
|
||||||
|
# container's writable layer and dies with it. /chroma/chroma is where
|
||||||
|
# pre-1.0 images kept the store; mounting there now catches nothing.
|
||||||
|
- chromadb-data:/data
|
||||||
environment:
|
environment:
|
||||||
- ANONYMIZED_TELEMETRY=FALSE
|
- ANONYMIZED_TELEMETRY=FALSE
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -134,7 +134,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- chromadb-data:/chroma/chroma
|
# The image runs `chroma run /config.yaml`, whose persist_path is "/data".
|
||||||
|
# It declares no VOLUME, so anything written outside a mount lives in the
|
||||||
|
# container's writable layer and dies with it. /chroma/chroma is where
|
||||||
|
# pre-1.0 images kept the store; mounting there now catches nothing.
|
||||||
|
- chromadb-data:/data
|
||||||
environment:
|
environment:
|
||||||
- ANONYMIZED_TELEMETRY=FALSE
|
- ANONYMIZED_TELEMETRY=FALSE
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+5
-1
@@ -112,7 +112,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
- "${CHROMADB_BIND:-127.0.0.1}:8100:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- chromadb-data:/chroma/chroma
|
# The image runs `chroma run /config.yaml`, whose persist_path is "/data".
|
||||||
|
# It declares no VOLUME, so anything written outside a mount lives in the
|
||||||
|
# container's writable layer and dies with it. /chroma/chroma is where
|
||||||
|
# pre-1.0 images kept the store; mounting there now catches nothing.
|
||||||
|
- chromadb-data:/data
|
||||||
environment:
|
environment:
|
||||||
- ANONYMIZED_TELEMETRY=FALSE
|
- ANONYMIZED_TELEMETRY=FALSE
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# ChromaDB Volume Migration — Upgrading from pre-#6166 installs
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> This guide is only for Docker-based installs that were deployed before PR #6166
|
||||||
|
> was merged. Native host installs are unaffected (they persist to `data/chroma/`
|
||||||
|
> on the host filesystem, which was always stored correctly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What changed and why
|
||||||
|
|
||||||
|
Before PR #6166, the `chromadb-data` named volume was mounted at `/chroma/chroma` inside
|
||||||
|
the ChromaDB container. However, current `chromadb/chroma` images (version 1.0+) write their
|
||||||
|
database to `/data` — configured by the container image's internal `/config.yaml` (`persist_path: "/data"`).
|
||||||
|
Because the upstream ChromaDB image declares no `VOLUME` directive:
|
||||||
|
|
||||||
|
* **Any vector data written to `/data` accumulated exclusively in the container's writable layer.**
|
||||||
|
* The mounted `chromadb-data` named volume at `/chroma/chroma` remained completely empty.
|
||||||
|
* Any recreation or removal of the container (`docker compose down`, `docker compose up --force-recreate`, `docker rm`)
|
||||||
|
permanently destroys that writable layer and all vector embeddings within it.
|
||||||
|
|
||||||
|
PR #6166 corrects the mount target across all Compose configurations to `chromadb-data:/data`.
|
||||||
|
|
||||||
|
Fresh installations will persist data directly to the named volume automatically. However, for
|
||||||
|
**existing running installations**, the vector store currently resides in the old container's
|
||||||
|
writable layer.
|
||||||
|
|
||||||
|
This guide provides a zero-data-loss, fail-closed operational procedure to quiesce, preserve,
|
||||||
|
back up, verify, seed, and validate that data before performing cutover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture and Safety Principles
|
||||||
|
|
||||||
|
1. **No Live `docker cp`:** ChromaDB uses SQLite (`chroma.sqlite3`) and binary HNSW segment index
|
||||||
|
files (`data_level0.bin`, `header.bin`, `length.bin`, `link_lists.bin`). Taking a file copy while
|
||||||
|
the database engine is running risks capturing torn SQLite pages or out-of-sync HNSW graph segments.
|
||||||
|
Writers must be quiesced and ChromaDB gracefully stopped before copying files.
|
||||||
|
2. **Do Not Trust `docker rename` Alone:** Docker Compose tracks containers by internal metadata labels
|
||||||
|
(`com.docker.compose.project`, `com.docker.compose.service`, `com.docker.compose.container-number`),
|
||||||
|
**not** merely by container name. Renaming a container does not strip its labels; Compose commands
|
||||||
|
can still discover, restart, or recreate it. To guarantee preservation of the writable layer, the
|
||||||
|
stopped container must be committed to an immutable Docker image (`docker commit`).
|
||||||
|
3. **Never Overwrite Existing Volumes:** Named volumes must be positively verified as empty prior to seeding.
|
||||||
|
4. **Staging Validation Before Production Cutover:** The seeded named volume must be independently validated
|
||||||
|
using a disposable ChromaDB instance on an isolated port before touching the production Compose deployment.
|
||||||
|
5. **No Rollback to `/chroma/chroma`:** Rolling back to pre-#6166 Compose files reintroduces the broken
|
||||||
|
mount path where ChromaDB never looks. Rollback procedures must restore data into the corrected `/data` path
|
||||||
|
or redeploy from the immutable backup image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Step-by-Step Migration Procedure
|
||||||
|
|
||||||
|
### Step 0: Identify your project and volume names
|
||||||
|
|
||||||
|
Docker Compose prefixes container and volume names with the project name (usually the root directory name).
|
||||||
|
Identify yours before proceeding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Identify running ChromaDB container
|
||||||
|
docker ps --filter "ancestor=chromadb/chroma" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}"
|
||||||
|
|
||||||
|
# Identify the named volume
|
||||||
|
docker volume ls --filter "name=chromadb"
|
||||||
|
```
|
||||||
|
|
||||||
|
Throughout this guide:
|
||||||
|
* `<project>` represents your Compose project (e.g. `odysseus` or `odysseus-maintainer-preview`).
|
||||||
|
* `<container>` represents the running ChromaDB container (e.g. `<project>-chromadb-1`).
|
||||||
|
* `<volume>` represents the named volume (e.g. `<project>_chromadb-data`).
|
||||||
|
|
||||||
|
Set shell variables for convenience:
|
||||||
|
```bash
|
||||||
|
PROJECT="<project>"
|
||||||
|
CONTAINER="${PROJECT}-chromadb-1"
|
||||||
|
VOLUME="${PROJECT}_chromadb-data"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 1: Pre-Migration Collection Inventory (Baseline)
|
||||||
|
|
||||||
|
Establish a baseline of all existing collections and their embedding counts through the live ChromaDB API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all collections and record counts
|
||||||
|
curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \
|
||||||
|
| python3 -c '
|
||||||
|
import json, sys, urllib.request
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(f"Total collections found: {len(data)}")
|
||||||
|
for col in data:
|
||||||
|
c_id = col["id"]
|
||||||
|
name = col["name"]
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp:
|
||||||
|
count = resp.read().decode().strip()
|
||||||
|
except Exception as e:
|
||||||
|
count = f"error: {e}"
|
||||||
|
print(f"Collection: {name} (ID: {c_id}) -> Count: {count}")
|
||||||
|
' | tee pre_migration_inventory.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Save `pre_migration_inventory.txt` securely. This baseline is used in Step 7 and Step 9 to verify
|
||||||
|
zero data loss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Quiesce Writers and Gracefully Stop ChromaDB
|
||||||
|
|
||||||
|
To ensure transactional and referential consistency across SQLite and HNSW segment files:
|
||||||
|
|
||||||
|
1. Stop application writers (Odysseus) to prevent any in-flight requests:
|
||||||
|
```bash
|
||||||
|
docker compose stop odysseus
|
||||||
|
```
|
||||||
|
2. Gracefully stop the ChromaDB service. This permits SQLite to checkpoint the Write-Ahead Log (WAL)
|
||||||
|
and flushes all in-memory vector index segments to disk:
|
||||||
|
```bash
|
||||||
|
docker compose stop chromadb
|
||||||
|
```
|
||||||
|
3. Confirm that the container status is `Exited`:
|
||||||
|
```bash
|
||||||
|
docker ps -a --filter "name=${CONTAINER}" --format "table {{.ID}}\t{{.Status}}\t{{.Names}}"
|
||||||
|
```
|
||||||
|
*Do not proceed if the container is still running.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 3: Preserve the Original Container as an Immutable Image
|
||||||
|
|
||||||
|
Because Docker Compose discovers containers via service labels, running `docker compose up` later
|
||||||
|
could recreate and destroy the original container's writable layer.
|
||||||
|
|
||||||
|
Preserve the complete writable layer into an immutable Docker image:
|
||||||
|
```bash
|
||||||
|
BACKUP_IMAGE="${PROJECT}-chromadb-backup:pre-migration-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
docker commit "${CONTAINER}" "${BACKUP_IMAGE}"
|
||||||
|
echo "Committed container writable layer to: ${BACKUP_IMAGE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the image was created:
|
||||||
|
```bash
|
||||||
|
docker images "${BACKUP_IMAGE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
This image is your ultimate insurance: even if all containers and volumes are deleted, your
|
||||||
|
data remains safely archived in Docker's image store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 4: Take a Cold File Backup
|
||||||
|
|
||||||
|
With the container stopped, copy the `/data` directory out to a dedicated host backup directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BACKUP_DIR="./chromadb-backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
|
||||||
|
docker cp "${CONTAINER}:/data/." "${BACKUP_DIR}/"
|
||||||
|
echo "Cold backup created at: ${BACKUP_DIR}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 5: Verify Backup Integrity
|
||||||
|
|
||||||
|
Never proceed to destructive cutover without verifying both SQLite and vector index structures.
|
||||||
|
|
||||||
|
1. **Verify SQLite Database File:**
|
||||||
|
```bash
|
||||||
|
test -s "${BACKUP_DIR}/chroma.sqlite3" || { echo "ERROR: chroma.sqlite3 is missing or 0 bytes"; exit 1; }
|
||||||
|
```
|
||||||
|
2. **Execute SQLite PRAGMA Integrity Check:**
|
||||||
|
```bash
|
||||||
|
INTEGRITY=$(sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "PRAGMA integrity_check;")
|
||||||
|
if [ "${INTEGRITY}" != "ok" ]; then
|
||||||
|
echo "ERROR: SQLite integrity check failed: ${INTEGRITY}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SQLite integrity check: OK"
|
||||||
|
```
|
||||||
|
3. **Verify Collections Table Structure:**
|
||||||
|
```bash
|
||||||
|
sqlite3 "${BACKUP_DIR}/chroma.sqlite3" "SELECT id, name FROM collections;"
|
||||||
|
```
|
||||||
|
4. **Verify HNSW Index Binary Files:**
|
||||||
|
Vector representations are not stored inside SQLite; they are kept in segment directories under `/data`:
|
||||||
|
```bash
|
||||||
|
find "${BACKUP_DIR}" -type f -name "*.bin"
|
||||||
|
```
|
||||||
|
Ensure `.bin` files (`data_level0.bin`, `header.bin`, etc.) exist for indexed collections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 6: Verify Destination Volume is Empty and Seed Safely
|
||||||
|
|
||||||
|
The named volume `${VOLUME}` must be empty before seeding. Under no circumstances should
|
||||||
|
migration files be merged into a populated volume.
|
||||||
|
|
||||||
|
1. **Positive Empty Check:**
|
||||||
|
```bash
|
||||||
|
VOL_FILES=$(docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -A /vol)
|
||||||
|
if [ -n "${VOL_FILES}" ]; then
|
||||||
|
echo "ERROR: Named volume ${VOLUME} already contains files: ${VOL_FILES}"
|
||||||
|
echo "Aborting migration to prevent overwriting existing volume data."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Volume ${VOLUME} is empty and ready for seeding."
|
||||||
|
```
|
||||||
|
2. **Seed the Volume:**
|
||||||
|
Use a disposable Alpine container to populate the volume with exact ownership and permissions:
|
||||||
|
```bash
|
||||||
|
docker run --rm \
|
||||||
|
-v "${VOLUME}:/target" \
|
||||||
|
-v "$(realpath "${BACKUP_DIR}"):/source:ro" \
|
||||||
|
alpine sh -c "cp -a /source/. /target/ && ls -la /target/"
|
||||||
|
```
|
||||||
|
3. **Verify Seeded Contents:**
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "${VOLUME}:/vol:ro" alpine ls -la /vol
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 7: Staging Validation in an Isolated Disposable Container
|
||||||
|
|
||||||
|
Before modifying your production Compose stack, validate that ChromaDB boots successfully from the
|
||||||
|
seeded volume and serves all collections.
|
||||||
|
|
||||||
|
1. **Start Disposable Staging Container:**
|
||||||
|
Run a temporary ChromaDB instance bound to an isolated loopback port (e.g. `127.0.0.1:18199`):
|
||||||
|
```bash
|
||||||
|
docker run -d --name "${PROJECT}-chromadb-staging" \
|
||||||
|
-p 127.0.0.1:18199:8000 \
|
||||||
|
-v "${VOLUME}:/data" \
|
||||||
|
chromadb/chroma:latest
|
||||||
|
|
||||||
|
# Wait for staging ChromaDB to boot
|
||||||
|
sleep 4
|
||||||
|
curl -s http://127.0.0.1:18199/api/v2/heartbeat || { echo "Staging container failed to respond"; exit 1; }
|
||||||
|
```
|
||||||
|
2. **Compare Collection Counts with Baseline:**
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections \
|
||||||
|
| python3 -c '
|
||||||
|
import json, sys, urllib.request
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(f"Staging collections verified: {len(data)}")
|
||||||
|
for col in data:
|
||||||
|
c_id = col["id"]
|
||||||
|
name = col["name"]
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:18199/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp:
|
||||||
|
count = resp.read().decode().strip()
|
||||||
|
print(f" {name}: {count} records")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
*Confirm that the output matches `pre_migration_inventory.txt` from Step 1.*
|
||||||
|
|
||||||
|
3. **Tear Down Staging Container:**
|
||||||
|
```bash
|
||||||
|
docker rm -f "${PROJECT}-chromadb-staging"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 8: Apply Updated Compose Files and Perform Production Cutover
|
||||||
|
|
||||||
|
Once the seeded volume is proven healthy:
|
||||||
|
|
||||||
|
1. Update your repository to the revision containing PR #6166:
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
```
|
||||||
|
2. Verify the mount line in your Compose file:
|
||||||
|
```bash
|
||||||
|
grep -E 'chromadb-data:/data' docker-compose.yml
|
||||||
|
```
|
||||||
|
3. Bring up the updated ChromaDB container:
|
||||||
|
```bash
|
||||||
|
docker compose up -d chromadb
|
||||||
|
```
|
||||||
|
Docker Compose will detect the updated mount configuration, replace the stopped container,
|
||||||
|
and attach `${VOLUME}` directly to `/data`.
|
||||||
|
4. Bring up the application stack:
|
||||||
|
```bash
|
||||||
|
docker compose up -d odysseus
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 9: Post-Migration Production Verification
|
||||||
|
|
||||||
|
1. **Confirm Mount Point:**
|
||||||
|
```bash
|
||||||
|
docker inspect "${CONTAINER}" --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}'
|
||||||
|
```
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
<project>_chromadb-data -> /data
|
||||||
|
```
|
||||||
|
2. **Confirm Production Collection Counts:**
|
||||||
|
```bash
|
||||||
|
curl -s http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections \
|
||||||
|
| python3 -c '
|
||||||
|
import json, sys, urllib.request
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(f"Production collections: {len(data)}")
|
||||||
|
for col in data:
|
||||||
|
c_id = col["id"]
|
||||||
|
name = col["name"]
|
||||||
|
with urllib.request.urlopen(f"http://127.0.0.1:8100/api/v2/tenants/default_tenant/databases/default_database/collections/{c_id}/count") as resp:
|
||||||
|
count = resp.read().decode().strip()
|
||||||
|
print(f" {name}: {count} records")
|
||||||
|
'
|
||||||
|
```
|
||||||
|
3. **Application Verification:**
|
||||||
|
Log in to Odysseus, navigate to **Settings -> Memory**, and confirm that memory items,
|
||||||
|
tool embeddings, and RAG document queries operate normally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rollback Procedures
|
||||||
|
|
||||||
|
### Scenario A: Failure During Staging (Before Compose Cutover)
|
||||||
|
|
||||||
|
If staging verification in Step 7 fails or reveals discrepancies:
|
||||||
|
1. Remove the staging container:
|
||||||
|
```bash
|
||||||
|
docker rm -f "${PROJECT}-chromadb-staging"
|
||||||
|
```
|
||||||
|
2. Clear the failed destination volume:
|
||||||
|
```bash
|
||||||
|
docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*"
|
||||||
|
```
|
||||||
|
3. Restart the original production container. Because the original container was only stopped (never recreated),
|
||||||
|
its writable layer remains completely intact:
|
||||||
|
```bash
|
||||||
|
docker compose start chromadb
|
||||||
|
docker compose start odysseus
|
||||||
|
```
|
||||||
|
4. Verify production status on port 8100.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario B: Failure After Compose Cutover
|
||||||
|
|
||||||
|
If an issue occurs after `docker compose up -d`:
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Do not `git checkout` the pre-#6166 Compose file!**
|
||||||
|
> In the pre-#6166 Compose file, `chromadb-data` was mounted to `/chroma/chroma`.
|
||||||
|
> ChromaDB 1.0+ persists to `/data`, so checking out the old Compose file will cause
|
||||||
|
> ChromaDB to ignore the restored volume and boot with an empty database.
|
||||||
|
|
||||||
|
To restore service:
|
||||||
|
1. **Option 1 — Restore from Verified Host Backup into the `/data` Volume:**
|
||||||
|
If the container failed due to data corruption during startup:
|
||||||
|
```bash
|
||||||
|
docker compose stop chromadb
|
||||||
|
docker run --rm -v "${VOLUME}:/target" alpine sh -c "rm -rf /target/*"
|
||||||
|
docker run --rm \
|
||||||
|
-v "${VOLUME}:/target" \
|
||||||
|
-v "$(realpath "${BACKUP_DIR}"):/source:ro" \
|
||||||
|
alpine cp -a /source/. /target/
|
||||||
|
docker compose up -d chromadb
|
||||||
|
```
|
||||||
|
2. **Option 2 — Fallback to Committed Backup Image:**
|
||||||
|
If Compose cannot start ChromaDB with the volume:
|
||||||
|
```bash
|
||||||
|
docker compose stop chromadb
|
||||||
|
docker run -d --name "${PROJECT}-chromadb-fallback" \
|
||||||
|
-p 127.0.0.1:8100:8000 \
|
||||||
|
"${BACKUP_IMAGE}"
|
||||||
|
```
|
||||||
|
This immediately restores the exact pre-migration container state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Post-Migration Cleanup (Explicit Authorization)
|
||||||
|
|
||||||
|
Retain the host backup directory (`${BACKUP_DIR}`) and the backup Docker image (`${BACKUP_IMAGE}`)
|
||||||
|
for at least 7 days until routine backups have succeeded.
|
||||||
|
|
||||||
|
Once you are completely confident in system stability, manually clean up:
|
||||||
|
```bash
|
||||||
|
# Remove host backup
|
||||||
|
rm -rf ./chromadb-backup-*
|
||||||
|
|
||||||
|
# Remove backup Docker image
|
||||||
|
docker rmi "${BACKUP_IMAGE}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Re-indexing (Disaster Recovery Only)
|
||||||
|
|
||||||
|
If an installation has already destroyed its pre-#6166 container writable layer (e.g. prior to following
|
||||||
|
this guide), the vector embeddings cannot be restored from disk. In this scenario:
|
||||||
|
|
||||||
|
* **RAG Documents:** Re-upload files under **Settings -> Memory -> RAG**. Odysseus automatically re-chunks
|
||||||
|
and re-indexes documents upon upload.
|
||||||
|
* **Semantic Memories:** Text memory records reside authoritatively in `data/app.db` (SQLite). Vector representations
|
||||||
|
are re-indexed automatically when Odysseus connects to a healthy ChromaDB instance.
|
||||||
|
* **Tool Index:** Rebuilt automatically by Odysseus upon first query.
|
||||||
|
|
||||||
|
Re-indexing is a disaster recovery fallback, not a substitute for preserving existing database state.
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# ADR 001: Feature-first native ESM with controlled deployment output
|
||||||
|
|
||||||
|
Date: 2026-10-13. Status: implemented F1 proof, proposed for maintainer adoption.
|
||||||
|
Baseline: official `upstream/dev` at `8feb50e89468235cd3e0986da80edbd3acaf2c40`.
|
||||||
|
Scope: source ownership and distribution foundation. Notes implementation is deferred.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Maintain migrated JavaScript in `frontend/src/`. Publish native ES modules to
|
||||||
|
explicitly registered paths in `static/js/`, and commit source and output together.
|
||||||
|
Use `python scripts/build_frontend.py` to copy bytes without rewriting imports,
|
||||||
|
bundling, minification, source maps, timestamps or generated headers. A checked-in
|
||||||
|
`frontend/modules.json` maps source paths to deployment paths. All other static
|
||||||
|
files remain authored legacy modules or deployment assets until a reviewed slice
|
||||||
|
transfers their ownership. Do not claim that the whole static tree is generated.
|
||||||
|
|
||||||
|
The proof transfers maintenance of the stateless action-menu ordering helper to
|
||||||
|
`frontend/src/shared/actionMenuOrder.js`. Its output remains
|
||||||
|
`/static/js/actionMenuOrder.js`, with the same three exports and identical bytes.
|
||||||
|
All seven current consumers, HTML, service worker and runtime entry points retain
|
||||||
|
their existing URLs. There is no wrapper, extra request or second browser module
|
||||||
|
instance. The source copy is a development input, not a second runtime entry point.
|
||||||
|
|
||||||
|
Use `frontend/src/app/` for composition, `features/<feature>/index.js` for feature
|
||||||
|
public APIs, and responsibility directories only when implementation requires
|
||||||
|
them. The [handbook](handbook.md) defines dependency, ownership and lifecycle rules.
|
||||||
|
F1 creates only the shared proof, without empty application or feature scaffolding.
|
||||||
|
|
||||||
|
## Evidence and alternatives
|
||||||
|
|
||||||
|
Both options were executed against this helper, rather than assumed to work.
|
||||||
|
Measurements are local compatibility observations, not general performance claims.
|
||||||
|
|
||||||
|
| Property | Controlled native ESM copy, selected | esbuild 0.25.12 controlled ESM build |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Proof output | 1,688 bytes, identical SHA-256 to existing runtime | 1,554 bytes with `--bundle --format=esm --target=es2022 --platform=browser` |
|
||||||
|
| Repeatability | Clean temporary publication and idempotent rerun tested | Two independent compiler invocations emitted identical bytes |
|
||||||
|
| Runtime contract | Existing exports, imports, cache entry and module identity unchanged | Export names, icon and ordering matched in an executed Node import |
|
||||||
|
| Tooling | Python standard library; no production dependency | Compiler and platform binary needed for artifact production |
|
||||||
|
| Local cost | Copy check: 0.03 s, 15,596 KB maximum RSS | Cached `npx` ESM transform: 0.58 s, 114,196 KB maximum RSS |
|
||||||
|
| Incremental migration | Explicit file ownership, preserved module boundaries | Feasible for the stateless leaf; stateful facade/chunk identity still needs a separate graph spike |
|
||||||
|
| Release implications | Commit output; install/startup paths consume existing static assets | Would also need committed or release-produced output plus compiler locking and facade/chunk manifest policy |
|
||||||
|
|
||||||
|
The timing rows measure different tool paths, including `npx` overhead. The
|
||||||
|
compiler's single-leaf success does not establish stateful graph or lazy chunk
|
||||||
|
compatibility. The 134-byte bundle reduction does not justify that additional
|
||||||
|
contract in F1. A future compiler adoption requires a new ADR and graph-level
|
||||||
|
evidence. React, Vue, TypeScript, a global state library, hashed chunks and a
|
||||||
|
development server have no demonstrated necessity here.
|
||||||
|
|
||||||
|
Acorn 8.19.0 is added only as an exact, lockfile-pinned development dependency for
|
||||||
|
architecture checks. A real JavaScript parser is needed to enforce boundaries
|
||||||
|
across multiline imports, re-exports and lazy thunks without treating comments
|
||||||
|
and string literals as executable imports. It is MIT licensed, about 568 KB
|
||||||
|
unpacked, and contributes no deployed JavaScript or runtime dependency.
|
||||||
|
|
||||||
|
## Distribution contract
|
||||||
|
|
||||||
|
1. Source and generated output are reviewed and committed together. CI checks
|
||||||
|
byte parity; it does not silently rebuild and accept drift.
|
||||||
|
2. Native Python startup continues to use `STATIC_DIR` and `/static`, without
|
||||||
|
invoking the publisher. Native installers already obtain tracked static files.
|
||||||
|
3. Docker's current `COPY . .` includes the output. `.dockerignore` excludes
|
||||||
|
`node_modules/`. The Docker runtime and entrypoint require no frontend build.
|
||||||
|
4. `Odysseus.spec` and `build-windows-portable.ps1` already include `static/`.
|
||||||
|
The macOS wrapper uses the Python checkout and its static assets. Neither
|
||||||
|
needs the source tree or frontend tooling to serve the proof.
|
||||||
|
5. A release producer runs the checks before packaging and distributes the
|
||||||
|
entire matching static graph. The publisher is not a transactional deployment
|
||||||
|
mechanism. Partial copying of a new graph into a live deployment is unsupported.
|
||||||
|
6. New output paths must declare LF checkout behavior in `.gitattributes`, as
|
||||||
|
this proof does, so Windows checkout conversion cannot create false byte drift.
|
||||||
|
Before checking parity or writing output, the publisher uses the manifest to
|
||||||
|
require effective `text: set` and `eol: lf` on every source/output path. It asks
|
||||||
|
Git in a temporary repository containing only the applicable repository-owned
|
||||||
|
`.gitattributes` files, including nested rules. Global/local Git overrides
|
||||||
|
cannot supply missing declarations. No blanket legacy-JS rule is introduced.
|
||||||
|
Git is a development/publication prerequisite, not a startup dependency.
|
||||||
|
|
||||||
|
An isolated distribution probe executes the real FastAPI lifespan, shell and
|
||||||
|
static requests after omitting `frontend/` and `node_modules/` and removing Node
|
||||||
|
from the child process search path. It verifies bytes, MIME, CSP, revalidation,
|
||||||
|
source-tree 404 behavior and static path confinement. The same probe runs in an existing Docker image
|
||||||
|
with networking disabled. This is not a claim that a fresh full Docker image or
|
||||||
|
Windows/macOS executable was built; see [validation](validation.md).
|
||||||
|
|
||||||
|
## Imports, identity and offline operation
|
||||||
|
|
||||||
|
New maintained modules use one unversioned deployment URL. Existing legacy URLs,
|
||||||
|
including their version queries, stay unchanged. A copy mapping must preserve
|
||||||
|
the resolution of every literal relative import; the architecture checker rejects
|
||||||
|
a mapping that changes that resolution. Canonical absolute `/static/js/...`
|
||||||
|
imports are also supported. No bare packages or browser imports of `frontend/src`
|
||||||
|
are supported. Existing graph and identity guards continue to cover the runtime.
|
||||||
|
|
||||||
|
Each registered output joins the exact `PRECACHE` or `PANEL_PRECACHE` array. The
|
||||||
|
new checker parses those arrays, rather than accepting a URL mentioned in a
|
||||||
|
comment. Installation fetches do not evaluate lazy modules. Import-time DOM work,
|
||||||
|
timers, state creation and network I/O must be characterized before changing an
|
||||||
|
entry point's load timing. New source dependency cycles are rejected; existing
|
||||||
|
legacy cycles are inventoried and remain outside the new rules.
|
||||||
|
|
||||||
|
The proof already has its exact precache entry, so no SW cache-name or HTML edit
|
||||||
|
is needed. Its first evaluation in a fresh offline browser page passes, and
|
||||||
|
importing the panel registry does not evaluate the editor. The current worker's
|
||||||
|
`/static/` scope cannot control the SPA root. Open PR #6227 addresses that existing
|
||||||
|
limitation; F1 does not fix or mask it. Full root offline reload is not certified.
|
||||||
|
|
||||||
|
## Security and compatibility
|
||||||
|
|
||||||
|
Keep backend authorization and owner scope authoritative. No agent, database,
|
||||||
|
auth, CSP, static mount or API implementation changes belong to this decision.
|
||||||
|
The helper remains same-origin ESM without eval, compiler runtime or external
|
||||||
|
resources. No new source maps or vendored assets are emitted; existing SRI/CSP
|
||||||
|
policy and distribution notices remain unchanged. The publisher validates path
|
||||||
|
containment, symlinks, duplicate ownership and unmapped source before writing.
|
||||||
|
It never deletes unowned static assets. Deleting or retiring outputs is an
|
||||||
|
explicit reviewed migration operation, not automatic cleanup.
|
||||||
|
|
||||||
|
## Rollout, rollback and limits
|
||||||
|
|
||||||
|
Adopt F1 for controlled, reviewed slices after maintainer review. Leave Notes,
|
||||||
|
bootstrap, HTML and service worker unchanged here. Register new files explicitly;
|
||||||
|
update offline manifests only when new runtime dependencies are introduced.
|
||||||
|
F1 rollback can remove the source manifest, proof tooling, gates and documentation
|
||||||
|
without changing runtime bytes. Later rollback restores the complete prior
|
||||||
|
source/output/facade/precache set through an authorized follow-up change.
|
||||||
|
|
||||||
|
Boundary gates cannot prove semantic purity, owner isolation or correct teardown.
|
||||||
|
Domain platform/clock checks are intentionally conservative syntax guards;
|
||||||
|
behavior tests still establish those contracts. Worker URLs, styles and vendored
|
||||||
|
libraries are outside the maintained-JS import gate and need explicit review if
|
||||||
|
a future lane changes them. Legacy exceptions require an exact specifier and a
|
||||||
|
module-scoped reason; there are none in F1. New source is not permission to
|
||||||
|
grandfather new dependency cycles or to replicate broadly shared mutable state.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# F1 runtime and architecture assessment
|
||||||
|
|
||||||
|
Assessment date: 2026-10-13. Repository: `odysseus-dev/odysseus`.
|
||||||
|
Worktree: `worktrees/architecture/frontend-foundation`.
|
||||||
|
Branch: `refactor/frontend-architecture-foundation`.
|
||||||
|
HEAD, local `upstream/dev`, and the live GitHub official `dev` commit were all
|
||||||
|
`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The initial worktree was clean.
|
||||||
|
That commit is the merged PR #6495, selected Notes/Todos agent context.
|
||||||
|
`origin` points to the maintainer fork; integration targets official `upstream/dev`.
|
||||||
|
|
||||||
|
Inputs read completely: the 2026-10-13 frontend architecture RFC and coding brief
|
||||||
|
from Downloads, `specs/frontend.md`, repository instructions and existing tests.
|
||||||
|
The current user brief supersedes the coding brief's `origin/dev` wording and
|
||||||
|
defines the Notes state/workflow slice before reminders. The design is self-contained
|
||||||
|
in this directory and does not require access to Downloads.
|
||||||
|
|
||||||
|
Input provenance:
|
||||||
|
|
||||||
|
- RFC SHA-256: `11dedcbb9971f4d787f2721b9c27aa43299baba39ca91066d01507d2d69ac4f8`.
|
||||||
|
- Coding brief SHA-256: `e4882867617b9c732ce4f445e3bc2da0b4ec1315119f8226c1fd8c1acbacf29d`.
|
||||||
|
|
||||||
|
## Module graph and bootstrap
|
||||||
|
|
||||||
|
`node scripts/check_frontend.mjs --inventory` reproduces the parser-based inventory.
|
||||||
|
It excludes vendored `static/lib/` and committed editor build output. At this base
|
||||||
|
it scans 230 JS files, finds 707 static import/re-export edges and 108 literal
|
||||||
|
dynamic import edges, and finds no missing targets. No computed dynamic imports
|
||||||
|
were found in the original parser audit. The earlier existing-regex scan counted
|
||||||
|
814 edges; executable syntax parsing counts 815. Use parser evidence for design
|
||||||
|
and retain the existing regression guard unchanged.
|
||||||
|
|
||||||
|
| Area | Current boundary and implication |
|
||||||
|
| --- | --- |
|
||||||
|
| HTML shell | `static/index.html`: nonce-bearing boot scripts, CSS/preloads, direct ESM script tags, then app/init/a11y. Multiple entry roots mean module identity matters. |
|
||||||
|
| App composition | `static/app.js`: feature imports, globals, route/sidebar composition, session hydration and auth-fetch wiring. It eagerly imports Notes, Calendar, Chat, Documents and Settings. |
|
||||||
|
| Startup | `static/js/startupShell.js`, `sessions.js`, `init.js`: first-paint scheduling, deferred hydration, user-switch storage cleanup and cosmetic permission gates. Preserve boot ordering. |
|
||||||
|
| Lazy loading | `static/js/panels.js`: only `editor` is registered. A memoized import thunk shares successful/in-flight promises and retries failed imports. Do not label all panels lazy. |
|
||||||
|
| Optional libraries | `markdown.js` lazily loads vendored KaTeX/Mermaid. KaTeX and fonts are precached; Mermaid becomes cached after first use. Pyodide has a CDN dependency. |
|
||||||
|
| Feature packages | Settings registry/navigation/finder/lifecycle, Calendar utilities/reminders, Email wrapper/package and editor tools already demonstrate partial decomposition. |
|
||||||
|
|
||||||
|
Highest import fan-in: `ui.js` 77, `spinner.js` 55, `editor/state.js` 39,
|
||||||
|
`escMenuStack.js` 23, and storage/modal/z-order helpers 17 each. The measured
|
||||||
|
static strongly connected components are:
|
||||||
|
|
||||||
|
- Theme and UI.
|
||||||
|
- Email Inbox, compatibility wrapper, package index, AI reply, attachments,
|
||||||
|
body rendering, menus, reader, settings page and unsubscribe.
|
||||||
|
- Cookbook, diagnosis, HW Fit, download, running and serve modules.
|
||||||
|
|
||||||
|
These are existing compatibility constraints, not approved patterns for new
|
||||||
|
source. Dynamic edges connect more features but do not themselves establish
|
||||||
|
initialization cycles. No global legacy dependency rewrite is attempted in F1.
|
||||||
|
|
||||||
|
## State, side effects and ownership
|
||||||
|
|
||||||
|
| Owner | Evidence and architectural treatment |
|
||||||
|
| --- | --- |
|
||||||
|
| Shared configuration | `appConfig.js` owns invalidatable settings/tool request promises and consumes a sessionStorage prefetch once. Retain single identity and writer invalidation. |
|
||||||
|
| Browser persistence | `storage.js` centralizes keys and safe access; feature files also use direct localStorage. `init.js` cleans storage on user changes. Preferences are not an authorization store. |
|
||||||
|
| Feature state | Notes has module variables, selection sets, undo/drafts and reminder state. Email exports a broadly mutable package state object; editor state is imported by 39 modules. Keep ownership explicit; do not expand these into a global store. |
|
||||||
|
| Import-time effects | Notes reads storage/window and schedules reminders. Email state reads localStorage. Theme/UI initialize from DOM readiness; modal manager installs global listeners. Chip/mobile helpers bind at startup. Moving an import can change behavior before any exported function is called. |
|
||||||
|
| Compatibility globals | App exposes controllers on `window`; Notes exposes `window.notesModule`. They are compatibility bridges, not the new inter-feature API convention. |
|
||||||
|
| Async ownership | Session hydration, config invalidation, draft serialized saves and request-generation guards already prevent stale completion. New slices must preserve these rather than only move declarations. |
|
||||||
|
|
||||||
|
Good patterns to retain: narrow zero-state leaves, explicit wrapper export lists,
|
||||||
|
public API tests, retryable memoized lazy imports, shared config invalidation,
|
||||||
|
request-generation checks and source/package-aware regression helpers.
|
||||||
|
|
||||||
|
Patterns to avoid: blanket barrels exposing internals, feature imports from
|
||||||
|
generic helpers, cycles through coordinators, direct writes to another feature's
|
||||||
|
state or DOM, anonymous global listener ownership, import-triggered network/timer
|
||||||
|
initialization in new APIs, and helpers grouped by convenience instead of purpose.
|
||||||
|
|
||||||
|
## Runtime, assets and distribution
|
||||||
|
|
||||||
|
| Contract | Verified source |
|
||||||
|
| --- | --- |
|
||||||
|
| Static URL and revalidation | `app.py`: `register_static_mime_types`, `_RevalidatingStatic`, `/static` mount. JS MIME is `text/javascript`; JS/CSS/HTML have `Cache-Control: no-cache`. |
|
||||||
|
| App paths | `src/constants.py` and `src/runtime_paths.py`: checkout root in native mode, PyInstaller `_MEIPASS` in frozen mode; static assets follow app root, persisted data follows data-directory policy. |
|
||||||
|
| CSP | `core/middleware.py`: same-origin scripts, nonce and jsDelivr allowance; `src/app_helpers.py` injects HTML nonces. No CSP relaxation is needed for the proof. |
|
||||||
|
| Integrity | No `integrity=` attributes were found in the shell. Proof output is byte-identical and emits no source maps; no existing SRI or vendored artifacts change. |
|
||||||
|
| Runtime-generated assets | `/api/generated-image/...` uses a confined data path and owner handling; report/render endpoints, Blob manifests/icons and uploads are runtime concerns outside static source publication. |
|
||||||
|
| Docker | `Dockerfile` copies app code and existing static output; `.dockerignore` excludes node_modules. Node/npm in the image support Browser MCP, not frontend startup. |
|
||||||
|
| Native installers | `setup.py`, `start-macos.sh`, `launch-windows.ps1`, systemd service: Python dependencies/startup consume the checkout. No frontend build is invoked. |
|
||||||
|
| Windows desktop | `Odysseus.spec` and `build-windows-portable.ps1` include the whole static directory. Maintain output here rather than require a new bundle path or compiler. |
|
||||||
|
| macOS | `build-macos-app.sh` creates a launcher around the Python checkout. Static serving follows the existing checkout path. Installer execution was not performed on Linux. |
|
||||||
|
|
||||||
|
## PWA limitations and test coverage
|
||||||
|
|
||||||
|
`static/sw.js` matches exact URLs, including queries. Install fetches each
|
||||||
|
precache entry separately and tolerates failed fetches. It does not discover
|
||||||
|
transitive dependencies. HTML root and JS/CSS handlers are network-first;
|
||||||
|
other static assets are cache-first with background refresh. API and non-GET
|
||||||
|
requests bypass caching. Cache updates need explicit version/manifest review.
|
||||||
|
The older frontend spec's root stale-while-revalidate description is stale.
|
||||||
|
|
||||||
|
Registration in `static/index.html` currently requests no wider scope, so the
|
||||||
|
worker controls `/static/`, not `/`. Root offline reload is therefore a known
|
||||||
|
pre-existing gap. PR #6227 proposes the scope/header correction. F1 tests actual
|
||||||
|
offline ESM loading under the current scope and leaves that separate fix alone.
|
||||||
|
Offline APIs, first-use Mermaid and optional CDN runtimes are not certified.
|
||||||
|
|
||||||
|
Existing coverage includes runtime graph resolution and version parity; editor
|
||||||
|
offline graph coverage and lazy-loader retries; Email API/export/evaluation
|
||||||
|
coverage; Notes autosave, XSS, selection/listener and agent-context regressions;
|
||||||
|
MIME, nonce and runtime paths. Tests use pytest, Node's built-in runner and existing
|
||||||
|
Playwright Chromium. `tests/TESTING_STANDARD.md` requires behavior-first, isolated
|
||||||
|
tests. F1 adds real publication failures, architecture violations and an offline
|
||||||
|
browser proof. It does not replace existing source-sensitive tests.
|
||||||
|
|
||||||
|
## Open PR overlap, checked live on 2026-10-13
|
||||||
|
|
||||||
|
The open-PR listing was fetched through all pages; selected PR file lists were
|
||||||
|
inspected. Refresh this inventory before any later slice.
|
||||||
|
|
||||||
|
| PR | Overlap and disposition |
|
||||||
|
| --- | --- |
|
||||||
|
| [#6470](https://github.com/odysseus-dev/odysseus/pull/6470) | Theme/sidebar redesign touches HTML, SW, shared UI and CSS. F1 changes none of those assets. Future manifest/UI slices need collision review. |
|
||||||
|
| [#6695](https://github.com/odysseus-dev/odysseus/pull/6695) | OXS touches bootstrap, HTML, platform, middleware and other backend boundaries. F1 does not establish an extension API or modify that platform. |
|
||||||
|
| [#6227](https://github.com/odysseus-dev/odysseus/pull/6227) | Root SW scope/header fix. Independent follow-up dependency for broader offline certification. |
|
||||||
|
| [#6394](https://github.com/odysseus-dev/odysseus/pull/6394) | Older CSS extraction touches legacy style/SW assets; requires separate supersession review. |
|
||||||
|
| [#6627](https://github.com/odysseus-dev/odysseus/pull/6627) | npm development-dependency update overlaps package/lock files; reconcile the exact Acorn development entry on integration. |
|
||||||
|
| [#6585](https://github.com/odysseus-dev/odysseus/pull/6585), [#6647](https://github.com/odysseus-dev/odysseus/pull/6647), [#6652](https://github.com/odysseus-dev/odysseus/pull/6652) | Notes optional-field persistence and agent routing/dispatch. Future characterization must reflect their merged state; F1 changes no Notes or backend behavior. |
|
||||||
|
| [#6594](https://github.com/odysseus-dev/odysseus/pull/6594), [#6152](https://github.com/odysseus-dev/odysseus/pull/6152) | Desktop launcher/build and frozen-runtime packaging work. Existing paths stay stable; packaging still needs platform-owner validation. |
|
||||||
|
|
||||||
|
PR titles alone do not establish compatibility. This report records the checked
|
||||||
|
file overlap and relevant dependencies, not approval of those implementations.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Odysseus frontend architecture handbook
|
||||||
|
|
||||||
|
Status: F1 foundation and continuation guide, pending review. The final Notes
|
||||||
|
reference case study belongs to the comprehensive Notes draft PR; the recipient
|
||||||
|
handoff follows its integration. This document must evolve with reviewed internal
|
||||||
|
checkpoints and distinguish executed work from proposals.
|
||||||
|
|
||||||
|
Read the [ADR](ADR-001-source-and-deployment.md), [assessment](assessment.md),
|
||||||
|
[validation report](validation.md) and [Notes roadmap](notes-roadmap.md).
|
||||||
|
The frontend program ends with the Notes reference decomposition and handoff.
|
||||||
|
Calendar, Documents, Email, Chat, Skills, Settings and other later decompositions
|
||||||
|
belong to the designated frontend maintainer; do not start them automatically.
|
||||||
|
|
||||||
|
## Source, ownership and public APIs
|
||||||
|
|
||||||
|
`frontend/src/app/` composes public feature APIs and platform ports. Each
|
||||||
|
`features/<feature>/index.js` is an intentional API, with explicit named exports
|
||||||
|
and a compatibility default only when existing consumers require one. Internals
|
||||||
|
live under `domain/`, `application/`, `infrastructure/` or `ui/` as needed. Do not
|
||||||
|
create empty directories, boilerplate controllers or a framework by convention.
|
||||||
|
Feature names `app` and `shared` are reserved.
|
||||||
|
|
||||||
|
Keep pure business rules in domain; transitions and workflows in application;
|
||||||
|
HTTP/storage/browser services in infrastructure; DOM rendering and lifecycle in
|
||||||
|
UI. The index composes adapters, state and views. Application receives adapters
|
||||||
|
as explicit arguments or constructor ports, rather than importing concrete
|
||||||
|
infrastructure. Views call application operations; infrastructure does not own
|
||||||
|
business decisions. A public API returns data/operations, not mutable stores or
|
||||||
|
private DOM nodes. Importing an API should define capabilities; explicit lifecycle
|
||||||
|
methods activate them. Legacy side effects remain characterized until a reviewed
|
||||||
|
slice safely changes their placement.
|
||||||
|
|
||||||
|
Shared code has a named, proven cross-feature responsibility. `shared/domain/`
|
||||||
|
holds pure primitives, `shared/platform/` holds narrowly scoped platform adapters,
|
||||||
|
and `shared/ui/` holds common DOM capabilities. A named stateless root leaf such
|
||||||
|
as `shared/actionMenuOrder.js` is also allowed and receives domain restrictions.
|
||||||
|
Do not create a central `utils.js` collection or move feature business rules into
|
||||||
|
shared simply to satisfy an import rule.
|
||||||
|
|
||||||
|
## Mechanically checked dependency contract
|
||||||
|
|
||||||
|
| Importer | Permitted maintained-source targets |
|
||||||
|
| --- | --- |
|
||||||
|
| App composition | Other app code, shared code, feature `index.js` APIs |
|
||||||
|
| Feature index | Its own responsibility modules and shared code; other feature public indexes |
|
||||||
|
| Feature domain | Its own domain and shared domain/stateless leaves |
|
||||||
|
| Feature application | Its own application/domain, shared domain, other feature public indexes |
|
||||||
|
| Feature infrastructure | Its own infrastructure/domain, shared domain/platform |
|
||||||
|
| Feature UI | Its own UI/application/domain, shared domain/platform/UI |
|
||||||
|
| Shared domain leaf | Shared domain leaves only |
|
||||||
|
| Shared platform | Shared platform/domain only |
|
||||||
|
| Shared UI | Shared UI/platform/domain only |
|
||||||
|
|
||||||
|
No feature imports app composition. No shared code imports a feature. No external
|
||||||
|
feature imports private responsibility modules. UI and infrastructure communicate
|
||||||
|
with another feature through a supplied application operation/port. Maintained
|
||||||
|
source dependency cycles, including literal lazy imports, fail the architecture
|
||||||
|
gate. If features need bidirectional coordination, app composition injects a
|
||||||
|
callback or adapter instead of creating mutual imports. Prefer this even when a
|
||||||
|
one-way public index import would pass the gate.
|
||||||
|
|
||||||
|
Cross-feature events must document event name, payload, acknowledgement, owner,
|
||||||
|
subscription disposal and delivery timing. Keep them local and specific; F1 adds
|
||||||
|
no global event bus. Preserve `odysseus:notes-context-selected` and its synchronous
|
||||||
|
`accepted` acknowledgement in future Notes work. The server remains authoritative
|
||||||
|
for owner filtering and untrusted selected IDs. Frontend APIs cannot grant access.
|
||||||
|
|
||||||
|
The checker parses executable ESM, forbids computed imports in new source,
|
||||||
|
requires exact precache entries, rejects unresolved/remapped relative imports,
|
||||||
|
and permits only canonical static JS URLs with one unversioned identity for
|
||||||
|
maintained modules. Domain code cannot mention known browser/HTTP/timer globals
|
||||||
|
or an ambient clock; use `now`/clock inputs. This is a conservative vocabulary
|
||||||
|
check, including shadowed names, not a semantic purity proof. Randomness, hidden
|
||||||
|
side effects, authorization and lifecycle behavior require behavioral tests.
|
||||||
|
|
||||||
|
Legacy imports from new source default to forbidden. If an incremental bridge is
|
||||||
|
necessary, declare `legacyImports` on that source's manifest entry as a list of
|
||||||
|
`{ "specifier": "/static/js/example.js?v=existing", "reason": "contract, owner and removal slice" }`.
|
||||||
|
Only feature composition, infrastructure or UI boundaries may use it. Exact
|
||||||
|
specifier/reason and target existence are required; unused exceptions fail.
|
||||||
|
Keep the existing query identity. New domain/application modules receive no
|
||||||
|
legacy exception. F1 has zero exceptions. Do not use exceptions to invent new
|
||||||
|
shared platform dependencies on feature internals.
|
||||||
|
|
||||||
|
## State and lifecycle
|
||||||
|
|
||||||
|
Each feature has one authoritative mutable application-state owner. A transition
|
||||||
|
operation owns writes; UI receives a view/snapshot or subscription. Preserve
|
||||||
|
existing objects and initialization timing when extracting a stateful module.
|
||||||
|
Do not instantiate a new store in both the legacy facade and the feature API.
|
||||||
|
Compatibility facades only forward the same live implementation.
|
||||||
|
|
||||||
|
Document lifecycle scopes: page, authenticated user, session, mounted panel and
|
||||||
|
editor/draft. Name owners of subscriptions, DOM listeners, timers, observers,
|
||||||
|
pending requests and caches. Mounting must be idempotent; disposal releases the
|
||||||
|
resources it owns. Background reminders may outlive an open panel. Closing the
|
||||||
|
panel must not silently stop those reminders during extraction.
|
||||||
|
|
||||||
|
Keep request-generation and selected-session checks around asynchronous
|
||||||
|
completions. Serialize writes where the current contract requires it. Dispose
|
||||||
|
subscriptions explicitly and prevent late updates to a replaced view. Feature
|
||||||
|
state changes on user/session switch must follow current cleanup policy. Browser
|
||||||
|
storage is for preferences/drafts/cache state, never a substitute for backend
|
||||||
|
authorization, credential storage or owner filtering. Define every storage key's
|
||||||
|
owner, retention/reset behavior and compatibility format before moving it.
|
||||||
|
|
||||||
|
## Creating a module or introducing a feature
|
||||||
|
|
||||||
|
1. Identify responsibility, owner, public consumers, side effects and behavioral
|
||||||
|
evidence before editing. Start with a pure leaf when introducing a feature.
|
||||||
|
2. Place only the required source file(s), for example
|
||||||
|
`features/example/domain/calculate.js`. Add an index only when there is a
|
||||||
|
real supported feature API to compose.
|
||||||
|
3. Register each source in `frontend/modules.json`. A typical new mapping is
|
||||||
|
`features/example/domain/calculate.js` to
|
||||||
|
`js/features/example/domain/calculate.js`. Preserve the source/deployment
|
||||||
|
relative graph. The proof's legacy URL mapping is an explicit special case.
|
||||||
|
4. For an extracted leaf, the legacy feature coordinator may import its output
|
||||||
|
as a temporary composition bridge. New maintained features use public indexes;
|
||||||
|
the legacy tree is not globally forced into the new boundary rules in F1.
|
||||||
|
5. Add the exact output URL to the appropriate explicit SW array, retain lazy
|
||||||
|
evaluation timing and bump the cache name when the graph changes. Declare
|
||||||
|
`text eol=lf` for both mapped paths in repository-owned `.gitattributes`
|
||||||
|
files; source-tree or scoped output patterns are allowed. The publisher checks
|
||||||
|
effective Git rules, including nested overrides, and rejects a missing LF rule
|
||||||
|
even if output bytes already match. Do not broaden unrelated legacy checkout
|
||||||
|
rules or automatically rewrite all existing query identities.
|
||||||
|
6. Run the publisher, inspect the source/output diff, then run architecture,
|
||||||
|
identity, graph, offline and focused behavior checks. Include both sides of
|
||||||
|
the mapping in review. Retiring output requires a separate reviewed deletion;
|
||||||
|
the publisher does not clean the static tree.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python scripts/build_frontend.py
|
||||||
|
python scripts/build_frontend.py --check
|
||||||
|
npm ci --ignore-scripts --no-audit --no-fund
|
||||||
|
node scripts/check_frontend.mjs
|
||||||
|
node scripts/check_frontend.mjs --inventory
|
||||||
|
node --test tests/frontendArchitecture.test.mjs
|
||||||
|
./venv/bin/python -m pytest -q -rs tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py tests/test_panel_loader_js.py
|
||||||
|
./venv/bin/python scripts/verify_frontend_runtime.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Use your actual repository virtualenv path; the F1 environment is recorded in
|
||||||
|
validation. Node 20+ follows current CI. Existing Playwright/browser prerequisites
|
||||||
|
are documented in `tests/README.md` and `tests/KNOWN_FAILURES.md`. Missing Node can
|
||||||
|
skip Python browser wrappers, which is a coverage gap, not a pass. The standalone
|
||||||
|
Node gates require locked development dependencies and fail when missing.
|
||||||
|
|
||||||
|
The publisher validates all inputs before writing and only overwrites owned
|
||||||
|
outputs whose bytes differ. It is idempotent and working-directory independent.
|
||||||
|
Git must be available for publication and `--check`; neither runs at application
|
||||||
|
startup. Attribute evaluation uses a private temporary Git repository and ignores
|
||||||
|
global/local overrides, so checkout consistency is reproducible in CI. It adds
|
||||||
|
no runtime configuration or backend imports. CI checks LF attributes, parity and source
|
||||||
|
syntax in existing jobs; the Node job also runs architecture self-tests. Existing
|
||||||
|
runtime identity tests remain authoritative across legacy imports, HTML and SW.
|
||||||
|
The isolated runtime probe is included in the new pytest publication tests.
|
||||||
|
|
||||||
|
## Review, release and rollback
|
||||||
|
|
||||||
|
Review the source/output mapping and exact exported API, then the owner/dependency
|
||||||
|
contract and behavior tests. Check manifests, URL identities, MIME/CSP, load timing,
|
||||||
|
storage compatibility and disposal. Reject unexplained feature behavior changes.
|
||||||
|
Do not repair unrelated defects under an extraction title.
|
||||||
|
|
||||||
|
Release from a validated revision with committed output. Native startup and
|
||||||
|
packagers consume static files without a compiler. Run parity before producing a
|
||||||
|
Docker or desktop artifact. Test the actual packaged application on its platform;
|
||||||
|
Linux contract probes cannot certify macOS/Windows executables. Deploy matching
|
||||||
|
shell, facades, graph and manifests as a unit. Keep an authorized path to the last
|
||||||
|
complete artifact. Rollback restores the whole matching set, not just an entry
|
||||||
|
file or cache version. Do not delete a facade until its public consumers have
|
||||||
|
migrated and compatibility tests make that decision reviewable.
|
||||||
|
|
||||||
|
## Proposal and PR templates
|
||||||
|
|
||||||
|
For a decomposition proposal, record: baseline; responsibility map; current public
|
||||||
|
API and consumers; state owner; I/O and lifecycle boundaries; planned slice;
|
||||||
|
allowed dependencies/temporary exceptions; preserved behavior; deterministic
|
||||||
|
characterization tests; deployment/offline changes; overlapping PRs; rollback;
|
||||||
|
explicit non-goals and remaining uncertainties.
|
||||||
|
|
||||||
|
For each migration PR, lead with the responsibility moved and resulting boundary.
|
||||||
|
Record source/output paths, API compatibility, state/lifecycle ownership,
|
||||||
|
characterization results, exact validation commands, platform gaps, exception
|
||||||
|
removal plan, upstream dependencies and rollback. Attach a concrete before/after
|
||||||
|
example when needed to establish behavioral equivalence. Preserve the repository
|
||||||
|
PR template and never claim unexecuted checks.
|
||||||
|
|
||||||
|
F1 is an entirely separate foundation-only PR. Deliver the complete Notes
|
||||||
|
decomposition, regression tests, compatibility validation and final reference
|
||||||
|
case study through one draft PR targeting official `dev`, using one Notes branch
|
||||||
|
and worktree after F1 integration. N0 through N6 and the N5 substages are internal
|
||||||
|
engineering checkpoints, not independent PRs or merges. Each checkpoint records
|
||||||
|
focused tests, review evidence, its validated revision or reproducible snapshot,
|
||||||
|
and rollback to the prior matching source/output/facade/precache set. Keep the
|
||||||
|
draft open while those checkpoints progress; mark it ready only after the entire
|
||||||
|
decomposition passes the [roadmap's acceptance gates](notes-roadmap.md).
|
||||||
|
|
||||||
|
Structure the Notes PR description around the final integrated design and link
|
||||||
|
each checkpoint's evidence. The receiving maintainer may use smaller independent
|
||||||
|
PRs for future frontend features at their discretion; the one-PR Notes decision
|
||||||
|
does not impose that delivery size on later work.
|
||||||
|
|
||||||
|
## Program backlog and final handoff
|
||||||
|
|
||||||
|
Priority order: review/integrate F1; resolve root offline certification with the
|
||||||
|
owner of PR #6227; complete the Notes checkpoints and final reference case study
|
||||||
|
within one draft PR following the [roadmap](notes-roadmap.md); integrate the
|
||||||
|
complete Notes PR after its acceptance gates; finalize the recipient handoff.
|
||||||
|
Only then does the receiving maintainer prioritize Calendar, Documents, Email,
|
||||||
|
Chat, Skills and Settings against current behavior and risk. Existing partial
|
||||||
|
packages and high fan-in helpers need assessment, not automatic replacement.
|
||||||
|
|
||||||
|
Final H acceptance requires a designated maintainer, the integrated Notes PR
|
||||||
|
revision and its internal checkpoint receipts, the actual public API/state/lifecycle
|
||||||
|
map and final reference case study included in that PR, validated deployment/offline
|
||||||
|
commands, known exceptions/debt,
|
||||||
|
outstanding PR dependencies, rollback evidence and a reproducible independent
|
||||||
|
setup by the recipient. F1 provides the handbook structure and contracts; it does
|
||||||
|
not claim Notes or the completed handoff have occurred. The initiating architect
|
||||||
|
then returns to separately authorized non-frontend architecture work.
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Notes reference decomposition roadmap
|
||||||
|
|
||||||
|
Status: planning only. F1 changes no Notes implementation. Begin after the
|
||||||
|
foundation has been reviewed and integrated into official `upstream/dev`.
|
||||||
|
The reference program ends after Notes integration and the completed H handoff.
|
||||||
|
F1 remains an entirely separate foundation-only PR. The complete Notes
|
||||||
|
decomposition, regression tests, compatibility validation and final reference
|
||||||
|
case study belong to one comprehensive draft PR targeting official `dev`.
|
||||||
|
|
||||||
|
## Verified current responsibility and compatibility map
|
||||||
|
|
||||||
|
Baseline: `8feb50e89468235cd3e0986da80edbd3acaf2c40`.
|
||||||
|
The module is `static/js/notes.js`, about 5,470 lines, with browser identity
|
||||||
|
`/static/js/notes.js?v=20260911notesselectioncancel1`. Preserve that identity across
|
||||||
|
the app import, HTML, SW and lazy consumers until a separately reviewed migration
|
||||||
|
changes the contract everywhere.
|
||||||
|
|
||||||
|
| Responsibility | Current evidence | Intended owner after its reviewed slice |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| State and CRUD | `_notes`, selection/filter/editing/loading variables, `_undoStack`, `_fetchNotes`, `_saveNote`, `_patchNote`, `_archiveNoteById` | One application state owner and workflow operations; no second reminder/UI copy |
|
||||||
|
| Dates and recurrence | `_normalizeRepeat`, `_advanceRecurring`, `_nthWeekdayOfMonth`, `_lastWeekdayOfMonth`, `_toLocalDatetimeStr`, repeat formatting helpers | Pure domain calculations with explicit clock inputs |
|
||||||
|
| Network and persistence | Notes CRUD/reorder, upload, reminder firing, draft and reminder localStorage keys | Infrastructure adapters preserving request payloads, credentials and error contracts |
|
||||||
|
| Reminders | `_checkReminders`, `_startReminderLoop`, `_initReminders`, notification permission/display and due/fired badge calculations | Application reminder workflow plus injected clock/storage/notification/API ports |
|
||||||
|
| Draft autosave | `_draftKey`, `_collectFormDraft`, `_wireDraftAutosave`, `_applyDraftToNote`, `_commitOpenInPlaceEditor` | Draft workflow with editor view/persistence ports and serialized saves |
|
||||||
|
| UI | `openPanel`, `closePanel`, `_renderNotes`, forms/cards/checklists, masonry, color/photo/drawing, mobile swipe/reorder | Scoped UI modules with explicit mount/dispose and application operations |
|
||||||
|
| Agent context | `#notes-send-agent` handler, selected IDs, `odysseus:notes-context-selected` | Preserve event payload and synchronous acknowledgement; Chat owns attachment/session state |
|
||||||
|
| Agent solve action | `_agentSolveNote`: default-chat lookup, session creation and chat stream | Separate existing workflow; do not conflate with selected-note attachment |
|
||||||
|
| Compatibility coordinator | Default controller, named aliases, `window.notesModule`, delayed reminder startup | Stable legacy facade/composition bridge until consumers safely migrate |
|
||||||
|
|
||||||
|
The default API currently includes `openPanel`, `closePanel`, `togglePanel`,
|
||||||
|
`isPanelOpen`, `openNote`, `newNote`, `openNotes`, `closeNotes`, `isNotesOpen` and
|
||||||
|
`refreshDueBadge`. Named exports also include `dismissFiredReminderDot` and the
|
||||||
|
panel/open aliases. Characterization must pin the complete actual namespace and
|
||||||
|
default object separately, including identity of aliases and the window bridge.
|
||||||
|
|
||||||
|
Consumers include `static/app.js` sidebar/routes/badge polling,
|
||||||
|
`chatStream.js` note tool updates, `chatRenderer.js` note links and
|
||||||
|
`document.js` coordination. `static/index.html` and `static/sw.js` are entry/cache
|
||||||
|
contracts. The shared UI, confetti, modal, color picker, drag, tiling, docking,
|
||||||
|
z-order and Escape helpers are direct dependencies. Those legacy dependencies
|
||||||
|
remain in the coordinator until their ownership is reviewed, rather than moving
|
||||||
|
all UI coupling into domain modules.
|
||||||
|
|
||||||
|
Import-time behavior includes window origin/storage reads and
|
||||||
|
`setTimeout(_initReminders, 3000)`. The successful bootstrap loads Notes and starts
|
||||||
|
a 30-second reminder interval. App composition separately refreshes the badge
|
||||||
|
every five minutes. Panel/editing listeners, draft timers, highlight animations,
|
||||||
|
ResizeObserver and drawing/touch handlers have shorter scopes. Record them before
|
||||||
|
extraction; do not stop background reminders merely because a panel closes.
|
||||||
|
Listener cleanup concerns require characterization, not an unrelated fix hidden
|
||||||
|
inside an extraction.
|
||||||
|
|
||||||
|
Persistence includes `odysseus-notes-view`, label collapse and hint preferences,
|
||||||
|
fired/glowed/pending/active reminder markers, dismissed timestamp, and
|
||||||
|
`odysseus-note-draft-<id>` including `__new__`. Current direct APIs include Notes
|
||||||
|
CRUD/reorder/fire-reminder and upload, plus default-chat/session/chat-stream for
|
||||||
|
the existing solve action. Backend owner isolation and permission handling stay
|
||||||
|
authoritative and are outside this frontend decomposition's implementation scope.
|
||||||
|
|
||||||
|
## PR #6495 contract that every relevant slice preserves
|
||||||
|
|
||||||
|
Notes sends `odysseus:notes-context-selected` with `{ noteIds, accepted: false }`.
|
||||||
|
Chat's synchronous listener accepts only a valid active or pending chat, updates
|
||||||
|
session/pending selection, sets `accepted`, and updates the context indicator.
|
||||||
|
Notes clears select mode only after acceptance. Session lifecycle events
|
||||||
|
`odysseus:session-changed` carry session/materialization information. Chat appends
|
||||||
|
selected IDs as `selected_note_ids` when sending; server parsing validates IDs,
|
||||||
|
filters by owner and treats the resulting prompt context as untrusted.
|
||||||
|
|
||||||
|
Keep selection transfer to a materialized chat, reset on a new chat, rejection
|
||||||
|
when no chat/model can accept it, aliases and indicator behavior. Do not broaden
|
||||||
|
frontend selections into permissions or bypass the backend. See
|
||||||
|
`tests/selectedNotesContext.test.mjs`, `tests/test_selected_notes_context.py`,
|
||||||
|
`static/js/chat.js` selected-context listeners and send payload, and
|
||||||
|
`static/js/sessions.js` lifecycle event producers.
|
||||||
|
|
||||||
|
## One Notes branch, worktree and draft PR
|
||||||
|
|
||||||
|
After F1 integration and authorization to begin Notes, create one worktree and
|
||||||
|
branch, suggested name `refactor/notes-reference-decomposition`, from the latest
|
||||||
|
official `upstream/dev`. N0 through N6 are sequential internal engineering
|
||||||
|
checkpoints on that same branch. No independent GitHub PR is required for a
|
||||||
|
checkpoint or a UI substage. Maintain one draft PR targeting official `dev`, with
|
||||||
|
checkpoint evidence linked from its description. Keep it a draft until the
|
||||||
|
complete decomposition passes all acceptance gates below.
|
||||||
|
|
||||||
|
| Checkpoint | Responsibility | Deliverable and exit evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| N0 | Characterization | Public API/consumer, responsibility, state, lifecycle and network map; deterministic behavior tests; PR-overlap refresh; no extraction |
|
||||||
|
| N1 | Recurrence domain | Only pure date/recurrence rules to `features/notes/domain/recurrence.js`; preserve old wrapper call contracts, time-read position and returned strings; new offline dependency registered |
|
||||||
|
| N2 | Infrastructure | HTTP and storage adapters with exact request/error/key contracts; retain state ownership and backend authorization |
|
||||||
|
| N3 | Application | Single state owner and CRUD/optimistic/undo workflows; UI/reminders call operations; no competing arrays or stores |
|
||||||
|
| N4 | Reminders | Timer/notification/reminder orchestration through explicit ports; characterize startup, catch-up, deduplication, badge and background behavior |
|
||||||
|
| N5 | UI substages | Bounded card/panel, editor/draft, checklist, drawing and mobile reviews within the same PR; explicit listener/observer disposal and the same application operations |
|
||||||
|
| N6 | Compatibility and reference case study | Reduce coordinator; full API/consumer/lifecycle/offline comparison; retain legacy facade unless removal is proven safe; complete the final reference case study in the same PR |
|
||||||
|
|
||||||
|
Do not create the Notes worktree, branch or PR during F1. N5 remains several
|
||||||
|
bounded internal reviews rather than a second monolith move. Every checkpoint
|
||||||
|
and UI substage must have its own focused tests, review evidence, lessons,
|
||||||
|
exceptions and a rollback plan. Record the validated revision or reproducible
|
||||||
|
patch/snapshot, prior known-good checkpoint, exact test commands/results and
|
||||||
|
how to restore its matching source/output/facade/precache set while preserving
|
||||||
|
unrelated work. Checkpoint commits require the applicable maintainer authority;
|
||||||
|
no checkpoint or commit is created by this planning document.
|
||||||
|
|
||||||
|
Advance only after the current checkpoint's required checks and review findings
|
||||||
|
are resolved. A failed checkpoint returns to its prior validated state or receives
|
||||||
|
a bounded correction on the same branch; it does not require a separate PR.
|
||||||
|
If upstream changes require updating the baseline, update the same Notes branch,
|
||||||
|
record that revision and rerun affected checkpoint and compatibility gates.
|
||||||
|
|
||||||
|
## Complete Notes PR acceptance gates
|
||||||
|
|
||||||
|
Before marking the single draft PR ready for review:
|
||||||
|
|
||||||
|
- Complete N0 through N6, including all N5 substages, with their tests, review
|
||||||
|
receipts and reproducible checkpoint/rollback evidence.
|
||||||
|
- Pass the required full Notes regression and compatibility checks on the final
|
||||||
|
combined revision, including PR #6495 selection/context behavior, public APIs,
|
||||||
|
module identities, single state ownership, storage formats, timers and teardown.
|
||||||
|
- Pass source/output parity, LF attribute, architecture, import graph and exact
|
||||||
|
offline manifest gates; verify current bootstrap/lazy behavior and relevant
|
||||||
|
native/packaging contracts. Record platform limits and distinguish preserved
|
||||||
|
offline module behavior from separately certified root offline reload.
|
||||||
|
- Resolve required gate failures and review findings; document unrelated existing
|
||||||
|
failures or coverage gaps explicitly rather than bypassing required checks.
|
||||||
|
- Include the final reference case study in this PR: actual before/after APIs,
|
||||||
|
dependency/state/lifecycle maps, stage decisions and lessons, validation recipes,
|
||||||
|
temporary exceptions, known debt and tested rollback/compatibility procedures.
|
||||||
|
|
||||||
|
These are readiness gates for the complete PR, not separate stage merges.
|
||||||
|
The receiving maintainer may choose smaller independent PRs for future frontend
|
||||||
|
features; this one-PR delivery decision applies to the Notes reference program.
|
||||||
|
|
||||||
|
## N0 and N1 characterization requirements
|
||||||
|
|
||||||
|
Pin recurrence normalization for legacy bare repeat values and explicit values;
|
||||||
|
none/unknown/malformed input; daily/weekly/monthly/yearly advancement; month ends,
|
||||||
|
leap years, nth/last weekdays including absent fifth occurrences; overdue catch-up
|
||||||
|
and the 5,000-step guard; future anchors; local serialization and preserved hours;
|
||||||
|
DST gaps/overlaps under explicit time zones. `_advanceRecurring` currently reads
|
||||||
|
`Date.now()` after its first step. Inject a clock without changing that ordering.
|
||||||
|
Record the existing behavior of invalid dates rather than inventing improvements.
|
||||||
|
|
||||||
|
Characterize request error handling, optimistic rollback/undo, archive/delete,
|
||||||
|
selection/filter/reset, ordering, uploads and notification permission errors.
|
||||||
|
For drafts, preserve synchronous local backup before the 500 ms persistence
|
||||||
|
timer, intentional empty text, checklist type fallback, serialized writes,
|
||||||
|
failure recovery and refusal to clear a newer draft when an older save finishes.
|
||||||
|
|
||||||
|
Use stub clocks/ports and temporary storage for unit behavior; browser tests for
|
||||||
|
public namespace evaluation, DOM lifecycle, selected context and actual offline
|
||||||
|
loading. Run real modules through their output paths. Do not rewrite source-range
|
||||||
|
tests wholesale; migrate each only with its responsible slice and equivalent
|
||||||
|
behavioral assertions. Existing source-sensitive Notes autosave tests currently
|
||||||
|
read `notes.js`, so extraction has an explicit test-maintenance dependency.
|
||||||
|
|
||||||
|
Before a slice, refresh #6585/#6647/#6652 backend behavior and #6470/#6695 shared
|
||||||
|
UI/platform overlaps from the [assessment](assessment.md). Existing fixes may
|
||||||
|
change characterization inputs, but this program does not merge or silently
|
||||||
|
reimplement those PRs. The independent root-SW fix #6227 is required before a
|
||||||
|
claim of full SPA offline reload readiness.
|
||||||
|
|
||||||
|
## H completion and stopping rule
|
||||||
|
|
||||||
|
The final reference case study is completed inside the Notes PR before it becomes
|
||||||
|
ready for review. After that PR is integrated, finalize H with the integrated
|
||||||
|
revision and checkpoint-to-integration receipts, outstanding dependencies and
|
||||||
|
the recipient's independent reproduction. Have the receiving maintainer
|
||||||
|
reproduce setup and validation independently and record that receipt. Transfer
|
||||||
|
future feature ownership explicitly. Do not automatically begin Calendar,
|
||||||
|
Documents, Email, Chat, Skills or Settings; the initiating architect continues
|
||||||
|
with separately approved non-frontend monolith work.
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
# F1 implementation and review report
|
||||||
|
|
||||||
|
Date: 2026-10-13. Recommendation: ready for controlled incremental adoption after
|
||||||
|
maintainer review, with the platform/offline limits below. The foundation proof
|
||||||
|
is implemented and tested; Notes decomposition and the completed H handoff are
|
||||||
|
planned only. No commit, push, merge, PR or issue operation was performed.
|
||||||
|
|
||||||
|
## Notes delivery documentation correction
|
||||||
|
|
||||||
|
The maintainer's final delivery decision is one comprehensive Notes draft PR
|
||||||
|
targeting official `dev`, after the separate foundation-only F1 PR is integrated.
|
||||||
|
N0 through N6 are internal engineering checkpoints in one Notes worktree and
|
||||||
|
branch. The complete decomposition, regression tests, compatibility validation
|
||||||
|
and final reference case study belong to that single PR; it stays a draft until
|
||||||
|
the complete acceptance gates pass. Each stage retains its own test results,
|
||||||
|
review receipts and reproducible rollback/checkpoint strategy, without requiring
|
||||||
|
an independent GitHub PR. The receiving maintainer may choose smaller independent
|
||||||
|
PRs for future frontend features.
|
||||||
|
|
||||||
|
This correction updates only `notes-roadmap.md`, `handbook.md` and this report.
|
||||||
|
Implementation and test files retain their pre-correction bytes. All five
|
||||||
|
foundation documents passed link, fence, whitespace, no-em-dash and delivery
|
||||||
|
wording checks. `git diff --check` and the preserved-runtime diff check passed.
|
||||||
|
A before/after snapshot confirmed exactly these three changed documents and
|
||||||
|
15 unchanged existing F1 files. The implementation test results below remain
|
||||||
|
the previous executed evidence; implementation tests were not rerun in this
|
||||||
|
documentation-only pass.
|
||||||
|
|
||||||
|
## Independent review correction
|
||||||
|
|
||||||
|
The maintainer reports that the independent F1 review found no critical
|
||||||
|
architectural defects. This pass preserves the reviewed native ESM architecture
|
||||||
|
and scope. Its correction is to turn the documented LF checkout convention into
|
||||||
|
a manifest-driven publisher/CI requirement. The full independent review text
|
||||||
|
was not supplied; this report records the supplied finding and executed evidence.
|
||||||
|
|
||||||
|
The correction changes only `scripts/build_frontend.py`,
|
||||||
|
`tests/test_frontend_build.py`, the ADR, the handbook and this report. The
|
||||||
|
`.gitattributes` rules, architecture checker, CI workflow, manifest, dependencies
|
||||||
|
and stateless source remain as reviewed. The full F1 inventory below still has
|
||||||
|
18 files, including `.gitattributes`.
|
||||||
|
|
||||||
|
The publisher now evaluates both paths of every manifest entry through Git's
|
||||||
|
actual attribute engine before parity evaluation or any deployment write. It
|
||||||
|
requires `text: set` and `eol: lf` from applicable repository-owned attribute files,
|
||||||
|
including nested overrides. A private temporary repository and isolated Git
|
||||||
|
configuration prevent global attributes, local `.git/info/attributes`, templates
|
||||||
|
or alternate Git directory settings from masking an omitted declaration. Paths
|
||||||
|
are sent through NUL-delimited stdin, preserving spaces and using no shell/path
|
||||||
|
quoting assumptions. The working repository's attributes, index and output are
|
||||||
|
not changed by this check. Git is a publication/development prerequisite; the
|
||||||
|
application does not invoke it to build or check frontend assets at startup.
|
||||||
|
|
||||||
|
The production integration diff is the new `check_lf_attributes(root, paths)`
|
||||||
|
helper, four standard-library imports, this call, and updated CLI help:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
@@ synchronize
|
||||||
|
if unlisted:
|
||||||
|
raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}")
|
||||||
|
+ check_lf_attributes(root, sources | outputs)
|
||||||
|
stale = []
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests add fixture LF declarations and 14 cases. Ten parameterized cases
|
||||||
|
reject missing LF, CRLF, auto-text and binary-text rules for source/output paths
|
||||||
|
in both publish and check modes, even when bytes already match. The other four
|
||||||
|
cover a future manifest pair failing before any output is published and passing
|
||||||
|
after its LF declaration; nested rules and a spaced path; global/local settings
|
||||||
|
failing to mask an omitted repository rule; and clear failure without development
|
||||||
|
Git. Existing publication, ownership, confinement, parity and runtime tests remain.
|
||||||
|
|
||||||
|
## Exact attributes diff and checkout evidence
|
||||||
|
|
||||||
|
`git diff -- .gitattributes` is unchanged from the reviewed F1 diff:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
diff --git a/.gitattributes b/.gitattributes
|
||||||
|
index 8681aee1..dd49c4a0 100644
|
||||||
|
--- a/.gitattributes
|
||||||
|
+++ b/.gitattributes
|
||||||
|
@@ -10,6 +10,10 @@
|
||||||
|
entrypoint.sh text eol=lf
|
||||||
|
docker/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
|
+# Maintained frontend modules and this lane's byte-identical deployment output.
|
||||||
|
+frontend/src/** text eol=lf
|
||||||
|
+static/js/actionMenuOrder.js text eol=lf
|
||||||
|
+
|
||||||
|
# Windows-native scripts stay CRLF.
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact command and results:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||||
|
frontend/src/shared/actionMenuOrder.js: text: set
|
||||||
|
frontend/src/shared/actionMenuOrder.js: eol: lf
|
||||||
|
static/js/actionMenuOrder.js: text: set
|
||||||
|
static/js/actionMenuOrder.js: eol: lf
|
||||||
|
```
|
||||||
|
|
||||||
|
The sampled legacy contracts were also inspected:
|
||||||
|
|
||||||
|
| Path | text | eol |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `static/js/notes.js` | auto | unspecified |
|
||||||
|
| `static/index.html` | auto | unspecified |
|
||||||
|
| `launch-windows.ps1` | set | crlf |
|
||||||
|
| `start-macos.sh` | set | lf |
|
||||||
|
|
||||||
|
Beyond samples, all 2,479 unrelated tracked paths were compared against HEAD for
|
||||||
|
both attributes and matched. The current Git 2.53.0 supports the diagnostic
|
||||||
|
`--source=HEAD` option; the publisher uses only the longstanding plain
|
||||||
|
`check-attr --stdin` interface, so it does not depend on that newer option.
|
||||||
|
The read-only whole-tree comparison command was:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 - <<'PY'
|
||||||
|
import subprocess
|
||||||
|
paths = subprocess.check_output(['git', 'ls-files', '-z'])
|
||||||
|
def attributes(source):
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'check-attr', *source, '-z', '--stdin', 'text', 'eol'],
|
||||||
|
input=paths, capture_output=True, check=True,
|
||||||
|
)
|
||||||
|
fields = result.stdout.decode().split('\0')[:-1]
|
||||||
|
return {(fields[i], fields[i + 1]): fields[i + 2]
|
||||||
|
for i in range(0, len(fields), 3)}
|
||||||
|
before, after = attributes(['--source=HEAD']), attributes([])
|
||||||
|
owned = lambda p: p.startswith('frontend/src/') or p == 'static/js/actionMenuOrder.js'
|
||||||
|
changes = [(p, a, before[(p, a)], value) for (p, a), value in after.items()
|
||||||
|
if not owned(p) and before[(p, a)] != value]
|
||||||
|
assert not changes, changes
|
||||||
|
print(len({p for p, a in after if not owned(p)}), 'unrelated tracked paths unchanged')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ownership enforcement after correction
|
||||||
|
|
||||||
|
| Contract | Enforcement and CI receipt |
|
||||||
|
| --- | --- |
|
||||||
|
| Canonical, contained source/output paths and unique ownership | Publisher validates the whole manifest before writing; `python-syntax` runs its `--check` |
|
||||||
|
| Every maintained JS source is registered | Publisher rejects unlisted source; duplicate sources/outputs also fail |
|
||||||
|
| Reproducible LF checkout of both mapped paths | Publisher queries isolated repository-owned attributes, even for already matching bytes; the same CI `--check` now enforces it |
|
||||||
|
| Committed output equals maintained source | Publisher checks byte parity without rebuilding in CI; normal publication writes only stale owned outputs |
|
||||||
|
| Feature API/layer direction, no new cycles, stable relative resolution and maintained identities | Existing architecture checker and Node self-tests in `node-syntax`; no checker changes needed |
|
||||||
|
| Explicit exact-URL offline coverage | Architecture checker parses the existing SW arrays; real offline browser proof remains green |
|
||||||
|
| Legacy runtime graph/URL identity and behavior | Existing focused pytest guards and the full CI pytest jobs remain in place |
|
||||||
|
|
||||||
|
These gates enforce the documented maintained-JS contract together. They do not
|
||||||
|
retroactively impose source ownership on all legacy static files, clean retired
|
||||||
|
outputs or prove semantic state/lifecycle correctness. Those existing limits and
|
||||||
|
the independent packaging/offline limitations below remain unchanged.
|
||||||
|
|
||||||
|
## Final correction validation
|
||||||
|
|
||||||
|
The full focused pytest command printed below was rerun with the same interpreter
|
||||||
|
and all listed files: **193 passed, 1 skipped, 1 existing SQLAlchemy warning in
|
||||||
|
12.33 s**. It includes all 27 publisher tests and the isolated native startup,
|
||||||
|
static traversal and no-Node distribution probe. The new 14 tests account for
|
||||||
|
the increase from the initial F1 run's 179 passes. No test failed in this pass.
|
||||||
|
|
||||||
|
| Correction check | Actual result |
|
||||||
|
| --- | --- |
|
||||||
|
| Combined focused pytest command below | 193 passed, 1 unavailable-image skip |
|
||||||
|
| `node --test tests/frontendArchitecture.test.mjs` | 10 passed |
|
||||||
|
| Notes oracle/autosave/selected-context command below | 11 passed |
|
||||||
|
| `python3 scripts/build_frontend.py` | Passed, zero output changes, attributes enforced |
|
||||||
|
| `python3 scripts/build_frontend.py --check` | Passed, attributes and byte parity enforced |
|
||||||
|
| `node scripts/check_frontend.mjs` | Passed, one maintained module |
|
||||||
|
| Helper SHA-256 and `git check-attr` | Identical source/output hash; both text=set, eol=lf |
|
||||||
|
| All unrelated tracked attributes versus HEAD | 2,479 paths unchanged |
|
||||||
|
| `git diff --check` and preserved-runtime command below | Passed, no runtime/backend/packaging diff |
|
||||||
|
| `python -m compileall -q scripts/build_frontend.py tests/test_frontend_build.py` | Passed using the recorded virtualenv interpreter |
|
||||||
|
| Isolated Docker runtime command below | Passed, networking disabled; no source tree, node_modules or Node on PATH |
|
||||||
|
| Complete inventory, whitespace and documentation links | All 18 changed files matched the inventory; checks passed |
|
||||||
|
|
||||||
|
The one skip remains the existing test requiring `odysseus-odysseus:latest`.
|
||||||
|
No new skip or waiver was introduced. Windows/macOS executable builds, a fresh
|
||||||
|
full Dockerfile build, the full pytest suite, Firefox/WebKit and CI's exact
|
||||||
|
Python 3.11/Node 20 versions remain unexecuted locally. Native Git semantics,
|
||||||
|
nested overrides, paths with spaces and ignored `core.autocrlf`/global attribute
|
||||||
|
settings are covered by executed Linux tests; actual Windows execution remains a
|
||||||
|
platform-owner check. The existing `/static/` worker scope still prevents a claim
|
||||||
|
of full root offline reload readiness. No service-worker scope change is included.
|
||||||
|
|
||||||
|
The earlier local ChromaDB probe incident remains recorded under failures below.
|
||||||
|
All runtime checks in this correction pass used the isolated distribution probe
|
||||||
|
with reserved non-listening Chroma port and disabled warmups; Docker additionally
|
||||||
|
had networking disabled. This pass performed no new unisolated ChromaDB probe and
|
||||||
|
no attempt to restore or alter the existing local service's tool index.
|
||||||
|
|
||||||
|
## Reviewer-ready PR summary
|
||||||
|
|
||||||
|
Suggested title: `Establish frontend source ownership and native ESM deployment contracts`.
|
||||||
|
|
||||||
|
Maintain a stateless helper under `frontend/src/` while publishing identical bytes
|
||||||
|
at its existing static URL. Add manifest-owned publication, repository-owned LF
|
||||||
|
attribute and byte-parity checks, parsed dependency/API/identity/offline gates,
|
||||||
|
and a self-contained architecture handbook with the Notes decomposition roadmap.
|
||||||
|
Startup and existing feature behavior retain their current runtime contracts.
|
||||||
|
|
||||||
|
Validation: 193 focused Python tests passed with one existing Docker-image skip;
|
||||||
|
10 architecture and 11 Notes compatibility tests passed; source/output parity,
|
||||||
|
LF gates, preserved-runtime diff and isolated native/Docker runtime proofs passed.
|
||||||
|
Desktop executable builds and full root offline reload remain explicit limits.
|
||||||
|
All 18 files are inventoried below. The working diff is ready for review and is
|
||||||
|
uncommitted; commit, push and PR creation require separate authorization.
|
||||||
|
|
||||||
|
## Verified repository state
|
||||||
|
|
||||||
|
Repository: `odysseus-dev/odysseus`. Branch:
|
||||||
|
`refactor/frontend-architecture-foundation`. Worktree:
|
||||||
|
`/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation`.
|
||||||
|
Initial worktree was clean. HEAD and local official `upstream/dev` are
|
||||||
|
`8feb50e89468235cd3e0986da80edbd3acaf2c40`. The live official GitHub `dev` SHA
|
||||||
|
matched at both initial and final verification. This is PR #6495's merge commit.
|
||||||
|
The `origin` remote is the maintainer fork, not the integration target.
|
||||||
|
|
||||||
|
Final changes are uncommitted F1 files only. Runtime JavaScript, Notes, HTML,
|
||||||
|
SW, backend, Dockerfile and desktop startup/packaging implementations have no
|
||||||
|
diff. The stateless source and existing deployed helper are both 1,688 bytes,
|
||||||
|
SHA-256 `e17baec5d6ac381ce6234ab6f028a8208487280dd07d6950a3469f8488809684`.
|
||||||
|
The final publisher run reported zero changed output modules.
|
||||||
|
|
||||||
|
## Decisions and scope
|
||||||
|
|
||||||
|
The [ADR](ADR-001-source-and-deployment.md) compares two executed approaches:
|
||||||
|
controlled native ESM copying and esbuild 0.25.12 ESM compilation/bundling.
|
||||||
|
Native byte copying wins on existing URL/module/cache compatibility and absence
|
||||||
|
of runtime tooling. Commit explicit output beside maintained source, publish
|
||||||
|
only manifest-owned modules and enforce parity in CI. Python startup never builds.
|
||||||
|
|
||||||
|
The [handbook](handbook.md) defines feature public indexes, responsibility layers,
|
||||||
|
state/lifecycle ownership, cross-feature ports/events, shared-code limits, legacy
|
||||||
|
coexistence, module creation, validation, rollback and handoff contracts.
|
||||||
|
The [assessment](assessment.md) records graph/bootstrap/state/storage/security,
|
||||||
|
packaging assumptions and current open-PR overlaps. The
|
||||||
|
[Notes roadmap](notes-roadmap.md) defines N0 through N6 as internal checkpoints
|
||||||
|
for one comprehensive Notes draft PR, with the final reference case study in
|
||||||
|
that PR and H recipient handoff after integration. None is implemented in F1.
|
||||||
|
There is one maintained stateless leaf and zero legacy-import
|
||||||
|
exceptions, with no empty feature scaffolding.
|
||||||
|
|
||||||
|
Acorn 8.19.0 is the sole new, exact-pinned development dependency, used to parse
|
||||||
|
architecture edges accurately. It is not loaded or distributed as runtime JS.
|
||||||
|
esbuild was a pinned one-off spike and is not a project dependency. Existing
|
||||||
|
frameworks, application entry points and backend contracts are unchanged.
|
||||||
|
|
||||||
|
## Exact changed files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `.gitattributes` | LF checkout for maintained source and the registered deployed proof |
|
||||||
|
| `.github/workflows/ci.yml` | Parity check; locked parser install; source syntax and architecture self-tests |
|
||||||
|
| `package.json` | Exact Acorn development dependency |
|
||||||
|
| `package-lock.json` | Acorn lock/integrity entry |
|
||||||
|
| `frontend/modules.json` | Explicit source/deployment ownership mapping |
|
||||||
|
| `frontend/src/package.json` | Native ESM interpretation for source tests, no dependencies |
|
||||||
|
| `frontend/src/shared/actionMenuOrder.js` | Authoritative stateless source, copied verbatim from the existing helper |
|
||||||
|
| `scripts/build_frontend.py` | Validated source publisher with manifest-driven, repository-owned LF enforcement and non-mutating parity check |
|
||||||
|
| `scripts/check_frontend.mjs` | Parsed import/layer/API/cycle/identity/precache guards and graph inventory |
|
||||||
|
| `scripts/verify_frontend_runtime.py` | Isolated current-code distribution/runtime compatibility probe |
|
||||||
|
| `tests/frontendArchitecture.test.mjs` | Behavioral API equivalence and positive/negative architecture gates |
|
||||||
|
| `tests/test_frontend_build.py` | Publication/ownership/drift/runtime coverage plus positive and negative Git attribute enforcement tests |
|
||||||
|
| `tests/test_frontend_foundation_offline_js.py` | Real SW precache, offline first evaluation, canonical namespace identity and lazy registry proof |
|
||||||
|
| `docs/frontend-architecture/ADR-001-source-and-deployment.md` | Decision, alternatives and distribution/rollback contracts |
|
||||||
|
| `docs/frontend-architecture/assessment.md` | Evidence-backed architecture and PR-overlap inventory |
|
||||||
|
| `docs/frontend-architecture/handbook.md` | Permanent foundation and maintainer continuation methodology |
|
||||||
|
| `docs/frontend-architecture/notes-roadmap.md` | Reference decomposition and final handoff plan |
|
||||||
|
| `docs/frontend-architecture/validation.md` | This implementation/review/validation report |
|
||||||
|
|
||||||
|
## Initial F1 validation before the review correction
|
||||||
|
|
||||||
|
Environment: Linux; native Python 3.14.4; pytest 9.1.1; FastAPI 0.143.0;
|
||||||
|
Starlette 1.8.0; Node 22.23.1; Playwright 1.62.1 with installed Chromium.
|
||||||
|
CI remains Python 3.11 and Node 20; that exact interpreter pair was not executed
|
||||||
|
locally. Use the repository virtualenv, not the system Python missing dependencies.
|
||||||
|
The interpreter used here was
|
||||||
|
`/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python`.
|
||||||
|
|
||||||
|
The historical counts below are coordinator-executed checks in this worktree, excluding
|
||||||
|
delegated claims and earlier repeated subsets. Delegation was discontinued on
|
||||||
|
user instruction; no delegated implementation was integrated.
|
||||||
|
|
||||||
|
| Check | Actual result |
|
||||||
|
| --- | --- |
|
||||||
|
| Read-only baseline: graph, identity, helper, loader, MIME, nonce, runtime paths | 54 passed, one existing SQLAlchemy deprecation warning |
|
||||||
|
| Final combined focused pytest command below | 179 passed, 1 skipped, one existing SQLAlchemy warning, 12.62 s |
|
||||||
|
| Final `pytest -q -rs tests/test_frontend_build.py` review rerun | 13 passed, including native startup and encoded static traversal rejection |
|
||||||
|
| `node --test tests/frontendArchitecture.test.mjs` | 10 passed, no skips or failures; rerun after final gate review |
|
||||||
|
| Notes oracle/autosave/selected-context Node command below | 11 passed, no skips or failures |
|
||||||
|
| `python3 scripts/build_frontend.py` and `--check` | Zero output changes; byte parity passed |
|
||||||
|
| `node scripts/check_frontend.mjs` | Passed, one maintained module |
|
||||||
|
| `node scripts/check_frontend.mjs --inventory` | 230 files, 707 static edges, 108 lazy literal edges, no missing targets, three existing static cyclic components |
|
||||||
|
| `npm ci --ignore-scripts --no-audit --no-fund` | Locked development dependency installation succeeded |
|
||||||
|
| Native `scripts/verify_frontend_runtime.py` | Real FastAPI lifespan and shell/static assertions passed without maintained source, node_modules or Node on PATH |
|
||||||
|
| Docker runtime probe below | Same current-code assertions passed with networking disabled and source mount read-only |
|
||||||
|
| esbuild spike | Two identical 1,554-byte bundle outputs; executed exports/icon/ordering equivalence passed |
|
||||||
|
| JS syntax checks and Python compileall below | Passed |
|
||||||
|
| `git diff --check`, preserved-scope diff check, hash/LF checks | Passed; no runtime/backend/packaging source diff |
|
||||||
|
| Foundation documentation links, trailing whitespace and em-dash checks | All five files passed |
|
||||||
|
|
||||||
|
The skipped test is
|
||||||
|
`tests/test_docker_devops_hardening.py:187`, which requires the unavailable
|
||||||
|
`odysseus-odysseus:latest` image. It was not relabeled or bypassed. F1's separate
|
||||||
|
Docker runtime probe used the available `odysseus-maintainer-preview-odysseus:latest`,
|
||||||
|
image ID `sha256:cc2d47e2327d573af01c6b027f23d2ab0f2ee9b85d658e9eb8065bd02b9c3515`,
|
||||||
|
with Python 3.14.7, FastAPI 0.141.1 and Starlette 1.6.0. This proves compatibility
|
||||||
|
of current candidate code/assets with that image's runtime dependencies, not a
|
||||||
|
fresh build of the full repository Dockerfile or a deployment.
|
||||||
|
|
||||||
|
Reproduce the final focused command with the actual tested interpreter:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m pytest -q -rs \
|
||||||
|
tests/test_frontend_build.py tests/test_frontend_foundation_offline_js.py \
|
||||||
|
tests/test_frontend_module_graph.py tests/test_frontend_module_version_parity.py \
|
||||||
|
tests/test_action_menu_order.py tests/test_panel_loader_js.py \
|
||||||
|
tests/test_app_static_mime.py tests/test_serve_html_with_nonce.py \
|
||||||
|
tests/test_runtime_paths.py tests/test_ci_authoritative_validation.py \
|
||||||
|
tests/test_selected_notes_context.py tests/test_email_library_module_graph_js.py \
|
||||||
|
tests/test_settings_module_import_paths.py tests/test_app_config_shared_fetch_js.py \
|
||||||
|
tests/test_markdown_lazy_lib_loading_js.py tests/test_startup_shell_js.py \
|
||||||
|
tests/test_static_stylesheet_manifest.py tests/test_notes_dom_xss_helpers.py \
|
||||||
|
tests/test_notes_reminder_temporal_redos.py tests/test_notes_sidebar_new_note.py \
|
||||||
|
tests/test_notes_mobile_checklist_toggle_static.py \
|
||||||
|
tests/test_notes_document_mutual_minimize.py tests/test_docker_devops_hardening.py
|
||||||
|
|
||||||
|
node --test tests/frontendArchitecture.test.mjs
|
||||||
|
ENDPOINT_URL=http://127.0.0.1:1 node --test \
|
||||||
|
tests/noteTestOracle.test.mjs tests/notesDraftAutosave.test.mjs \
|
||||||
|
tests/selectedNotesContext.test.mjs
|
||||||
|
|
||||||
|
python3 scripts/build_frontend.py --check
|
||||||
|
node scripts/check_frontend.mjs
|
||||||
|
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python scripts/verify_frontend_runtime.py
|
||||||
|
|
||||||
|
docker run --rm --network none \
|
||||||
|
--mount type=bind,src=/home/alteixeira20/Projects/Odysseus/worktrees/architecture/frontend-foundation,dst=/candidate,readonly \
|
||||||
|
--workdir /candidate --entrypoint python \
|
||||||
|
odysseus-maintainer-preview-odysseus:latest scripts/verify_frontend_runtime.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`ENDPOINT_URL` satisfies the existing oracle's import-time configuration requirement.
|
||||||
|
Its unit-test imports exercise pure exported functions; its network-driving main
|
||||||
|
is guarded by the executable-module check and does not run. The closed loopback
|
||||||
|
address is not a live model endpoint. No oracle implementation was changed.
|
||||||
|
|
||||||
|
Syntax commands executed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash -c 'shopt -s globstar nullglob; for f in static/app.js static/js/**/*.js frontend/src/**/*.js scripts/check_frontend.mjs tests/frontendArchitecture.test.mjs; do node --check "$f" || exit; done'
|
||||||
|
/home/alteixeira20/.venvs/odysseus-pr-6495/bin/python -m compileall -q app.py core routes src services scripts tests
|
||||||
|
git diff --check
|
||||||
|
git diff --exit-code -- static app.py Dockerfile .dockerignore Odysseus.spec build-macos-app.sh build-windows-portable.ps1 launcher.py core routes src services
|
||||||
|
git check-attr text eol -- frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||||
|
sha256sum frontend/src/shared/actionMenuOrder.js static/js/actionMenuOrder.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Spike compiler invocation, executed twice with equality and Node import assertions:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npx --yes esbuild@0.25.12 frontend/src/shared/actionMenuOrder.js \
|
||||||
|
--bundle --format=esm --target=es2022 --platform=browser --log-level=error
|
||||||
|
```
|
||||||
|
|
||||||
|
The clean publication proof uses temporary roots in `test_frontend_build.py`,
|
||||||
|
including an initially absent output tree. Real-checkout publication is idempotent.
|
||||||
|
No emitted JS bytes, URLs, boot scripts or cache manifests changed in this lane.
|
||||||
|
|
||||||
|
## Failures encountered and their resolution
|
||||||
|
|
||||||
|
- An early architecture self-test expected an identity diagnostic but got an
|
||||||
|
unresolved source path for a query-bearing relative import. The checker now
|
||||||
|
rejects alternate identity before resolving that source path. All final tests
|
||||||
|
pass; no test requirement was weakened.
|
||||||
|
- The initial Notes oracle command failed during import because `ENDPOINT_URL`
|
||||||
|
was missing. Inspection established that the unit-test path never runs its
|
||||||
|
endpoint-driving main. The explicit closed-address configuration above passed
|
||||||
|
all 11 tests. This prerequisite is recorded for N0 rather than hidden.
|
||||||
|
- The first isolated distribution fixture omitted the existing `companion/`
|
||||||
|
package, so both probes failed with `ModuleNotFoundError`. The fixture now
|
||||||
|
includes that required runtime directory; neither application nor packaging
|
||||||
|
implementation was modified to make the tests pass.
|
||||||
|
- The first exploratory native startup check connected to the existing local
|
||||||
|
ChromaDB and refreshed the built-in tool index. That was unintended. Subsequent
|
||||||
|
probes use a private distribution/data directory, a reserved non-listening
|
||||||
|
Chroma port, disabled existing warmup options and no Node on PATH. Docker also
|
||||||
|
disables networking. No attempt was made to overwrite or restore that service's
|
||||||
|
data; the earlier unisolated probe is not the reproducible validation recipe.
|
||||||
|
|
||||||
|
## Remaining risks and limits
|
||||||
|
|
||||||
|
Root offline reload is not certified: existing SW scope is `/static/`, and open
|
||||||
|
[#6227](https://github.com/odysseus-dev/odysseus/pull/6227) proposes the independent
|
||||||
|
scope/header correction. F1 preserves and tests actual offline module behavior
|
||||||
|
at the current scope. No first-use offline guarantee is claimed for optional
|
||||||
|
CDN libraries, Mermaid or server APIs.
|
||||||
|
|
||||||
|
Windows/macOS packaged executables, a fresh full Dockerfile build, the full pytest
|
||||||
|
suite, Firefox/WebKit offline behavior and CI's exact Python 3.11/Node 20 versions
|
||||||
|
were not executed here. Existing packaging paths are inspected and unchanged;
|
||||||
|
platform owners should run release validation before broader certification.
|
||||||
|
The unavailable-image test remains a coverage gap. Legacy source still has
|
||||||
|
cross-feature coupling, global side effects and static cycles. New boundaries
|
||||||
|
are enforced on maintained source, not retrospectively on the entire legacy tree.
|
||||||
|
|
||||||
|
The publisher intentionally does not clean retired outputs or provide atomic
|
||||||
|
multi-file deployment. Review full source/output/facade/manifest changes and
|
||||||
|
ship a coherent artifact. Domain syntax checks cannot prove all semantic purity
|
||||||
|
or disposal; behavioral characterization remains required. Reconcile the package
|
||||||
|
lock with #6627 and refresh all PR overlaps before integration.
|
||||||
|
|
||||||
|
## Reviewer checklist
|
||||||
|
|
||||||
|
- Verify the base contains #6495 and the official integration target is dev.
|
||||||
|
- Confirm the deployed helper hash and runtime-scope diff are unchanged.
|
||||||
|
- Review the layer matrix, public API rules, single-state ownership and zero
|
||||||
|
current legacy exceptions; verify negative tests actually reject violations.
|
||||||
|
- Review the manifest mapping, byte-parity/LF policy, exact offline precache and
|
||||||
|
source/output relative-resolution guard; verify missing or overridden LF
|
||||||
|
declarations fail publication and CI even when output bytes already match.
|
||||||
|
- Reproduce the focused tests and inspect the skipped-image prerequisite.
|
||||||
|
- Confirm no frontend compiler/runtime dependency is required at startup and
|
||||||
|
inspect the isolated Python/Docker probe's limits.
|
||||||
|
- Review overlap with #6470/#6695/#6227/#6627 and the Notes backend follow-ups.
|
||||||
|
- Treat the ADR as awaiting adoption review, and the Notes/H documents as plans.
|
||||||
|
- Authorize any commit/push/PR action separately. Do not begin Notes on this branch.
|
||||||
|
|
||||||
|
## Required next reviews and continuation
|
||||||
|
|
||||||
|
1. Review this F1 working diff. On explicit authorization, commit and submit only
|
||||||
|
the foundation-only F1 PR to official dev using the normal maintainer workflow.
|
||||||
|
2. Coordinate the existing #6227 offline fix and platform release validation as
|
||||||
|
separate work. Do not bundle that behavioral change into F1.
|
||||||
|
3. After F1 integration and Notes implementation authorization, create one Notes
|
||||||
|
worktree and branch from the latest official dev, preserving any subsequently
|
||||||
|
merged Notes changes. Use one comprehensive draft PR targeting official dev.
|
||||||
|
4. Complete N0 through N6 from the [Notes roadmap](notes-roadmap.md) on that same
|
||||||
|
branch, recording tests, review and rollback evidence at each checkpoint. Include
|
||||||
|
the final reference case study in the same PR and keep it a draft until the full
|
||||||
|
decomposition and regression/compatibility acceptance gates pass. Recurrence
|
||||||
|
extraction follows characterization as an internal stage, not another PR.
|
||||||
|
5. Complete H only after Notes integration and recipient reproduction. Transfer
|
||||||
|
future frontend work to the designated maintainer and stop this program.
|
||||||
|
|
||||||
|
Another maintainer can start at the handbook, run the documented source/parity,
|
||||||
|
architecture, runtime and behavior checks, inspect this report and select the
|
||||||
|
next authorized slice without relying on unwritten architecture decisions.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"source": "shared/actionMenuOrder.js",
|
||||||
|
"deployment": "js/actionMenuOrder.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** Stable semantic ordering for item-level action menus. */
|
||||||
|
const COMMON_ACTION_ORDER = [
|
||||||
|
{ rank: 200, pattern: /^(rename|change name)\b/i },
|
||||||
|
{ rank: 650, pattern: /^select\b/i },
|
||||||
|
{ rank: 400, pattern: /^(favorite|unfavorite|favourite|unfavourite|pin|unpin)\b/i },
|
||||||
|
{ rank: 500, pattern: /^(copy|clone|duplicate)\b/i },
|
||||||
|
{ rank: 550, pattern: /^(export|download)\b/i },
|
||||||
|
{ rank: 900, pattern: /^(delete|remove|hide|dismiss|clear from list|move to trash|move to spam)\b/i },
|
||||||
|
{ rank: 700, pattern: /^(move to archive|archive|unarchive|restore)\b/i },
|
||||||
|
{ rank: 600, pattern: /^(move(?! to (?:archive|trash|spam)\b)|add to folder)\b/i },
|
||||||
|
{ rank: 1000, pattern: /^cancel\b/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Canonical Select glyph for item-level menus. Toolbar toggles may use the
|
||||||
|
// smaller version, but dropdown rows should all use this exact SVG.
|
||||||
|
export const SELECT_MENU_ICON = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg>';
|
||||||
|
|
||||||
|
export function actionMenuRank(item) {
|
||||||
|
if (Number.isFinite(item?.menuOrder)) return item.menuOrder;
|
||||||
|
const value = String(typeof item?.action === 'string' ? item.action : (item?.label || '')).trim();
|
||||||
|
return COMMON_ACTION_ORDER.find(entry => entry.pattern.test(value))?.rank ?? 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderActionMenuItems(items) {
|
||||||
|
return items
|
||||||
|
.map((item, index) => ({ item, index, rank: actionMenuRank(item) }))
|
||||||
|
.sort((a, b) => a.rank - b.rank || a.index - b.index)
|
||||||
|
.map(entry => entry.item);
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
# Save to gallery
|
# Save to gallery
|
||||||
try:
|
try:
|
||||||
from src.database import SessionLocal, GalleryImage
|
from src.database import SessionLocal, GalleryImage
|
||||||
|
owner = str(arguments.get("_odysseus_owner") or "").strip() or None
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
db.add(GalleryImage(
|
db.add(GalleryImage(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -147,9 +148,16 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
model=model_id,
|
model=model_id,
|
||||||
size=size,
|
size=size,
|
||||||
quality=payload.get("quality", "medium"),
|
quality=payload.get("quality", "medium"),
|
||||||
|
owner=owner,
|
||||||
))
|
))
|
||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
if owner:
|
||||||
|
try:
|
||||||
|
from src.generated_images import register_generated_image
|
||||||
|
register_generated_image(filename, owner)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
Generated
+15
-1
@@ -7,7 +7,8 @@
|
|||||||
"name": "odysseus",
|
"name": "odysseus",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.7.0",
|
"@antithesishq/bombadil": "^0.7.0",
|
||||||
"@playwright/test": "^1.62.1"
|
"@playwright/test": "^1.62.1",
|
||||||
|
"acorn": "8.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@antithesishq/bombadil": {
|
"node_modules/@antithesishq/bombadil": {
|
||||||
@@ -36,6 +37,19 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.7.0",
|
"@antithesishq/bombadil": "^0.7.0",
|
||||||
"@playwright/test": "^1.62.1"
|
"@playwright/test": "^1.62.1",
|
||||||
|
"acorn": "8.19.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-25
@@ -15,6 +15,7 @@ from pathlib import Path
|
|||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
from src.settings import (
|
from src.settings import (
|
||||||
@@ -129,6 +130,21 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
return auth_manager.get_username_for_token(token)
|
return auth_manager.get_username_for_token(token)
|
||||||
|
|
||||||
|
def _require_admin_user(request: Request) -> Optional[str]:
|
||||||
|
"""Resolve the current user, enforcing admin access.
|
||||||
|
|
||||||
|
Honors AUTH_ENABLED=false the same way core.middleware.require_admin
|
||||||
|
does: when the operator has explicitly disabled auth (e.g. behind a
|
||||||
|
reverse proxy that handles login), there is no session user, and
|
||||||
|
admin-gated app configuration must stay reachable.
|
||||||
|
"""
|
||||||
|
user = _get_current_user(request)
|
||||||
|
if _auth_disabled():
|
||||||
|
return user
|
||||||
|
if not user or not auth_manager.is_admin(user):
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
return user
|
||||||
|
|
||||||
@router.post("/setup")
|
@router.post("/setup")
|
||||||
async def first_run_setup(body: SetupRequest, request: Request):
|
async def first_run_setup(body: SetupRequest, request: Request):
|
||||||
"""Create initial admin account. Only works if no accounts exist."""
|
"""Create initial admin account. Only works if no accounts exist."""
|
||||||
@@ -718,9 +734,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.post("/features")
|
@router.post("/features")
|
||||||
async def set_features(request: Request):
|
async def set_features(request: Request):
|
||||||
"""Admin only: update feature toggles."""
|
"""Admin only: update feature toggles."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
current = _load_features()
|
current = _load_features()
|
||||||
for key in current:
|
for key in current:
|
||||||
@@ -738,16 +752,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
settings = without_retired_settings(_load_settings())
|
settings = without_retired_settings(_load_settings())
|
||||||
if user and auth_manager.is_admin(user):
|
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
||||||
return settings
|
return settings
|
||||||
return scrub_settings(settings)
|
return scrub_settings(settings)
|
||||||
|
|
||||||
@router.post("/settings")
|
@router.post("/settings")
|
||||||
async def set_settings(request: Request):
|
async def set_settings(request: Request):
|
||||||
"""Admin only: update app settings."""
|
"""Admin only: update app settings."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
current = _load_settings()
|
current = _load_settings()
|
||||||
# Per-key validation for numeric settings: coerce to int and clamp to a
|
# Per-key validation for numeric settings: coerce to int and clamp to a
|
||||||
@@ -780,9 +792,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
"""Infer the general prose style from one user-supplied document."""
|
"""Infer the general prose style from one user-supplied document."""
|
||||||
user = _get_current_user(request)
|
user = _require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
filename = Path(file.filename or "sample.txt").name
|
filename = Path(file.filename or "sample.txt").name
|
||||||
suffix = Path(filename).suffix.lower()
|
suffix = Path(filename).suffix.lower()
|
||||||
allowed = {
|
allowed = {
|
||||||
@@ -861,9 +871,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.get("/integrations")
|
@router.get("/integrations")
|
||||||
async def list_integrations_route(request: Request):
|
async def list_integrations_route(request: Request):
|
||||||
"""List all integrations (admin only, keys masked)."""
|
"""List all integrations (admin only, keys masked)."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
items = load_integrations()
|
items = load_integrations()
|
||||||
# Mask API keys for frontend display
|
# Mask API keys for frontend display
|
||||||
safe = [mask_integration_secret(item) for item in items]
|
safe = [mask_integration_secret(item) for item in items]
|
||||||
@@ -877,9 +885,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.post("/integrations")
|
@router.post("/integrations")
|
||||||
async def create_integration(request: Request):
|
async def create_integration(request: Request):
|
||||||
"""Create a new integration (admin only)."""
|
"""Create a new integration (admin only)."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
item = add_integration(body)
|
item = add_integration(body)
|
||||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||||
@@ -887,9 +893,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.put("/integrations/{integration_id}")
|
@router.put("/integrations/{integration_id}")
|
||||||
async def update_integration_route(integration_id: str, request: Request):
|
async def update_integration_route(integration_id: str, request: Request):
|
||||||
"""Update an existing integration (admin only)."""
|
"""Update an existing integration (admin only)."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
item = update_integration(integration_id, body)
|
item = update_integration(integration_id, body)
|
||||||
if not item:
|
if not item:
|
||||||
@@ -899,9 +903,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.delete("/integrations/{integration_id}")
|
@router.delete("/integrations/{integration_id}")
|
||||||
async def delete_integration_route(integration_id: str, request: Request):
|
async def delete_integration_route(integration_id: str, request: Request):
|
||||||
"""Delete an integration (admin only)."""
|
"""Delete an integration (admin only)."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
ok = delete_integration(integration_id)
|
ok = delete_integration(integration_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(404, "Integration not found")
|
raise HTTPException(404, "Integration not found")
|
||||||
@@ -910,9 +912,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
@router.post("/integrations/{integration_id}/test")
|
@router.post("/integrations/{integration_id}/test")
|
||||||
async def test_integration_route(integration_id: str, request: Request):
|
async def test_integration_route(integration_id: str, request: Request):
|
||||||
"""Test connectivity to an integration (admin only)."""
|
"""Test connectivity to an integration (admin only)."""
|
||||||
user = _get_current_user(request)
|
_require_admin_user(request)
|
||||||
if not user or not auth_manager.is_admin(user):
|
|
||||||
raise HTTPException(403, "Admin only")
|
|
||||||
integ = get_integration(integration_id)
|
integ = get_integration(integration_id)
|
||||||
if not integ:
|
if not integ:
|
||||||
raise HTTPException(404, "Integration not found")
|
raise HTTPException(404, "Integration not found")
|
||||||
|
|||||||
@@ -551,6 +551,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
try:
|
try:
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.task_endpoint import resolve_task_endpoint
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
# Find first user message
|
# Find first user message
|
||||||
first_msg = ""
|
first_msg = ""
|
||||||
@@ -603,6 +604,7 @@ async def auto_name_session(session_manager, sess):
|
|||||||
max_tokens=64,
|
max_tokens=64,
|
||||||
headers=t_headers,
|
headers=t_headers,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||||
)
|
)
|
||||||
|
|
||||||
title = title.strip().strip('"\'').strip()
|
title = title.strip().strip('"\'').strip()
|
||||||
@@ -1010,6 +1012,7 @@ async def build_chat_context(
|
|||||||
chat_processor,
|
chat_processor,
|
||||||
message: str,
|
message: str,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
selected_notes_context: str | None = None,
|
||||||
preset_id=None,
|
preset_id=None,
|
||||||
att_ids: list = None,
|
att_ids: list = None,
|
||||||
use_web=None,
|
use_web=None,
|
||||||
@@ -1182,6 +1185,23 @@ async def build_chat_context(
|
|||||||
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
if search_context and allow_tool_preprocessing and not casual_low_signal:
|
||||||
preface.append(untrusted_context_message("prefetched search context", search_context))
|
preface.append(untrusted_context_message("prefetched search context", search_context))
|
||||||
|
|
||||||
|
# Inject selected notes as context
|
||||||
|
if selected_notes_context:
|
||||||
|
selected_notes_context = (
|
||||||
|
"The following selected notes and todos are reference context explicitly chosen "
|
||||||
|
"by the user. Use them directly when relevant to the user's request. Do not "
|
||||||
|
"ask for permission to use this context, and do not call note-management "
|
||||||
|
"tools just to retrieve the same notes; use those tools only for a requested "
|
||||||
|
"change or when this context is insufficient.\n\n"
|
||||||
|
+ selected_notes_context
|
||||||
|
)
|
||||||
|
preface.append(
|
||||||
|
untrusted_context_message(
|
||||||
|
"Selected notes and todos",
|
||||||
|
selected_notes_context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# YouTube transcripts
|
# YouTube transcripts
|
||||||
for transcript in preprocessed.youtube_transcripts:
|
for transcript in preprocessed.youtube_transcripts:
|
||||||
preface.append(untrusted_context_message("youtube transcript", transcript))
|
preface.append(untrusted_context_message("youtube transcript", transcript))
|
||||||
|
|||||||
+82
-1
@@ -58,7 +58,7 @@ from src.auth_helpers import (
|
|||||||
from routes.session_routes import _verify_session_owner
|
from routes.session_routes import _verify_session_owner
|
||||||
from routes.document_helpers import _owner_session_filter
|
from routes.document_helpers import _owner_session_filter
|
||||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||||
from core.database import Session as DBSession, ChatMessage as DBChatMessage
|
from core.database import Session as DBSession, ChatMessage as DBChatMessage, Note
|
||||||
from core.database import Document as DBDocument, ModelEndpoint
|
from core.database import Document as DBDocument, ModelEndpoint
|
||||||
from core.log_safety import redact_url
|
from core.log_safety import redact_url
|
||||||
from routes.research_routes import _resolve_research_endpoint
|
from routes.research_routes import _resolve_research_endpoint
|
||||||
@@ -2153,6 +2153,68 @@ def _set_user_time_from_request(request: Request) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _parse_selected_note_ids(raw) -> list[str]:
|
||||||
|
"""Validate user-selected Note IDs before passing them to SQLAlchemy."""
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return []
|
||||||
|
if not isinstance(raw, str) or len(raw) > 8192:
|
||||||
|
raise HTTPException(400, "Invalid selected_note_ids payload")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(400, "Invalid selected_note_ids JSON") from exc
|
||||||
|
if (
|
||||||
|
not isinstance(parsed, list)
|
||||||
|
or len(parsed) > 50
|
||||||
|
or any(
|
||||||
|
not isinstance(note_id, str)
|
||||||
|
or not note_id.strip()
|
||||||
|
or len(note_id) > 128
|
||||||
|
for note_id in parsed
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise HTTPException(400, "selected_note_ids must be an array of up to 50 non-empty string IDs")
|
||||||
|
return list(dict.fromkeys(parsed))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_selected_notes_context(notes) -> str:
|
||||||
|
"""Format selected Notes/Todos as reference context for the model."""
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
for index, note in enumerate(notes, start=1):
|
||||||
|
lines = [
|
||||||
|
f"--- Selected Note {index} ---",
|
||||||
|
f"Title: {note.title or '(untitled)'}",
|
||||||
|
f"Type: {note.note_type or 'note'}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if note.content:
|
||||||
|
lines.extend([
|
||||||
|
"Content:",
|
||||||
|
str(note.content),
|
||||||
|
])
|
||||||
|
|
||||||
|
if note.items:
|
||||||
|
try:
|
||||||
|
items = json.loads(note.items)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if isinstance(items, list):
|
||||||
|
lines.append("Items:")
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = "done" if item.get("done") else "pending"
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
|
||||||
|
if text:
|
||||||
|
lines.append(f"- [{status}] {text}")
|
||||||
|
|
||||||
|
sections.append("\n".join(lines))
|
||||||
|
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager):
|
def _resolve_prompt_thinking_mode(explicit_mode, preset_id, preset_manager):
|
||||||
"""Use an explicit request override, then fall back to the active preset."""
|
"""Use an explicit request override, then fall back to the active preset."""
|
||||||
@@ -2423,6 +2485,8 @@ def setup_chat_routes(
|
|||||||
form_data = await request.form()
|
form_data = await request.form()
|
||||||
message = form_data.get("message")
|
message = form_data.get("message")
|
||||||
session = form_data.get("session")
|
session = form_data.get("session")
|
||||||
|
selected_note_ids = _parse_selected_note_ids(form_data.get("selected_note_ids"))
|
||||||
|
|
||||||
attachments = form_data.get("attachments")
|
attachments = form_data.get("attachments")
|
||||||
use_web = form_data.get("use_web")
|
use_web = form_data.get("use_web")
|
||||||
use_research = form_data.get("use_research")
|
use_research = form_data.get("use_research")
|
||||||
@@ -2730,6 +2794,22 @@ def setup_chat_routes(
|
|||||||
current_rejected=workspace_rejected,
|
current_rejected=workspace_rejected,
|
||||||
)
|
)
|
||||||
owner = effective_user(request)
|
owner = effective_user(request)
|
||||||
|
selected_notes = []
|
||||||
|
if selected_note_ids:
|
||||||
|
notes_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
selected_notes = (
|
||||||
|
notes_db.query(Note)
|
||||||
|
.filter(
|
||||||
|
Note.id.in_(selected_note_ids),
|
||||||
|
Note.owner == owner,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
notes_db.close()
|
||||||
|
selected_notes_context = _format_selected_notes_context(selected_notes)
|
||||||
|
|
||||||
if tool_approval_id:
|
if tool_approval_id:
|
||||||
_reject_delegated_tool_approval(request)
|
_reject_delegated_tool_approval(request)
|
||||||
from src.agent_runtime.authority import require_user_approval_request
|
from src.agent_runtime.authority import require_user_approval_request
|
||||||
@@ -3001,6 +3081,7 @@ def setup_chat_routes(
|
|||||||
sess, request, chat_handler, chat_processor,
|
sess, request, chat_handler, chat_processor,
|
||||||
message=message,
|
message=message,
|
||||||
session_id=session,
|
session_id=session,
|
||||||
|
selected_notes_context=selected_notes_context,
|
||||||
preset_id=preset_id,
|
preset_id=preset_id,
|
||||||
att_ids=att_ids,
|
att_ids=att_ids,
|
||||||
use_web=use_web,
|
use_web=use_web,
|
||||||
|
|||||||
@@ -443,9 +443,11 @@ async def _generate_email_summary(
|
|||||||
headers: dict | None = None,
|
headers: dict | None = None,
|
||||||
max_tokens: int = 8192,
|
max_tokens: int = 8192,
|
||||||
timeout: int = 180,
|
timeout: int = 180,
|
||||||
|
owner: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate an interactive email summary through the shared LLM adapter."""
|
"""Generate an interactive email summary through the shared LLM adapter."""
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
raw = await llm_call_async(
|
raw = await llm_call_async(
|
||||||
url=url,
|
url=url,
|
||||||
@@ -456,6 +458,7 @@ async def _generate_email_summary(
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
workload="foreground",
|
workload="foreground",
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner),
|
||||||
)
|
)
|
||||||
return _normalize_email_summary(raw)
|
return _normalize_email_summary(raw)
|
||||||
|
|
||||||
|
|||||||
@@ -1031,6 +1031,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
fallback_url=url, fallback_model=model, fallback_headers=headers,
|
||||||
owner=account_owner or None,
|
owner=account_owner or None,
|
||||||
temperature=0.7, max_tokens=1024, timeout=90,
|
temperature=0.7, max_tokens=1024, timeout=90,
|
||||||
|
reasoning_effort="",
|
||||||
)
|
)
|
||||||
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
reply = _apply_email_style_mechanics(_extract_reply(reply or ""))
|
||||||
if reply:
|
if reply:
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ _SERVER_OWNED_OAUTH_FIELDS = {
|
|||||||
"oauth_refresh_token",
|
"oauth_refresh_token",
|
||||||
"oauth_token_expiry",
|
"oauth_token_expiry",
|
||||||
}
|
}
|
||||||
|
_CACHE_HOOKS: dict = {"invalidate_list": None}
|
||||||
|
|
||||||
|
|
||||||
def _normalized_mail_host(value) -> str:
|
def _normalized_mail_host(value) -> str:
|
||||||
@@ -572,6 +573,23 @@ def _uid_exists(conn, uid: str, *, strict: bool = False) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_has_deleted_flag(conn, uid: str) -> bool:
|
||||||
|
r"""Return True if message already carries \Deleted flag."""
|
||||||
|
try:
|
||||||
|
status, data = conn.uid("FETCH", _uid_bytes(uid), "(FLAGS)")
|
||||||
|
if status == "OK" and data:
|
||||||
|
for part in data:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
meta = part[0] if isinstance(part, tuple) else part
|
||||||
|
meta_b = meta if isinstance(meta, bytes) else str(meta).encode()
|
||||||
|
if rb"\Deleted" in meta_b:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _resolve_current_email_uid(conn, uid: str, message_id: str | None = None) -> str:
|
def _resolve_current_email_uid(conn, uid: str, message_id: str | None = None) -> str:
|
||||||
"""Resolve a stale cached UID by the message's stable RFC Message-ID."""
|
"""Resolve a stale cached UID by the message's stable RFC Message-ID."""
|
||||||
uid = str(uid or "").strip()
|
uid = str(uid or "").strip()
|
||||||
@@ -1831,6 +1849,12 @@ def setup_email_routes():
|
|||||||
delete, move) so the UI doesn't show stale read/unread counts for up to
|
delete, move) so the UI doesn't show stale read/unread counts for up to
|
||||||
the 8s TTL after a manual flag change. With no args, clears everything.
|
the 8s TTL after a manual flag change. With no args, clears everything.
|
||||||
"""
|
"""
|
||||||
|
hook = _CACHE_HOOKS.get("invalidate_list")
|
||||||
|
if hook:
|
||||||
|
try:
|
||||||
|
hook(account_id, folder)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if account_id is None and folder is None:
|
if account_id is None and folder is None:
|
||||||
_LIST_CACHE.clear()
|
_LIST_CACHE.clear()
|
||||||
return
|
return
|
||||||
@@ -4764,6 +4788,7 @@ def setup_email_routes():
|
|||||||
return {"success": False, "error": "No email UIDs provided", "deleted_uids": [], "failed_uids": []}
|
return {"success": False, "error": "No email UIDs provided", "deleted_uids": [], "failed_uids": []}
|
||||||
deleted_uids = []
|
deleted_uids = []
|
||||||
failed_uids = []
|
failed_uids = []
|
||||||
|
trash_folder = None
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
select_status, _ = conn.select(_q(folder), readonly=False)
|
select_status, _ = conn.select(_q(folder), readonly=False)
|
||||||
@@ -4779,7 +4804,8 @@ def setup_email_routes():
|
|||||||
trash_folder = "Trash"
|
trash_folder = "Trash"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
pending_expunge = False
|
pending_expunge = []
|
||||||
|
preexisting_deleted = set()
|
||||||
for uid in uids:
|
for uid in uids:
|
||||||
try:
|
try:
|
||||||
if not _uid_exists(conn, uid):
|
if not _uid_exists(conn, uid):
|
||||||
@@ -4789,32 +4815,75 @@ def setup_email_routes():
|
|||||||
if status != "OK":
|
if status != "OK":
|
||||||
copy_status, _ = conn.uid("COPY", _uid_bytes(uid), _q(trash_folder))
|
copy_status, _ = conn.uid("COPY", _uid_bytes(uid), _q(trash_folder))
|
||||||
if copy_status != "OK":
|
if copy_status != "OK":
|
||||||
# Keep deletion reliable even when this IMAP
|
# Bulk Delete means move to Trash. Never remove
|
||||||
# server has no writable Trash folder.
|
# the original without a recoverable Trash copy.
|
||||||
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
failed_uids.append(uid)
|
||||||
if store_status != "OK":
|
continue
|
||||||
failed_uids.append(uid)
|
if _uid_has_deleted_flag(conn, uid):
|
||||||
continue
|
preexisting_deleted.add(uid)
|
||||||
pending_expunge = True
|
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
||||||
else:
|
if store_status != "OK":
|
||||||
store_status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "\\Deleted")
|
failed_uids.append(uid)
|
||||||
if store_status != "OK":
|
continue
|
||||||
failed_uids.append(uid)
|
# Keep the local index until the server confirms
|
||||||
continue
|
# that the copied originals have been removed.
|
||||||
pending_expunge = True
|
pending_expunge.append(uid)
|
||||||
|
continue
|
||||||
deleted_uids.append(uid)
|
deleted_uids.append(uid)
|
||||||
_email_index_delete(owner, account_id, folder, uid)
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
except Exception:
|
except Exception:
|
||||||
failed_uids.append(uid)
|
failed_uids.append(uid)
|
||||||
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
|
||||||
if pending_expunge:
|
if pending_expunge:
|
||||||
conn.expunge()
|
expunge_ok = False
|
||||||
if deleted_uids:
|
# Attempt selective RFC 4315 UID EXPUNGE so unrelated messages
|
||||||
_invalidate_list_cache(account_id, folder)
|
# carrying \Deleted in the selected mailbox are never expunged.
|
||||||
return {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids}
|
# Mailbox-wide EXPUNGE is strictly avoided to eliminate TOCTOU races
|
||||||
|
# where concurrent clients mark unrelated messages \Deleted.
|
||||||
|
try:
|
||||||
|
uid_set_bytes = _uid_bytes(",".join(pending_expunge))
|
||||||
|
uid_exp_status, _ = conn.uid("EXPUNGE", uid_set_bytes)
|
||||||
|
if uid_exp_status == "OK":
|
||||||
|
expunge_ok = True
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Selective UID EXPUNGE failed", exc_info=True)
|
||||||
|
expunge_ok = False
|
||||||
|
|
||||||
|
if expunge_ok:
|
||||||
|
for uid in pending_expunge:
|
||||||
|
deleted_uids.append(uid)
|
||||||
|
_email_index_delete(owner, account_id, folder, uid)
|
||||||
|
else:
|
||||||
|
# Prioritize preservation over cleanup: unflag \Deleted from our
|
||||||
|
# copied messages so future EXPUNGE operations do not destroy them.
|
||||||
|
# Do not remove a pre-existing \Deleted flag present before this operation.
|
||||||
|
for uid in pending_expunge:
|
||||||
|
if uid not in preexisting_deleted:
|
||||||
|
try:
|
||||||
|
conn.uid("STORE", _uid_bytes(uid), "-FLAGS", "\\Deleted")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
failed_uids.extend(pending_expunge)
|
||||||
|
if not deleted_uids:
|
||||||
|
return {"success": False, "error": "Failed to delete requested emails", "deleted_uids": [], "failed_uids": failed_uids}
|
||||||
|
res = {"success": True, "deleted_uids": deleted_uids, "failed_uids": failed_uids}
|
||||||
|
if failed_uids:
|
||||||
|
res["error"] = "Mail operation partially failed"
|
||||||
|
return res
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to bulk delete emails: {e}")
|
logger.error(f"Failed to bulk delete emails: {e}")
|
||||||
return {"success": False, "error": "Mail operation failed", "deleted_uids": deleted_uids, "failed_uids": [u for u in uids if u not in deleted_uids]}
|
failed_all = [u for u in uids if u not in deleted_uids]
|
||||||
|
return {
|
||||||
|
"success": bool(deleted_uids),
|
||||||
|
"error": "Mail operation failed" if not deleted_uids else "Mail operation partially failed",
|
||||||
|
"deleted_uids": deleted_uids,
|
||||||
|
"failed_uids": failed_all,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if deleted_uids:
|
||||||
|
_invalidate_list_cache(account_id, folder)
|
||||||
|
if trash_folder:
|
||||||
|
_invalidate_list_cache(account_id, trash_folder)
|
||||||
|
|
||||||
@router.delete("/odysseus/reminders")
|
@router.delete("/odysseus/reminders")
|
||||||
async def delete_odysseus_reminder_emails(
|
async def delete_odysseus_reminder_emails(
|
||||||
@@ -5920,7 +5989,12 @@ def setup_email_routes():
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
style = await llm_call_async(url, model, messages, headers=headers, max_tokens=2048)
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
|
style = await llm_call_async(
|
||||||
|
url, model, messages, headers=headers, max_tokens=2048,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner),
|
||||||
|
)
|
||||||
style = _strip_think(style or "")
|
style = _strip_think(style or "")
|
||||||
if not style:
|
if not style:
|
||||||
return {"success": False, "error": "LLM failed to generate style description"}
|
return {"success": False, "error": "LLM failed to generate style description"}
|
||||||
@@ -6004,6 +6078,7 @@ def setup_email_routes():
|
|||||||
headers=req_headers,
|
headers=req_headers,
|
||||||
max_tokens=8192,
|
max_tokens=8192,
|
||||||
timeout=180,
|
timeout=180,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -6125,6 +6200,8 @@ def setup_email_routes():
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
return {"success": False, "error": "No LLM endpoint configured"}
|
return {"success": False, "error": "No LLM endpoint configured"}
|
||||||
|
|
||||||
|
from src.utility_effort import candidate_effort_factory
|
||||||
|
|
||||||
content = await llm_call_async_with_fallback(
|
content = await llm_call_async_with_fallback(
|
||||||
candidates,
|
candidates,
|
||||||
messages=[
|
messages=[
|
||||||
@@ -6152,6 +6229,7 @@ def setup_email_routes():
|
|||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
max_tokens=8192,
|
max_tokens=8192,
|
||||||
timeout=180,
|
timeout=180,
|
||||||
|
candidate_request_factory=candidate_effort_factory(owner),
|
||||||
)
|
)
|
||||||
model = candidates[0][1] if candidates else ""
|
model = candidates[0][1] if candidates else ""
|
||||||
content = (content or "").strip()
|
content = (content or "").strip()
|
||||||
|
|||||||
@@ -237,6 +237,18 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int |
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _effort_mode(model: str, raw_effort: Any) -> str:
|
||||||
|
"""Session ``thinking_mode`` for a picked effort: ``effort:<level>`` or ``off``.
|
||||||
|
|
||||||
|
Validated like the chat routes do, so llama-swap advertised efforts persist
|
||||||
|
as well as ChatGPT subscription levels.
|
||||||
|
"""
|
||||||
|
from src.chatgpt_subscription import validate_reasoning_effort
|
||||||
|
|
||||||
|
effort = validate_reasoning_effort(model, raw_effort)
|
||||||
|
return f"effort:{effort}" if effort else "off"
|
||||||
|
|
||||||
|
|
||||||
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
tags=["history"],
|
tags=["history"],
|
||||||
@@ -1058,26 +1070,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
mode = getattr(session, "thinking_mode", "off") or "off"
|
mode = getattr(session, "thinking_mode", "off") or "off"
|
||||||
raw_effort = body.get("reasoning_effort")
|
raw_effort = body.get("reasoning_effort")
|
||||||
if raw_effort is not None:
|
if raw_effort is not None:
|
||||||
clean_effort = str(raw_effort).strip().lower()
|
mode = _effort_mode(session.model, raw_effort)
|
||||||
if clean_effort in {"", "default"}:
|
|
||||||
mode = "off"
|
|
||||||
else:
|
|
||||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
|
||||||
meta = get_chatgpt_model_metadata(session.model)
|
|
||||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
|
||||||
mode = f"effort:{clean_effort}"
|
|
||||||
else:
|
|
||||||
mode = "off"
|
|
||||||
elif "thinking_mode" in body:
|
elif "thinking_mode" in body:
|
||||||
raw_mode = str(body.get("thinking_mode") or "").strip().lower()
|
raw_mode = str(body.get("thinking_mode") or "").strip().lower()
|
||||||
if raw_mode.startswith("effort:"):
|
if raw_mode.startswith("effort:"):
|
||||||
clean_effort = raw_mode[7:].strip()
|
mode = _effort_mode(session.model, raw_mode[7:])
|
||||||
from src.chatgpt_subscription import get_chatgpt_model_metadata
|
|
||||||
meta = get_chatgpt_model_metadata(session.model)
|
|
||||||
if meta and clean_effort in [lvl.lower() for lvl in meta.get("supported_reasoning_levels", [])]:
|
|
||||||
mode = f"effort:{clean_effort}"
|
|
||||||
else:
|
|
||||||
mode = "off"
|
|
||||||
elif raw_mode in {"", "on", "off"}:
|
elif raw_mode in {"", "on", "off"}:
|
||||||
mode = raw_mode
|
mode = raw_mode
|
||||||
from src.model_profiles import supports_user_thinking_toggle
|
from src.model_profiles import supports_user_thinking_toggle
|
||||||
@@ -1156,7 +1153,14 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use utility model if available
|
# Use utility model if available
|
||||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner or None)
|
from src.utility_effort import effort_for_call
|
||||||
|
util_url, util_model, util_headers = resolve_endpoint(
|
||||||
|
"utility",
|
||||||
|
fallback_url=getattr(session, "endpoint_url", None),
|
||||||
|
fallback_model=getattr(session, "model", None),
|
||||||
|
fallback_headers=getattr(session, "headers", None),
|
||||||
|
owner=owner or None,
|
||||||
|
)
|
||||||
compact_url = util_url or session.endpoint_url
|
compact_url = util_url or session.endpoint_url
|
||||||
compact_model = util_model or session.model
|
compact_model = util_model or session.model
|
||||||
compact_headers = util_headers if util_url else session.headers
|
compact_headers = util_headers if util_url else session.headers
|
||||||
@@ -1172,6 +1176,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
],
|
],
|
||||||
temperature=0.2, max_tokens=1024,
|
temperature=0.2, max_tokens=1024,
|
||||||
headers=compact_headers, timeout=30,
|
headers=compact_headers, timeout=30,
|
||||||
|
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||||
)
|
)
|
||||||
summary = normalize_compaction_summary(summary)
|
summary = normalize_compaction_summary(summary)
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
try:
|
try:
|
||||||
suggestion_text = await llm_call_async(
|
suggestion_text = await llm_call_async(
|
||||||
t_url,
|
t_url,
|
||||||
@@ -269,6 +271,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
max_tokens=500,
|
max_tokens=500,
|
||||||
headers=t_headers,
|
headers=t_headers,
|
||||||
|
reasoning_effort=effort_for_call(t_url, t_model, _owner(request), sess),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
suggestions = json.loads(suggestion_text)
|
suggestions = json.loads(suggestion_text)
|
||||||
@@ -349,6 +352,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
endpoint_url = None
|
endpoint_url = None
|
||||||
model = None
|
model = None
|
||||||
headers = {}
|
headers = {}
|
||||||
|
sess = None
|
||||||
|
|
||||||
user = _owner(request)
|
user = _owner(request)
|
||||||
|
|
||||||
@@ -453,6 +457,8 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
"Return ONLY valid JSON, no markdown fences."
|
"Return ONLY valid JSON, no markdown fences."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = await llm_call_async(
|
raw = await llm_call_async(
|
||||||
endpoint_url,
|
endpoint_url,
|
||||||
@@ -464,6 +470,7 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
max_tokens=2000,
|
max_tokens=2000,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
reasoning_effort=effort_for_call(endpoint_url, model, user, sess),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON
|
# Parse JSON
|
||||||
|
|||||||
@@ -509,6 +509,7 @@ def setup_session_routes(
|
|||||||
"has_documents": s.id in doc_session_ids,
|
"has_documents": s.id in doc_session_ids,
|
||||||
"has_images": s.id in img_session_ids,
|
"has_images": s.id in img_session_ids,
|
||||||
"mode": s.mode,
|
"mode": s.mode,
|
||||||
|
"thinking_mode": s.thinking_mode or "off",
|
||||||
"message_count": s.message_count or 0,
|
"message_count": s.message_count or 0,
|
||||||
})
|
})
|
||||||
finally:
|
finally:
|
||||||
@@ -1042,6 +1043,7 @@ def setup_session_routes(
|
|||||||
"id": s.id,
|
"id": s.id,
|
||||||
"name": s.name,
|
"name": s.name,
|
||||||
"model": s.model,
|
"model": s.model,
|
||||||
|
"thinking_mode": s.thinking_mode or "off",
|
||||||
"message_count": s.message_count or 0,
|
"message_count": s.message_count or 0,
|
||||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||||
@@ -1238,9 +1240,16 @@ def setup_session_routes(
|
|||||||
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
from src.context_compactor import SELF_SUMMARY_SYSTEM_PROMPT
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
owner = getattr(session, "owner", None) or effective_user(request)
|
owner = getattr(session, "owner", None) or effective_user(request)
|
||||||
url, model, headers = resolve_endpoint("utility", owner=owner)
|
url, model, headers = resolve_endpoint(
|
||||||
|
"utility",
|
||||||
|
fallback_url=getattr(session, "endpoint_url", None),
|
||||||
|
fallback_model=getattr(session, "model", None),
|
||||||
|
fallback_headers=getattr(session, "headers", None),
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
url, model, headers = session.endpoint_url, session.model, session.headers
|
url, model, headers = session.endpoint_url, session.model, session.headers
|
||||||
if not url or not model:
|
if not url or not model:
|
||||||
@@ -1268,6 +1277,7 @@ def setup_session_routes(
|
|||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner, session),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Manual compaction failed: %s", e)
|
logger.error("Manual compaction failed: %s", e)
|
||||||
@@ -1311,6 +1321,7 @@ def setup_session_routes(
|
|||||||
users can clean junk without spending tokens.
|
users can clean junk without spending tokens.
|
||||||
"""
|
"""
|
||||||
from src.llm_core import llm_call
|
from src.llm_core import llm_call
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
single_user_mode = not user and _auth_disabled()
|
single_user_mode = not user and _auth_disabled()
|
||||||
user_sessions = session_manager.get_sessions_for_user(user)
|
user_sessions = session_manager.get_sessions_for_user(user)
|
||||||
@@ -1483,7 +1494,8 @@ def setup_session_routes(
|
|||||||
# reasoning model spends tokens thinking first — 4096 truncated the
|
# reasoning model spends tokens thinking first — 4096 truncated the
|
||||||
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
|
||||||
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
|
||||||
temperature=0.3, max_tokens=16384, headers=headers, timeout=120)
|
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
|
||||||
|
reasoning_effort=effort_for_call(url, model, user))
|
||||||
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
|
||||||
# Extract JSON from response — handle markdown fences, leading text,
|
# Extract JSON from response — handle markdown fences, leading text,
|
||||||
# reasoning-model <think> blocks, and trailing commas.
|
# reasoning-model <think> blocks, and trailing commas.
|
||||||
|
|||||||
+125
-105
@@ -9,6 +9,7 @@ The on-disk format is SKILL.md (frontmatter + structured body) under
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from contextlib import aclosing
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -20,6 +21,7 @@ from services.memory.skills import SkillsManager
|
|||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from src.text_helpers import strip_closed_think_blocks
|
from src.text_helpers import strip_closed_think_blocks
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -169,7 +171,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
baseline_transcript: str = "",
|
baseline_transcript: str = "",
|
||||||
skill_stats: Optional[dict] = None,
|
skill_stats: Optional[dict] = None,
|
||||||
baseline_stats: Optional[dict] = None,
|
baseline_stats: Optional[dict] = None,
|
||||||
workload: str = "foreground") -> dict:
|
workload: str = "foreground", owner: Optional[str] = None) -> dict:
|
||||||
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
"""LLM-as-judge: grade a skill test run from its transcript. Advisory only.
|
||||||
|
|
||||||
Robust against local reasoning models (strips <think>, lenient JSON,
|
Robust against local reasoning models (strips <think>, lenient JSON,
|
||||||
@@ -348,6 +350,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
url, model, msgs,
|
url, model, msgs,
|
||||||
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't give up on a transient first-attempt error — let the second
|
# Don't give up on a transient first-attempt error — let the second
|
||||||
@@ -367,7 +370,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
|||||||
|
|
||||||
async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: str,
|
async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: str,
|
||||||
headers: Optional[dict],
|
headers: Optional[dict],
|
||||||
workload: str = "foreground") -> Optional[dict]:
|
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
||||||
"""Advisory judge: is this skill worth keeping, or is it redundant / trivially
|
"""Advisory judge: is this skill worth keeping, or is it redundant / trivially
|
||||||
unnecessary? Sees the OTHER skills' names+descriptions so it can spot
|
unnecessary? Sees the OTHER skills' names+descriptions so it can spot
|
||||||
duplicates. Returns {necessary, redundant_with, reason} or None. Never acts —
|
duplicates. Returns {necessary, redundant_with, reason} or None. Never acts —
|
||||||
@@ -400,6 +403,7 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
|
|||||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||||
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Necessity check failed: {e}")
|
logger.warning(f"Necessity check failed: {e}")
|
||||||
@@ -452,7 +456,7 @@ def _should_check_retrieval_precision(skill: dict) -> bool:
|
|||||||
async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||||
url: str, model: str,
|
url: str, model: str,
|
||||||
headers: Optional[dict],
|
headers: Optional[dict],
|
||||||
workload: str = "foreground") -> Optional[dict]:
|
workload: str = "foreground", owner: Optional[str] = None) -> Optional[dict]:
|
||||||
"""Advisory judge: would this skill's metadata make retrieval over-select it?
|
"""Advisory judge: would this skill's metadata make retrieval over-select it?
|
||||||
|
|
||||||
This is distinct from "does the procedure work?". It asks whether tags,
|
This is distinct from "does the procedure work?". It asks whether tags,
|
||||||
@@ -490,6 +494,7 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
|||||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||||
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Retrieval precision check failed: {e}")
|
logger.warning(f"Retrieval precision check failed: {e}")
|
||||||
@@ -558,70 +563,74 @@ async def _run_skill_test_job(
|
|||||||
|
|
||||||
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
messages = list(messages) if isinstance(messages, list) else _skill_test_messages(md, task)
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_agent_loop(
|
# aclosing: pausing for an approval leaves this loop early, and the
|
||||||
|
# stream's ContextVar cleanup must run in this task, not in asyncio's
|
||||||
|
# finalizer (#6635).
|
||||||
|
async with aclosing(stream_agent_loop(
|
||||||
url, model, messages, headers=headers,
|
url, model, messages, headers=headers,
|
||||||
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
|
||||||
exact_approval=exact_approval,
|
exact_approval=exact_approval,
|
||||||
request_authority=(request_authority or (
|
request_authority=(request_authority or (
|
||||||
exact_approval.pending.request_authority if exact_approval is not None else None
|
exact_approval.pending.request_authority if exact_approval is not None else None
|
||||||
) or RequestAuthority.empty(owner=owner)),
|
) or RequestAuthority.empty(owner=owner)),
|
||||||
):
|
)) as stream:
|
||||||
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
async for chunk in stream:
|
||||||
continue
|
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
|
||||||
try:
|
continue
|
||||||
d = _json.loads(chunk[6:])
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if d.get("delta"):
|
|
||||||
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
|
||||||
elif d.get("type") == "tool_start":
|
|
||||||
_flush_say()
|
|
||||||
skill_stats["tool_calls"] += 1
|
|
||||||
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
|
||||||
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
|
||||||
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
|
||||||
elif d.get("type") == "tool_output":
|
|
||||||
_flush_say()
|
|
||||||
out = str(d.get("output") or "")[:600]
|
|
||||||
tool_log = {"type": "tool_output", "output": out}
|
|
||||||
approval = d.get("ask_user")
|
|
||||||
if isinstance(approval, dict):
|
|
||||||
tool_log["ask_user"] = approval
|
|
||||||
log.append(tool_log)
|
|
||||||
transcript.append(f"[output] {out}\n")
|
|
||||||
if (
|
|
||||||
isinstance(approval, dict)
|
|
||||||
and approval.get("kind") == "tool_approval"
|
|
||||||
and approval.get("approval_id")
|
|
||||||
):
|
|
||||||
# Manual skill tests have their own polling UI instead of a
|
|
||||||
# chat session. Pause the run and retain only server-side
|
|
||||||
# continuation state until the same owner approves/denies
|
|
||||||
# this exact sealed action.
|
|
||||||
job["status"] = "awaiting_approval"
|
|
||||||
job["approval"] = approval
|
|
||||||
job["_transcript"] = transcript
|
|
||||||
return
|
|
||||||
elif d.get("type") == "agent_step":
|
|
||||||
_flush_say()
|
|
||||||
try:
|
try:
|
||||||
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
d = _json.loads(chunk[6:])
|
||||||
except (TypeError, ValueError):
|
except Exception:
|
||||||
pass
|
continue
|
||||||
log.append({"type": "agent_step", "round": d.get("round")})
|
if d.get("delta"):
|
||||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
say_buf.append(d["delta"]); transcript.append(d["delta"])
|
||||||
elif d.get("type") == "metrics":
|
elif d.get("type") == "tool_start":
|
||||||
data = d.get("data") or {}
|
_flush_say()
|
||||||
try:
|
skill_stats["tool_calls"] += 1
|
||||||
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
cmd = str(d.get("command") or d.get("args") or "")[:300]
|
||||||
except (TypeError, ValueError):
|
log.append({"type": "tool_start", "tool": d.get("tool"), "command": cmd})
|
||||||
pass
|
transcript.append(f"\n[tool {d.get('tool')}] {cmd}\n")
|
||||||
try:
|
elif d.get("type") == "tool_output":
|
||||||
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
_flush_say()
|
||||||
except (TypeError, ValueError):
|
out = str(d.get("output") or "")[:600]
|
||||||
pass
|
tool_log = {"type": "tool_output", "output": out}
|
||||||
if len(log) > 600:
|
approval = d.get("ask_user")
|
||||||
del log[0:len(log) - 600]
|
if isinstance(approval, dict):
|
||||||
|
tool_log["ask_user"] = approval
|
||||||
|
log.append(tool_log)
|
||||||
|
transcript.append(f"[output] {out}\n")
|
||||||
|
if (
|
||||||
|
isinstance(approval, dict)
|
||||||
|
and approval.get("kind") == "tool_approval"
|
||||||
|
and approval.get("approval_id")
|
||||||
|
):
|
||||||
|
# Manual skill tests have their own polling UI instead of a
|
||||||
|
# chat session. Pause the run and retain only server-side
|
||||||
|
# continuation state until the same owner approves/denies
|
||||||
|
# this exact sealed action.
|
||||||
|
job["status"] = "awaiting_approval"
|
||||||
|
job["approval"] = approval
|
||||||
|
job["_transcript"] = transcript
|
||||||
|
return
|
||||||
|
elif d.get("type") == "agent_step":
|
||||||
|
_flush_say()
|
||||||
|
try:
|
||||||
|
skill_stats["turns"] = max(skill_stats["turns"], int(d.get("round") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
log.append({"type": "agent_step", "round": d.get("round")})
|
||||||
|
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||||
|
elif d.get("type") == "metrics":
|
||||||
|
data = d.get("data") or {}
|
||||||
|
try:
|
||||||
|
skill_stats["turns"] = max(skill_stats["turns"], int(data.get("agent_rounds") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
skill_stats["tool_calls"] = max(skill_stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if len(log) > 600:
|
||||||
|
del log[0:len(log) - 600]
|
||||||
_flush_say()
|
_flush_say()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_flush_say()
|
_flush_say()
|
||||||
@@ -662,6 +671,7 @@ async def _run_skill_test_job(
|
|||||||
baseline_transcript=baseline_transcript,
|
baseline_transcript=baseline_transcript,
|
||||||
skill_stats=skill_stats,
|
skill_stats=skill_stats,
|
||||||
baseline_stats=baseline_stats,
|
baseline_stats=baseline_stats,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
job["verdict"] = {"verdict": "unknown", "confidence": 0, "summary": f"Eval failed: {e}", "issues": []}
|
job["verdict"] = {"verdict": "unknown", "confidence": 0, "summary": f"Eval failed: {e}", "issues": []}
|
||||||
@@ -1054,53 +1064,55 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
|
|||||||
# OpenAI-compat) generate an empty completion, which manifested as
|
# OpenAI-compat) generate an empty completion, which manifested as
|
||||||
# the skill test returning nothing while chat (which carries its
|
# the skill test returning nothing while chat (which carries its
|
||||||
# preset's max_tokens) worked. 4096 matches the chat default.
|
# preset's max_tokens) worked. 4096 matches the chat default.
|
||||||
async for chunk in stream_agent_loop(
|
# aclosing: stopping for an approval leaves this loop early (#6635).
|
||||||
|
async with aclosing(stream_agent_loop(
|
||||||
url, model, messages, headers=headers,
|
url, model, messages, headers=headers,
|
||||||
temperature=0.3, max_tokens=4096, max_rounds=8,
|
temperature=0.3, max_tokens=4096, max_rounds=8,
|
||||||
owner=owner, workload=workload, suppress_skills=True,
|
owner=owner, workload=workload, suppress_skills=True,
|
||||||
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
|
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
|
||||||
):
|
)) as stream:
|
||||||
# Streams can include an SSE event line before the data line,
|
async for chunk in stream:
|
||||||
# notably `event: error`. Do not silently discard those failures.
|
# Streams can include an SSE event line before the data line,
|
||||||
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
# notably `event: error`. Do not silently discard those failures.
|
||||||
if payload is None or payload == "[DONE]":
|
payload = next((line[6:] for line in chunk.splitlines() if line.startswith("data: ")), None)
|
||||||
continue
|
if payload is None or payload == "[DONE]":
|
||||||
try:
|
continue
|
||||||
d = _json.loads(payload)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if d.get("error") or d.get("type") == "error":
|
|
||||||
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
|
||||||
if d.get("delta"):
|
|
||||||
transcript.append(d["delta"])
|
|
||||||
elif d.get("type") == "tool_start":
|
|
||||||
stats["tool_calls"] += 1
|
|
||||||
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
|
||||||
elif d.get("type") == "tool_output":
|
|
||||||
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
|
||||||
approval = d.get("ask_user")
|
|
||||||
if (
|
|
||||||
isinstance(approval, dict)
|
|
||||||
and approval.get("kind") == "tool_approval"
|
|
||||||
):
|
|
||||||
approval_required = approval
|
|
||||||
break
|
|
||||||
elif d.get("type") == "agent_step":
|
|
||||||
try:
|
try:
|
||||||
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
d = _json.loads(payload)
|
||||||
except (TypeError, ValueError):
|
except Exception:
|
||||||
pass
|
continue
|
||||||
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
if d.get("error") or d.get("type") == "error":
|
||||||
elif d.get("type") == "metrics":
|
raise SkillAuditUnavailable(str(d.get("error") or d.get("message") or "Audit stream failed"))
|
||||||
data = d.get("data") or {}
|
if d.get("delta"):
|
||||||
try:
|
transcript.append(d["delta"])
|
||||||
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
elif d.get("type") == "tool_start":
|
||||||
except (TypeError, ValueError):
|
stats["tool_calls"] += 1
|
||||||
pass
|
transcript.append(f"\n[tool {d.get('tool')}] {str(d.get('command') or d.get('args') or '')[:300]}\n")
|
||||||
try:
|
elif d.get("type") == "tool_output":
|
||||||
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
transcript.append(f"[output] {str(d.get('output') or '')[:600]}\n")
|
||||||
except (TypeError, ValueError):
|
approval = d.get("ask_user")
|
||||||
pass
|
if (
|
||||||
|
isinstance(approval, dict)
|
||||||
|
and approval.get("kind") == "tool_approval"
|
||||||
|
):
|
||||||
|
approval_required = approval
|
||||||
|
break
|
||||||
|
elif d.get("type") == "agent_step":
|
||||||
|
try:
|
||||||
|
stats["turns"] = max(stats["turns"], int(d.get("round") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
transcript.append(f"\n--- round {d.get('round')} ---\n")
|
||||||
|
elif d.get("type") == "metrics":
|
||||||
|
data = d.get("data") or {}
|
||||||
|
try:
|
||||||
|
stats["turns"] = max(stats["turns"], int(data.get("agent_rounds") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
stats["tool_calls"] = max(stats["tool_calls"], int(data.get("tool_calls") or 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
except SkillAuditUnavailable:
|
except SkillAuditUnavailable:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1150,12 +1162,13 @@ async def _run_skill_test_once(md: str, task: str, url, model, headers, owner,
|
|||||||
headers,
|
headers,
|
||||||
skill_stats=stats,
|
skill_stats=stats,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
return text, verdict
|
return text, verdict
|
||||||
|
|
||||||
|
|
||||||
async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url, model, headers,
|
async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url, model, headers,
|
||||||
workload: str = "foreground"):
|
workload: str = "foreground", owner: Optional[str] = None):
|
||||||
"""Have a model rewrite SKILL.md to fix the reviewer's issues. Returns the
|
"""Have a model rewrite SKILL.md to fix the reviewer's issues. Returns the
|
||||||
corrected markdown, or None if it couldn't produce a usable change."""
|
corrected markdown, or None if it couldn't produce a usable change."""
|
||||||
import re as _re
|
import re as _re
|
||||||
@@ -1185,7 +1198,8 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
|
|||||||
[{"role": "system", "content": sys_prompt},
|
[{"role": "system", "content": sys_prompt},
|
||||||
{"role": "user", "content": user_msg}],
|
{"role": "user", "content": user_msg}],
|
||||||
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
||||||
workload=workload)
|
workload=workload,
|
||||||
|
reasoning_effort=effort_for_call(url, model, owner))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Audit: improve call failed: {e}")
|
logger.warning(f"Audit: improve call failed: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1255,6 +1269,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
]
|
]
|
||||||
nec = await _eval_skill_necessity(
|
nec = await _eval_skill_necessity(
|
||||||
md, others, url, model, headers, workload=workload,
|
md, others, url, model, headers, workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
if nec is not None:
|
if nec is not None:
|
||||||
skills_manager.set_necessity(name, nec.get("necessary", True),
|
skills_manager.set_necessity(name, nec.get("necessary", True),
|
||||||
@@ -1271,6 +1286,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
if _should_check_retrieval_precision(skill):
|
if _should_check_retrieval_precision(skill):
|
||||||
rp = await _eval_skill_retrieval_precision(
|
rp = await _eval_skill_retrieval_precision(
|
||||||
md, others, url, model, headers, workload=workload,
|
md, others, url, model, headers, workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
if rp and not rp.get("ok"):
|
if rp and not rp.get("ok"):
|
||||||
issues = rp.get("issues") or ["metadata: retrieval: narrow tags and when_to_use to the intended trigger"]
|
issues = rp.get("issues") or ["metadata: retrieval: narrow tags and when_to_use to the intended trigger"]
|
||||||
@@ -1281,7 +1297,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
"summary": rp.get("summary") or "Retrieval metadata is too broad.",
|
"summary": rp.get("summary") or "Retrieval metadata is too broad.",
|
||||||
"issues": issues,
|
"issues": issues,
|
||||||
}, "Retrieval audit only: the procedure may work, but matching metadata is too broad.",
|
}, "Retrieval audit only: the procedure may work, but matching metadata is too broad.",
|
||||||
url, model, headers, workload=workload)
|
url, model, headers, workload=workload, owner=owner)
|
||||||
if fixed and fixed.strip() != md.strip() and _apply_skill_md(skills_manager, name, fixed, owner):
|
if fixed and fixed.strip() != md.strip() and _apply_skill_md(skills_manager, name, fixed, owner):
|
||||||
md = fixed
|
md = fixed
|
||||||
refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None)
|
refreshed = next((s for s in skills_manager.load(owner=owner) if s.get("name") == name), None)
|
||||||
@@ -1357,6 +1373,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
skill_stats=skill_stats,
|
skill_stats=skill_stats,
|
||||||
baseline_stats=baseline_stats,
|
baseline_stats=baseline_stats,
|
||||||
workload=workload,
|
workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
v = verdict.get("verdict")
|
v = verdict.get("verdict")
|
||||||
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
log(f"{name}: verdict = {v} ({verdict.get('summary', '')[:80]})")
|
||||||
@@ -1390,6 +1407,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
log(f"{name}: pass, but fixing {len(meta_issues)} metadata issue(s)…")
|
log(f"{name}: pass, but fixing {len(meta_issues)} metadata issue(s)…")
|
||||||
fixed = await _improve_skill_md(
|
fixed = await _improve_skill_md(
|
||||||
md, verdict, transcript, url, model, headers, workload=workload,
|
md, verdict, transcript, url, model, headers, workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
if fixed and fixed.strip() != md.strip():
|
if fixed and fixed.strip() != md.strip():
|
||||||
_apply_skill_md(skills_manager, name, fixed, owner)
|
_apply_skill_md(skills_manager, name, fixed, owner)
|
||||||
@@ -1423,6 +1441,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
log(f"{name}: self-editing to fix issues…")
|
log(f"{name}: self-editing to fix issues…")
|
||||||
new_md = await _improve_skill_md(
|
new_md = await _improve_skill_md(
|
||||||
md, verdict, transcript, url, model, headers, workload=workload,
|
md, verdict, transcript, url, model, headers, workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
if new_md and new_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, new_md, owner):
|
if new_md and new_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, new_md, owner):
|
||||||
md = new_md
|
md = new_md
|
||||||
@@ -1457,6 +1476,7 @@ async def _audit_one_skill(skills_manager, skill, url, model, headers,
|
|||||||
log(f"{name}: teacher {t_model} rewriting the skill…")
|
log(f"{name}: teacher {t_model} rewriting the skill…")
|
||||||
t_md = await _improve_skill_md(
|
t_md = await _improve_skill_md(
|
||||||
md, verdict, transcript, t_url, t_model, t_headers, workload=workload,
|
md, verdict, transcript, t_url, t_model, t_headers, workload=workload,
|
||||||
|
owner=owner,
|
||||||
)
|
)
|
||||||
if t_md and t_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, t_md, owner):
|
if t_md and t_md.strip() != md.strip() and _apply_skill_md(skills_manager, name, t_md, owner):
|
||||||
md = t_md
|
md = t_md
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publish explicitly owned native ES modules; never run at application startup."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def module_path(base: Path, relative: str) -> Path:
|
||||||
|
"""Accept canonical JS paths only, contained within the declared tree."""
|
||||||
|
if not isinstance(relative, str):
|
||||||
|
raise ValueError("module paths must be strings")
|
||||||
|
parts = PurePosixPath(relative)
|
||||||
|
if (
|
||||||
|
parts.is_absolute()
|
||||||
|
or parts.as_posix() != relative
|
||||||
|
or ".." in parts.parts
|
||||||
|
or "\\" in relative
|
||||||
|
or "?" in relative
|
||||||
|
or "#" in relative
|
||||||
|
or parts.suffix != ".js"
|
||||||
|
):
|
||||||
|
raise ValueError(f"invalid module path: {relative!r}")
|
||||||
|
path = base / relative
|
||||||
|
if not path.resolve().is_relative_to(base.resolve()):
|
||||||
|
raise ValueError(f"module path escapes its tree: {relative!r}")
|
||||||
|
if any(p.is_symlink() for p in (path, *path.parents)):
|
||||||
|
raise ValueError(f"symlink in module path: {relative!r}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def check_lf_attributes(root: Path, paths: set[Path]) -> None:
|
||||||
|
"""Require portable, repository-owned LF attributes for every mapped path.
|
||||||
|
|
||||||
|
Ask Git in an isolated temporary repository so global attributes, local
|
||||||
|
info/attributes and alternate Git directories cannot mask missing rules.
|
||||||
|
Copy only attribute files at ancestors of the manifest's source/output paths.
|
||||||
|
"""
|
||||||
|
git = shutil.which("git")
|
||||||
|
if git is None:
|
||||||
|
raise ValueError("Git is required for frontend publication attribute checks")
|
||||||
|
attributes = {root / ".gitattributes"}
|
||||||
|
for path in paths:
|
||||||
|
for parent in path.parents:
|
||||||
|
if parent == root:
|
||||||
|
break
|
||||||
|
attributes.add(parent / ".gitattributes")
|
||||||
|
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||||
|
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
|
||||||
|
relative = sorted(path.relative_to(root).as_posix() for path in paths)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ody-frontend-attributes-") as directory:
|
||||||
|
target = Path(directory)
|
||||||
|
for attribute in attributes:
|
||||||
|
if attribute.is_symlink():
|
||||||
|
raise ValueError(f"symlink attribute file: {attribute.relative_to(root)}")
|
||||||
|
if attribute.is_file():
|
||||||
|
copy = target / attribute.relative_to(root)
|
||||||
|
copy.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
copy.write_bytes(attribute.read_bytes())
|
||||||
|
command = [git, "-c", f"core.attributesFile={os.devnull}", "-C", str(target)]
|
||||||
|
try:
|
||||||
|
subprocess.run(command + ["init", "--quiet", "--template="], env=env, check=True, capture_output=True, timeout=15)
|
||||||
|
result = subprocess.run(
|
||||||
|
command + ["check-attr", "-z", "--stdin", "text", "eol"],
|
||||||
|
input=("\0".join(relative) + "\0").encode("utf-8"),
|
||||||
|
env=env, check=True, capture_output=True, timeout=15,
|
||||||
|
)
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||||
|
raise ValueError("Git frontend attribute check failed") from error
|
||||||
|
fields = result.stdout.decode("utf-8").split("\0")[:-1]
|
||||||
|
effective = {name: {} for name in relative}
|
||||||
|
for index in range(0, len(fields), 3):
|
||||||
|
name, attribute, value = fields[index:index + 3]
|
||||||
|
effective[name][attribute] = value
|
||||||
|
invalid = [
|
||||||
|
f"{name} (text={values.get('text')}, eol={values.get('eol')})"
|
||||||
|
for name, values in effective.items()
|
||||||
|
if values != {"text": "set", "eol": "lf"}
|
||||||
|
]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError("manifest paths require repository-owned 'text eol=lf' Git attributes: " + ", ".join(invalid))
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize(root: Path = ROOT, *, check: bool = False) -> list[str]:
|
||||||
|
"""Validate the entire plan before writing. Return stale deployment paths."""
|
||||||
|
root = root.resolve()
|
||||||
|
source_root = root / "frontend" / "src"
|
||||||
|
static_root = root / "static"
|
||||||
|
manifest = json.loads((root / "frontend" / "modules.json").read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(manifest, dict) or manifest.get("version") != 1 or not isinstance(manifest.get("modules"), list):
|
||||||
|
raise ValueError("expected frontend module manifest version 1")
|
||||||
|
if not manifest["modules"]:
|
||||||
|
raise ValueError("module manifest must not be empty")
|
||||||
|
sources, outputs, plan = set(), set(), []
|
||||||
|
for entry in manifest["modules"]:
|
||||||
|
source = module_path(source_root, entry["source"])
|
||||||
|
output = module_path(static_root, entry["deployment"])
|
||||||
|
if not entry["deployment"].startswith("js/"):
|
||||||
|
raise ValueError("deployment modules must be under static/js/")
|
||||||
|
if source in sources or output in outputs:
|
||||||
|
raise ValueError("each source and deployment path must be unique")
|
||||||
|
sources.add(source)
|
||||||
|
outputs.add(output)
|
||||||
|
content = source.read_bytes()
|
||||||
|
if output.exists() and not output.is_file():
|
||||||
|
raise ValueError(f"deployment path is not a file: {output}")
|
||||||
|
plan.append((output, content))
|
||||||
|
unlisted = set(source_root.rglob("*.js")) - sources
|
||||||
|
if unlisted:
|
||||||
|
raise ValueError(f"source modules missing from manifest: {sorted(map(str, unlisted))}")
|
||||||
|
check_lf_attributes(root, sources | outputs)
|
||||||
|
stale = []
|
||||||
|
for output, content in plan:
|
||||||
|
if not output.is_file() or output.read_bytes() != content:
|
||||||
|
stale.append(output.relative_to(root).as_posix())
|
||||||
|
if not check:
|
||||||
|
for output, content in plan:
|
||||||
|
if output.relative_to(root).as_posix() in stale:
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_bytes(content)
|
||||||
|
return stale
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true", help="fail on invalid attributes or stale output; write no deployment files")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
stale = synchronize(check=args.check)
|
||||||
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||||
|
parser.exit(2, f"frontend: {error}\n")
|
||||||
|
if args.check and stale:
|
||||||
|
print("Stale frontend deployment modules: " + ", ".join(stale))
|
||||||
|
print("Run python scripts/build_frontend.py and include the source and output changes together.")
|
||||||
|
return 1
|
||||||
|
print(f"Frontend {'check passed' if args.check else 'published'} ({len(stale)} changed modules).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/** Architecture gates for maintained source. Legacy static modules stay under existing guards. */
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { parse } from 'acorn';
|
||||||
|
|
||||||
|
const layers = ['domain', 'application', 'infrastructure', 'ui'];
|
||||||
|
const platformIdentifiers = new Set(['window', 'document', 'globalThis', 'navigator', 'localStorage', 'sessionStorage', 'fetch', 'Notification', 'indexedDB', 'WebSocket', 'XMLHttpRequest', 'Worker', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'requestAnimationFrame', 'cancelAnimationFrame', 'performance', 'eval', 'Function']);
|
||||||
|
|
||||||
|
function ownership(source) {
|
||||||
|
const parts = source.split('/');
|
||||||
|
if (parts[0] === 'app' && parts.length >= 2) return { feature: 'app', layer: 'index' };
|
||||||
|
if (parts[0] === 'shared') {
|
||||||
|
if (parts.length === 2) return { feature: 'shared', layer: 'domain' };
|
||||||
|
const layer = { domain: 'domain', platform: 'infrastructure', ui: 'ui' }[parts[1]];
|
||||||
|
if (layer) return { feature: 'shared', layer };
|
||||||
|
}
|
||||||
|
if (parts[0] === 'features' && parts.length >= 3) {
|
||||||
|
if (['app', 'shared'].includes(parts[1])) throw new Error(`reserved feature name: ${parts[1]}`);
|
||||||
|
if (parts.length === 3 && parts[2] === 'index.js') return { feature: parts[1], layer: 'index' };
|
||||||
|
if (layers.includes(parts[2]) && parts.length >= 4) return { feature: parts[1], layer: parts[2] };
|
||||||
|
}
|
||||||
|
throw new Error(`unsupported source ownership: ${source}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allowsDependency(from, to) {
|
||||||
|
const a = ownership(from), b = ownership(to);
|
||||||
|
if (b.feature === 'app') return a.feature === 'app';
|
||||||
|
if (a.feature === 'app') return b.feature === 'shared' || b.layer === 'index';
|
||||||
|
if (b.feature === 'shared') {
|
||||||
|
const shared = { domain: ['domain'], application: ['domain'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'infrastructure', 'ui'], index: layers };
|
||||||
|
return shared[a.layer].includes(b.layer);
|
||||||
|
}
|
||||||
|
if (a.feature === 'shared') return false;
|
||||||
|
if (a.feature !== b.feature) return ['application', 'index'].includes(a.layer) && b.layer === 'index';
|
||||||
|
const internal = { domain: ['domain'], application: ['domain', 'application'], infrastructure: ['domain', 'infrastructure'], ui: ['domain', 'application', 'ui'], index: layers };
|
||||||
|
return internal[a.layer].includes(b.layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imports(source, { domain = false } = {}) {
|
||||||
|
const ast = parse(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
|
||||||
|
const found = [];
|
||||||
|
function visit(node, parent, field) {
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
// Domain modules use explicit values/ports, including an injected clock.
|
||||||
|
// Keep platform vocabulary out even when a local binding shadows a global.
|
||||||
|
const propertyName = parent && !parent.computed && (
|
||||||
|
(parent.type === 'MemberExpression' && field === 'property') ||
|
||||||
|
(['Property', 'MethodDefinition'].includes(parent.type) && field === 'key' && !parent.shorthand)
|
||||||
|
);
|
||||||
|
if (domain && !propertyName && node.type === 'Identifier' && platformIdentifiers.has(node.name)) {
|
||||||
|
throw new Error(`platform identifier in domain at line ${node.loc.start.line}: ${node.name}`);
|
||||||
|
}
|
||||||
|
if (domain && ((node.type === 'MemberExpression' && node.object.name === 'Date' && (node.property.name === 'now' || node.property.value === 'now')) || (['CallExpression', 'NewExpression'].includes(node.type) && node.callee.name === 'Date' && !node.arguments.length))) {
|
||||||
|
throw new Error(`ambient clock in domain at line ${node.loc.start.line}; inject time`);
|
||||||
|
}
|
||||||
|
if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(node.type) && node.source) {
|
||||||
|
if (node.source.type !== 'Literal' || typeof node.source.value !== 'string') {
|
||||||
|
throw new Error(`computed import at line ${node.loc.start.line}; use explicit import thunks`);
|
||||||
|
}
|
||||||
|
found.push({ specifier: node.source.value, line: node.loc.start.line, dynamic: node.type === 'ImportExpression' });
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(node)) {
|
||||||
|
if (Array.isArray(value)) value.forEach(item => visit(item, node, key));
|
||||||
|
else if (value && typeof value === 'object' && value.type) visit(value, node, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(ast);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkArchitecture(root) {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json'), 'utf8'));
|
||||||
|
const bySource = new Map(manifest.modules.map(m => [m.source, m]));
|
||||||
|
const byOutput = new Map(manifest.modules.map(m => [`/static/${m.deployment}`, m.source]));
|
||||||
|
const graph = new Map();
|
||||||
|
const errors = [];
|
||||||
|
const sw = fs.readFileSync(path.join(root, 'static/sw.js'), 'utf8');
|
||||||
|
// Parse the actual manifest arrays, not comments or unrelated strings.
|
||||||
|
const swAst = parse(sw, { ecmaVersion: 'latest', sourceType: 'script' });
|
||||||
|
const precached = new Set();
|
||||||
|
for (const statement of swAst.body) {
|
||||||
|
for (const declaration of statement.declarations || []) {
|
||||||
|
if (['PRECACHE', 'PANEL_PRECACHE'].includes(declaration.id.name)) {
|
||||||
|
for (const item of declaration.init.elements) {
|
||||||
|
if (item?.type === 'Literal' && typeof item.value === 'string') precached.add(item.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const module of manifest.modules) {
|
||||||
|
try {
|
||||||
|
const owner = ownership(module.source);
|
||||||
|
const deployed = `/static/${module.deployment}`;
|
||||||
|
if (!precached.has(deployed)) throw new Error(`deployment URL absent from explicit precache: ${deployed}`);
|
||||||
|
const dependencies = [];
|
||||||
|
const exceptions = module.legacyImports || [];
|
||||||
|
const used = new Set();
|
||||||
|
for (const { specifier, line } of imports(fs.readFileSync(path.join(root, 'frontend/src', module.source), 'utf8'), { domain: owner.layer === 'domain' })) {
|
||||||
|
const fail = message => { throw new Error(`line ${line}: ${message}: ${specifier}`); };
|
||||||
|
const runtime = new URL(specifier, `https://odysseus.invalid${deployed}`);
|
||||||
|
if (!runtime.pathname.startsWith('/static/js/') || !runtime.pathname.endsWith('.js') || runtime.pathname.includes('%')) fail('runtime imports must name canonical static JS paths');
|
||||||
|
let target;
|
||||||
|
if (specifier.startsWith('.')) {
|
||||||
|
if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity');
|
||||||
|
target = path.posix.normalize(path.posix.join(path.posix.dirname(module.source), specifier));
|
||||||
|
if (!bySource.has(target)) fail('relative import must name maintained source');
|
||||||
|
if (runtime.pathname !== `/static/${bySource.get(target).deployment}`) fail('source and deployment relative paths resolve differently');
|
||||||
|
} else if (specifier.startsWith('/static/')) {
|
||||||
|
target = byOutput.get(runtime.pathname);
|
||||||
|
if (!target) {
|
||||||
|
const exception = exceptions.find(e => e.specifier === specifier && e.reason?.trim());
|
||||||
|
if (!exception || !['index', 'infrastructure', 'ui'].includes(owner.layer) || owner.feature === 'shared') fail('legacy import requires an explicit module-scoped rationale at a composition, infrastructure or UI boundary');
|
||||||
|
if (!fs.statSync(path.join(root, runtime.pathname.slice(1))).isFile()) fail('legacy target does not exist');
|
||||||
|
used.add(exception);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else fail('bare, external and source-tree runtime imports are unsupported');
|
||||||
|
if (runtime.search || runtime.hash) fail('maintained modules use one unversioned URL identity');
|
||||||
|
if (!allowsDependency(module.source, target)) fail(`forbidden dependency on ${target}`);
|
||||||
|
dependencies.push(target);
|
||||||
|
}
|
||||||
|
if (exceptions.some(e => !used.has(e))) throw new Error('unused legacy import exception');
|
||||||
|
graph.set(module.source, dependencies);
|
||||||
|
} catch (error) { errors.push(`${module.source}: ${error.message}`); }
|
||||||
|
}
|
||||||
|
const visiting = new Set(), visited = new Set();
|
||||||
|
function visit(source, trail = []) {
|
||||||
|
if (visiting.has(source)) throw new Error(`maintained-source dependency cycle: ${[...trail, source].join(' -> ')}`);
|
||||||
|
if (visited.has(source)) return;
|
||||||
|
visiting.add(source);
|
||||||
|
for (const target of graph.get(source) || []) visit(target, [...trail, source]);
|
||||||
|
visiting.delete(source);
|
||||||
|
visited.add(source);
|
||||||
|
}
|
||||||
|
try { for (const source of graph.keys()) visit(source); }
|
||||||
|
catch (error) { errors.push(error.message); }
|
||||||
|
if (errors.length) throw new Error(errors.join('\n'));
|
||||||
|
return graph.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inventory(root) {
|
||||||
|
const staticRoot = path.join(root, 'static');
|
||||||
|
const files = fs.readdirSync(staticRoot, { recursive: true }).map(p => p.split(path.sep).join('/')).filter(p => p.endsWith('.js') && !p.startsWith('lib/') && !p.startsWith('js/editor/build/')).sort();
|
||||||
|
const graph = new Map(), fan = new Map(), missing = [];
|
||||||
|
let staticImports = 0, dynamicLiteralImports = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
const targets = [];
|
||||||
|
for (const { specifier, dynamic } of imports(fs.readFileSync(path.join(staticRoot, file), 'utf8'))) {
|
||||||
|
if (!specifier.startsWith('.') && !specifier.startsWith('/static/')) continue;
|
||||||
|
const target = new URL(specifier, `https://odysseus.invalid/static/${file}`).pathname.slice('/static/'.length);
|
||||||
|
if (dynamic) dynamicLiteralImports++;
|
||||||
|
else { staticImports++; targets.push(target); }
|
||||||
|
fan.set(target, (fan.get(target) || 0) + 1);
|
||||||
|
if (!fs.existsSync(path.join(staticRoot, target))) missing.push({ file, specifier });
|
||||||
|
}
|
||||||
|
graph.set(file, targets);
|
||||||
|
}
|
||||||
|
const indices = new Map(), low = new Map(), stack = [], active = new Set(), staticCycles = [];
|
||||||
|
function visit(v) {
|
||||||
|
indices.set(v, indices.size); low.set(v, indices.get(v)); stack.push(v); active.add(v);
|
||||||
|
for (const w of graph.get(v) || []) {
|
||||||
|
if (!indices.has(w)) { visit(w); low.set(v, Math.min(low.get(v), low.get(w))); }
|
||||||
|
else if (active.has(w)) low.set(v, Math.min(low.get(v), indices.get(w)));
|
||||||
|
}
|
||||||
|
if (low.get(v) === indices.get(v)) {
|
||||||
|
const component = []; let w;
|
||||||
|
do { w = stack.pop(); active.delete(w); component.push(w); } while (w !== v);
|
||||||
|
if (component.length > 1) staticCycles.push(component.sort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const file of files) if (!indices.has(file)) visit(file);
|
||||||
|
return { files: files.length, staticImports, dynamicLiteralImports, missing, fanIn: [...fan].sort((a, b) => b[1] - a[1]).slice(0, 8), staticCycles };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
try { console.log(process.argv.includes('--inventory') ? JSON.stringify(inventory(root), null, 2) : `Frontend architecture passed (${checkArchitecture(root)} maintained modules).`); }
|
||||||
|
catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify the current Python runtime from an isolated distribution without frontend tooling."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
PROBE = """
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
from app import app
|
||||||
|
assert not Path('frontend').exists()
|
||||||
|
assert not Path('node_modules').exists()
|
||||||
|
assert shutil.which('node') is None
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get('/static/js/actionMenuOrder.js')
|
||||||
|
assert response.status_code == 200, response.status_code
|
||||||
|
assert response.content == Path('static/js/actionMenuOrder.js').read_bytes()
|
||||||
|
assert response.headers['content-type'].startswith('text/javascript'), response.headers
|
||||||
|
assert response.headers['cache-control'] == 'no-cache', response.headers
|
||||||
|
assert "script-src 'self' 'nonce-" in response.headers['content-security-policy'], response.headers
|
||||||
|
assert client.get('/frontend/src/shared/actionMenuOrder.js').status_code == 404
|
||||||
|
assert client.get('/static/%2e%2e/app.py').status_code == 404
|
||||||
|
shell = client.get('/')
|
||||||
|
assert shell.status_code == 200, shell.status_code
|
||||||
|
assert '{{CSP_NONCE}}' not in shell.text
|
||||||
|
assert '/static/app.js' in shell.text
|
||||||
|
print('PASS: isolated Python lifespan, shell, deployed bytes, MIME, revalidation, CSP and source-tree 404')
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ody-f1-distribution-") as directory, socket.socket() as chroma_guard:
|
||||||
|
target = Path(directory)
|
||||||
|
for name in ("app.py", "HARNESS_VERSION"):
|
||||||
|
shutil.copy2(ROOT / name, target / name)
|
||||||
|
for name in ("core", "routes", "src", "services", "resources", "mcp_servers", "companion", "config", "static", "scripts"):
|
||||||
|
shutil.copytree(ROOT / name, target / name, ignore=shutil.ignore_patterns("__pycache__", "node_modules", ".env", "secrets.env*", "*.db", "*.sqlite*"))
|
||||||
|
# Reserve a non-listening port so initialization cannot reach a real store.
|
||||||
|
chroma_guard.bind(("127.0.0.1", 0))
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(
|
||||||
|
ODYSSEUS_DATA_DIR=str(target / "data"),
|
||||||
|
DATABASE_URL="sqlite:///:memory:",
|
||||||
|
AUTH_ENABLED="false",
|
||||||
|
ODYSSEUS_STARTUP_WARMUPS="0",
|
||||||
|
ODYSSEUS_TOOL_INDEX_PREWARM="0",
|
||||||
|
CHROMADB_HOST="127.0.0.1",
|
||||||
|
CHROMADB_PORT=str(chroma_guard.getsockname()[1]),
|
||||||
|
FASTEMBED_CACHE_PATH=str(target / "fastembed"),
|
||||||
|
PATH=str(target / "no-development-tools"),
|
||||||
|
)
|
||||||
|
# A caller's Python search path must not import the original checkout.
|
||||||
|
env.pop("PYTHONPATH", None)
|
||||||
|
result = subprocess.run([sys.executable, "-c", PROBE], cwd=target, env=env, capture_output=True, text=True, timeout=60)
|
||||||
|
print(result.stdout, end="")
|
||||||
|
if result.returncode:
|
||||||
|
print(result.stderr[-8000:], file=sys.stderr)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -331,6 +331,7 @@ async def extract_and_store(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
# Get last N messages from session
|
# Get last N messages from session
|
||||||
messages = session.get_context_messages()
|
messages = session.get_context_messages()
|
||||||
@@ -404,6 +405,9 @@ async def extract_and_store(
|
|||||||
# enough once thinking has room.
|
# enough once thinking has room.
|
||||||
max_tokens=4096,
|
max_tokens=4096,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
reasoning_effort=effort_for_call(
|
||||||
|
endpoint_url, model, getattr(session, "owner", None), session
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
|
# Parse JSON, tolerating reasoning-model noise (<think> blocks, a
|
||||||
@@ -559,6 +563,7 @@ async def update_persona_memory(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
from src.text_helpers import strip_think
|
from src.text_helpers import strip_think
|
||||||
|
|
||||||
custom = {}
|
custom = {}
|
||||||
@@ -607,6 +612,9 @@ async def update_persona_memory(
|
|||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
max_tokens=1200,
|
max_tokens=1200,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
reasoning_effort=effort_for_call(
|
||||||
|
endpoint_url, model, getattr(session, "owner", None), session
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
||||||
@@ -643,6 +651,7 @@ async def audit_memories(
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
existing = memory_manager.load(owner=owner)
|
existing = memory_manager.load(owner=owner)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -691,6 +700,7 @@ async def audit_memories(
|
|||||||
# Bound the call so the Tidy whirlpool can't spin indefinitely on a
|
# Bound the call so the Tidy whirlpool can't spin indefinitely on a
|
||||||
# slow/large generation.
|
# slow/large generation.
|
||||||
timeout=120,
|
timeout=120,
|
||||||
|
reasoning_effort=effort_for_call(endpoint_url, model, owner),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ async def maybe_extract_skill(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
|
|
||||||
# Get recent messages
|
# Get recent messages
|
||||||
history = session.get_context_messages()
|
history = session.get_context_messages()
|
||||||
@@ -204,6 +205,7 @@ async def maybe_extract_skill(
|
|||||||
],
|
],
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
reasoning_effort=effort_for_call(endpoint_url, model, owner, session),
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[skill-extract] LLM returned in %.1fs (len=%d, head=%r)",
|
"[skill-extract] LLM returned in %.1fs (len=%d, head=%r)",
|
||||||
|
|||||||
@@ -674,6 +674,7 @@ class SkillsManager:
|
|||||||
*,
|
*,
|
||||||
active_toolsets: Optional[List[str]] = None,
|
active_toolsets: Optional[List[str]] = None,
|
||||||
platform: Optional[str] = None,
|
platform: Optional[str] = None,
|
||||||
|
local_model: Optional[bool] = None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Return the `[{name, description, category, status}]` list the
|
"""Return the `[{name, description, category, status}]` list the
|
||||||
agent sees in its system prompt.
|
agent sees in its system prompt.
|
||||||
@@ -710,6 +711,11 @@ class SkillsManager:
|
|||||||
# Platform gating
|
# Platform gating
|
||||||
if platform and s.get("platforms") and platform not in s["platforms"]:
|
if platform and s.get("platforms") and platform not in s["platforms"]:
|
||||||
continue
|
continue
|
||||||
|
# remote-only tag: hide from local/private-endpoint models. Big
|
||||||
|
# on-demand reference lookups overwhelm small local contexts.
|
||||||
|
# local_model=None (API listings, UI) never gates.
|
||||||
|
if local_model and "remote-only" in (s.get("tags") or []):
|
||||||
|
continue
|
||||||
# requires_toolsets: hide unless every required toolset is active.
|
# requires_toolsets: hide unless every required toolset is active.
|
||||||
# active_toolsets=None means the caller doesn't know the active
|
# active_toolsets=None means the caller doesn't know the active
|
||||||
# set (API listings, chat preface) — don't gate in that case;
|
# set (API listings, chat preface) — don't gate in that case;
|
||||||
@@ -745,6 +751,7 @@ class SkillsManager:
|
|||||||
min_confidence: float = 0.0,
|
min_confidence: float = 0.0,
|
||||||
available_toolsets: Optional[Iterable[str]] = None,
|
available_toolsets: Optional[Iterable[str]] = None,
|
||||||
platform: Optional[str] = None,
|
platform: Optional[str] = None,
|
||||||
|
published_only: bool = False,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
if skills is None:
|
if skills is None:
|
||||||
skills = self.load_all()
|
skills = self.load_all()
|
||||||
@@ -775,9 +782,15 @@ class SkillsManager:
|
|||||||
if not skill.get("platforms") or platform in skill.get("platforms", [])
|
if not skill.get("platforms") or platform in skill.get("platforms", [])
|
||||||
]
|
]
|
||||||
# Prompt injection is fail-closed for user skills. Built-ins are
|
# Prompt injection is fail-closed for user skills. Built-ins are
|
||||||
# shipped procedures; every other skill needs a passing audit and a
|
# shipped procedures. With Auto-approve OFF (published_only), the
|
||||||
# confidence score at the user's current threshold.
|
# user's publish is the approval; otherwise a skill needs a passing
|
||||||
if min_confidence > 0:
|
# audit and a confidence score at the user's current threshold.
|
||||||
|
if published_only:
|
||||||
|
skills = [
|
||||||
|
s for s in skills
|
||||||
|
if s.get("source") == "builtin" or s.get("status") == "published"
|
||||||
|
]
|
||||||
|
elif min_confidence > 0:
|
||||||
def _passes(s):
|
def _passes(s):
|
||||||
if s.get("source") == "builtin":
|
if s.get("source") == "builtin":
|
||||||
return True
|
return True
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ This spec covers the current browser app in:
|
|||||||
|
|
||||||
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
|
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
|
||||||
|
|
||||||
The static PWA icon files (192px, 512px, maskable) were deliberately omitted from publication, and `static/icons/` is intentionally absent. `static/manifest.json` declares no `icons`, and neither `static/index.html` nor `static/login.html` ships a static `apple-touch-icon` link. Icons come from generated inline SVG at runtime: the `data:image/svg+xml` favicon, the `apple-touch-icon` link that `static/index.html` and `static/js/theme.js` create, and per-route Blob manifests whose `icons` point at that SVG. Do not reintroduce references to icon files that are not shipped.
|
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
|
||||||
|
|
||||||
## Current Call Sites Include
|
## Current Call Sites Include
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Storage/secrets policy:
|
|||||||
- other static assets use cache-first with background refresh;
|
- other static assets use cache-first with background refresh;
|
||||||
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
|
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
|
||||||
|
|
||||||
`static/manifest.json` owns default PWA metadata and intentionally has no `icons` entry. Route-specific manifests can be generated as Blob URLs when supported; their icons are the route's generated inline SVG, not files.
|
`static/manifest.json` owns default PWA metadata, referencing the restored icons under `static/icons/`. Route-specific manifests can be generated as Blob URLs when supported; dynamic icons use the route's generated inline SVG, while the default manifest uses the static icons.
|
||||||
|
|
||||||
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
|
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
|
||||||
|
|
||||||
|
|||||||
+21
-6
@@ -6933,6 +6933,7 @@ _AGENT_RULES = """\
|
|||||||
- If the user asks for multiple specific emails and you call `read_email` more than once, your final answer MUST include every successfully read email, clearly separated and linked by UID. Do not answer with only the last email you read.
|
- If the user asks for multiple specific emails and you call `read_email` more than once, your final answer MUST include every successfully read email, clearly separated and linked by UID. Do not answer with only the last email you read.
|
||||||
- Multiple email accounts: if tool output says "Other accounts" or the user asks "my Gmail?", "other inbox?", "work mail?", "custom domain mail?", or names any mailbox/account, DO NOT answer from memory. Call `list_email_accounts` if needed, then call `list_emails`/`read_email`/`bulk_email` with the exact `account` value for that mailbox. Account names are user-defined labels; if the user typo-matches a known account, use the closest listed account instead of claiming it does not exist. NEVER use `app_api` or `/api/email/accounts` to discover email accounts; that route is owner-filtered in tool context and can falsely return empty.
|
- Multiple email accounts: if tool output says "Other accounts" or the user asks "my Gmail?", "other inbox?", "work mail?", "custom domain mail?", or names any mailbox/account, DO NOT answer from memory. Call `list_email_accounts` if needed, then call `list_emails`/`read_email`/`bulk_email` with the exact `account` value for that mailbox. Account names are user-defined labels; if the user typo-matches a known account, use the closest listed account instead of claiming it does not exist. NEVER use `app_api` or `/api/email/accounts` to discover email accounts; that route is owner-filtered in tool context and can falsely return empty.
|
||||||
- User identity facts/preferences ("my name is <name>", "I live in <place>", "I prefer concise replies", "call me <name>") → use `manage_memory` with action=add. NEVER use `manage_contact` for facts about the user unless the user explicitly says to create/update a contact and provides contact details such as an email or phone.
|
- User identity facts/preferences ("my name is <name>", "I live in <place>", "I prefer concise replies", "call me <name>") → use `manage_memory` with action=add. NEVER use `manage_contact` for facts about the user unless the user explicitly says to create/update a contact and provides contact details such as an email or phone.
|
||||||
|
- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed.
|
||||||
- "Create/add/write a note" / "notes" / "todos" / "remind me to X at <time>" → use `manage_notes`. Do NOT store notes in `manage_memory`; memory is for persistent facts/preferences about the user, not note content. For reminders, include a `due_date`; for todos, use `note_type=checklist` when appropriate.
|
- "Create/add/write a note" / "notes" / "todos" / "remind me to X at <time>" → use `manage_notes`. Do NOT store notes in `manage_memory`; memory is for persistent facts/preferences about the user, not note content. For reminders, include a `due_date`; for todos, use `note_type=checklist` when appropriate.
|
||||||
- "Do X every morning / daily / on a schedule / automatically" (e.g. "summarize my inbox every morning") → this is a request to CREATE A SCHEDULED TASK, not to do X once right now. Call `manage_tasks` with action=create (prompt = what to do, schedule + cron/time). Do NOT just perform the action inline this turn — the user wants it to recur. After creating, return a clickable `[Task name](#task-<id>)` link and tell them it'll run on schedule and show in the Tasks panel. If you also want to show a sample of this run, do that AFTER creating the task, not instead of it.
|
- "Do X every morning / daily / on a schedule / automatically" (e.g. "summarize my inbox every morning") → this is a request to CREATE A SCHEDULED TASK, not to do X once right now. Call `manage_tasks` with action=create (prompt = what to do, schedule + cron/time). Do NOT just perform the action inline this turn — the user wants it to recur. After creating, return a clickable `[Task name](#task-<id>)` link and tell them it'll run on schedule and show in the Tasks panel. If you also want to show a sample of this run, do that AFTER creating the task, not instead of it.
|
||||||
- There is NO generic sleep / auto-wakeup / resume-after-this-turn primitive. Background jobs and subagent-style work should return a job/task id and notify the session automatically when finished; do not sleep or poll for their progress. If the user explicitly asks you to retry or do something later, call `manage_tasks` with action=create, `task_type=llm`, `schedule=once`, and a self-contained prompt. NEVER claim you will wake up, retry later, or handle something later unless a `manage_tasks` create call succeeds.
|
- There is NO generic sleep / auto-wakeup / resume-after-this-turn primitive. Background jobs and subagent-style work should return a job/task id and notify the session automatically when finished; do not sleep or poll for their progress. If the user explicitly asks you to retry or do something later, call `manage_tasks` with action=create, `task_type=llm`, `schedule=once`, and a self-contained prompt. NEVER claim you will wake up, retry later, or handle something later unless a `manage_tasks` create call succeeds.
|
||||||
@@ -7041,6 +7042,7 @@ _AGENT_RULES = """\
|
|||||||
- After a tool fails, retry with a concrete fix or state what is blocking you.
|
- After a tool fails, retry with a concrete fix or state what is blocking you.
|
||||||
- Finish only when the user's concrete request is actually done, or clearly state that you are blocked.
|
- Finish only when the user's concrete request is actually done, or clearly state that you are blocked.
|
||||||
- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts.
|
- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts.
|
||||||
|
- When the prompt contains "Selected notes and todos", treat that content as already-provided reference context. Use it directly when it answers the request; do not call `manage_notes` to list/search/view the same notes, and do not ask for permission merely to use the selected context. Call `manage_notes` only when the user requests a note change or the selected context is insufficient and a fresh lookup is needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_API_AGENT_RULES = """\
|
_API_AGENT_RULES = """\
|
||||||
@@ -7099,7 +7101,7 @@ _DOMAIN_RULES = {
|
|||||||
- After a successful serve, verify with `list_served_models`; if an external server is running but invisible, use `adopt_served_model`.""",
|
- After a successful serve, verify with `list_served_models`; if an external server is running but invisible, use `adopt_served_model`.""",
|
||||||
"notes_calendar_tasks": """\
|
"notes_calendar_tasks": """\
|
||||||
## Notes/calendar/tasks rules
|
## Notes/calendar/tasks rules
|
||||||
- Notes/todos/reminders use `manage_notes`, not memory.
|
- Notes/todos/reminders use `manage_notes`, not memory. However, when the prompt contains "Selected notes and todos", those notes are already available as reference context: use them directly and do not list/search/view them again or ask permission to use them. Use `manage_notes` only for a requested change or when a fresh lookup is genuinely needed.
|
||||||
- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first.
|
- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first.
|
||||||
- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""",
|
- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""",
|
||||||
"memory": """\
|
"memory": """\
|
||||||
@@ -10323,9 +10325,10 @@ def _has_matching_skill_for_turn(
|
|||||||
))))
|
))))
|
||||||
if max_items == 0:
|
if max_items == 0:
|
||||||
return False
|
return False
|
||||||
|
published_only = not prefs.get("auto_approve_skills", True)
|
||||||
min_confidence = (
|
min_confidence = (
|
||||||
2.0
|
0.0
|
||||||
if not prefs.get("auto_approve_skills", True)
|
if published_only
|
||||||
else float(prefs.get(
|
else float(prefs.get(
|
||||||
"skill_min_confidence",
|
"skill_min_confidence",
|
||||||
get_setting("skill_autosave_min_confidence", 0.85),
|
get_setting("skill_autosave_min_confidence", 0.85),
|
||||||
@@ -10342,6 +10345,7 @@ def _has_matching_skill_for_turn(
|
|||||||
threshold=0.25,
|
threshold=0.25,
|
||||||
max_items=max_items,
|
max_items=max_items,
|
||||||
min_confidence=min_confidence,
|
min_confidence=min_confidence,
|
||||||
|
published_only=published_only,
|
||||||
))
|
))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("skill preflight failed (non-fatal): %s", exc)
|
logger.debug("skill preflight failed (non-fatal): %s", exc)
|
||||||
@@ -14419,6 +14423,7 @@ def _build_system_prompt(
|
|||||||
suppress_skills: bool = False,
|
suppress_skills: bool = False,
|
||||||
active_email: Optional[Dict[str, str]] = None,
|
active_email: Optional[Dict[str, str]] = None,
|
||||||
workspace: Optional[str] = None,
|
workspace: Optional[str] = None,
|
||||||
|
endpoint_url: Optional[str] = None,
|
||||||
client_runtime_context: Optional[Dict[str, Any]] = None,
|
client_runtime_context: Optional[Dict[str, Any]] = None,
|
||||||
preserve_conversation: bool = False,
|
preserve_conversation: bool = False,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
@@ -14520,6 +14525,7 @@ def _build_system_prompt(
|
|||||||
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
|
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
|
||||||
suppress_local_context=suppress_local_context,
|
suppress_local_context=suppress_local_context,
|
||||||
suppress_skills=suppress_skills,
|
suppress_skills=suppress_skills,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
agent_prompt, _skill_index_block = _build_base_prompt(
|
agent_prompt, _skill_index_block = _build_base_prompt(
|
||||||
@@ -14532,6 +14538,7 @@ def _build_system_prompt(
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
suppress_local_context=suppress_local_context,
|
suppress_local_context=suppress_local_context,
|
||||||
suppress_skills=suppress_skills,
|
suppress_skills=suppress_skills,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
)
|
)
|
||||||
if not active_document:
|
if not active_document:
|
||||||
_cached_base_prompt = agent_prompt
|
_cached_base_prompt = agent_prompt
|
||||||
@@ -14950,8 +14957,9 @@ def _build_system_prompt(
|
|||||||
# confidence threshold. Approve OFF → published-only (no draft
|
# confidence threshold. Approve OFF → published-only (no draft
|
||||||
# passes). Approve ON → drafts at/above the chosen confidence
|
# passes). Approve ON → drafts at/above the chosen confidence
|
||||||
# (0 = "All"). Falls back to the global default setting.
|
# (0 = "All"). Falls back to the global default setting.
|
||||||
if not _prefs.get("auto_approve_skills", True):
|
_skill_published_only = not _prefs.get("auto_approve_skills", True)
|
||||||
_skill_min_conf = 2.0 # nothing draft clears it → published only
|
if _skill_published_only:
|
||||||
|
_skill_min_conf = 0.0 # published_only decides eligibility
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
_skill_min_conf = float(_prefs.get(
|
_skill_min_conf = float(_prefs.get(
|
||||||
@@ -14974,6 +14982,7 @@ def _build_system_prompt(
|
|||||||
max_items=_skill_max_injected,
|
max_items=_skill_max_injected,
|
||||||
min_confidence=_skill_min_conf,
|
min_confidence=_skill_min_conf,
|
||||||
available_toolsets=relevant_tools,
|
available_toolsets=relevant_tools,
|
||||||
|
published_only=_skill_published_only,
|
||||||
) if _skill_max_injected > 0 else []
|
) if _skill_max_injected > 0 else []
|
||||||
else:
|
else:
|
||||||
# Explicit client activation chooses which eligible skill
|
# Explicit client activation chooses which eligible skill
|
||||||
@@ -15208,6 +15217,7 @@ def _build_base_prompt(
|
|||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
suppress_local_context: bool = False,
|
suppress_local_context: bool = False,
|
||||||
suppress_skills: bool = False,
|
suppress_skills: bool = False,
|
||||||
|
endpoint_url: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Build the agent prompt with only relevant tools included.
|
"""Build the agent prompt with only relevant tools included.
|
||||||
|
|
||||||
@@ -15266,7 +15276,11 @@ def _build_base_prompt(
|
|||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
_sm = SkillsManager(DATA_DIR)
|
_sm = SkillsManager(DATA_DIR)
|
||||||
active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or []))
|
active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or []))
|
||||||
skill_idx = _sm.index_for(owner=owner, active_toolsets=active_tools)
|
skill_idx = _sm.index_for(
|
||||||
|
owner=owner,
|
||||||
|
active_toolsets=active_tools,
|
||||||
|
local_model=bool(endpoint_url) and is_local_endpoint(endpoint_url),
|
||||||
|
)
|
||||||
if skill_idx:
|
if skill_idx:
|
||||||
lines = ["## Available skills",
|
lines = ["## Available skills",
|
||||||
"Procedures the assistant should consult before doing domain work. "
|
"Procedures the assistant should consult before doing domain work. "
|
||||||
@@ -24297,6 +24311,7 @@ async def stream_agent_loop(
|
|||||||
active_email=active_email,
|
active_email=active_email,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
client_runtime_context=client_runtime_context,
|
client_runtime_context=client_runtime_context,
|
||||||
|
endpoint_url=candidate_url,
|
||||||
)
|
)
|
||||||
# The request user turn is authoritative and must never disappear
|
# The request user turn is authoritative and must never disappear
|
||||||
# while rebuilding the route prompt. Some native terminal requests
|
# while rebuilding the route prompt. Some native terminal requests
|
||||||
|
|||||||
@@ -801,15 +801,18 @@ class LsTool:
|
|||||||
return {"error": f"ls: {e}", "exit_code": 1}
|
return {"error": f"ls: {e}", "exit_code": 1}
|
||||||
|
|
||||||
def _ls():
|
def _ls():
|
||||||
|
from src.agent_runtime.resources import _control_plane_snapshot
|
||||||
if not os.path.isdir(root):
|
if not os.path.isdir(root):
|
||||||
return None, f"ls: {root}: not a directory"
|
return None, f"ls: {root}: not a directory"
|
||||||
rows = []
|
rows = []
|
||||||
|
snapshot = _control_plane_snapshot()
|
||||||
try:
|
try:
|
||||||
with os.scandir(root) as it:
|
with os.scandir(root) as it:
|
||||||
for entry in it:
|
for entry in it:
|
||||||
if entry.name.startswith("."):
|
if entry.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
if _is_denied_tool_path(os.path.realpath(entry.path)) or not _visible_bound_resource(entry.path):
|
if (_is_denied_tool_path(os.path.realpath(entry.path), snapshot=snapshot)
|
||||||
|
or not _visible_bound_resource(entry.path)):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
is_dir = entry.is_dir(follow_symlinks=False)
|
is_dir = entry.is_dir(follow_symlinks=False)
|
||||||
@@ -864,10 +867,12 @@ class GlobTool:
|
|||||||
return {"error": f"glob: {e}", "exit_code": 1}
|
return {"error": f"glob: {e}", "exit_code": 1}
|
||||||
|
|
||||||
def _glob():
|
def _glob():
|
||||||
|
from src.agent_runtime.resources import _control_plane_snapshot
|
||||||
base = os.path.abspath(root)
|
base = os.path.abspath(root)
|
||||||
if not os.path.isdir(base):
|
if not os.path.isdir(base):
|
||||||
return None, f"glob: {root}: not a directory"
|
return None, f"glob: {root}: not a directory"
|
||||||
rbase = os.path.realpath(base)
|
rbase = os.path.realpath(base)
|
||||||
|
snapshot = _control_plane_snapshot()
|
||||||
norm_pat = pattern.replace("\\", "/")
|
norm_pat = pattern.replace("\\", "/")
|
||||||
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
# Fast path: literal pattern (no wildcards) → direct path lookup.
|
||||||
if not any(c in norm_pat for c in "*?["):
|
if not any(c in norm_pat for c in "*?["):
|
||||||
@@ -884,7 +889,8 @@ class GlobTool:
|
|||||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||||
# otherwise glob would surface secret paths that read_file /
|
# otherwise glob would surface secret paths that read_file /
|
||||||
# grep already refuse to touch.
|
# grep already refuse to touch.
|
||||||
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand) and _visible_bound_resource(cand):
|
if (inside and os.path.exists(cand) and not _is_denied_tool_path(cand, snapshot=snapshot)
|
||||||
|
and _visible_bound_resource(cand)):
|
||||||
return [cand], None
|
return [cand], None
|
||||||
# Literal not at exact path — fall through to walk so
|
# Literal not at exact path — fall through to walk so
|
||||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||||
@@ -894,7 +900,7 @@ class GlobTool:
|
|||||||
cap = _CODENAV_MAX_HITS * 5
|
cap = _CODENAV_MAX_HITS * 5
|
||||||
try:
|
try:
|
||||||
for dp, dns, fns in os.walk(base):
|
for dp, dns, fns in os.walk(base):
|
||||||
if not _can_traverse_tool_path(os.path.realpath(dp)):
|
if not _can_traverse_tool_path(os.path.realpath(dp), snapshot=snapshot):
|
||||||
dns[:] = []
|
dns[:] = []
|
||||||
continue
|
continue
|
||||||
# Prune skipped dirs before descending (unlike rglob which
|
# Prune skipped dirs before descending (unlike rglob which
|
||||||
@@ -905,7 +911,7 @@ class GlobTool:
|
|||||||
d for d in dns
|
d for d in dns
|
||||||
if d not in _CODENAV_SKIP_DIRS
|
if d not in _CODENAV_SKIP_DIRS
|
||||||
and d not in _SENSITIVE_BASENAMES
|
and d not in _SENSITIVE_BASENAMES
|
||||||
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)))
|
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)), snapshot=snapshot)
|
||||||
]
|
]
|
||||||
for name in fns + dns:
|
for name in fns + dns:
|
||||||
full = os.path.join(dp, name)
|
full = os.path.join(dp, name)
|
||||||
@@ -913,7 +919,8 @@ class GlobTool:
|
|||||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||||
# known_hosts, …) the same way grep does.
|
# known_hosts, …) the same way grep does.
|
||||||
if _is_denied_tool_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
if (_is_denied_tool_path(os.path.realpath(full), snapshot=snapshot)
|
||||||
|
or not _visible_bound_resource(full)):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
mtime = os.stat(full).st_mtime
|
mtime = os.stat(full).st_mtime
|
||||||
@@ -981,6 +988,7 @@ class GrepTool:
|
|||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
from src.agent_runtime.resources import _control_plane_snapshot
|
||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
|
|
||||||
rg = shutil.which("rg")
|
rg = shutil.which("rg")
|
||||||
@@ -992,6 +1000,7 @@ class GrepTool:
|
|||||||
if bound is not None:
|
if bound is not None:
|
||||||
bound.validate()
|
bound.validate()
|
||||||
files = []
|
files = []
|
||||||
|
snapshot = _control_plane_snapshot()
|
||||||
|
|
||||||
def check_deadline():
|
def check_deadline():
|
||||||
if time.monotonic() >= deadline:
|
if time.monotonic() >= deadline:
|
||||||
@@ -1002,7 +1011,7 @@ class GrepTool:
|
|||||||
if os.path.islink(path):
|
if os.path.islink(path):
|
||||||
return
|
return
|
||||||
canonical = os.path.realpath(path)
|
canonical = os.path.realpath(path)
|
||||||
if not _path_within(canonical, base) or _is_denied_tool_path(canonical):
|
if not _path_within(canonical, base) or _is_denied_tool_path(canonical, snapshot=snapshot):
|
||||||
return
|
return
|
||||||
if bound is not None:
|
if bound is not None:
|
||||||
try:
|
try:
|
||||||
@@ -1037,7 +1046,7 @@ class GrepTool:
|
|||||||
while pending_directories:
|
while pending_directories:
|
||||||
check_deadline()
|
check_deadline()
|
||||||
directory = pending_directories.pop()
|
directory = pending_directories.pop()
|
||||||
if not _can_traverse_tool_path(directory):
|
if not _can_traverse_tool_path(directory, snapshot=snapshot):
|
||||||
continue
|
continue
|
||||||
with os.scandir(directory) as entries:
|
with os.scandir(directory) as entries:
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
@@ -1052,8 +1061,8 @@ class GrepTool:
|
|||||||
raise ValueError("grep: directory identity changed during enumeration")
|
raise ValueError("grep: directory identity changed during enumeration")
|
||||||
if entry.is_dir(follow_symlinks=False):
|
if entry.is_dir(follow_symlinks=False):
|
||||||
if (entry.name not in _CODENAV_SKIP_DIRS
|
if (entry.name not in _CODENAV_SKIP_DIRS
|
||||||
and _can_traverse_tool_path(canonical)
|
and _can_traverse_tool_path(canonical, snapshot=snapshot)
|
||||||
and (bound is None or _is_denied_tool_path(canonical)
|
and (bound is None or _is_denied_tool_path(canonical, snapshot=snapshot)
|
||||||
or _visible_bound_resource(canonical))):
|
or _visible_bound_resource(canonical))):
|
||||||
pending_directories.append(canonical)
|
pending_directories.append(canonical)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1372,6 +1372,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
||||||
|
if owner:
|
||||||
|
from src.generated_images import register_generated_image
|
||||||
|
register_generated_image(filename, owner)
|
||||||
image_url = f"/api/generated-image/{filename}"
|
image_url = f"/api/generated-image/{filename}"
|
||||||
image_id = _save_to_gallery(filename)
|
image_id = _save_to_gallery(filename)
|
||||||
|
|
||||||
@@ -1392,6 +1395,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
|||||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||||
img_path = img_dir / filename
|
img_path = img_dir / filename
|
||||||
img_path.write_bytes(dl_resp.content)
|
img_path.write_bytes(dl_resp.content)
|
||||||
|
if owner:
|
||||||
|
from src.generated_images import register_generated_image
|
||||||
|
register_generated_image(filename, owner)
|
||||||
image_url = f"/api/generated-image/{filename}"
|
image_url = f"/api/generated-image/{filename}"
|
||||||
image_id = _save_to_gallery(filename)
|
image_id = _save_to_gallery(filename)
|
||||||
else:
|
else:
|
||||||
@@ -1537,6 +1543,9 @@ async def do_edit_image(
|
|||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||||
(img_dir / filename).write_bytes(image_bytes)
|
(img_dir / filename).write_bytes(image_bytes)
|
||||||
|
if owner:
|
||||||
|
from src.generated_images import register_generated_image
|
||||||
|
register_generated_image(filename, owner)
|
||||||
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
|
return f"/api/generated-image/{filename}", _save_edited_image_to_gallery(filename)
|
||||||
|
|
||||||
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
|
async def _try_local_img2img_fallback(client: httpx.AsyncClient) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
+32
-9
@@ -2297,10 +2297,10 @@ async def action_audit_skills(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
|
|
||||||
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||||
"""Background note-due scanner. Fires a reminder for any note whose
|
"""Background note-due scanner. Fires a reminder for any note whose
|
||||||
`due_date` falls in the current ±5-minute window and hasn't been pinged
|
`due_date` falls in the current ±90-second window and hasn't been delivered
|
||||||
within the last 25 minutes. Mirrors `action_ping_events` for calendar.
|
through its configured channel in the last 25 minutes.
|
||||||
|
|
||||||
State (`data/note_pings.json`): {note_id: iso_ts_of_last_ping}. Pruned
|
Per-owner state: {note_id: {at, channel}}, with legacy timestamp support. Pruned
|
||||||
on each run by dropping entries for notes that are gone/archived/replied.
|
on each run by dropping entries for notes that are gone/archived/replied.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -2309,6 +2309,11 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||||||
from pathlib import Path as _P
|
from pathlib import Path as _P
|
||||||
from core.database import SessionLocal as _SL, Note as _N
|
from core.database import SessionLocal as _SL, Note as _N
|
||||||
|
from src.settings import load_settings
|
||||||
|
|
||||||
|
channel = load_settings().get("reminder_channel", "browser")
|
||||||
|
external_channel = channel in ("email", "ntfy", "webhook")
|
||||||
|
delivery_key = f"{channel}_sent" if external_channel else "browser_sent"
|
||||||
|
|
||||||
# Per-owner state file so cache-pruning doesn't cross-delete other
|
# Per-owner state file so cache-pruning doesn't cross-delete other
|
||||||
# users' entries (review C4). Legacy path kept as fallback so a
|
# users' entries (review C4). Legacy path kept as fallback so a
|
||||||
@@ -2373,20 +2378,27 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
due = _parse_due(n.due_date)
|
due = _parse_due(n.due_date)
|
||||||
if not due:
|
if not due:
|
||||||
continue
|
continue
|
||||||
# Inside the ±5min window?
|
# Inside the due window?
|
||||||
if abs((due - now).total_seconds()) > window.total_seconds():
|
if abs((due - now).total_seconds()) > window.total_seconds():
|
||||||
continue
|
continue
|
||||||
# Recently pinged? Skip.
|
# Recently pinged? Skip.
|
||||||
last = cache.get(n.id)
|
last = cache.get(n.id)
|
||||||
|
browser_recent = False
|
||||||
if last:
|
if last:
|
||||||
try:
|
try:
|
||||||
|
last_channel = None
|
||||||
if isinstance(last, dict):
|
if isinstance(last, dict):
|
||||||
|
last_channel = last.get("channel")
|
||||||
last = last.get("at")
|
last = last.get("at")
|
||||||
last_dt = _dt.fromisoformat(str(last))
|
last_dt = _dt.fromisoformat(str(last))
|
||||||
if last_dt.tzinfo is None:
|
if last_dt.tzinfo is None:
|
||||||
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
last_dt = last_dt.replace(tzinfo=_tz.utc)
|
||||||
if last_dt >= reping_cutoff:
|
if last_dt >= reping_cutoff:
|
||||||
continue
|
if not external_channel or last_channel == channel:
|
||||||
|
continue
|
||||||
|
# Browser-only receipts do not prove external
|
||||||
|
# delivery, but the fallback must not be queued twice.
|
||||||
|
browser_recent = last_channel in (None, "browser")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Compose + dispatch.
|
# Compose + dispatch.
|
||||||
@@ -2410,15 +2422,26 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
body = "\n\n".join(p for p in body_parts if p) or title
|
body = "\n\n".join(p for p in body_parts if p) or title
|
||||||
try:
|
try:
|
||||||
from routes.note_routes import dispatch_reminder
|
from routes.note_routes import dispatch_reminder
|
||||||
await dispatch_reminder(
|
result = await dispatch_reminder(
|
||||||
title=title, note_body=body, note_id=n.id,
|
title=title, note_body=body, note_id=n.id,
|
||||||
owner=n.owner or owner or "",
|
owner=n.owner or owner or "",
|
||||||
|
queue_browser=not browser_recent,
|
||||||
)
|
)
|
||||||
cache[n.id] = now.isoformat()
|
if result.get("skipped"):
|
||||||
sent.append(title)
|
continue
|
||||||
|
if result.get(delivery_key):
|
||||||
|
sent.append(title)
|
||||||
|
else:
|
||||||
|
logger.warning("ping_notes: %s delivery failed for %s", channel, n.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
|
logger.warning(f"ping_notes: dispatch failed for {n.id}: {e}")
|
||||||
|
|
||||||
|
# Dispatch owns delivery receipts. Reload before pruning so this
|
||||||
|
# scanner cannot replace a fresh channel receipt with a timestamp.
|
||||||
|
try:
|
||||||
|
cache = _json.loads(STATE.read_text(encoding="utf-8")) if STATE.exists() else {}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
# Prune cache entries for notes that no longer exist.
|
# Prune cache entries for notes that no longer exist.
|
||||||
for stale in [k for k in cache if k not in seen_ids]:
|
for stale in [k for k in cache if k not in seen_ids]:
|
||||||
cache.pop(stale, None)
|
cache.pop(stale, None)
|
||||||
@@ -2429,7 +2452,7 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
|
|||||||
logger.warning(f"ping_notes: cache write failed: {e}")
|
logger.warning(f"ping_notes: cache write failed: {e}")
|
||||||
|
|
||||||
if not sent:
|
if not sent:
|
||||||
raise TaskNoop(f"scanned {len(notes)} note(s), none due in ±{WINDOW_SEC}s")
|
raise TaskNoop(f"scanned {len(notes)} note(s), no reminders delivered")
|
||||||
preview = "; ".join(sent[:3])
|
preview = "; ".join(sent[:3])
|
||||||
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
extra = f" (+{len(sent) - 3} more)" if len(sent) > 3 else ""
|
||||||
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
return f"Pinged {len(sent)} note(s): {preview}{extra}", True
|
||||||
|
|||||||
@@ -96,6 +96,41 @@ _PUBLIC_PREVIEW_TOOL_ERRORS = {message: message for message in (
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
def _schema_argument_hint(args, schema):
|
||||||
|
"""Describe a schema mismatch from the schema and the call's own arguments.
|
||||||
|
|
||||||
|
Rebuilt from a fresh validation rather than read off the caught exception,
|
||||||
|
so nothing the exception carries can reach the model.
|
||||||
|
"""
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
validator = jsonschema.validators.validator_for(schema)(schema)
|
||||||
|
error = jsonschema.exceptions.best_match(validator.iter_errors(args))
|
||||||
|
except jsonschema.exceptions.SchemaError:
|
||||||
|
return ''
|
||||||
|
if error is None:
|
||||||
|
return ''
|
||||||
|
location = '.'.join(str(part) for part in error.absolute_path)[:80]
|
||||||
|
field = f"'{location}' " if location else ''
|
||||||
|
if error.validator == 'required' and isinstance(error.instance, dict):
|
||||||
|
missing = [str(name) for name in error.validator_value if name not in error.instance]
|
||||||
|
prefix = f'{location}.' if location else ''
|
||||||
|
hint = 'Missing required argument: ' + ', '.join(f"'{prefix}{name}'" for name in missing) + '.'
|
||||||
|
elif error.validator == 'type':
|
||||||
|
expected = error.validator_value
|
||||||
|
expected = ' or '.join(expected) if isinstance(expected, list) else str(expected)
|
||||||
|
hint = f'Argument {field}must be of type {expected}.'
|
||||||
|
elif error.validator == 'enum':
|
||||||
|
allowed = ', '.join(str(value) for value in error.validator_value[:12])
|
||||||
|
hint = f'Argument {field}must be one of: {allowed}.'
|
||||||
|
elif error.validator == 'additionalProperties':
|
||||||
|
hint = 'Remove arguments the offered tool schema does not define.'
|
||||||
|
else:
|
||||||
|
return ''
|
||||||
|
return f'{hint} Correct the call using the offered tool schema.'
|
||||||
|
|
||||||
|
|
||||||
def _public_preview_tool_error(exc, *, execution_attempted=False):
|
def _public_preview_tool_error(exc, *, execution_attempted=False):
|
||||||
"""Keep useful domain guidance while withholding arbitrary diagnostics."""
|
"""Keep useful domain guidance while withholding arbitrary diagnostics."""
|
||||||
if execution_attempted:
|
if execution_attempted:
|
||||||
@@ -6782,6 +6817,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
result, desc, policy_denied, block = None, name, False, None
|
result, desc, policy_denied, block = None, name, False, None
|
||||||
args = {}
|
args = {}
|
||||||
execution_attempted = False
|
execution_attempted = False
|
||||||
|
# Guidance a preview validator authored for this call. It
|
||||||
|
# is the only failure text returned verbatim; exception
|
||||||
|
# text goes through _public_preview_tool_error.
|
||||||
|
validator_error = ''
|
||||||
call_signature = None
|
call_signature = None
|
||||||
semantic_scope = None
|
semantic_scope = None
|
||||||
try:
|
try:
|
||||||
@@ -6840,11 +6879,12 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
prior_outcome = browser_navigation_outcomes.get(requested_url)
|
prior_outcome = browser_navigation_outcomes.get(requested_url)
|
||||||
if requested_url and prior_outcome and prior_outcome[1] >= 2:
|
if requested_url and prior_outcome and prior_outcome[1] >= 2:
|
||||||
calls += 1
|
calls += 1
|
||||||
raise ValueError(
|
validator_error = (
|
||||||
f'Opening {requested_url} twice reached the same page '
|
f'Opening {requested_url} twice reached the same page '
|
||||||
f'({prior_outcome[0]}). Do not repeat it; use the current '
|
f'({prior_outcome[0]}). Do not repeat it; use the current '
|
||||||
'page evidence or a different navigation strategy.'
|
'page evidence or a different navigation strategy.'
|
||||||
)
|
)
|
||||||
|
raise ValueError(validator_error)
|
||||||
if tool_type == 'web_search':
|
if tool_type == 'web_search':
|
||||||
web_search_attempts += 1
|
web_search_attempts += 1
|
||||||
if not native_workspace_enabled and web_search_attempts > 3:
|
if not native_workspace_enabled and web_search_attempts > 3:
|
||||||
@@ -6938,6 +6978,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
'This exact invalid call was repeated after two validation failures; '
|
'This exact invalid call was repeated after two validation failures; '
|
||||||
'the tool is disabled for this turn.'
|
'the tool is disabled for this turn.'
|
||||||
)
|
)
|
||||||
|
validator_error = semantic_error
|
||||||
raise ValueError(semantic_error)
|
raise ValueError(semantic_error)
|
||||||
if canonical(name) == 'bash':
|
if canonical(name) == 'bash':
|
||||||
command = str(args.get('command') or '')
|
command = str(args.get('command') or '')
|
||||||
@@ -6946,6 +6987,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
calls += 1
|
calls += 1
|
||||||
suppressed_tool_until_round['bash'] = round_number + 1
|
suppressed_tool_until_round['bash'] = round_number + 1
|
||||||
round_recovery_messages.append(sensitive_error)
|
round_recovery_messages.append(sensitive_error)
|
||||||
|
validator_error = sensitive_error
|
||||||
raise ValueError(sensitive_error)
|
raise ValueError(sensitive_error)
|
||||||
misused_native_tool = shell_native_tool_command_misuse(
|
misused_native_tool = shell_native_tool_command_misuse(
|
||||||
command, round_offered,
|
command, round_offered,
|
||||||
@@ -6959,6 +7001,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
'with its offered schema.'
|
'with its offered schema.'
|
||||||
)
|
)
|
||||||
round_recovery_messages.append(recovery)
|
round_recovery_messages.append(recovery)
|
||||||
|
validator_error = recovery
|
||||||
raise ValueError(recovery)
|
raise ValueError(recovery)
|
||||||
semantic_scope = semantic_repeat_scope(name, args)
|
semantic_scope = semantic_repeat_scope(name, args)
|
||||||
if (
|
if (
|
||||||
@@ -7015,10 +7058,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
if canonical(name) in permanently_suppressed_tools:
|
if canonical(name) in permanently_suppressed_tools:
|
||||||
calls += 1
|
calls += 1
|
||||||
terminal_suppression_violation = True
|
terminal_suppression_violation = True
|
||||||
raise ValueError(
|
validator_error = (
|
||||||
f'{name} was disabled after repeated identical calls; '
|
f'{name} was disabled after repeated identical calls; '
|
||||||
'no further execution was attempted.'
|
'no further execution was attempted.'
|
||||||
)
|
)
|
||||||
|
raise ValueError(validator_error)
|
||||||
success_repeat_limit = (
|
success_repeat_limit = (
|
||||||
private_browser_success_repeat_limit(args)
|
private_browser_success_repeat_limit(args)
|
||||||
if tool_type == 'private_browser' else 1
|
if tool_type == 'private_browser' else 1
|
||||||
@@ -7111,6 +7155,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
)
|
)
|
||||||
if artifact_code_error:
|
if artifact_code_error:
|
||||||
round_recovery_messages.append(artifact_code_error)
|
round_recovery_messages.append(artifact_code_error)
|
||||||
|
validator_error = artifact_code_error
|
||||||
raise ValueError(artifact_code_error)
|
raise ValueError(artifact_code_error)
|
||||||
decision = evaluate_preview_call(
|
decision = evaluate_preview_call(
|
||||||
name, args, latest_user,
|
name, args, latest_user,
|
||||||
@@ -7316,8 +7361,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
|||||||
logging.getLogger(__name__).warning(
|
logging.getLogger(__name__).warning(
|
||||||
'Clean v3 tool call failed: %s', exc, exc_info=True,
|
'Clean v3 tool call failed: %s', exc, exc_info=True,
|
||||||
)
|
)
|
||||||
|
if not validator_error and isinstance(exc, jsonschema.ValidationError) and schema:
|
||||||
|
validator_error = _schema_argument_hint(args, schema['function']['parameters'])
|
||||||
result = {
|
result = {
|
||||||
'error': _public_preview_tool_error(exc, execution_attempted=execution_attempted),
|
'error': (
|
||||||
|
validator_error if validator_error and not execution_attempted
|
||||||
|
else _public_preview_tool_error(exc, execution_attempted=execution_attempted)
|
||||||
|
),
|
||||||
'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments',
|
'error_category': 'tool_execution_error' if execution_attempted else 'invalid_tool_arguments',
|
||||||
'exit_code': 1,
|
'exit_code': 1,
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-35
@@ -37,6 +37,41 @@ def _apply_owner_filter(query, DbSession, owner: Optional[str]):
|
|||||||
return query.filter(DbSession.owner == owner)
|
return query.filter(DbSession.owner == owner)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_count_subquery(db, DbSession, DbChatMessage):
|
||||||
|
"""Count durable rows instead of trusting the derived session counter."""
|
||||||
|
from sqlalchemy import func
|
||||||
|
return db.query(func.count(DbChatMessage.id)).filter(
|
||||||
|
DbChatMessage.session_id == DbSession.id
|
||||||
|
).correlate(DbSession).scalar_subquery()
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_session_ids(db, DbSession, owner: Optional[str]) -> set:
|
||||||
|
"""Fetch the bounded set of recently created session IDs to preserve."""
|
||||||
|
recent_q = db.query(DbSession.id).order_by(DbSession.created_at.desc())
|
||||||
|
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
||||||
|
return {row[0] for row in recent_q.limit(CleanupConfig.PRESERVE_RECENT_COUNT).all()}
|
||||||
|
|
||||||
|
|
||||||
|
def _supports_delete_returning(db) -> bool:
|
||||||
|
"""Check if the current database backend and runtime version support DELETE ... RETURNING."""
|
||||||
|
dialect = getattr(db.get_bind(), "dialect", None)
|
||||||
|
if dialect is None:
|
||||||
|
return False
|
||||||
|
if dialect.name == "postgresql":
|
||||||
|
return True
|
||||||
|
if dialect.name == "sqlite":
|
||||||
|
# SQLite added RETURNING support in 3.35.0 (March 2021).
|
||||||
|
version = getattr(dialect, "server_version_info", None)
|
||||||
|
if version is not None:
|
||||||
|
return version >= (3, 35, 0)
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
return sqlite3.sqlite_version_info >= (3, 35, 0)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return getattr(dialect, "delete_returning", False)
|
||||||
|
|
||||||
|
|
||||||
async def archive_inactive_sessions(session_manager, owner: Optional[str] = None) -> int:
|
async def archive_inactive_sessions(session_manager, owner: Optional[str] = None) -> int:
|
||||||
"""
|
"""
|
||||||
Archive sessions that haven't been accessed in the configured number of days.
|
Archive sessions that haven't been accessed in the configured number of days.
|
||||||
@@ -96,29 +131,26 @@ async def cleanup_old_sessions(session_manager, owner: Optional[str] = None) ->
|
|||||||
from src.database import SessionLocal, Session as DbSession, ChatMessage as DbChatMessage
|
from src.database import SessionLocal, Session as DbSession, ChatMessage as DbChatMessage
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
recent_q = db.query(DbSession).order_by(DbSession.created_at.desc())
|
recent_session_ids = _recent_session_ids(db, DbSession, owner)
|
||||||
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
|
||||||
all_sessions = recent_q.all()
|
|
||||||
recent_session_ids = {session.id for session in all_sessions[:CleanupConfig.PRESERVE_RECENT_COUNT]}
|
|
||||||
|
|
||||||
base_query = db.query(DbSession).filter(
|
base_query = db.query(DbSession).filter(
|
||||||
DbSession.archived == True,
|
DbSession.archived == True,
|
||||||
DbSession.last_accessed < cutoff_date,
|
DbSession.last_accessed < cutoff_date,
|
||||||
DbSession.is_important == False,
|
DbSession.is_important == False,
|
||||||
DbSession.message_count < CleanupConfig.MIN_MESSAGES_TO_KEEP
|
|
||||||
)
|
)
|
||||||
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
||||||
|
actual_count = _message_count_subquery(db, DbSession, DbChatMessage)
|
||||||
candidate_sessions = base_query.all()
|
candidate_sessions = base_query.add_columns(actual_count).all()
|
||||||
sessions_to_delete = []
|
sessions_to_delete = []
|
||||||
|
message_counts = {}
|
||||||
preserved_count = 0
|
preserved_count = 0
|
||||||
|
|
||||||
for session in candidate_sessions:
|
for session, message_count in candidate_sessions:
|
||||||
if session.id in recent_session_ids:
|
if session.id in recent_session_ids:
|
||||||
preserved_count += 1
|
preserved_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if session.message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
if message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||||
preserved_count += 1
|
preserved_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -128,20 +160,52 @@ async def cleanup_old_sessions(session_manager, owner: Optional[str] = None) ->
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
sessions_to_delete.append(session)
|
sessions_to_delete.append(session)
|
||||||
|
message_counts[session.id] = message_count
|
||||||
for session in sessions_to_delete:
|
|
||||||
message_count = db.query(DbChatMessage).filter(
|
|
||||||
DbChatMessage.session_id == session.id
|
|
||||||
).count()
|
|
||||||
space_freed += message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
|
||||||
|
|
||||||
session_ids = [session.id for session in sessions_to_delete]
|
session_ids = [session.id for session in sessions_to_delete]
|
||||||
if session_ids:
|
if session_ids:
|
||||||
db.query(DbSession).filter(DbSession.id.in_(session_ids)).delete(synchronize_session=False)
|
# Recheck the real count in the DELETE statement itself. A message
|
||||||
deleted_count = len(session_ids)
|
# arriving after candidate selection must retain the protection.
|
||||||
|
# Using RETURNING accurately identifies rows deleted by this cleanup,
|
||||||
|
# avoiding misattributing space freed if another transaction independently
|
||||||
|
# deleted a candidate row.
|
||||||
|
from sqlalchemy import delete
|
||||||
|
stmt = delete(DbSession).filter(
|
||||||
|
DbSession.id.in_(session_ids),
|
||||||
|
DbSession.archived == True,
|
||||||
|
DbSession.last_accessed < cutoff_date,
|
||||||
|
DbSession.is_important == False,
|
||||||
|
actual_count < CleanupConfig.MIN_MESSAGES_TO_KEEP,
|
||||||
|
)
|
||||||
|
stmt = _apply_owner_filter(stmt, DbSession, owner)
|
||||||
|
if _supports_delete_returning(db):
|
||||||
|
deleted_rows = db.execute(stmt.returning(DbSession.id)).scalars().all()
|
||||||
|
deleted_ids = set(deleted_rows)
|
||||||
|
deleted_count = len(deleted_ids)
|
||||||
|
surviving_ids = {row[0] for row in db.query(DbSession.id).filter(
|
||||||
|
DbSession.id.in_(session_ids)
|
||||||
|
).all()}
|
||||||
|
unaccounted_ids = set(session_ids) - surviving_ids - deleted_ids
|
||||||
|
space_freed = sum(message_counts[sid] for sid in deleted_ids) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||||
|
else:
|
||||||
|
# Portable fallback for legacy SQLite (< 3.35.0) which lacks RETURNING syntax.
|
||||||
|
res = db.execute(stmt)
|
||||||
|
deleted_count = res.rowcount or 0
|
||||||
|
surviving_ids = {row[0] for row in db.query(DbSession.id).filter(
|
||||||
|
DbSession.id.in_(session_ids)
|
||||||
|
).all()}
|
||||||
|
deleted_ids = set(session_ids) - surviving_ids
|
||||||
|
unaccounted_ids = set()
|
||||||
|
if deleted_count == 0:
|
||||||
|
space_freed = 0
|
||||||
|
elif len(deleted_ids) <= deleted_count:
|
||||||
|
space_freed = sum(message_counts[sid] for sid in deleted_ids) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||||
|
else:
|
||||||
|
space_freed = sum(message_counts[sid] for sid in sorted(deleted_ids)[:deleted_count]) * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
for session_id in session_ids:
|
cache_evict_ids = deleted_ids | unaccounted_ids
|
||||||
|
for session_id in cache_evict_ids:
|
||||||
if session_id in session_manager.sessions:
|
if session_id in session_manager.sessions:
|
||||||
del session_manager.sessions[session_id]
|
del session_manager.sessions[session_id]
|
||||||
|
|
||||||
@@ -176,7 +240,7 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
|||||||
estimated_space_freed = 0
|
estimated_space_freed = 0
|
||||||
preserved_sessions = []
|
preserved_sessions = []
|
||||||
|
|
||||||
from src.database import SessionLocal, Session as DbSession
|
from src.database import SessionLocal, Session as DbSession, ChatMessage as DbChatMessage
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
archive_q = db.query(DbSession).filter(
|
archive_q = db.query(DbSession).filter(
|
||||||
@@ -184,49 +248,46 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
|||||||
DbSession.archived == False
|
DbSession.archived == False
|
||||||
)
|
)
|
||||||
archive_q = _apply_owner_filter(archive_q, DbSession, owner)
|
archive_q = _apply_owner_filter(archive_q, DbSession, owner)
|
||||||
archive_candidates = archive_q.all()
|
actual_count = _message_count_subquery(db, DbSession, DbChatMessage)
|
||||||
|
archive_candidates = archive_q.add_columns(actual_count).all()
|
||||||
|
|
||||||
for session in archive_candidates:
|
for session, message_count in archive_candidates:
|
||||||
sessions_to_archive.append({
|
sessions_to_archive.append({
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"name": session.name,
|
"name": session.name,
|
||||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||||
"message_count": session.message_count
|
"message_count": message_count
|
||||||
})
|
})
|
||||||
|
|
||||||
recent_q = db.query(DbSession).order_by(DbSession.created_at.desc())
|
recent_session_ids = _recent_session_ids(db, DbSession, owner)
|
||||||
recent_q = _apply_owner_filter(recent_q, DbSession, owner)
|
|
||||||
all_sessions = recent_q.all()
|
|
||||||
recent_session_ids = {session.id for session in all_sessions[:CleanupConfig.PRESERVE_RECENT_COUNT]}
|
|
||||||
|
|
||||||
base_query = db.query(DbSession).filter(
|
base_query = db.query(DbSession).filter(
|
||||||
DbSession.archived == True,
|
DbSession.archived == True,
|
||||||
DbSession.last_accessed < cutoff_delete,
|
DbSession.last_accessed < cutoff_delete,
|
||||||
DbSession.is_important == False,
|
DbSession.is_important == False,
|
||||||
DbSession.message_count < CleanupConfig.MIN_MESSAGES_TO_KEEP
|
|
||||||
)
|
)
|
||||||
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
base_query = _apply_owner_filter(base_query, DbSession, owner)
|
||||||
|
|
||||||
candidate_sessions = base_query.all()
|
candidate_sessions = base_query.add_columns(actual_count).all()
|
||||||
|
|
||||||
for session in candidate_sessions:
|
for session, message_count in candidate_sessions:
|
||||||
if session.id in recent_session_ids:
|
if session.id in recent_session_ids:
|
||||||
preserved_sessions.append({
|
preserved_sessions.append({
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"name": session.name,
|
"name": session.name,
|
||||||
"reason": f"part of last {CleanupConfig.PRESERVE_RECENT_COUNT} sessions",
|
"reason": f"part of last {CleanupConfig.PRESERVE_RECENT_COUNT} sessions",
|
||||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||||
"message_count": session.message_count
|
"message_count": message_count
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if session.message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
if message_count >= CleanupConfig.MIN_MESSAGES_TO_KEEP:
|
||||||
preserved_sessions.append({
|
preserved_sessions.append({
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"name": session.name,
|
"name": session.name,
|
||||||
"reason": f"has {CleanupConfig.MIN_MESSAGES_TO_KEEP}+ messages",
|
"reason": f"has {CleanupConfig.MIN_MESSAGES_TO_KEEP}+ messages",
|
||||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||||
"message_count": session.message_count
|
"message_count": message_count
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -238,18 +299,18 @@ async def get_cleanup_preview(owner: Optional[str] = None) -> Dict[str, Any]:
|
|||||||
"name": session.name,
|
"name": session.name,
|
||||||
"reason": f"contains keyword: {matching_keywords[0]}",
|
"reason": f"contains keyword: {matching_keywords[0]}",
|
||||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||||
"message_count": session.message_count
|
"message_count": message_count
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session_space = session.message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
session_space = message_count * CleanupConfig.ESTIMATED_MESSAGE_SIZE_BYTES
|
||||||
estimated_space_freed += session_space
|
estimated_space_freed += session_space
|
||||||
|
|
||||||
sessions_to_delete.append({
|
sessions_to_delete.append({
|
||||||
"id": session.id,
|
"id": session.id,
|
||||||
"name": session.name,
|
"name": session.name,
|
||||||
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
"last_accessed": session.last_accessed.isoformat() if session.last_accessed else "Unknown",
|
||||||
"message_count": session.message_count,
|
"message_count": message_count,
|
||||||
"estimated_size_kb": round(session_space / 1024, 2)
|
"estimated_size_kb": round(session_space / 1024, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
|||||||
from src.model_context import estimate_text_tokens, get_context_length, estimate_tokens
|
from src.model_context import estimate_text_tokens, get_context_length, estimate_tokens
|
||||||
from src.llm_core import llm_call_async
|
from src.llm_core import llm_call_async
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.utility_effort import effort_for_call
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
|
|
||||||
@@ -556,7 +557,13 @@ async def maybe_compact(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Use utility model if configured, otherwise fall back to session model
|
# Use utility model if configured, otherwise fall back to session model
|
||||||
util_url, util_model, util_headers = resolve_endpoint("utility", owner=owner)
|
util_url, util_model, util_headers = resolve_endpoint(
|
||||||
|
"utility",
|
||||||
|
fallback_url=endpoint_url,
|
||||||
|
fallback_model=model,
|
||||||
|
fallback_headers=headers,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
compact_url = util_url or endpoint_url
|
compact_url = util_url or endpoint_url
|
||||||
compact_model = util_model or model
|
compact_model = util_model or model
|
||||||
compact_headers = util_headers if util_url else headers
|
compact_headers = util_headers if util_url else headers
|
||||||
@@ -580,6 +587,7 @@ async def maybe_compact(
|
|||||||
max_tokens=SUMMARY_MAX_TOKENS,
|
max_tokens=SUMMARY_MAX_TOKENS,
|
||||||
headers=compact_headers,
|
headers=compact_headers,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
reasoning_effort=effort_for_call(compact_url, compact_model, owner, session),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Compaction summary failed: {e}")
|
logger.error(f"Compaction summary failed: {e}")
|
||||||
|
|||||||
+42
-1
@@ -1,5 +1,8 @@
|
|||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
@@ -12,10 +15,47 @@ GENERATED_IMAGE_RE = re.compile(
|
|||||||
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
|
r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$"
|
||||||
)
|
)
|
||||||
GENERATED_IMAGE_HEADERS = {
|
GENERATED_IMAGE_HEADERS = {
|
||||||
"Cache-Control": "public, max-age=31536000, immutable",
|
"Cache-Control": "private, no-cache, must-revalidate",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_RECENT_GENERATED_IMAGES: dict[str, tuple[str, float]] = {}
|
||||||
|
_RECENT_IMAGES_LOCK = threading.Lock()
|
||||||
|
_RECENT_IMAGES_TTL = 3600 # 1 hour TTL
|
||||||
|
|
||||||
|
|
||||||
|
def register_generated_image(filename: str, owner: Optional[str]) -> None:
|
||||||
|
"""Record ownership of a freshly generated image.
|
||||||
|
|
||||||
|
Ensures legitimate access to freshly generated images even before
|
||||||
|
or in case Gallery database records are created/committed.
|
||||||
|
"""
|
||||||
|
if not filename or not owner:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
with _RECENT_IMAGES_LOCK:
|
||||||
|
if len(_RECENT_GENERATED_IMAGES) > 200:
|
||||||
|
expired = [k for k, (_, exp) in _RECENT_GENERATED_IMAGES.items() if exp < now]
|
||||||
|
for k in expired:
|
||||||
|
_RECENT_GENERATED_IMAGES.pop(k, None)
|
||||||
|
_RECENT_GENERATED_IMAGES[filename] = (owner, now + _RECENT_IMAGES_TTL)
|
||||||
|
|
||||||
|
|
||||||
|
def get_registered_image_owner(filename: str) -> Optional[str]:
|
||||||
|
"""Return the owner of a freshly generated image if still valid in the registry."""
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
with _RECENT_IMAGES_LOCK:
|
||||||
|
entry = _RECENT_GENERATED_IMAGES.get(filename)
|
||||||
|
if not entry:
|
||||||
|
return None
|
||||||
|
owner, expiry = entry
|
||||||
|
if now > expiry:
|
||||||
|
_RECENT_GENERATED_IMAGES.pop(filename, None)
|
||||||
|
return None
|
||||||
|
return owner
|
||||||
|
|
||||||
|
|
||||||
def resolve_generated_image_path(filename: str) -> Path:
|
def resolve_generated_image_path(filename: str) -> Path:
|
||||||
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
|
if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename):
|
||||||
@@ -27,3 +67,4 @@ def resolve_generated_image_path(filename: str) -> Path:
|
|||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|||||||
+37
-4
@@ -2268,10 +2268,25 @@ def normalize_model_id(
|
|||||||
return a
|
return a
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_explicit_nonstream_reasoning_effort(
|
||||||
|
payload: Dict, reasoning_effort: Optional[str]
|
||||||
|
) -> None:
|
||||||
|
"""Apply explicit effort to an OpenAI-compatible non-streaming request.
|
||||||
|
|
||||||
|
No explicit effort preserves existing provider-specific defaults.
|
||||||
|
Native Ollama, Anthropic and ChatGPT Responses use separate builders.
|
||||||
|
"""
|
||||||
|
effort = str(reasoning_effort or "").strip().lower()
|
||||||
|
if effort and effort != "default":
|
||||||
|
payload["reasoning_effort"] = effort
|
||||||
|
|
||||||
|
|
||||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
thinking_mode: Optional[str] = None) -> str:
|
thinking_mode: Optional[str] = None,
|
||||||
|
reasoning_effort: Optional[str] = None) -> str:
|
||||||
"""Synchronous LLM call with optional prompt type enhancement."""
|
"""Synchronous LLM call with optional prompt type enhancement."""
|
||||||
h = _provider_headers(_detect_provider(url))
|
h = _provider_headers(_detect_provider(url))
|
||||||
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
||||||
@@ -2303,7 +2318,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
provider = _detect_provider(url)
|
provider = _detect_provider(url)
|
||||||
cache_key = _get_cache_key(
|
cache_key = _get_cache_key(
|
||||||
url, model, messages_copy, temperature, max_tokens, headers=headers,
|
url, model, messages_copy, temperature, max_tokens, headers=headers,
|
||||||
thinking_mode=thinking_mode,
|
thinking_mode=thinking_mode, reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
cached_response = _get_cached_response(cache_key)
|
cached_response = _get_cached_response(cache_key)
|
||||||
if cached_response:
|
if cached_response:
|
||||||
@@ -2343,6 +2358,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
)
|
)
|
||||||
if provider == "mistral" and _supports_thinking(model):
|
if provider == "mistral" and _supports_thinking(model):
|
||||||
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
payload["reasoning_effort"] = _MISTRAL_REASONING_EFFORT
|
||||||
|
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||||
@@ -2465,14 +2481,30 @@ def llm_call_with_fallback(candidates, messages, **kwargs) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def llm_call_async_with_fallback(candidates, messages, **kwargs) -> str:
|
async def llm_call_async_with_fallback(candidates, messages, **kwargs) -> str:
|
||||||
"""Async variant of `llm_call_with_fallback` — same semantics."""
|
"""Async variant of `llm_call_with_fallback` — same semantics.
|
||||||
|
|
||||||
|
An optional ``candidate_request_factory(index, url, model, headers)`` may
|
||||||
|
return ``{"messages": ..., "kwargs": {...}}`` to adjust the request per
|
||||||
|
candidate (same contract as `llm_call_async_with_route_fallback`).
|
||||||
|
"""
|
||||||
|
candidate_request_factory = kwargs.pop("candidate_request_factory", None)
|
||||||
cands = dedupe_model_candidates(candidates)
|
cands = dedupe_model_candidates(candidates)
|
||||||
if not cands:
|
if not cands:
|
||||||
raise HTTPException(503, "No model endpoint configured")
|
raise HTTPException(503, "No model endpoint configured")
|
||||||
last_err = None
|
last_err = None
|
||||||
for i, (url, model, headers) in enumerate(cands):
|
for i, (url, model, headers) in enumerate(cands):
|
||||||
try:
|
try:
|
||||||
return await llm_call_async(url, model, messages, headers=headers, **kwargs)
|
candidate_messages = messages
|
||||||
|
candidate_kwargs = kwargs
|
||||||
|
if candidate_request_factory is not None:
|
||||||
|
request = candidate_request_factory(i, url, model, headers) or {}
|
||||||
|
if hasattr(request, "__await__"):
|
||||||
|
request = await request
|
||||||
|
candidate_messages = request.get("messages", messages)
|
||||||
|
candidate_kwargs = {**kwargs, **(request.get("kwargs") or {})}
|
||||||
|
return await llm_call_async(
|
||||||
|
url, model, candidate_messages, headers=headers, **candidate_kwargs
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = e
|
last_err = e
|
||||||
tag = "primary" if i == 0 else "candidate"
|
tag = "primary" if i == 0 else "candidate"
|
||||||
@@ -2740,6 +2772,7 @@ async def llm_call_async(
|
|||||||
_apply_deepseek_v4_reasoning_defaults(
|
_apply_deepseek_v4_reasoning_defaults(
|
||||||
payload, target_url, model, thinking_mode
|
payload, target_url, model, thinking_mode
|
||||||
)
|
)
|
||||||
|
_apply_explicit_nonstream_reasoning_effort(payload, reasoning_effort)
|
||||||
|
|
||||||
if _is_host_dead(target_url):
|
if _is_host_dead(target_url):
|
||||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||||
|
|||||||
@@ -160,6 +160,10 @@ DEFAULT_SETTINGS = {
|
|||||||
# Ordered fallback chain for the Utility model (summarization, naming,
|
# Ordered fallback chain for the Utility model (summarization, naming,
|
||||||
# tidy actions, etc.).
|
# tidy actions, etc.).
|
||||||
"utility_model_fallbacks": [],
|
"utility_model_fallbacks": [],
|
||||||
|
# Opt-in reasoning effort for utility/background LLM calls. Empty keeps the
|
||||||
|
# provider default; a value is sent only to utility-chain candidates whose
|
||||||
|
# model advertises that level.
|
||||||
|
"utility_reasoning_effort": "",
|
||||||
"teacher_model": "",
|
"teacher_model": "",
|
||||||
"teacher_enabled": False,
|
"teacher_enabled": False,
|
||||||
"teacher_tier2_enabled": False,
|
"teacher_tier2_enabled": False,
|
||||||
@@ -290,6 +294,7 @@ _PER_USER_KEYS = {
|
|||||||
# got injected into the chat composer on first open.
|
# got injected into the chat composer on first open.
|
||||||
"default_endpoint_id", "default_model",
|
"default_endpoint_id", "default_model",
|
||||||
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||||
|
"utility_reasoning_effort",
|
||||||
"research_endpoint_id", "research_model",
|
"research_endpoint_id", "research_model",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,4 +106,11 @@ async def task_llm_call_async(
|
|||||||
raise RuntimeError("No LLM endpoint available for background task")
|
raise RuntimeError("No LLM endpoint available for background task")
|
||||||
await wait_for_interactive_quiet("background task LLM")
|
await wait_for_interactive_quiet("background task LLM")
|
||||||
kwargs.setdefault("workload", "background")
|
kwargs.setdefault("workload", "background")
|
||||||
|
if kwargs.get("reasoning_effort") is None:
|
||||||
|
kwargs.pop("reasoning_effort", None)
|
||||||
|
from src.utility_effort import candidate_effort_factory
|
||||||
|
|
||||||
|
factory = candidate_effort_factory(owner)
|
||||||
|
if factory is not None:
|
||||||
|
kwargs.setdefault("candidate_request_factory", factory)
|
||||||
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
return await llm_call_async_with_fallback(candidates, messages=messages, **kwargs)
|
||||||
|
|||||||
+17
-2
@@ -415,9 +415,24 @@ class ToolApprovalStore:
|
|||||||
backend_operation = BoundBackendOperation(backend,
|
backend_operation = BoundBackendOperation(backend,
|
||||||
request_authority.request_id if request_authority is not None else "",
|
request_authority.request_id if request_authority is not None else "",
|
||||||
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
_normalized_owner(owner), str(session_id or ""), operation.transport_tool, operation.input)
|
||||||
from src.agent_runtime.process_resources import needs_process_binding, resolve_process_operation
|
from src.agent_runtime.process_resources import (
|
||||||
|
LAUNCH_TOOLS, needs_process_binding, resolve_process_operation, seal_launch_scope)
|
||||||
if request_authority is not None and needs_process_binding(operation, backend):
|
if request_authority is not None and needs_process_binding(operation, backend):
|
||||||
process_operation = resolve_process_operation(request_authority, operation, backend)
|
process_authority = request_authority
|
||||||
|
if (not request_authority.inherited and operation.tool in LAUNCH_TOOLS
|
||||||
|
and not any(s.backend == backend for s in request_authority.launch_scopes)):
|
||||||
|
# Launch scopes exist only for granted bash/python, but the
|
||||||
|
# approval exists to admit an ungranted one. Seal its launch
|
||||||
|
# boundary now, in the request's workspace, the same way an
|
||||||
|
# ungranted filesystem action gets its root below (#6635).
|
||||||
|
roots = request_authority.resource_roots
|
||||||
|
if not roots and workspace:
|
||||||
|
roots = (FilesystemRoot.seal(workspace, owner=_normalized_owner(owner)),)
|
||||||
|
if len(roots) == 1:
|
||||||
|
from dataclasses import replace
|
||||||
|
process_authority = replace(
|
||||||
|
request_authority, launch_scopes=(seal_launch_scope(backend, roots[0]),))
|
||||||
|
process_operation = resolve_process_operation(process_authority, operation, backend)
|
||||||
from src.browser_identity import native_browser, resolve_browser_operation
|
from src.browser_identity import native_browser, resolve_browser_operation
|
||||||
if native_browser(operation, backend):
|
if native_browser(operation, backend):
|
||||||
if request_authority is None:
|
if request_authority is None:
|
||||||
|
|||||||
+37
-17
@@ -964,24 +964,28 @@ def _is_hardlinked_regular_file(resolved: str) -> bool:
|
|||||||
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
|
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
|
||||||
|
|
||||||
|
|
||||||
def _is_denied_tool_path(resolved: str) -> bool:
|
def _is_denied_tool_path(resolved: str, *, snapshot=None) -> bool:
|
||||||
"""Apply every path deny to a canonical traversal result."""
|
"""Apply every path deny to a canonical traversal result.
|
||||||
|
|
||||||
|
Directory scans pass one ``_control_plane_snapshot()`` for the whole walk;
|
||||||
|
rebuilding it per entry made grep/ls/glob linear in protected-state stats.
|
||||||
|
"""
|
||||||
from src.agent_runtime.resources import _control_plane_path
|
from src.agent_runtime.resources import _control_plane_path
|
||||||
return (
|
return (
|
||||||
_is_sensitive_path(resolved)
|
_is_sensitive_path(resolved)
|
||||||
or _is_app_state_path(resolved)
|
or _is_app_state_path(resolved)
|
||||||
or _is_hardlinked_regular_file(resolved)
|
or _is_hardlinked_regular_file(resolved)
|
||||||
or _control_plane_path(resolved)
|
or _control_plane_path(resolved, snapshot=snapshot)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _can_traverse_tool_path(resolved: str) -> bool:
|
def _can_traverse_tool_path(resolved: str, *, snapshot=None) -> bool:
|
||||||
"""Allow walking a denied state parent only to reach safe carve-outs."""
|
"""Allow walking a denied state parent only to reach safe carve-outs."""
|
||||||
if _is_sensitive_path(resolved):
|
if _is_sensitive_path(resolved):
|
||||||
return False
|
return False
|
||||||
if not _is_app_state_path(resolved):
|
if not _is_app_state_path(resolved):
|
||||||
from src.agent_runtime.resources import _control_plane_path
|
from src.agent_runtime.resources import _control_plane_path
|
||||||
return not _control_plane_path(resolved)
|
return not _control_plane_path(resolved, snapshot=snapshot)
|
||||||
return any(
|
return any(
|
||||||
_path_within(readable, resolved)
|
_path_within(readable, resolved)
|
||||||
for readable in _agent_readable_data_subdirs()
|
for readable in _agent_readable_data_subdirs()
|
||||||
@@ -1313,7 +1317,8 @@ def _parse_qualified_mcp_args(tool: str, content: str) -> tuple[Dict, Optional[s
|
|||||||
if tool.startswith("mcp__email__"):
|
if tool.startswith("mcp__email__"):
|
||||||
return {}, "Email MCP tool arguments must be a JSON object."
|
return {}, "Email MCP tool arguments must be a JSON object."
|
||||||
return {}, None
|
return {}, None
|
||||||
return parsed, None
|
sanitized = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
|
||||||
|
return sanitized, None
|
||||||
|
|
||||||
|
|
||||||
def _parse_generate_image(content: str) -> Dict:
|
def _parse_generate_image(content: str) -> Dict:
|
||||||
@@ -1398,9 +1403,12 @@ def _build_mcp_args(tool: str, content: str) -> Dict:
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
decoded = None
|
decoded = None
|
||||||
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
|
if isinstance(decoded, dict) and any(k in decoded for k in primaries):
|
||||||
return decoded
|
return {k: v for k, v in decoded.items() if not k.startswith("_odysseus_")}
|
||||||
parser = _MCP_ARG_PARSERS.get(tool)
|
parser = _MCP_ARG_PARSERS.get(tool)
|
||||||
return parser(content) if parser else {}
|
res = parser(content) if parser else {}
|
||||||
|
if isinstance(res, dict):
|
||||||
|
return {k: v for k, v in res.items() if not k.startswith("_odysseus_")}
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
def _normalize_mcp_text_error(result: Dict) -> Dict:
|
def _normalize_mcp_text_error(result: Dict) -> Dict:
|
||||||
@@ -1417,20 +1425,25 @@ async def _call_mcp_tool(
|
|||||||
tool: str,
|
tool: str,
|
||||||
content: str,
|
content: str,
|
||||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||||
|
owner: Optional[str] = None,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
|
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
|
||||||
bound = active_backend_operation()
|
bound = active_backend_operation()
|
||||||
if bound is not None and isinstance(bound.resource, NativeBackendResource):
|
if bound is not None and isinstance(bound.resource, NativeBackendResource):
|
||||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
|
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"Native tool '{tool}' unavailable", "exit_code": 1}
|
||||||
mcp = get_mcp_manager()
|
mcp = get_mcp_manager()
|
||||||
if not mcp:
|
if not mcp:
|
||||||
if bound is not None:
|
if bound is not None:
|
||||||
raise ResourceIdentityError("Pinned MCP backend is unavailable")
|
raise ResourceIdentityError("Pinned MCP backend is unavailable")
|
||||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
return await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||||
|
|
||||||
server_id, tool_name = _MCP_TOOL_MAP[tool]
|
server_id, tool_name = _MCP_TOOL_MAP[tool]
|
||||||
qualified = f"mcp__{server_id}__{tool_name}"
|
qualified = f"mcp__{server_id}__{tool_name}"
|
||||||
args = _build_mcp_args(tool, content)
|
args = _build_mcp_args(tool, content)
|
||||||
|
if tool == "generate_image":
|
||||||
|
args.pop("_odysseus_owner", None)
|
||||||
|
if owner:
|
||||||
|
args["_odysseus_owner"] = owner
|
||||||
result = await mcp.call_tool(qualified, args)
|
result = await mcp.call_tool(qualified, args)
|
||||||
|
|
||||||
# Stdio MCP servers can only return TextContent, so an explicit
|
# Stdio MCP servers can only return TextContent, so an explicit
|
||||||
@@ -1440,7 +1453,7 @@ async def _call_mcp_tool(
|
|||||||
|
|
||||||
# If MCP server not connected, try direct fallback
|
# If MCP server not connected, try direct fallback
|
||||||
if bound is None and isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
|
if bound is None and isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
|
||||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
|
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, owner=owner)
|
||||||
if fallback:
|
if fallback:
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
@@ -2189,7 +2202,7 @@ async def _execute_tool_block_impl(
|
|||||||
elif tool in _MCP_TOOL_MAP:
|
elif tool in _MCP_TOOL_MAP:
|
||||||
first_line = content.split(chr(10))[0][:80]
|
first_line = content.split(chr(10))[0][:80]
|
||||||
desc = f"{tool}: {first_line}"
|
desc = f"{tool}: {first_line}"
|
||||||
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb))
|
result = await dispatched(_call_mcp_tool(tool, content, progress_cb=progress_cb, owner=owner))
|
||||||
elif tool in ("grep", "glob", "ls", "get_workspace", "host_shell"):
|
elif tool in ("grep", "glob", "ls", "get_workspace", "host_shell"):
|
||||||
# Code-navigation tools — no MCP server; run the direct implementation.
|
# Code-navigation tools — no MCP server; run the direct implementation.
|
||||||
first_line = content.split(chr(10))[0][:80]
|
first_line = content.split(chr(10))[0][:80]
|
||||||
@@ -2375,7 +2388,7 @@ async def _execute_tool_block_impl(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
args = parsed
|
args = {k: v for k, v in parsed.items() if not k.startswith("_odysseus_")}
|
||||||
else:
|
else:
|
||||||
_args_error = (
|
_args_error = (
|
||||||
f"'{tool}' arguments must be a JSON object, "
|
f"'{tool}' arguments must be a JSON object, "
|
||||||
@@ -2384,11 +2397,12 @@ async def _execute_tool_block_impl(
|
|||||||
if _args_error is not None:
|
if _args_error is not None:
|
||||||
result = {"error": _args_error, "exit_code": 1}
|
result = {"error": _args_error, "exit_code": 1}
|
||||||
else:
|
else:
|
||||||
|
args = dict(args)
|
||||||
|
args.pop(_EMAIL_MCP_OWNER_ARG, None)
|
||||||
|
args.pop(_EMAIL_MCP_SESSION_ARG, None)
|
||||||
if owner:
|
if owner:
|
||||||
args = dict(args)
|
|
||||||
args[_EMAIL_MCP_OWNER_ARG] = owner
|
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||||
if session_id:
|
if session_id:
|
||||||
args = dict(args)
|
|
||||||
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
||||||
result = await dispatched(mcp.call_tool(qualified, args))
|
result = await dispatched(mcp.call_tool(qualified, args))
|
||||||
else:
|
else:
|
||||||
@@ -2402,14 +2416,20 @@ async def _execute_tool_block_impl(
|
|||||||
if parse_error:
|
if parse_error:
|
||||||
result = {"error": parse_error, "exit_code": 1}
|
result = {"error": parse_error, "exit_code": 1}
|
||||||
else:
|
else:
|
||||||
|
args = dict(args)
|
||||||
|
args.pop("_odysseus_owner", None)
|
||||||
|
args.pop("_odysseus_session_id", None)
|
||||||
if tool.startswith("mcp__email__"):
|
if tool.startswith("mcp__email__"):
|
||||||
if owner:
|
if owner:
|
||||||
args = dict(args)
|
|
||||||
args[_EMAIL_MCP_OWNER_ARG] = owner
|
args[_EMAIL_MCP_OWNER_ARG] = owner
|
||||||
if session_id:
|
if session_id:
|
||||||
args = dict(args)
|
|
||||||
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
args[_EMAIL_MCP_SESSION_ARG] = session_id
|
||||||
|
elif tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
|
||||||
|
if owner:
|
||||||
|
args["_odysseus_owner"] = owner
|
||||||
result = _normalize_mcp_text_error(await dispatched(mcp.call_tool(tool, args)))
|
result = _normalize_mcp_text_error(await dispatched(mcp.call_tool(tool, args)))
|
||||||
|
if tool == "mcp__image_gen__generate_image" or tool.startswith("mcp__image_gen__"):
|
||||||
|
_promote_image_fields(result)
|
||||||
else:
|
else:
|
||||||
desc = f"mcp: {tool}"
|
desc = f"mcp: {tool}"
|
||||||
result = {"error": "MCP manager not available", "exit_code": 1}
|
result = {"error": "MCP manager not available", "exit_code": 1}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Reasoning effort for utility-role LLM calls.
|
||||||
|
|
||||||
|
One owner for the rule. Precedence for a utility call:
|
||||||
|
|
||||||
|
1. an explicit caller effort (never touched here);
|
||||||
|
2. the ``utility_reasoning_effort`` setting, sent only to routes on the
|
||||||
|
utility chain (the Utility endpoint/model plus its fallbacks, matched by
|
||||||
|
``(url, model)``);
|
||||||
|
3. the chat session's own effort (``thinking_mode == "effort:<level>"``), when
|
||||||
|
the call is made for a chat session and its route is that session's route.
|
||||||
|
|
||||||
|
Every level is validated against what the model advertises; a model without
|
||||||
|
evidence gets nothing. Calls with no session only ever use (2).
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def utility_routes(owner: Optional[str] = None) -> set:
|
||||||
|
"""The ``(url, model)`` routes that make up the utility chain."""
|
||||||
|
from src.endpoint_resolver import resolve_endpoint, resolve_utility_fallback_candidates
|
||||||
|
|
||||||
|
return {
|
||||||
|
(url, model)
|
||||||
|
for url, model, _headers in [
|
||||||
|
resolve_endpoint("utility", owner=owner),
|
||||||
|
*resolve_utility_fallback_candidates(owner=owner),
|
||||||
|
]
|
||||||
|
if url and model
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configured_effort(owner: Optional[str] = None) -> str:
|
||||||
|
"""The raw setting, lowercased; empty when unset (the default)."""
|
||||||
|
from src.settings import get_user_setting
|
||||||
|
|
||||||
|
return str(get_user_setting("utility_reasoning_effort", owner or "", "") or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_route_efforts(owner: Optional[str] = None) -> dict[tuple[str, str], str]:
|
||||||
|
"""Configured effort per fallback candidate in ``utility_model_fallbacks``."""
|
||||||
|
from src.endpoint_resolver import resolve_endpoint_by_id
|
||||||
|
from src.settings import get_user_setting, load_settings
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
chain = get_user_setting(
|
||||||
|
"utility_model_fallbacks",
|
||||||
|
owner or "",
|
||||||
|
settings.get("utility_model_fallbacks") or [],
|
||||||
|
) or []
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
res = {}
|
||||||
|
for entry in chain:
|
||||||
|
if not isinstance(entry, dict) or "reasoning_effort" not in entry:
|
||||||
|
continue
|
||||||
|
effort = str(entry.get("reasoning_effort") or "").strip().lower()
|
||||||
|
ep = resolve_endpoint_by_id(
|
||||||
|
entry.get("endpoint_id", ""),
|
||||||
|
entry.get("model", ""),
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if ep:
|
||||||
|
url, model, _ = ep
|
||||||
|
if url and model:
|
||||||
|
res[(url, model)] = effort
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def effort_for_route(url: str, model: str, owner: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""Validated effort for ``(url, model)``, or None when it must not be sent."""
|
||||||
|
fb_efforts = fallback_route_efforts(owner)
|
||||||
|
if (url, model) in fb_efforts:
|
||||||
|
fb_effort = fb_efforts[(url, model)]
|
||||||
|
if not fb_effort or fb_effort in {"off", "none", "default"}:
|
||||||
|
return None
|
||||||
|
from src.chatgpt_subscription import validate_reasoning_effort
|
||||||
|
|
||||||
|
return validate_reasoning_effort(model, fb_effort)
|
||||||
|
|
||||||
|
effort = configured_effort(owner)
|
||||||
|
if not effort or (url, model) not in utility_routes(owner):
|
||||||
|
return None
|
||||||
|
from src.chatgpt_subscription import validate_reasoning_effort
|
||||||
|
|
||||||
|
return validate_reasoning_effort(model, effort)
|
||||||
|
|
||||||
|
|
||||||
|
def session_effort(session) -> Optional[str]:
|
||||||
|
"""The raw effort level the chat session is set to, or None."""
|
||||||
|
mode = str(getattr(session, "thinking_mode", "") or "")
|
||||||
|
if not mode.startswith("effort:"):
|
||||||
|
return None
|
||||||
|
return mode[len("effort:"):].strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_session_route(url: str, model: str, session) -> bool:
|
||||||
|
from src.endpoint_resolver import same_endpoint_base
|
||||||
|
|
||||||
|
return bool(
|
||||||
|
model
|
||||||
|
and model == getattr(session, "model", None)
|
||||||
|
and same_endpoint_base(url, getattr(session, "endpoint_url", None))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def effort_for_call(url: str, model: str, owner: Optional[str] = None, session=None) -> Optional[str]:
|
||||||
|
"""Effort an owner-aware utility call should send to ``(url, model)``.
|
||||||
|
|
||||||
|
``session`` is the chat session the call is made for, or None for
|
||||||
|
session-less work (scheduled tasks, pollers, auto-sort). Never raises:
|
||||||
|
a lookup failure means "send nothing".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
raw = configured_effort(owner)
|
||||||
|
if raw in {"off", "none", "default"}:
|
||||||
|
return None
|
||||||
|
effort = effort_for_route(url, model, owner)
|
||||||
|
if effort or session is None:
|
||||||
|
return effort
|
||||||
|
level = session_effort(session)
|
||||||
|
if not level or not _is_session_route(url, model, session):
|
||||||
|
return None
|
||||||
|
from src.chatgpt_subscription import validate_reasoning_effort
|
||||||
|
|
||||||
|
return validate_reasoning_effort(model, level)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_effort_factory(owner=None):
|
||||||
|
"""Per-candidate factory for `llm_call_async_with_fallback`.
|
||||||
|
|
||||||
|
Returns None when neither the setting nor any fallback effort is configured.
|
||||||
|
Otherwise a factory sending the effort only to utility-chain candidates
|
||||||
|
(owner-scoped) whose model advertises it.
|
||||||
|
"""
|
||||||
|
raw = configured_effort(owner)
|
||||||
|
has_primary = bool(raw and raw not in {"off", "none", "default"})
|
||||||
|
fb_efforts = fallback_route_efforts(owner)
|
||||||
|
has_fb = any(eff and eff not in {"off", "none", "default"} for eff in fb_efforts.values())
|
||||||
|
if not has_primary and not has_fb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def factory(_index, url, model, _headers):
|
||||||
|
effort = effort_for_route(url, model, owner)
|
||||||
|
return {"kwargs": {"reasoning_effort": effort}} if effort else {}
|
||||||
|
|
||||||
|
return factory
|
||||||
@@ -445,6 +445,7 @@
|
|||||||
flex-shrink: 0; color: var(--fg); opacity: 0.85;
|
flex-shrink: 0; color: var(--fg); opacity: 0.85;
|
||||||
}
|
}
|
||||||
.adm-provider-logo svg { width: 14px; height: 14px; }
|
.adm-provider-logo svg { width: 14px; height: 14px; }
|
||||||
|
.adm-model-logo { margin-right: 8px; }
|
||||||
/* Pull the settings-select 4px closer to its logo chip so the row
|
/* Pull the settings-select 4px closer to its logo chip so the row
|
||||||
reads as logo+select instead of having an extra gap. */
|
reads as logo+select instead of having an extra gap. */
|
||||||
.adm-model-logo + .settings-select { margin-left: -4px; }
|
.adm-model-logo + .settings-select { margin-left: -4px; }
|
||||||
@@ -454,6 +455,8 @@
|
|||||||
select shrinks to its content and the trash button rides up
|
select shrinks to its content and the trash button rides up
|
||||||
tight to it. */
|
tight to it. */
|
||||||
.settings-fallback-row > .settings-select { flex: 1 1 0; min-width: 0; }
|
.settings-fallback-row > .settings-select { flex: 1 1 0; min-width: 0; }
|
||||||
|
.settings-fallback-row > .settings-select:first-of-type { flex: 0 1 128px; max-width: 34%; }
|
||||||
|
.settings-fallback-row > .settings-select.settings-fallback-effort { flex: 0 1 130px; max-width: 32%; }
|
||||||
/* Constrain logo SVGs in settings dropdown chips. Many provider
|
/* Constrain logo SVGs in settings dropdown chips. Many provider
|
||||||
icons in providers.js declare only viewBox (no width/height),
|
icons in providers.js declare only viewBox (no width/height),
|
||||||
so without this rule the browser falls back to 300x150 and
|
so without this rule the browser falls back to 300x150 and
|
||||||
|
|||||||
@@ -1000,6 +1000,12 @@ a.chat-link[href^="#research-"] {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
min-width: 70px;
|
min-width: 70px;
|
||||||
}
|
}
|
||||||
|
#set-utilityEffortLabel { flex: 0 0 85px; line-height: 1.2; }
|
||||||
|
.settings-label-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
.settings-select {
|
.settings-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
/* Allow the select to shrink below its longest option's width inside a flex
|
/* Allow the select to shrink below its longest option's width inside a flex
|
||||||
@@ -1117,6 +1123,11 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
|
|||||||
width: 0;
|
width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
.settings-fallback-row .settings-select.settings-fallback-effort {
|
||||||
|
flex: 0 1 130px;
|
||||||
|
width: 130px;
|
||||||
|
max-width: 32%;
|
||||||
|
}
|
||||||
details.hwfit-serve-advanced > .hwfit-serve-row,
|
details.hwfit-serve-advanced > .hwfit-serve-row,
|
||||||
details.hwfit-serve-advanced > .hwfit-serve-checks,
|
details.hwfit-serve-advanced > .hwfit-serve-checks,
|
||||||
details.hwfit-serve-advanced > .hwfit-serve-cmd-wrap,
|
details.hwfit-serve-advanced > .hwfit-serve-cmd-wrap,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -13,6 +13,7 @@
|
|||||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||||
browser-default styling that didn't match the Odysseus theme. -->
|
browser-default styling that didn't match the Odysseus theme. -->
|
||||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
window._odysseusLoadTime = Date.now();
|
window._odysseusLoadTime = Date.now();
|
||||||
(function(){
|
(function(){
|
||||||
@@ -1177,6 +1178,21 @@
|
|||||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Selected Notes/Todos indicator -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="input-icon-btn tool-indicator"
|
||||||
|
title="Selected Notes/Todos context — click to clear"
|
||||||
|
id="selected-notes-context-btn"
|
||||||
|
aria-label="Clear selected Notes/Todos context"
|
||||||
|
style="display:none;"
|
||||||
|
>
|
||||||
|
<span id="selected-notes-context-count" style="font-size:11px;margin-left:2px;"></span>
|
||||||
|
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
<button type="button" class="input-icon-btn tool-indicator" title="Workspace - click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||||
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
<svg class="workspace-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
@@ -1596,6 +1612,12 @@
|
|||||||
<span class="adm-model-logo" id="set-utilityModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
<span class="adm-model-logo" id="set-utilityModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||||
<select id="set-utilityModelSelect" class="settings-select"><option value="">—</option></select>
|
<select id="set-utilityModelSelect" class="settings-select"><option value="">—</option></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-row" id="set-utilityEffortRow" style="display:none;">
|
||||||
|
<label class="settings-label" id="set-utilityEffortLabel">Effort<span class="settings-label-sub" id="set-utilityEffortQualifier">(when supported)</span></label>
|
||||||
|
<span class="adm-model-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
|
||||||
|
<select id="set-utilityEffortSelect" class="settings-select"><option value="">Provider default</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="admin-toggle-sub" id="set-utilityEffortHelp" style="display:none;margin-top:-2px;">Applied to utility and background calls, not chat. Use “Same as chat” to match active chat effort and potentially ensure cache hits to speed up responses. Reasoning tokens count against each call's output limit, so higher efforts can truncate or empty short outputs such as auto-generated titles.</div>
|
||||||
<div class="settings-row" style="align-items:flex-start;">
|
<div class="settings-row" style="align-items:flex-start;">
|
||||||
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
|
||||||
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
let _contextHeaderAnchorEl = null;
|
let _contextHeaderAnchorEl = null;
|
||||||
let _contextHeaderPopupCleanup = null;
|
let _contextHeaderPopupCleanup = null;
|
||||||
let _pendingToolApproval = null;
|
let _pendingToolApproval = null;
|
||||||
|
const _selectedNoteIdsBySession = new Map();
|
||||||
|
let _pendingSelectedNoteIds = [];
|
||||||
let _lastPrivateBrowserUrl = '';
|
let _lastPrivateBrowserUrl = '';
|
||||||
function _isPrivateBrowserTool(tool) {
|
function _isPrivateBrowserTool(tool) {
|
||||||
const name = String(tool || '').toLowerCase();
|
const name = String(tool || '').toLowerCase();
|
||||||
@@ -240,6 +242,46 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.addEventListener('odysseus:notes-context-selected', (event) => {
|
||||||
|
const noteIds = event.detail?.noteIds;
|
||||||
|
if (!Array.isArray(noteIds) || noteIds.length === 0) return;
|
||||||
|
|
||||||
|
const sessionId = sessionModule.getCurrentSessionId?.();
|
||||||
|
if (!sessionId && !sessionModule.hasPendingChat?.()) {
|
||||||
|
console.warn('Cannot attach Notes context: no active or pending chat');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
_selectedNoteIdsBySession.set(sessionId, [...noteIds]);
|
||||||
|
} else {
|
||||||
|
_pendingSelectedNoteIds = [...noteIds];
|
||||||
|
}
|
||||||
|
if (event.detail) event.detail.accepted = true;
|
||||||
|
_renderSelectedNotesIndicator(noteIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('odysseus:session-changed', (event) => {
|
||||||
|
const sessionId = event.detail?.sessionId;
|
||||||
|
if (sessionId && event.detail?.materialized && _pendingSelectedNoteIds.length) {
|
||||||
|
_selectedNoteIdsBySession.set(sessionId, _pendingSelectedNoteIds);
|
||||||
|
}
|
||||||
|
_pendingSelectedNoteIds = [];
|
||||||
|
_renderSelectedNotesIndicator(
|
||||||
|
sessionId ? (_selectedNoteIdsBySession.get(sessionId) || []) : []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('selected-notes-context-btn')?.addEventListener('click', () => {
|
||||||
|
const sessionId = sessionModule.getCurrentSessionId?.();
|
||||||
|
if (sessionId) {
|
||||||
|
_selectedNoteIdsBySession.delete(sessionId);
|
||||||
|
} else {
|
||||||
|
_pendingSelectedNoteIds = [];
|
||||||
|
}
|
||||||
|
_renderSelectedNotesIndicator([]);
|
||||||
|
});
|
||||||
|
|
||||||
function _fmtContextNumber(n) {
|
function _fmtContextNumber(n) {
|
||||||
const v = Number(n || 0);
|
const v = Number(n || 0);
|
||||||
return v ? v.toLocaleString() : '?';
|
return v ? v.toLocaleString() : '?';
|
||||||
@@ -290,6 +332,26 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
return Math.max(50, Math.min(95, Math.round(n)));
|
return Math.max(50, Math.min(95, Math.round(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _renderSelectedNotesIndicator(noteIds){
|
||||||
|
const button = document.getElementById('selected-notes-context-btn');
|
||||||
|
const count = document.getElementById('selected-notes-context-count');
|
||||||
|
|
||||||
|
if (!button || !count) return;
|
||||||
|
|
||||||
|
const total = Array.isArray(noteIds) ? noteIds.length : 0;
|
||||||
|
if(!total){
|
||||||
|
button.style.display = 'none';
|
||||||
|
count.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
count.textContent = `${total} note${total === 1 ? '' : 's'} selected`;
|
||||||
|
button.style.display = '';
|
||||||
|
button.title = `${total} selected Note/Todo${total === 1 ? '' : 's'} — click to clear`;
|
||||||
|
button.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`Clear ${total} selected Note/Todo${total === 1 ? '' : 's'} context`
|
||||||
|
);
|
||||||
|
}
|
||||||
function _liveSessionModule() {
|
function _liveSessionModule() {
|
||||||
return (window.sessionModule && window.sessionModule.getCurrentSessionId)
|
return (window.sessionModule && window.sessionModule.getCurrentSessionId)
|
||||||
? window.sessionModule
|
? window.sessionModule
|
||||||
@@ -2497,6 +2559,12 @@ import { invalidateSettings } from './appConfig.js';
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
|
||||||
fd.append('session', streamSessionId);
|
fd.append('session', streamSessionId);
|
||||||
|
|
||||||
|
const selectedNoteIds = _selectedNoteIdsBySession.get(streamSessionId) || [];
|
||||||
|
if(selectedNoteIds.length){
|
||||||
|
fd.append('selected_note_ids', JSON.stringify(selectedNoteIds))
|
||||||
|
}
|
||||||
|
|
||||||
if (approvalForSend) {
|
if (approvalForSend) {
|
||||||
fd.append('tool_approval_id', approvalForSend.approval_id);
|
fd.append('tool_approval_id', approvalForSend.approval_id);
|
||||||
fd.append('tool_approval_decision', approvalForSend.decision);
|
fd.append('tool_approval_decision', approvalForSend.decision);
|
||||||
|
|||||||
@@ -1230,6 +1230,7 @@ export function openPanel() {
|
|||||||
<label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label>
|
<label class="memory-bulk-check-all"><input type="checkbox" id="notes-select-all" /> All</label>
|
||||||
<span id="notes-selected-count">0 Selected</span>
|
<span id="notes-selected-count">0 Selected</span>
|
||||||
<span style="flex:1"></span>
|
<span style="flex:1"></span>
|
||||||
|
<button id="notes-send-agent" class="memory-toolbar-btn" disabled><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>Send to Agent</button>
|
||||||
<button id="notes-bulk-archive" class="memory-toolbar-btn" disabled>
|
<button id="notes-bulk-archive" class="memory-toolbar-btn" disabled>
|
||||||
<svg 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-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive
|
<svg 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-right:4px;"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>Archive
|
||||||
</button>
|
</button>
|
||||||
@@ -1379,6 +1380,17 @@ export function openPanel() {
|
|||||||
_renderNotes();
|
_renderNotes();
|
||||||
_updateBulkBar();
|
_updateBulkBar();
|
||||||
});
|
});
|
||||||
|
document.getElementById('notes-send-agent').addEventListener('click', async () => {
|
||||||
|
const ids = [..._selectedIds];
|
||||||
|
if (!ids.length) return;
|
||||||
|
const detail = { noteIds: ids, accepted: false };
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:notes-context-selected', { detail }));
|
||||||
|
if (!detail.accepted) {
|
||||||
|
uiModule.showToast('Choose a chat or model before attaching notes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_exitSelectMode();
|
||||||
|
})
|
||||||
document.getElementById('notes-bulk-archive').addEventListener('click', async () => {
|
document.getElementById('notes-bulk-archive').addEventListener('click', async () => {
|
||||||
const ids = [..._selectedIds];
|
const ids = [..._selectedIds];
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
@@ -1511,10 +1523,12 @@ function _exitSelectMode() {
|
|||||||
function _updateBulkBar() {
|
function _updateBulkBar() {
|
||||||
const count = _selectedIds.size;
|
const count = _selectedIds.size;
|
||||||
const countEl = document.getElementById('notes-selected-count');
|
const countEl = document.getElementById('notes-selected-count');
|
||||||
|
const sendAgentBtn = document.getElementById('notes-send-agent');
|
||||||
const archiveBtn = document.getElementById('notes-bulk-archive');
|
const archiveBtn = document.getElementById('notes-bulk-archive');
|
||||||
const deleteBtn = document.getElementById('notes-bulk-delete');
|
const deleteBtn = document.getElementById('notes-bulk-delete');
|
||||||
const allEl = document.getElementById('notes-select-all');
|
const allEl = document.getElementById('notes-select-all');
|
||||||
if (countEl) countEl.textContent = `${count} Selected`;
|
if (countEl) countEl.textContent = `${count} Selected`;
|
||||||
|
if (sendAgentBtn) sendAgentBtn.disabled = count === 0;
|
||||||
if (archiveBtn) archiveBtn.disabled = count === 0;
|
if (archiveBtn) archiveBtn.disabled = count === 0;
|
||||||
if (deleteBtn) deleteBtn.disabled = count === 0;
|
if (deleteBtn) deleteBtn.disabled = count === 0;
|
||||||
if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id));
|
if (allEl) allEl.checked = _notes.length > 0 && _notes.every(n => _selectedIds.has(n.id));
|
||||||
|
|||||||
@@ -351,6 +351,9 @@ let _sessionListFocused = false;
|
|||||||
function _deselectCurrentSession(sid) {
|
function _deselectCurrentSession(sid) {
|
||||||
if (currentSessionId !== sid) return;
|
if (currentSessionId !== sid) return;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||||
|
detail: { sessionId: null },
|
||||||
|
}));
|
||||||
uiModule.el('chat-history').innerHTML = '';
|
uiModule.el('chat-history').innerHTML = '';
|
||||||
uiModule.el('current-meta').textContent = 'Odysseus Chat';
|
uiModule.el('current-meta').textContent = 'Odysseus Chat';
|
||||||
Storage.remove('lastSessionId');
|
Storage.remove('lastSessionId');
|
||||||
@@ -2065,6 +2068,9 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||||||
try { window.documentModule.clearSelection(); } catch {}
|
try { window.documentModule.clearSelection(); } catch {}
|
||||||
}
|
}
|
||||||
currentSessionId = id;
|
currentSessionId = id;
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||||
|
detail: { sessionId: id ? id : null },
|
||||||
|
}));
|
||||||
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
||||||
// Identify Assistant / task-output sessions so we don't "trap" the user
|
// Identify Assistant / task-output sessions so we don't "trap" the user
|
||||||
// there on return. Skipped from both `lastSessionId` persistence and the
|
// there on return. Skipped from both `lastSessionId` persistence and the
|
||||||
@@ -2445,6 +2451,9 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
|||||||
_skipAutoSelect = true;
|
_skipAutoSelect = true;
|
||||||
_suppressNextSessionLoading = true;
|
_suppressNextSessionLoading = true;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||||
|
detail: { sessionId: null },
|
||||||
|
}));
|
||||||
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
||||||
Storage.remove('lastSessionId');
|
Storage.remove('lastSessionId');
|
||||||
history.replaceState(null, '', window.location.pathname);
|
history.replaceState(null, '', window.location.pathname);
|
||||||
@@ -2555,6 +2564,9 @@ export async function materializePendingSession() {
|
|||||||
}
|
}
|
||||||
_pendingChat = null;
|
_pendingChat = null;
|
||||||
currentSessionId = payload.id;
|
currentSessionId = payload.id;
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||||
|
detail: { sessionId: payload.id, materialized: true },
|
||||||
|
}));
|
||||||
if (!isIncognito) {
|
if (!isIncognito) {
|
||||||
Storage.set('lastSessionId', payload.id);
|
Storage.set('lastSessionId', payload.id);
|
||||||
history.replaceState(null, '', '#' + payload.id);
|
history.replaceState(null, '', '#' + payload.id);
|
||||||
@@ -2626,6 +2638,9 @@ export function getCurrentEndpointUrl() {
|
|||||||
export function setCurrentSessionId(id) {
|
export function setCurrentSessionId(id) {
|
||||||
_sessionNavToken++;
|
_sessionNavToken++;
|
||||||
currentSessionId = id;
|
currentSessionId = id;
|
||||||
|
window.dispatchEvent(new CustomEvent('odysseus:session-changed', {
|
||||||
|
detail: { sessionId: id ? id : null },
|
||||||
|
}));
|
||||||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||||
if (!id) {
|
if (!id) {
|
||||||
_suppressNextSessionLoading = true;
|
_suppressNextSessionLoading = true;
|
||||||
|
|||||||
+166
-10
@@ -212,9 +212,11 @@ function _bindFallbackWidget(opts) {
|
|||||||
var endpointsRef = opts.endpoints; // mutable list reference
|
var endpointsRef = opts.endpoints; // mutable list reference
|
||||||
var modelsFilter = opts.modelsFilter || function() { return true; };
|
var modelsFilter = opts.modelsFilter || function() { return true; };
|
||||||
var settingKey = opts.settingKey;
|
var settingKey = opts.settingKey;
|
||||||
var current = opts.initial || []; // [{endpoint_id, model}]
|
var current = opts.initial || []; // [{endpoint_id, model, reasoning_effort}]
|
||||||
|
var supportsEffort = Boolean(opts.supportsEffort);
|
||||||
|
var getEffortLevels = opts.getEffortLevels;
|
||||||
|
|
||||||
if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} };
|
if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {}, refresh: function() {} };
|
||||||
|
|
||||||
function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
|
function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); }
|
||||||
|
|
||||||
@@ -233,8 +235,36 @@ function _bindFallbackWidget(opts) {
|
|||||||
if (selected) selectEl.value = selected;
|
if (selected) selectEl.value = selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fillEffort(selectEl, epId, modelId, selected) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
while (selectEl.options.length) selectEl.remove(0);
|
||||||
|
var levels = getEffortLevels ? getEffortLevels(epId, modelId) : [];
|
||||||
|
var base = document.createElement('option');
|
||||||
|
base.value = '';
|
||||||
|
base.textContent = 'Provider default';
|
||||||
|
selectEl.appendChild(base);
|
||||||
|
levels.forEach(function(lvl) {
|
||||||
|
var val = typeof lvl === 'string' ? lvl : (lvl && lvl.effort);
|
||||||
|
if (val) {
|
||||||
|
var o = document.createElement('option');
|
||||||
|
o.value = val.toLowerCase();
|
||||||
|
o.textContent = val;
|
||||||
|
selectEl.appendChild(o);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var validValues = [''].concat(levels.map(function(l) { return (typeof l === 'string' ? l : l.effort).toLowerCase(); }));
|
||||||
|
selectEl.value = validValues.indexOf((selected || '').toLowerCase()) >= 0 ? (selected || '').toLowerCase() : '';
|
||||||
|
selectEl.style.display = levels.length > 0 ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
var clean = current.filter(function(f) { return f.endpoint_id && f.model; });
|
var clean = current.filter(function(f) { return f.endpoint_id && f.model; }).map(function(f) {
|
||||||
|
var item = { endpoint_id: f.endpoint_id, model: f.model };
|
||||||
|
if (supportsEffort && typeof f.reasoning_effort !== 'undefined') {
|
||||||
|
item.reasoning_effort = f.reasoning_effort || '';
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
var body = {};
|
var body = {};
|
||||||
body[settingKey] = clean;
|
body[settingKey] = clean;
|
||||||
try {
|
try {
|
||||||
@@ -270,13 +300,36 @@ function _bindFallbackWidget(opts) {
|
|||||||
fb.endpoint_id = epS.value;
|
fb.endpoint_id = epS.value;
|
||||||
fb.model = mS.value;
|
fb.model = mS.value;
|
||||||
|
|
||||||
|
var effS = null;
|
||||||
|
if (supportsEffort) {
|
||||||
|
effS = document.createElement('select');
|
||||||
|
effS.className = 'settings-select settings-fallback-effort';
|
||||||
|
fillEffort(effS, epS.value, mS.value, fb.reasoning_effort);
|
||||||
|
fb.reasoning_effort = effS.value;
|
||||||
|
effS.addEventListener('change', function() {
|
||||||
|
fb.reasoning_effort = effS.value;
|
||||||
|
save();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
epS.addEventListener('change', function() {
|
epS.addEventListener('change', function() {
|
||||||
fb.endpoint_id = epS.value;
|
fb.endpoint_id = epS.value;
|
||||||
fillModels(mS, epS.value, '');
|
fillModels(mS, epS.value, '');
|
||||||
fb.model = mS.value;
|
fb.model = mS.value;
|
||||||
|
if (effS) {
|
||||||
|
fillEffort(effS, epS.value, mS.value, '');
|
||||||
|
fb.reasoning_effort = effS.value;
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
});
|
||||||
|
mS.addEventListener('change', function() {
|
||||||
|
fb.model = mS.value;
|
||||||
|
if (effS) {
|
||||||
|
fillEffort(effS, epS.value, mS.value, fb.reasoning_effort);
|
||||||
|
fb.reasoning_effort = effS.value;
|
||||||
|
}
|
||||||
save();
|
save();
|
||||||
});
|
});
|
||||||
mS.addEventListener('change', function() { fb.model = mS.value; save(); });
|
|
||||||
|
|
||||||
var rm = document.createElement('button');
|
var rm = document.createElement('button');
|
||||||
rm.type = 'button';
|
rm.type = 'button';
|
||||||
@@ -292,6 +345,7 @@ function _bindFallbackWidget(opts) {
|
|||||||
row.appendChild(num);
|
row.appendChild(num);
|
||||||
row.appendChild(epS);
|
row.appendChild(epS);
|
||||||
row.appendChild(mS);
|
row.appendChild(mS);
|
||||||
|
if (effS) row.appendChild(effS);
|
||||||
row.appendChild(rm);
|
row.appendChild(rm);
|
||||||
fbContainer.appendChild(row);
|
fbContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
@@ -299,7 +353,7 @@ function _bindFallbackWidget(opts) {
|
|||||||
|
|
||||||
addBtn.addEventListener('click', function() {
|
addBtn.addEventListener('click', function() {
|
||||||
var first = enabledEps()[0];
|
var first = enabledEps()[0];
|
||||||
current.push({ endpoint_id: first ? first.id : '', model: '' });
|
current.push({ endpoint_id: first ? first.id : '', model: '', reasoning_effort: '' });
|
||||||
render();
|
render();
|
||||||
save();
|
save();
|
||||||
});
|
});
|
||||||
@@ -371,9 +425,14 @@ async function initDefaultChat() {
|
|||||||
async function initUtilityModel() {
|
async function initUtilityModel() {
|
||||||
var epSel = el('set-utilityEpSelect');
|
var epSel = el('set-utilityEpSelect');
|
||||||
var modelSel = el('set-utilityModelSelect');
|
var modelSel = el('set-utilityModelSelect');
|
||||||
|
var effortSel = el('set-utilityEffortSelect');
|
||||||
|
var effortRow = el('set-utilityEffortRow');
|
||||||
|
var effortHelp = el('set-utilityEffortHelp');
|
||||||
|
var effortQualifier = el('set-utilityEffortQualifier');
|
||||||
var msg = el('set-utilityChatMsg');
|
var msg = el('set-utilityChatMsg');
|
||||||
var _endpoints = [];
|
var _endpoints = [];
|
||||||
var fallbackWidget = null;
|
var fallbackWidget = null;
|
||||||
|
var _levelsByEndpoint = {};
|
||||||
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
if (epSel && epSel.options[0]) epSel.options[0].textContent = 'Same as chat';
|
||||||
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
if (modelSel && modelSel.options[0]) modelSel.options[0].textContent = 'Same as chat';
|
||||||
|
|
||||||
@@ -382,24 +441,110 @@ async function initUtilityModel() {
|
|||||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
} catch (e) { console.warn('Failed to load endpoints for utility model', e); }
|
||||||
|
|
||||||
|
// Effort levels come from /api/models `models_metadata` (same evidence the
|
||||||
|
// chat composer uses); /api/model-endpoints does not carry them.
|
||||||
|
async function loadEffortLevels() {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/models', { credentials: 'same-origin' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
var data = await r.json();
|
||||||
|
var items = Array.isArray(data) ? data : (data.models || data.items || []);
|
||||||
|
_levelsByEndpoint = {};
|
||||||
|
items.forEach(function(item) {
|
||||||
|
if (item && item.endpoint_id) _levelsByEndpoint[item.endpoint_id] = item.models_metadata || {};
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn('Failed to load reasoning levels for utility model', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEffort(selected) {
|
||||||
|
if (!effortSel) return;
|
||||||
|
var targetEp = epSel.value;
|
||||||
|
var targetModel = modelSel.value;
|
||||||
|
if (!targetEp) {
|
||||||
|
var defEp = el('set-defaultEpSelect');
|
||||||
|
var defModel = el('set-defaultModelSelect');
|
||||||
|
if (defEp) targetEp = defEp.value;
|
||||||
|
if (defModel) targetModel = defModel.value;
|
||||||
|
}
|
||||||
|
var meta = (_levelsByEndpoint[targetEp] || {})[targetModel] || {};
|
||||||
|
var levels = Array.isArray(meta.supported_reasoning_levels) ? meta.supported_reasoning_levels.slice() : [];
|
||||||
|
var isInherited = !epSel.value;
|
||||||
|
if (isInherited && levels.length === 0) {
|
||||||
|
var allLevels = {};
|
||||||
|
Object.keys(_levelsByEndpoint).forEach(function(epId) {
|
||||||
|
var models = _levelsByEndpoint[epId] || {};
|
||||||
|
Object.keys(models).forEach(function(mId) {
|
||||||
|
var mLevels = (models[mId] || {}).supported_reasoning_levels;
|
||||||
|
if (Array.isArray(mLevels)) {
|
||||||
|
mLevels.forEach(function(lvl) {
|
||||||
|
var val = typeof lvl === 'string' ? lvl : (lvl && lvl.effort);
|
||||||
|
if (val) allLevels[val.toLowerCase()] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
levels = Object.keys(allLevels);
|
||||||
|
if (levels.length === 0) {
|
||||||
|
levels = ['low', 'medium', 'high'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (effortSel.options.length) effortSel.remove(0);
|
||||||
|
var base = document.createElement('option');
|
||||||
|
base.value = '';
|
||||||
|
base.textContent = isInherited ? 'Same as chat' : 'Provider default';
|
||||||
|
if (effortQualifier) effortQualifier.style.display = isInherited ? '' : 'none';
|
||||||
|
effortSel.appendChild(base);
|
||||||
|
if (isInherited) {
|
||||||
|
var offOpt = document.createElement('option');
|
||||||
|
offOpt.value = 'off';
|
||||||
|
offOpt.textContent = 'Provider default';
|
||||||
|
effortSel.appendChild(offOpt);
|
||||||
|
}
|
||||||
|
levels.forEach(function(level) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = level;
|
||||||
|
opt.textContent = level;
|
||||||
|
effortSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
var validValues = [''].concat(isInherited ? ['off'] : []).concat(levels);
|
||||||
|
effortSel.value = validValues.indexOf(selected) >= 0 ? selected : '';
|
||||||
|
var show = isInherited || levels.length > 0;
|
||||||
|
if (effortRow) effortRow.style.display = show ? '' : 'none';
|
||||||
|
if (effortHelp) effortHelp.style.display = show ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
function refreshModels(selectedModel) {
|
function refreshModels(selectedModel) {
|
||||||
var epId = epSel.value;
|
var epId = epSel.value;
|
||||||
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
var ep = _endpoints.find(function(e) { return e.id === epId; });
|
||||||
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
_fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadEffortLevels();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
if (settings.utility_endpoint_id) epSel.value = settings.utility_endpoint_id;
|
||||||
refreshModels(settings.utility_model || '');
|
refreshModels(settings.utility_model || '');
|
||||||
|
refreshEffort(settings.utility_reasoning_effort || '');
|
||||||
fallbackWidget = _bindFallbackWidget({
|
fallbackWidget = _bindFallbackWidget({
|
||||||
containerId: 'set-utilityFallbacks',
|
containerId: 'set-utilityFallbacks',
|
||||||
addBtnId: 'set-utilityAddFallback',
|
addBtnId: 'set-utilityAddFallback',
|
||||||
endpoints: function() { return _endpoints; },
|
endpoints: function() { return _endpoints; },
|
||||||
settingKey: 'utility_model_fallbacks',
|
settingKey: 'utility_model_fallbacks',
|
||||||
|
supportsEffort: true,
|
||||||
|
getEffortLevels: function(epId, modelId) {
|
||||||
|
var meta = (_levelsByEndpoint[epId] || {})[modelId] || {};
|
||||||
|
return Array.isArray(meta.supported_reasoning_levels) ? meta.supported_reasoning_levels : [];
|
||||||
|
},
|
||||||
initial: Array.isArray(settings.utility_model_fallbacks)
|
initial: Array.isArray(settings.utility_model_fallbacks)
|
||||||
? settings.utility_model_fallbacks.map(function(f) { return { endpoint_id: (f && f.endpoint_id) || '', model: (f && f.model) || '' }; })
|
? settings.utility_model_fallbacks.map(function(f) {
|
||||||
|
return {
|
||||||
|
endpoint_id: (f && f.endpoint_id) || '',
|
||||||
|
model: (f && f.model) || '',
|
||||||
|
reasoning_effort: (f && f.reasoning_effort) || '',
|
||||||
|
};
|
||||||
|
})
|
||||||
: [],
|
: [],
|
||||||
});
|
});
|
||||||
} catch (e) { console.warn('Failed to load utility model settings', e); }
|
} catch (e) { console.warn('Failed to load utility model settings', e); }
|
||||||
@@ -411,20 +556,30 @@ async function initUtilityModel() {
|
|||||||
try {
|
try {
|
||||||
await _postSettings({
|
await _postSettings({
|
||||||
utility_endpoint_id: epSel.value || '',
|
utility_endpoint_id: epSel.value || '',
|
||||||
utility_model: modelSel.value || ''
|
utility_model: modelSel.value || '',
|
||||||
|
utility_reasoning_effort: effortSel ? (effortSel.value || '') : ''
|
||||||
});
|
});
|
||||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
setTimeout(function() { msg.textContent = ''; }, 1500);
|
setTimeout(function() { msg.textContent = ''; }, 1500);
|
||||||
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
} catch (e) { msg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
epSel.addEventListener('change', function() { refreshModels(''); saveUtility(); });
|
// A new endpoint/model may not advertise the saved effort; refreshEffort
|
||||||
modelSel.addEventListener('change', saveUtility);
|
// resets it to the provider default, and saveUtility persists that.
|
||||||
|
epSel.addEventListener('change', function() { refreshModels(''); refreshEffort(''); saveUtility(); });
|
||||||
|
modelSel.addEventListener('change', function() { refreshEffort(effortSel ? effortSel.value : ''); saveUtility(); });
|
||||||
|
if (effortSel) effortSel.addEventListener('change', saveUtility);
|
||||||
|
var defEp = el('set-defaultEpSelect');
|
||||||
|
var defModel = el('set-defaultModelSelect');
|
||||||
|
if (defEp) defEp.addEventListener('change', function() { if (!epSel.value) refreshEffort(effortSel ? effortSel.value : ''); });
|
||||||
|
if (defModel) defModel.addEventListener('change', function() { if (!epSel.value) refreshEffort(effortSel ? effortSel.value : ''); });
|
||||||
|
|
||||||
_registerAiEndpointRefresh(function(endpoints) {
|
_registerAiEndpointRefresh(async function(endpoints) {
|
||||||
_endpoints = endpoints;
|
_endpoints = endpoints;
|
||||||
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
_fillEndpointSelect(epSel, _endpoints, epSel.value, true);
|
||||||
refreshModels(modelSel.value);
|
refreshModels(modelSel.value);
|
||||||
|
await loadEffortLevels();
|
||||||
|
refreshEffort(effortSel ? effortSel.value : '');
|
||||||
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
|
if (fallbackWidget && fallbackWidget.refresh) fallbackWidget.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1521,6 +1676,7 @@ async function initShortcuts() {
|
|||||||
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
|
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save keybinds:', e);
|
console.error('Failed to save keybinds:', e);
|
||||||
|
if (uiModule && uiModule.showToast) uiModule.showToast('Failed to save shortcut');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>Odysseus — Login</title>
|
<title>Odysseus — Login</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
<link rel="manifest" href="static/manifest.json">
|
<link rel="manifest" href="static/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
(function(){
|
(function(){
|
||||||
function computeAppBasePath() {
|
function computeAppBasePath() {
|
||||||
|
|||||||
@@ -7,5 +7,10 @@
|
|||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "any",
|
"orientation": "any",
|
||||||
"background_color": "#282c34",
|
"background_color": "#282c34",
|
||||||
"theme_color": "#282c34"
|
"theme_color": "#282c34",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||||
|
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -7,7 +7,7 @@
|
|||||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||||
// - API / non-GET: never cached.
|
// - API / non-GET: never cached.
|
||||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||||
const CACHE_NAME = 'odysseus-v657-publication-plan-b';
|
const CACHE_NAME = 'odysseus-v658-publication-assets-restore';
|
||||||
|
|
||||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||||
// gives offline math fallback glyphs instead of proper typesetting.
|
// gives offline math fallback glyphs instead of proper typesetting.
|
||||||
@@ -134,6 +134,10 @@ const PRECACHE = [
|
|||||||
'/static/lib/katex/katex.min.js',
|
'/static/lib/katex/katex.min.js',
|
||||||
'/static/lib/katex/katex.min.css',
|
'/static/lib/katex/katex.min.css',
|
||||||
...KATEX_FONTS,
|
...KATEX_FONTS,
|
||||||
|
'/static/manifest.json',
|
||||||
|
'/static/icons/icon-192.png',
|
||||||
|
'/static/icons/icon-512.png',
|
||||||
|
'/static/icons/icon-maskable-512.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { allowsDependency, imports, checkArchitecture, inventory } from '../scripts/check_frontend.mjs';
|
||||||
|
import * as source from '../frontend/src/shared/actionMenuOrder.js';
|
||||||
|
import * as deployed from '../static/js/actionMenuOrder.js';
|
||||||
|
|
||||||
|
test('stateless helper preserves its public API, ordering, references and inputs', () => {
|
||||||
|
for (const module of [source, deployed]) {
|
||||||
|
assert.deepEqual(Object.keys(module).sort(), ['SELECT_MENU_ICON', 'actionMenuRank', 'orderActionMenuItems']);
|
||||||
|
const callback = () => {};
|
||||||
|
const items = ['Delete', 'Select', 'Copy', 'Rename', 'Archive', 'Open', 'Cancel'].map(label => Object.freeze({ label, action: callback }));
|
||||||
|
Object.freeze(items);
|
||||||
|
const sorted = module.orderActionMenuItems(items);
|
||||||
|
assert.deepEqual(sorted.map(i => i.label), ['Open', 'Rename', 'Copy', 'Select', 'Archive', 'Delete', 'Cancel']);
|
||||||
|
assert.ok(sorted.every(i => items.includes(i)));
|
||||||
|
assert.deepEqual(module.orderActionMenuItems([]), []);
|
||||||
|
assert.equal(module.actionMenuRank({ menuOrder: 12, label: 'Delete' }), 12);
|
||||||
|
assert.equal(module.actionMenuRank({ label: 'Unknown' }), 100);
|
||||||
|
const equalRank = [{ label: 'Open first' }, { label: 'Open second' }];
|
||||||
|
assert.deepEqual(module.orderActionMenuItems(equalRank), equalRank);
|
||||||
|
}
|
||||||
|
assert.equal(source.SELECT_MENU_ICON, deployed.SELECT_MENU_ICON);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dependency direction separates composition, workflow, adapters and views', () => {
|
||||||
|
const n = name => `features/notes/${name}.js`;
|
||||||
|
const allowed = [
|
||||||
|
['app/bootstrap.js', n('index')],
|
||||||
|
[n('index'), n('infrastructure/api')],
|
||||||
|
[n('application/crud'), n('domain/recurrence')],
|
||||||
|
[n('infrastructure/api'), 'shared/platform/http.js'],
|
||||||
|
[n('ui/panel'), n('application/crud')],
|
||||||
|
[n('application/reminders'), 'features/calendar/index.js'],
|
||||||
|
];
|
||||||
|
for (const [from, to] of allowed) assert.ok(allowsDependency(from, to), `${from} -> ${to}`);
|
||||||
|
const forbidden = [
|
||||||
|
[n('domain/recurrence'), n('infrastructure/api')],
|
||||||
|
[n('application/crud'), n('ui/panel')],
|
||||||
|
[n('application/crud'), n('infrastructure/api')],
|
||||||
|
[n('ui/panel'), 'features/calendar/index.js'],
|
||||||
|
[n('application/crud'), 'features/calendar/application/store.js'],
|
||||||
|
['app/bootstrap.js', n('domain/recurrence')],
|
||||||
|
['shared/platform/http.js', n('index')],
|
||||||
|
[n('domain/recurrence'), 'app/bootstrap.js'],
|
||||||
|
];
|
||||||
|
for (const [from, to] of forbidden) assert.equal(allowsDependency(from, to), false, `${from} -> ${to}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parser sees re-exports, multiline and dynamic imports without comment false positives', () => {
|
||||||
|
const code = `// import './false.js';
|
||||||
|
const text = "import './false.js'";
|
||||||
|
import { a }\n from './a.js';
|
||||||
|
export { b } from './b.js';
|
||||||
|
export * from './c.js';
|
||||||
|
const load = () => import('./d.js');`;
|
||||||
|
assert.deepEqual(imports(code).map(i => i.specifier), ['./a.js', './b.js', './c.js', './d.js']);
|
||||||
|
assert.throws(() => imports('const load = p => import(p);'), /computed import/);
|
||||||
|
assert.throws(() => imports('export const now = () => Date.now();', { domain: true }), /ambient clock/);
|
||||||
|
assert.throws(() => imports('export const load = () => fetch("/api/notes");', { domain: true }), /platform identifier/);
|
||||||
|
assert.doesNotThrow(() => imports('export const day = value => new Date(value);', { domain: true }));
|
||||||
|
assert.doesNotThrow(() => imports('export const copy = value => ({ document: value.document });', { domain: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function fixture(t, modules, { cache = true } = {}) {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ody-frontend-gates-'));
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||||
|
const entries = Object.entries(modules).map(([name, code]) => {
|
||||||
|
const file = path.join(root, 'frontend/src', name);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, code);
|
||||||
|
return { source: name, deployment: `js/${name}` };
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(root, 'static'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify({ version: 1, modules: entries }));
|
||||||
|
const urls = cache ? entries.map(m => `/static/${m.deployment}`) : [];
|
||||||
|
fs.writeFileSync(path.join(root, 'static/sw.js'), `const PRECACHE = ${JSON.stringify(urls)}; const PANEL_PRECACHE = [];`);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('complete mapped graph and exact precache pass', t => {
|
||||||
|
const root = fixture(t, { 'features/notes/index.js': "export { value } from './domain/value.js';", 'features/notes/domain/value.js': 'export const value = 1;' });
|
||||||
|
assert.equal(checkArchitecture(root), 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('feature-private import fails through the full checker', t => {
|
||||||
|
const root = fixture(t, { 'features/notes/application/a.js': "import '../../calendar/domain/b.js';", 'features/calendar/domain/b.js': 'export const value = 1;' });
|
||||||
|
assert.throws(() => checkArchitecture(root), /forbidden dependency/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undeclared legacy coupling and alternate identities fail', t => {
|
||||||
|
for (const specifier of ['/static/js/notes.js?v=legacy', './b.js?v=other', '/frontend/src/shared/b.js', 'some-package']) {
|
||||||
|
const root = fixture(t, { 'shared/a.js': `import '${specifier}';`, 'shared/b.js': 'export const value = 1;' });
|
||||||
|
assert.throws(() => checkArchitecture(root), /legacy import|one unversioned|unsupported|canonical static/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a copy mapping cannot silently change relative import resolution', t => {
|
||||||
|
const root = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': 'export const value = 1;' });
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'frontend/modules.json')));
|
||||||
|
manifest.modules[1].deployment = 'js/elsewhere/b.js';
|
||||||
|
fs.writeFileSync(path.join(root, 'frontend/modules.json'), JSON.stringify(manifest));
|
||||||
|
fs.writeFileSync(path.join(root, 'static/sw.js'), "const PRECACHE = ['/static/js/shared/a.js', '/static/js/elsewhere/b.js'];");
|
||||||
|
assert.throws(() => checkArchitecture(root), /resolve differently/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new modules must join exact offline manifests and cannot introduce cycles', t => {
|
||||||
|
const missing = fixture(t, { 'shared/a.js': 'export const a = 1;' }, { cache: false });
|
||||||
|
assert.throws(() => checkArchitecture(missing), /absent from explicit precache/);
|
||||||
|
const cyclic = fixture(t, { 'shared/a.js': "import './b.js';", 'shared/b.js': "import './a.js';" });
|
||||||
|
assert.throws(() => checkArchitecture(cyclic), /dependency cycle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy adapters require exact, used exceptions and reserved names cannot bypass ownership', t => {
|
||||||
|
const root = fixture(t, { 'features/notes/infrastructure/api.js': "import '/static/js/legacy.js?v=existing';" });
|
||||||
|
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'static/js/legacy.js'), 'export const legacy = true;');
|
||||||
|
const file = path.join(root, 'frontend/modules.json');
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(file));
|
||||||
|
manifest.modules[0].legacyImports = [{ specifier: '/static/js/legacy.js?v=existing', reason: 'Temporary existing transport adapter; remove in N2.' }];
|
||||||
|
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||||
|
assert.equal(checkArchitecture(root), 1);
|
||||||
|
manifest.modules[0].legacyImports.push({ specifier: '/static/js/unused.js', reason: 'stale' });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(manifest));
|
||||||
|
assert.throws(() => checkArchitecture(root), /unused legacy import exception/);
|
||||||
|
assert.throws(() => allowsDependency('features/shared/index.js', 'app/bootstrap.js'), /reserved feature name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inventory reports executable static cycles and separates dynamic edges', t => {
|
||||||
|
const root = fixture(t, { 'shared/a.js': 'export const a = 1;' });
|
||||||
|
fs.mkdirSync(path.join(root, 'static/js'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'static/js/a.js'), "import './b.js'; const load = () => import('./c.js');");
|
||||||
|
fs.writeFileSync(path.join(root, 'static/js/b.js'), "import './a.js';");
|
||||||
|
fs.writeFileSync(path.join(root, 'static/js/c.js'), 'export const c = 1;');
|
||||||
|
const result = inventory(root);
|
||||||
|
assert.equal(result.staticImports, 2);
|
||||||
|
assert.equal(result.dynamicLiteralImports, 1);
|
||||||
|
assert.deepEqual(result.staticCycles, [['js/a.js', 'js/b.js']]);
|
||||||
|
assert.deepEqual(result.missing, []);
|
||||||
|
});
|
||||||
@@ -14,23 +14,57 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
|
|||||||
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
|
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
|
||||||
page.on('pageerror', e => errors.push(e.message));
|
page.on('pageerror', e => errors.push(e.message));
|
||||||
await page.goto(origin + '/website/index.html');
|
await page.goto(origin + '/website/index.html');
|
||||||
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0);
|
// Landing page contains expected preview panels, preview videos, and background video
|
||||||
assert.equal(await page.locator('.feature-description').count(), 8);
|
assert.equal(await page.locator('.preview-panel').count(), 8);
|
||||||
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim());
|
assert.equal(await page.locator('section#previews video').count(), 7);
|
||||||
|
assert.equal(await page.locator('section#how video.sec-bg').count(), 1);
|
||||||
|
assert.equal(await page.locator('section#how .sec-bg-tint').count(), 1);
|
||||||
|
|
||||||
|
// Expected video source paths exist and return 200
|
||||||
|
const expectedVideos = [
|
||||||
|
'chat.webm',
|
||||||
|
'research.webm',
|
||||||
|
'compare.webm',
|
||||||
|
'document.webm',
|
||||||
|
'notes.webm',
|
||||||
|
'gallery.webm',
|
||||||
|
'theme.webm',
|
||||||
|
'bg.webm',
|
||||||
|
];
|
||||||
|
const videoSources = await page.locator('video source').evaluateAll(els => els.map(s => s.getAttribute('src')));
|
||||||
|
assert.deepEqual(videoSources.sort(), expectedVideos.slice().sort());
|
||||||
|
for (const src of expectedVideos) {
|
||||||
|
const videoRes = await page.request.get(origin + '/website/' + src);
|
||||||
|
assert.equal(videoRes.status(), 200, `Video source ${src} should return 200`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature and video layout does not overflow at desktop or mobile widths
|
||||||
for (const width of [1280, 390]) {
|
for (const width of [1280, 390]) {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
assert(await page.locator('.feature-description').first().isVisible());
|
assert(await page.locator('.preview-panel').first().isVisible());
|
||||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||||
}
|
}
|
||||||
assert.deepEqual(mediaRequests, []);
|
|
||||||
assert.deepEqual(errors, []);
|
assert.deepEqual(errors, []);
|
||||||
|
|
||||||
|
// Manifest has valid icon entries that return successfully
|
||||||
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
|
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
|
||||||
assert.equal(manifest.display, 'standalone');
|
assert.equal(manifest.display, 'standalone');
|
||||||
assert(!manifest.icons);
|
assert(Array.isArray(manifest.icons) && manifest.icons.length === 3);
|
||||||
|
for (const icon of manifest.icons) {
|
||||||
|
assert(icon.src, 'Manifest icon entry must have src');
|
||||||
|
const iconUrl = new URL(icon.src, origin + '/static/manifest.json').href;
|
||||||
|
const iconRes = await page.request.get(iconUrl);
|
||||||
|
assert.equal(iconRes.status(), 200, `Manifest icon ${icon.src} should return 200`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple touch icon references in app HTML point to real files
|
||||||
for (const filename of ['index.html', 'login.html']) {
|
for (const filename of ['index.html', 'login.html']) {
|
||||||
const html = await (await page.request.get(origin + '/static/' + filename)).text();
|
const html = await (await page.request.get(origin + '/static/' + filename)).text();
|
||||||
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html));
|
const match = html.match(/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/);
|
||||||
|
assert(match, `missing apple-touch-icon in ${filename}`);
|
||||||
|
const iconUrl = new URL(match[1], origin + '/').href;
|
||||||
|
const res = await page.request.get(iconUrl);
|
||||||
|
assert.equal(res.status(), 200, `Apple touch icon in ${filename} should return 200`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A small host DOM runs the actual export function and markdown renderer.
|
// A small host DOM runs the actual export function and markdown renderer.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const chatSource = readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
|
||||||
|
const sessionSource = readFileSync(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
|
||||||
|
const notesSource = readFileSync(new URL('../static/js/notes.js', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const start = chatSource.indexOf(" window.addEventListener('odysseus:notes-context-selected'");
|
||||||
|
const end = chatSource.indexOf(' function _fmtContextNumber(n)', start);
|
||||||
|
assert.ok(start !== -1 && end > start, 'production event handlers must exist');
|
||||||
|
const handlers = new Map();
|
||||||
|
const rendered = [];
|
||||||
|
let sessionId = null;
|
||||||
|
let pending = false;
|
||||||
|
let clear;
|
||||||
|
const win = { addEventListener: (name, fn) => handlers.set(name, fn) };
|
||||||
|
const doc = {
|
||||||
|
getElementById: () => ({ addEventListener: (name, fn) => { if (name === 'click') clear = fn; } }),
|
||||||
|
};
|
||||||
|
const sessionModule = {
|
||||||
|
getCurrentSessionId: () => sessionId,
|
||||||
|
hasPendingChat: () => pending,
|
||||||
|
};
|
||||||
|
vm.runInNewContext(
|
||||||
|
`const _selectedNoteIdsBySession = new Map(); let _pendingSelectedNoteIds = [];
|
||||||
|
${chatSource.slice(start, end)}`,
|
||||||
|
{ window: win, document: doc, sessionModule, console, _renderSelectedNotesIndicator: ids => rendered.push(Array.from(ids)) },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
setSession(id, isPending = false) { sessionId = id; pending = isPending; },
|
||||||
|
emit(name, detail) { handlers.get(name)({ detail }); },
|
||||||
|
selected(ids) { const detail = { noteIds: ids, accepted: false }; handlers.get('odysseus:notes-context-selected')({ detail }); return detail; },
|
||||||
|
clear() { clear(); },
|
||||||
|
lastRendered() { return rendered.at(-1); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pending selections transfer only to the materialized chat', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
ctx.setSession(null, true);
|
||||||
|
assert.equal(ctx.selected(['a', 'b']).accepted, true);
|
||||||
|
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||||
|
ctx.setSession('new-chat');
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat', materialized: true });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||||
|
ctx.setSession('other-chat');
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: 'other-chat' });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), []);
|
||||||
|
ctx.setSession('new-chat');
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat' });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
|
||||||
|
ctx.clear();
|
||||||
|
assert.deepEqual(ctx.lastRendered(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new chat resets old indicator and invalid selection is rejected', () => {
|
||||||
|
const ctx = setup();
|
||||||
|
assert.equal(ctx.selected(['a']).accepted, false);
|
||||||
|
ctx.setSession('old-chat');
|
||||||
|
assert.equal(ctx.selected(['a']).accepted, true);
|
||||||
|
ctx.setSession(null, true);
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), []);
|
||||||
|
assert.equal(ctx.selected(['b']).accepted, true);
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: null });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), []);
|
||||||
|
ctx.setSession('fresh');
|
||||||
|
ctx.emit('odysseus:session-changed', { sessionId: 'fresh', materialized: true });
|
||||||
|
assert.deepEqual(ctx.lastRendered(), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('producer events and rejection acknowledgement are wired in source', () => {
|
||||||
|
const create = sessionSource.slice(sessionSource.indexOf('export function createDirectChat'), sessionSource.indexOf('export async function materializePendingSession'));
|
||||||
|
const materialize = sessionSource.slice(sessionSource.indexOf('export async function materializePendingSession'), sessionSource.indexOf('export function preMaterializePendingSession'));
|
||||||
|
assert.match(create, /currentSessionId = null;[\s\S]{0,180}odysseus:session-changed/);
|
||||||
|
assert.match(materialize, /currentSessionId = payload.id;[\s\S]{0,180}odysseus:session-changed/);
|
||||||
|
assert.match(notesSource, /if \(!detail\.accepted\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""Admin-gated app-config routes must honor AUTH_ENABLED=false.
|
||||||
|
|
||||||
|
Regression test for the inconsistency between core.middleware.require_admin
|
||||||
|
(which allows requests when the operator disabled auth) and the inline admin
|
||||||
|
checks in routes/auth_routes.py (which returned 403 unconditionally, so
|
||||||
|
settings changes were silently lost behind reverse-proxy auth setups).
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from core.auth import SetAdminResult
|
||||||
|
|
||||||
|
|
||||||
|
class _StoreDict(dict):
|
||||||
|
"""Dict-like settings store that also holds features and integrations."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.features = {"web_search": True, "web_fetch": True, "memory": True}
|
||||||
|
self.integrations = {
|
||||||
|
"int-1": {
|
||||||
|
"id": "int-1",
|
||||||
|
"name": "miniflux-sync",
|
||||||
|
"preset": "miniflux",
|
||||||
|
"api_key": "sk-secret-key-123",
|
||||||
|
"base_url": "http://127.0.0.1:8080",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
fastapi = pytest.importorskip("fastapi")
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from routes import auth_routes
|
||||||
|
|
||||||
|
stored = _StoreDict()
|
||||||
|
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(stored))
|
||||||
|
monkeypatch.setattr(auth_routes, "_save_settings", stored.update)
|
||||||
|
monkeypatch.setattr(auth_routes, "_load_features", lambda: dict(stored.features))
|
||||||
|
monkeypatch.setattr(auth_routes, "_save_features", stored.features.update)
|
||||||
|
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_routes, "load_integrations", lambda: list(stored.integrations.values()))
|
||||||
|
monkeypatch.setattr(auth_routes, "get_integration", lambda iid: stored.integrations.get(iid))
|
||||||
|
|
||||||
|
def _add_int(body):
|
||||||
|
item = dict(body, id=f"int-{len(stored.integrations) + 1}")
|
||||||
|
stored.integrations[item["id"]] = item
|
||||||
|
return item
|
||||||
|
|
||||||
|
def _update_int(iid, body):
|
||||||
|
if iid not in stored.integrations:
|
||||||
|
return None
|
||||||
|
stored.integrations[iid].update(body)
|
||||||
|
return stored.integrations[iid]
|
||||||
|
|
||||||
|
def _delete_int(iid):
|
||||||
|
return stored.integrations.pop(iid, None) is not None
|
||||||
|
|
||||||
|
async def _test_call(iid, method, path):
|
||||||
|
return {"exit_code": 0, "message": "Connection successful"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_routes, "add_integration", _add_int)
|
||||||
|
monkeypatch.setattr(auth_routes, "update_integration", _update_int)
|
||||||
|
monkeypatch.setattr(auth_routes, "delete_integration", _delete_int)
|
||||||
|
monkeypatch.setattr(auth_routes, "execute_api_call", _test_call)
|
||||||
|
|
||||||
|
class _AuthManager:
|
||||||
|
is_configured = True
|
||||||
|
signup_enabled = False
|
||||||
|
|
||||||
|
def get_username_for_token(self, token):
|
||||||
|
if token in ("session-token", "admin-token"):
|
||||||
|
return "admin"
|
||||||
|
if token == "user-token":
|
||||||
|
return "user"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_admin(self, user):
|
||||||
|
return user == "admin"
|
||||||
|
|
||||||
|
def list_users(self):
|
||||||
|
return [{"username": "admin", "is_admin": True}, {"username": "user", "is_admin": False}]
|
||||||
|
|
||||||
|
def create_user(self, username, password, is_admin=False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_privileges(self, username, privs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set_admin(self, username, is_admin, requesting_user):
|
||||||
|
return SetAdminResult.SUCCESS
|
||||||
|
|
||||||
|
def delete_user(self, username, requesting_user):
|
||||||
|
return True
|
||||||
|
|
||||||
|
app = fastapi.FastAPI()
|
||||||
|
app.include_router(auth_routes.setup_auth_routes(_AuthManager()))
|
||||||
|
return TestClient(app), stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_settings_allowed_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, stored = client
|
||||||
|
r = c.post("/api/auth/settings", json={"default_model": "some-model"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored.get("default_model") == "some-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_settings_unscrubbed_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, stored = client
|
||||||
|
stored["brave_api_key"] = "sk-secret"
|
||||||
|
r = c.get("/api/auth/settings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["brave_api_key"] == "sk-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_settings_still_403_when_auth_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, _ = client
|
||||||
|
r = c.post("/api/auth/settings", json={"default_model": "x"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_settings_admin_session_still_works(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, stored = client
|
||||||
|
c.cookies.set("odysseus_session", "session-token")
|
||||||
|
r = c.post("/api/auth/settings", json={"default_model": "admin-model"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored.get("default_model") == "admin-model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_numeric_validation_and_clamping_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, stored = client
|
||||||
|
# Clamp out-of-range integer values
|
||||||
|
r = c.post("/api/auth/settings", json={"agent_max_rounds": 999, "agent_max_tool_calls": 5000})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored["agent_max_rounds"] == 200
|
||||||
|
assert stored["agent_max_tool_calls"] == 1000
|
||||||
|
|
||||||
|
# Non-integer must raise 400
|
||||||
|
r = c.post("/api/auth/settings", json={"agent_max_rounds": "not-an-int"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
# Unknown settings keys are not saved
|
||||||
|
r = c.post("/api/auth/settings", json={"unsupported_key_xyz": "value"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "unsupported_key_xyz" not in stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_accessible_and_persisted_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, stored = client
|
||||||
|
# Public GET
|
||||||
|
r = c.get("/api/auth/features")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json().get("web_search") is True
|
||||||
|
|
||||||
|
# POST allowed without login
|
||||||
|
r = c.post("/api/auth/features", json={"web_search": False})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored.features["web_search"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrations_lifecycle_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, stored = client
|
||||||
|
|
||||||
|
# List: keys masked
|
||||||
|
r = c.get("/api/auth/integrations")
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json().get("integrations", [])
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0]["api_key"] != "sk-secret-key-123"
|
||||||
|
|
||||||
|
# Create
|
||||||
|
r = c.post("/api/auth/integrations", json={"name": "new-srv", "preset": "custom", "api_key": "raw-key"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
new_id = r.json()["integration"]["id"]
|
||||||
|
assert new_id in stored.integrations
|
||||||
|
|
||||||
|
# Update
|
||||||
|
r = c.put(f"/api/auth/integrations/{new_id}", json={"name": "updated-srv"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored.integrations[new_id]["name"] == "updated-srv"
|
||||||
|
|
||||||
|
# Test
|
||||||
|
r = c.post(f"/api/auth/integrations/{new_id}/test")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
r = c.delete(f"/api/auth/integrations/{new_id}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert new_id not in stored.integrations
|
||||||
|
|
||||||
|
# 404 for missing item
|
||||||
|
assert c.put("/api/auth/integrations/nonexistent", json={"name": "x"}).status_code == 404
|
||||||
|
assert c.delete("/api/auth/integrations/nonexistent").status_code == 404
|
||||||
|
assert c.post("/api/auth/integrations/nonexistent/test").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_management_fails_closed_when_auth_disabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c, _ = client
|
||||||
|
|
||||||
|
assert c.get("/api/auth/users").status_code == 403
|
||||||
|
assert c.post("/api/auth/users", json={"username": "hacker", "password": "password123", "is_admin": True}).status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/privileges", json={"can_chat": False}).status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/rename", json={"username": "newtarget"}).status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/admin", json={"is_admin": True}).status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||||
|
assert c.put("/api/auth/open-signup", json={"enabled": True}).status_code == 403
|
||||||
|
assert c.request("DELETE", "/api/auth/users", json={"username": "target"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_settings_scrubbed_for_unauthenticated_and_non_admin_when_auth_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, stored = client
|
||||||
|
stored["brave_api_key"] = "sk-secret-brave"
|
||||||
|
stored["default_model"] = "gpt-4o"
|
||||||
|
|
||||||
|
# Unauthenticated caller gets scrubbed settings
|
||||||
|
r = c.get("/api/auth/settings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["brave_api_key"] == ""
|
||||||
|
assert data["default_model"] == "gpt-4o"
|
||||||
|
|
||||||
|
# Non-admin authenticated user gets scrubbed settings
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
r = c.get("/api/auth/settings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["brave_api_key"] == ""
|
||||||
|
assert data["default_model"] == "gpt-4o"
|
||||||
|
|
||||||
|
# Admin caller gets full unscrubbed settings
|
||||||
|
c.cookies.set("odysseus_session", "session-token")
|
||||||
|
r = c.get("/api/auth/settings")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["brave_api_key"] == "sk-secret-brave"
|
||||||
|
assert data["default_model"] == "gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_settings_403_for_authenticated_non_admin(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, stored = client
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
r = c.post("/api/auth/settings", json={"default_model": "user-choice"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert stored.get("default_model") != "user-choice"
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_gated_when_auth_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, stored = client
|
||||||
|
|
||||||
|
# Unauthenticated
|
||||||
|
assert c.post("/api/auth/features", json={"web_search": False}).status_code == 403
|
||||||
|
|
||||||
|
# Non-admin
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
assert c.post("/api/auth/features", json={"web_search": False}).status_code == 403
|
||||||
|
assert stored.features["web_search"] is True
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
c.cookies.set("odysseus_session", "session-token")
|
||||||
|
r = c.post("/api/auth/features", json={"web_search": False})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert stored.features["web_search"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrations_gated_when_auth_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, stored = client
|
||||||
|
|
||||||
|
# Unauthenticated
|
||||||
|
assert c.get("/api/auth/integrations").status_code == 403
|
||||||
|
assert c.post("/api/auth/integrations", json={"name": "a"}).status_code == 403
|
||||||
|
|
||||||
|
# Non-admin
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
assert c.get("/api/auth/integrations").status_code == 403
|
||||||
|
assert c.post("/api/auth/integrations", json={"name": "a"}).status_code == 403
|
||||||
|
assert c.put("/api/auth/integrations/int-1", json={"name": "b"}).status_code == 403
|
||||||
|
assert c.delete("/api/auth/integrations/int-1").status_code == 403
|
||||||
|
assert c.post("/api/auth/integrations/int-1/test").status_code == 403
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
c.cookies.set("odysseus_session", "session-token")
|
||||||
|
assert c.get("/api/auth/integrations").status_code == 200
|
||||||
|
assert c.post("/api/auth/integrations", json={"name": "new", "preset": "custom"}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_management_requires_admin_when_auth_enabled(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
c, _ = client
|
||||||
|
|
||||||
|
# Unauthenticated
|
||||||
|
assert c.get("/api/auth/users").status_code == 403
|
||||||
|
assert c.post("/api/auth/users", json={"username": "a", "password": "b"}).status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||||
|
|
||||||
|
# Non-admin
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
assert c.get("/api/auth/users").status_code == 403
|
||||||
|
assert c.put("/api/auth/users/target/password", json={"new_password": "password123"}).status_code == 403
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
c.cookies.set("odysseus_session", "session-token")
|
||||||
|
assert c.get("/api/auth/users").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_style_extract_gating(client, monkeypatch):
|
||||||
|
c, _ = client
|
||||||
|
dummy_file = {"file": ("sample.txt", b"dummy prose content")}
|
||||||
|
|
||||||
|
# When auth enabled: unauthenticated is 403, non-admin is 403
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||||
|
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 403
|
||||||
|
|
||||||
|
c.cookies.set("odysseus_session", "user-token")
|
||||||
|
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 403
|
||||||
|
|
||||||
|
# When auth disabled: unauthenticated passes admin gate (400 for sample length < 80)
|
||||||
|
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||||
|
c.cookies.clear()
|
||||||
|
assert c.post("/api/auth/settings/document-style/extract", files=dummy_file).status_code == 400
|
||||||
@@ -176,6 +176,41 @@ def test_native_file_tools_hide_control_plane_hardlink_alias(tmp_path, monkeypat
|
|||||||
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
|
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_file_tools_take_one_control_plane_snapshot_per_scan(tmp_path, monkeypatch):
|
||||||
|
"""The deny check must not rebuild the control-plane snapshot per entry.
|
||||||
|
|
||||||
|
Each rebuild stats every protected state file, so a per-entry rebuild made
|
||||||
|
a 5,000-file grep about seven times slower and timed out near 20k files.
|
||||||
|
"""
|
||||||
|
resources = importlib.import_module("src.agent_runtime.resources")
|
||||||
|
data_dir = tmp_path / "data"
|
||||||
|
data_dir.mkdir()
|
||||||
|
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||||
|
for index in range(3):
|
||||||
|
directory = workspace / f"d{index}"
|
||||||
|
directory.mkdir(parents=True)
|
||||||
|
for item in range(20):
|
||||||
|
(directory / f"f{item}.txt").write_text("NEEDLE\n" if item == 0 else "x\n")
|
||||||
|
snapshots = []
|
||||||
|
original = resources._control_plane_snapshot
|
||||||
|
|
||||||
|
def counting_snapshot():
|
||||||
|
snapshots.append(1)
|
||||||
|
return original()
|
||||||
|
|
||||||
|
monkeypatch.setattr(resources, "_control_plane_snapshot", counting_snapshot)
|
||||||
|
for tool, args, expected in (
|
||||||
|
(LsTool(), {"path": str(workspace / "d0")}, "f19.txt"),
|
||||||
|
(GlobTool(), {"pattern": "**/*.txt", "path": str(workspace)}, "f19.txt"),
|
||||||
|
(GrepTool(), {"pattern": "NEEDLE", "path": str(workspace)}, "f0.txt:1:NEEDLE"),
|
||||||
|
):
|
||||||
|
snapshots.clear()
|
||||||
|
result = asyncio.run(tool.execute(json.dumps(args), {}))
|
||||||
|
assert expected in result.get("output", ""), result
|
||||||
|
# One fresh check resolves the search root; the walk shares one more.
|
||||||
|
assert len(snapshots) <= 2, (type(tool).__name__, len(snapshots))
|
||||||
|
|
||||||
|
|
||||||
def test_blocks_app_state_on_a_case_insensitive_filesystem():
|
def test_blocks_app_state_on_a_case_insensitive_filesystem():
|
||||||
"""On default macOS a case-variant path opens the same file, and realpath
|
"""On default macOS a case-variant path opens the same file, and realpath
|
||||||
does not canonicalise case there the way it does on Windows.
|
does not canonicalise case there the way it does on Windows.
|
||||||
@@ -331,7 +366,9 @@ def test_allows_files_in_the_agent_workspace():
|
|||||||
(PERSONAL_UPLOADS_DIR,
|
(PERSONAL_UPLOADS_DIR,
|
||||||
"indexed into personal docs by routes/personal_routes.py, and listed as "
|
"indexed into personal docs by routes/personal_routes.py, and listed as "
|
||||||
"an absolute path by manage_rag"),
|
"an absolute path by manage_rag"),
|
||||||
])
|
# The directories live under a per-worker data dir, so they cannot be test ids:
|
||||||
|
# xdist requires every worker to collect the same names.
|
||||||
|
], ids=["uploads", "mail_attachments", "personal", "personal_uploads"])
|
||||||
def test_allows_user_content_the_app_hands_to_the_model(directory, why):
|
def test_allows_user_content_the_app_hands_to_the_model(directory, why):
|
||||||
"""Carving these out is not convenience. The app gives the model these
|
"""Carving these out is not convenience. The app gives the model these
|
||||||
paths and tells it to read them, so denying them breaks the feature."""
|
paths and tells it to read them, so denying them breaks the feature."""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -8,11 +9,42 @@ def _src(path: str) -> str:
|
|||||||
return (ROOT / path).read_text(encoding="utf-8")
|
return (ROOT / path).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _utility_kwargs(source: str, function_name: str) -> dict:
|
||||||
|
tree = ast.parse(source)
|
||||||
|
|
||||||
|
functions = [
|
||||||
|
node for node in ast.walk(tree)
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name == function_name
|
||||||
|
]
|
||||||
|
assert len(functions) == 1, function_name
|
||||||
|
|
||||||
|
calls = [
|
||||||
|
node for node in ast.walk(functions[0])
|
||||||
|
if isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "resolve_endpoint"
|
||||||
|
and node.args
|
||||||
|
and isinstance(node.args[0], ast.Constant)
|
||||||
|
and node.args[0].value == "utility"
|
||||||
|
]
|
||||||
|
assert len(calls) == 1, function_name
|
||||||
|
|
||||||
|
return {
|
||||||
|
kw.arg: ast.unparse(kw.value)
|
||||||
|
for kw in calls[0].keywords
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_registered_manual_compaction_uses_session_owner_for_utility_endpoint():
|
def test_registered_manual_compaction_uses_session_owner_for_utility_endpoint():
|
||||||
session_src = _src("routes/session_routes.py")
|
session_src = _src("routes/session_routes.py")
|
||||||
|
|
||||||
assert 'owner = getattr(session, "owner", None) or effective_user(request)' in session_src
|
assert 'owner = getattr(session, "owner", None) or effective_user(request)' in session_src
|
||||||
assert 'resolve_endpoint("utility", owner=owner)' in session_src
|
kwargs = _utility_kwargs(session_src, "compact_session")
|
||||||
|
assert kwargs["owner"] == "owner"
|
||||||
|
assert kwargs["fallback_url"] == "getattr(session, 'endpoint_url', None)"
|
||||||
|
assert kwargs["fallback_model"] == "getattr(session, 'model', None)"
|
||||||
|
assert kwargs["fallback_headers"] == "getattr(session, 'headers', None)"
|
||||||
|
|
||||||
|
|
||||||
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
def test_task_name_generation_uses_owner_scoped_session_endpoint():
|
||||||
@@ -31,7 +63,11 @@ def test_auto_compaction_utility_endpoint_keeps_chat_owner():
|
|||||||
|
|
||||||
assert "owner=user" in helper_src
|
assert "owner=user" in helper_src
|
||||||
assert "owner: Optional[str] = None" in compact_src
|
assert "owner: Optional[str] = None" in compact_src
|
||||||
assert 'resolve_endpoint("utility", owner=owner)' in compact_src
|
kwargs = _utility_kwargs(compact_src, "maybe_compact")
|
||||||
|
assert kwargs["owner"] == "owner"
|
||||||
|
assert kwargs["fallback_url"] == "endpoint_url"
|
||||||
|
assert kwargs["fallback_model"] == "model"
|
||||||
|
assert kwargs["fallback_headers"] == "headers"
|
||||||
|
|
||||||
|
|
||||||
def test_background_session_sort_uses_owner_task_endpoint():
|
def test_background_session_sort_uses_owner_task_endpoint():
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ def test_history_and_chat_routes_support_reasoning_effort():
|
|||||||
"""Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
|
"""Verify history and chat routes support reasoning_effort and thinking_mode persistence."""
|
||||||
hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
|
hist_src = (ROOT / "routes/history/history_routes.py").read_text(encoding="utf-8")
|
||||||
assert "reasoning_effort" in hist_src
|
assert "reasoning_effort" in hist_src
|
||||||
assert 'mode = f"effort:{clean_effort}"' in hist_src
|
assert "def _effort_mode(" in hist_src
|
||||||
|
assert 'return f"effort:{effort}" if effort else "off"' in hist_src
|
||||||
|
assert "mode = _effort_mode(session.model, raw_effort)" in hist_src
|
||||||
|
assert "mode = _effort_mode(session.model, raw_mode[7:])" in hist_src
|
||||||
|
|
||||||
chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
chat_src = (ROOT / "routes/chat_routes.py").read_text(encoding="utf-8")
|
||||||
assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
|
assert "validate_reasoning_effort(sess.model, reasoning_effort)" in chat_src
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Regression guard for issue #6132 — the ChromaDB named volume was mounted at a
|
||||||
|
path the image never writes to, so the whole vector store lived in the
|
||||||
|
container's writable layer and was destroyed on every recreate. Silently: the
|
||||||
|
collection survived with a count of 0, and retrieval just stopped returning
|
||||||
|
anything.
|
||||||
|
|
||||||
|
``chromadb/chroma`` runs ``chroma run /config.yaml``, and that config is a single
|
||||||
|
line — ``persist_path: "/data"``. The image declares no ``VOLUME``, so nothing
|
||||||
|
outside a mount survives. ``/chroma/chroma`` is where pre-1.0 images kept the
|
||||||
|
store.
|
||||||
|
|
||||||
|
The documented backup procedure (website/backup-restore.md) archives the
|
||||||
|
``chromadb-data`` volume, so a wrong mount point also makes that tarball empty.
|
||||||
|
|
||||||
|
Pure YAML — no Docker required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
COMPOSE_FILES = [
|
||||||
|
ROOT / "docker-compose.yml",
|
||||||
|
ROOT / "docker-compose.gpu-nvidia.yml",
|
||||||
|
ROOT / "docker-compose.gpu-amd.yml",
|
||||||
|
]
|
||||||
|
|
||||||
|
# `persist_path` from the image's own /config.yaml.
|
||||||
|
CHROMA_PERSIST_PATH = "/data"
|
||||||
|
LEGACY_PERSIST_PATH = "/chroma/chroma"
|
||||||
|
VOLUME_NAME = "chromadb-data"
|
||||||
|
|
||||||
|
|
||||||
|
def _chromadb_volumes(path: Path) -> list[str]:
|
||||||
|
"""Return a normalised list of ``source:target`` strings for the chromadb service.
|
||||||
|
|
||||||
|
Docker Compose accepts volumes in two syntaxes:
|
||||||
|
|
||||||
|
* Short-form string: ``"chromadb-data:/data"`` or ``"chromadb-data:/data:ro"``
|
||||||
|
* Long-form mapping::
|
||||||
|
|
||||||
|
- source: chromadb-data
|
||||||
|
target: /data
|
||||||
|
type: volume
|
||||||
|
|
||||||
|
Both forms are normalised to ``"<source>:<target>"`` so that callers can
|
||||||
|
use ``split(":")`` to extract either part without worrying about the original
|
||||||
|
syntax or trailing mount-option fields (e.g. ``:z``, ``:ro``).
|
||||||
|
"""
|
||||||
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
service = compose["services"]["chromadb"]
|
||||||
|
raw = list(service.get("volumes") or [])
|
||||||
|
result: list[str] = []
|
||||||
|
for entry in raw:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
# Long-form: normalise to "source:target" for uniform handling.
|
||||||
|
source = entry.get("source", "")
|
||||||
|
target = entry.get("target", "")
|
||||||
|
result.append(f"{source}:{target}")
|
||||||
|
else:
|
||||||
|
# Short-form string: keep as-is; split(":")[1] is still the target
|
||||||
|
# even when a third field (mount options like :z, :ro) is present.
|
||||||
|
result.append(str(entry))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||||
|
def test_chromadb_volume_is_mounted_at_the_persist_path(path: Path):
|
||||||
|
mounts = _chromadb_volumes(path)
|
||||||
|
targets = {m.split(":")[1] for m in mounts if m.startswith(f"{VOLUME_NAME}:")}
|
||||||
|
assert targets, f"{path.name}: no {VOLUME_NAME} mount on the chromadb service"
|
||||||
|
assert targets == {CHROMA_PERSIST_PATH}, (
|
||||||
|
f"{path.name}: {VOLUME_NAME} is mounted at {sorted(targets)}, but the image "
|
||||||
|
f"persists to {CHROMA_PERSIST_PATH!r}. Anything written outside a mount is "
|
||||||
|
"lost when the container is recreated (issue #6132)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||||
|
def test_chromadb_does_not_use_the_pre_1_0_path(path: Path):
|
||||||
|
"""Verify that no mount places chromadb-data at the pre-1.0 store path.
|
||||||
|
|
||||||
|
Uses ``split(":")[1]`` rather than ``endswith()`` so that mount-option
|
||||||
|
suffixes (e.g. ``:z``, ``:ro``) do not mask a wrong target path.
|
||||||
|
"""
|
||||||
|
mounts = _chromadb_volumes(path)
|
||||||
|
legacy = [m for m in mounts if ":" in m and m.split(":")[1] == LEGACY_PERSIST_PATH]
|
||||||
|
assert not legacy, (
|
||||||
|
f"{path.name}: {LEGACY_PERSIST_PATH} is the pre-1.0 store location; "
|
||||||
|
f"current images never write there. Offending entries: {legacy}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.name)
|
||||||
|
def test_chromadb_data_volume_is_declared(path: Path):
|
||||||
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
assert VOLUME_NAME in (compose.get("volumes") or {}), (
|
||||||
|
f"{path.name}: the {VOLUME_NAME} volume is mounted but not declared"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit tests for _chromadb_volumes() — parse-correctness, not Compose state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _FakePath:
|
||||||
|
"""Minimal Path stand-in that returns a fixed YAML string."""
|
||||||
|
|
||||||
|
def __init__(self, yaml_text: str):
|
||||||
|
self._text = yaml_text
|
||||||
|
|
||||||
|
def read_text(self, encoding: str = "utf-8") -> str: # noqa: ARG002
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_normalises_long_form_volume_dict():
|
||||||
|
"""Long-form dict volume entries must not raise AttributeError."""
|
||||||
|
p = _FakePath(yaml.dump({
|
||||||
|
"services": {"chromadb": {"volumes": [
|
||||||
|
{"source": "chromadb-data", "target": "/data", "type": "volume"},
|
||||||
|
]}},
|
||||||
|
"volumes": {"chromadb-data": None},
|
||||||
|
}))
|
||||||
|
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||||
|
assert result == ["chromadb-data:/data"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_passes_short_form_string_unchanged():
|
||||||
|
"""Short-form strings (with or without mount options) are returned as-is."""
|
||||||
|
p = _FakePath(yaml.dump({
|
||||||
|
"services": {"chromadb": {"volumes": ["chromadb-data:/data:z"]}},
|
||||||
|
"volumes": {"chromadb-data": None},
|
||||||
|
}))
|
||||||
|
result = _chromadb_volumes(p) # type: ignore[arg-type]
|
||||||
|
assert result == ["chromadb-data:/data:z"]
|
||||||
|
# split(":")[1] still extracts the correct target regardless of :z suffix
|
||||||
|
assert result[0].split(":")[1] == "/data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_helper_legacy_check_catches_path_with_mount_option_suffix():
|
||||||
|
"""The split-based legacy check catches the old path even with :z appended."""
|
||||||
|
mount = f"{VOLUME_NAME}:{LEGACY_PERSIST_PATH}:z"
|
||||||
|
# Confirm endswith() would miss it (the bug we fixed)
|
||||||
|
assert not mount.endswith(f":{LEGACY_PERSIST_PATH}"), (
|
||||||
|
"endswith() should NOT catch ':z'-suffixed legacy path — that was the bug"
|
||||||
|
)
|
||||||
|
# Confirm split()[1] does catch it
|
||||||
|
assert mount.split(":")[1] == LEGACY_PERSIST_PATH, (
|
||||||
|
"split(':')[1] must still identify the legacy target path with :z suffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Regression tests for ChromaDB migration safety invariants (PR #6166 audit)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MIGRATION_DOC = ROOT / "docs" / "chromadb-volume-migration.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_requires_quiescence_before_backup():
|
||||||
|
"""Step 2 must stop ChromaDB before running docker cp to prevent torn SQLite pages."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "docker compose stop chromadb" in text, (
|
||||||
|
"Migration guide must require stopping ChromaDB before database backup"
|
||||||
|
)
|
||||||
|
# Confirm stopping step occurs prior to the cold backup step
|
||||||
|
stop_step = text.find("Step 2: Quiesce Writers and Gracefully Stop ChromaDB")
|
||||||
|
backup_step = text.find("Step 4: Take a Cold File Backup")
|
||||||
|
assert stop_step != -1 and backup_step != -1 and stop_step < backup_step, (
|
||||||
|
"Quiescence/stop step must precede cold backup step in migration sequence"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_mandates_container_preservation_via_commit():
|
||||||
|
"""Guide must preserve stopped container as an immutable image before any recreation."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "docker commit" in text, (
|
||||||
|
"Migration guide must instruct operators to commit the container to an immutable backup image"
|
||||||
|
)
|
||||||
|
assert "com.docker.compose.project" in text, (
|
||||||
|
"Guide must explain that Compose discovers containers by internal labels, not name alone"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_requires_sqlite_and_hnsw_integrity_verification():
|
||||||
|
"""Guide must mandate SQLite PRAGMA integrity_check and checking HNSW index files."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "PRAGMA integrity_check;" in text, (
|
||||||
|
"Migration guide must require SQLite PRAGMA integrity_check"
|
||||||
|
)
|
||||||
|
assert "data_level0.bin" in text or "*.bin" in text, (
|
||||||
|
"Migration guide must require verifying HNSW segment index binary files"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_requires_empty_volume_precheck():
|
||||||
|
"""Guide must verify destination volume is strictly empty prior to seeding."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "ls -A" in text or "empty and ready for seeding" in text, (
|
||||||
|
"Migration guide must check that the destination named volume is empty before seeding"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_includes_staging_validation():
|
||||||
|
"""Guide must validate the seeded volume with a staging container before Compose cutover."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "chromadb-staging" in text, (
|
||||||
|
"Migration guide must include staging container validation step"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_guide_warns_against_reverting_to_broken_mount():
|
||||||
|
"""Rollback instructions must explicitly prohibit git checkout to pre-#6166 broken mount."""
|
||||||
|
text = MIGRATION_DOC.read_text(encoding="utf-8")
|
||||||
|
assert "Do not `git checkout` the pre-#6166 Compose file" in text or "broken" in text.lower(), (
|
||||||
|
"Rollback instructions must explain why checking out old Compose file breaks volume mount"
|
||||||
|
)
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"""Cleanup must use durable transcript rows for its deletion safeguards."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine, event, insert
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
import core.database as database
|
||||||
|
import src.database as legacy_database
|
||||||
|
import routes.cleanup.cleanup_routes as routes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cleanup_api(tmp_path, monkeypatch):
|
||||||
|
engine = create_engine(
|
||||||
|
f"sqlite:///{(tmp_path / 'cleanup.db').as_posix()}",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
)
|
||||||
|
database.Base.metadata.create_all(engine)
|
||||||
|
factory = sessionmaker(bind=engine, autoflush=False)
|
||||||
|
monkeypatch.setattr(legacy_database, "SessionLocal", factory)
|
||||||
|
monkeypatch.setattr(routes, "get_current_user", lambda request: "alice")
|
||||||
|
manager = SimpleNamespace(sessions={})
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.setup_cleanup_routes(manager))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
yield client, factory, manager
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def seed(factory, manager, stored, actual, *, archived=True):
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
with factory() as db:
|
||||||
|
for index in range(10):
|
||||||
|
db.add(database.Session(
|
||||||
|
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(hours=index),
|
||||||
|
last_accessed=now, message_count=1,
|
||||||
|
))
|
||||||
|
db.add(database.Session(
|
||||||
|
id="old-chat", name="Project discussion", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(days=50),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=archived,
|
||||||
|
is_important=False, message_count=stored,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"message-{index}", session_id="old-chat", role="user",
|
||||||
|
content=f"Saved project discussion {index}", timestamp=now - timedelta(days=30),
|
||||||
|
) for index in range(actual))
|
||||||
|
db.commit()
|
||||||
|
manager.sessions["old-chat"] = SimpleNamespace(message_count=actual)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stored", [0, 3])
|
||||||
|
def test_cleanup_preserves_long_transcript_with_stale_count(cleanup_api, stored):
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, stored, 40)
|
||||||
|
preview = client.get("/api/cleanup/preview").json()
|
||||||
|
assert not preview["sessions_to_delete"]
|
||||||
|
protected = next(row for row in preview["preserved_sessions"] if row["id"] == "old-chat")
|
||||||
|
assert protected["message_count"] == 40
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
with factory() as db:
|
||||||
|
actual = db.query(database.ChatMessage).filter_by(session_id="old-chat").count()
|
||||||
|
survives = db.get(database.Session, "old-chat") is not None
|
||||||
|
assert response.json()["deleted_count"] == 0
|
||||||
|
assert survives and actual == 40, "Cleanup must count actual rows before hard deletion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_still_removes_genuinely_short_old_chat(cleanup_api):
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 3, 3)
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == 1
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "old-chat") is None
|
||||||
|
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_archive_is_not_deleted_in_same_cleanup(cleanup_api):
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 3, 3, archived=False)
|
||||||
|
preview = client.get("/api/cleanup/preview").json()
|
||||||
|
assert not preview["sessions_to_delete"]
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == 0
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "old-chat").archived
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("actual,expected_deleted", [(19, 1), (20, 0)])
|
||||||
|
def test_cleanup_threshold_uses_actual_count_even_when_metadata_is_high(cleanup_api, actual, expected_deleted):
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 100, actual)
|
||||||
|
preview = client.get("/api/cleanup/preview").json()
|
||||||
|
section = "sessions_to_delete" if expected_deleted else "preserved_sessions"
|
||||||
|
row = next(row for row in preview[section] if row["id"] == "old-chat")
|
||||||
|
assert row["message_count"] == actual
|
||||||
|
if expected_deleted:
|
||||||
|
assert row["estimated_size_kb"] == 19 * 512 / 1024
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == expected_deleted
|
||||||
|
with factory() as db:
|
||||||
|
assert (db.get(database.Session, "old-chat") is None) == bool(expected_deleted)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("protection", ["starred", "keyword", "other_owner"])
|
||||||
|
def test_cleanup_retains_existing_protections(cleanup_api, protection):
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 0, 3)
|
||||||
|
with factory() as db:
|
||||||
|
row = db.get(database.Session, "old-chat")
|
||||||
|
if protection == "starred":
|
||||||
|
row.is_important = True
|
||||||
|
elif protection == "keyword":
|
||||||
|
row.name = "Important project"
|
||||||
|
else:
|
||||||
|
row.owner = "bob"
|
||||||
|
# Preserve the old activity date despite the column's onupdate default.
|
||||||
|
row.last_accessed = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=30)
|
||||||
|
db.commit()
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == 0
|
||||||
|
with factory() as db:
|
||||||
|
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_rechecks_messages_at_the_delete_boundary(cleanup_api):
|
||||||
|
"""Deliver new rows after selection but before the DELETE executes."""
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 3, 3)
|
||||||
|
engine = factory.kw["bind"]
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def deliver_messages(connection, cursor, statement, parameters, context, executemany):
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||||
|
injected = True
|
||||||
|
connection.execute(insert(database.ChatMessage), [
|
||||||
|
{"id": f"incoming-{i}", "session_id": "old-chat", "role": "assistant",
|
||||||
|
"content": "New durable message"} for i in range(20)
|
||||||
|
])
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", deliver_messages)
|
||||||
|
try:
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", deliver_messages)
|
||||||
|
assert injected
|
||||||
|
assert response.json()["deleted_count"] == 0
|
||||||
|
assert response.json()["space_freed_mb"] == 0
|
||||||
|
assert "old-chat" in manager.sessions
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "old-chat") is not None
|
||||||
|
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 23
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_space_freed_ignores_concurrently_deleted_session(cleanup_api):
|
||||||
|
"""If another transaction deletes a candidate first, do not attribute its space."""
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
with factory() as db:
|
||||||
|
for index in range(10):
|
||||||
|
db.add(database.Session(
|
||||||
|
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(hours=index),
|
||||||
|
last_accessed=now, message_count=1,
|
||||||
|
))
|
||||||
|
db.add(database.Session(
|
||||||
|
id="chat-to-delete", name="Eligible 1", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(days=50),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=True,
|
||||||
|
is_important=False, message_count=3,
|
||||||
|
))
|
||||||
|
db.add(database.Session(
|
||||||
|
id="chat-concurrently-deleted", name="Eligible 2", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(days=50),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=True,
|
||||||
|
is_important=False, message_count=10,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"msg1-{i}", session_id="chat-to-delete", role="user",
|
||||||
|
content="m", timestamp=now - timedelta(days=30),
|
||||||
|
) for i in range(3))
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"msg2-{i}", session_id="chat-concurrently-deleted", role="user",
|
||||||
|
content="m", timestamp=now - timedelta(days=30),
|
||||||
|
) for i in range(10))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
manager.sessions["chat-to-delete"] = SimpleNamespace(message_count=3)
|
||||||
|
manager.sessions["chat-concurrently-deleted"] = SimpleNamespace(message_count=10)
|
||||||
|
|
||||||
|
engine = factory.kw["bind"]
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def delete_candidate(connection, cursor, statement, parameters, context, executemany):
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||||
|
injected = True
|
||||||
|
with factory() as other_db:
|
||||||
|
other_db.query(database.Session).filter_by(id="chat-concurrently-deleted").delete()
|
||||||
|
other_db.commit()
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", delete_candidate)
|
||||||
|
try:
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", delete_candidate)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted_count"] == 1
|
||||||
|
# Only chat-to-delete's 3 messages (1536 bytes) freed by this cleanup, not (3 + 10) * 512
|
||||||
|
expected_space_mb = round(3 * 512 / (1024 * 1024), 2)
|
||||||
|
assert data["space_freed_mb"] == expected_space_mb
|
||||||
|
assert "chat-to-delete" not in manager.sessions
|
||||||
|
assert "chat-concurrently-deleted" not in manager.sessions
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "chat-to-delete") is None
|
||||||
|
assert db.get(database.Session, "chat-concurrently-deleted") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_recent_session_query_is_bounded(cleanup_api):
|
||||||
|
"""Ensure recent-session preservation scales to large collections and respects limits."""
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
with factory() as db:
|
||||||
|
for index in range(25):
|
||||||
|
db.add(database.Session(
|
||||||
|
id=f"sess-{index:02d}", name=f"Session {index}", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(hours=index),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=True,
|
||||||
|
is_important=False, message_count=3,
|
||||||
|
))
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"msg-{index}-{m}", session_id=f"sess-{index:02d}", role="user",
|
||||||
|
content="m", timestamp=now - timedelta(days=30),
|
||||||
|
) for m in range(3))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
preview = client.get("/api/cleanup/preview").json()
|
||||||
|
preserved_ids = {row["id"] for row in preview["preserved_sessions"]}
|
||||||
|
to_delete_ids = {row["id"] for row in preview["sessions_to_delete"]}
|
||||||
|
|
||||||
|
# Top 10 newest (sess-00 to sess-09) must be preserved
|
||||||
|
expected_preserved = {f"sess-{i:02d}" for i in range(10)}
|
||||||
|
expected_to_delete = {f"sess-{i:02d}" for i in range(10, 25)}
|
||||||
|
assert preserved_ids == expected_preserved
|
||||||
|
assert to_delete_ids == expected_to_delete
|
||||||
|
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == 15
|
||||||
|
|
||||||
|
with factory() as db:
|
||||||
|
surviving = {row[0] for row in db.query(database.Session.id).all()}
|
||||||
|
assert surviving == expected_preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_handles_zero_actual_messages_session(cleanup_api):
|
||||||
|
"""Sessions with zero durable messages are properly previewed and cleaned up."""
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 10, 0)
|
||||||
|
preview = client.get("/api/cleanup/preview").json()
|
||||||
|
row = next(r for r in preview["sessions_to_delete"] if r["id"] == "old-chat")
|
||||||
|
assert row["message_count"] == 0
|
||||||
|
assert row["estimated_size_kb"] == 0.0
|
||||||
|
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
assert response.json()["deleted_count"] == 1
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "old-chat") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_db_error_preserves_cache_and_aborts_cleanly(cleanup_api):
|
||||||
|
"""Database execution failures must abort, rollback, and leave in-memory cache intact."""
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
seed(factory, manager, 3, 3)
|
||||||
|
engine = factory.kw["bind"]
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def break_delete(connection, cursor, statement, parameters, context, executemany):
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||||
|
injected = True
|
||||||
|
raise RuntimeError("Injected database error during DELETE")
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", break_delete)
|
||||||
|
try:
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", break_delete)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["deleted_count"] == 0
|
||||||
|
assert response.json()["space_freed_mb"] == 0
|
||||||
|
# In-memory session was not prematurely evicted
|
||||||
|
assert "old-chat" in manager.sessions
|
||||||
|
# Database row was rolled back and still exists
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "old-chat") is not None
|
||||||
|
assert db.query(database.ChatMessage).filter_by(session_id="old-chat").count() == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_portable_fallback_without_returning(cleanup_api, monkeypatch):
|
||||||
|
"""Engines lacking RETURNING (e.g. SQLite < 3.35.0) safely clean up without syntax errors."""
|
||||||
|
import src.cleanup_service as cleanup_service
|
||||||
|
monkeypatch.setattr(cleanup_service, "_supports_delete_returning", lambda db: False)
|
||||||
|
|
||||||
|
client, factory, manager = cleanup_api
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
with factory() as db:
|
||||||
|
for index in range(10):
|
||||||
|
db.add(database.Session(
|
||||||
|
id=f"recent-{index}", name="Recent", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(hours=index),
|
||||||
|
last_accessed=now, message_count=1,
|
||||||
|
))
|
||||||
|
db.add(database.Session(
|
||||||
|
id="chat-to-delete", name="Eligible 1", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(days=50),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=True,
|
||||||
|
is_important=False, message_count=3,
|
||||||
|
))
|
||||||
|
db.add(database.Session(
|
||||||
|
id="chat-concurrently-deleted", name="Eligible 2", endpoint_url="", model="",
|
||||||
|
owner="alice", created_at=now - timedelta(days=50),
|
||||||
|
last_accessed=now - timedelta(days=30), archived=True,
|
||||||
|
is_important=False, message_count=10,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"msg1-{i}", session_id="chat-to-delete", role="user",
|
||||||
|
content="m", timestamp=now - timedelta(days=30),
|
||||||
|
) for i in range(3))
|
||||||
|
db.add_all(database.ChatMessage(
|
||||||
|
id=f"msg2-{i}", session_id="chat-concurrently-deleted", role="user",
|
||||||
|
content="m", timestamp=now - timedelta(days=30),
|
||||||
|
) for i in range(10))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
manager.sessions["chat-to-delete"] = SimpleNamespace(message_count=3)
|
||||||
|
manager.sessions["chat-concurrently-deleted"] = SimpleNamespace(message_count=10)
|
||||||
|
|
||||||
|
engine = factory.kw["bind"]
|
||||||
|
injected = False
|
||||||
|
|
||||||
|
def delete_candidate(connection, cursor, statement, parameters, context, executemany):
|
||||||
|
nonlocal injected
|
||||||
|
if not injected and statement.lstrip().startswith("DELETE FROM sessions"):
|
||||||
|
injected = True
|
||||||
|
with factory() as other_db:
|
||||||
|
other_db.query(database.Session).filter_by(id="chat-concurrently-deleted").delete()
|
||||||
|
other_db.commit()
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", delete_candidate)
|
||||||
|
try:
|
||||||
|
response = client.post("/api/cleanup")
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", delete_candidate)
|
||||||
|
|
||||||
|
assert injected
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted_count"] == 1
|
||||||
|
expected_space_mb = round(3 * 512 / (1024 * 1024), 2)
|
||||||
|
assert data["space_freed_mb"] == expected_space_mb
|
||||||
|
assert "chat-to-delete" not in manager.sessions
|
||||||
|
assert "chat-concurrently-deleted" not in manager.sessions
|
||||||
|
with factory() as db:
|
||||||
|
assert db.get(database.Session, "chat-to-delete") is None
|
||||||
|
assert db.get(database.Session, "chat-concurrently-deleted") is None
|
||||||
@@ -311,7 +311,14 @@ class TestMaybeCompactFourthMessage:
|
|||||||
|
|
||||||
cc.get_context_length = lambda url, model: context_length
|
cc.get_context_length = lambda url, model: context_length
|
||||||
cc.llm_call_async = _fake_summary
|
cc.llm_call_async = _fake_summary
|
||||||
cc.resolve_endpoint = lambda which, owner=None: (None, None, None)
|
def fake_resolve_endpoint(which, **kwargs):
|
||||||
|
assert which == "utility"
|
||||||
|
assert kwargs["fallback_url"] == "http://local/v1/chat/completions"
|
||||||
|
assert kwargs["fallback_model"] == "local-model"
|
||||||
|
assert kwargs["fallback_headers"] == {}
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
cc.resolve_endpoint = fake_resolve_endpoint
|
||||||
cc._update_session_history = lambda *a, **k: None
|
cc._update_session_history = lambda *a, **k: None
|
||||||
try:
|
try:
|
||||||
return asyncio.run(
|
return asyncio.run(
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""Bulk Delete must retain messages that could not be moved to Trash."""
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class Mailbox:
|
||||||
|
def __init__(self):
|
||||||
|
self.inbox = {"101", "102", "103"}
|
||||||
|
self.trash = set()
|
||||||
|
self.deleted = set()
|
||||||
|
self.calls = []
|
||||||
|
self.move_status = "NO"
|
||||||
|
self.copy_status = {}
|
||||||
|
self.store_status = {}
|
||||||
|
self.expunge_status = "OK"
|
||||||
|
self.uid_expunge_status = None
|
||||||
|
self.select_status = "OK"
|
||||||
|
|
||||||
|
def select(self, folder, readonly=False):
|
||||||
|
return self.select_status, [b"3"]
|
||||||
|
|
||||||
|
def uid(self, command, raw_uid, *args):
|
||||||
|
uid = raw_uid.decode() if isinstance(raw_uid, bytes) else (str(raw_uid) if raw_uid is not None else "")
|
||||||
|
self.calls.append((command, uid))
|
||||||
|
if command == "FETCH":
|
||||||
|
if uid not in self.inbox:
|
||||||
|
return "OK", []
|
||||||
|
if args and "(FLAGS)" in args:
|
||||||
|
flags = rb"(\Deleted)" if uid in self.deleted else rb"()"
|
||||||
|
return "OK", [(f"1 (UID {uid} FLAGS ".encode() + flags + b")")]
|
||||||
|
return "OK", [f"1 (UID {uid})".encode()]
|
||||||
|
if command == "SEARCH":
|
||||||
|
if args and "DELETED" in args:
|
||||||
|
return "OK", [b" ".join(u.encode() for u in sorted(self.deleted))]
|
||||||
|
searched_uid = args[-1].split()[-1] if args else ""
|
||||||
|
return "OK", [searched_uid.encode() if searched_uid in self.inbox else b""]
|
||||||
|
if command == "MOVE":
|
||||||
|
status = self.move_status.get(uid, "NO") if isinstance(self.move_status, dict) else self.move_status
|
||||||
|
if isinstance(status, Exception):
|
||||||
|
raise status
|
||||||
|
if status == "OK":
|
||||||
|
self.trash.add(uid)
|
||||||
|
self.inbox.remove(uid)
|
||||||
|
return status, []
|
||||||
|
if command == "COPY":
|
||||||
|
status = self.copy_status.get(uid, "OK")
|
||||||
|
if isinstance(status, Exception):
|
||||||
|
raise status
|
||||||
|
if status == "OK":
|
||||||
|
self.trash.add(uid)
|
||||||
|
return status, []
|
||||||
|
if command == "STORE":
|
||||||
|
status = self.store_status.get(uid, "OK")
|
||||||
|
if isinstance(status, Exception):
|
||||||
|
raise status
|
||||||
|
if status == "OK":
|
||||||
|
if args and args[0] == "-FLAGS":
|
||||||
|
self.deleted.discard(uid)
|
||||||
|
else:
|
||||||
|
self.deleted.add(uid)
|
||||||
|
return status, []
|
||||||
|
if command == "EXPUNGE":
|
||||||
|
status = self.uid_expunge_status if self.uid_expunge_status is not None else self.expunge_status
|
||||||
|
if isinstance(status, Exception):
|
||||||
|
raise status
|
||||||
|
if status == "OK":
|
||||||
|
expunged = {u for u in uid.split(",") if u in self.deleted}
|
||||||
|
self.inbox.difference_update(expunged)
|
||||||
|
self.deleted.difference_update(expunged)
|
||||||
|
return status, []
|
||||||
|
raise AssertionError(f"Unexpected IMAP command: {command}")
|
||||||
|
|
||||||
|
def expunge(self):
|
||||||
|
self.calls.append(("EXPUNGE", None))
|
||||||
|
if isinstance(self.expunge_status, Exception):
|
||||||
|
raise self.expunge_status
|
||||||
|
if self.expunge_status == "OK":
|
||||||
|
self.inbox.difference_update(self.deleted)
|
||||||
|
self.deleted.clear()
|
||||||
|
return self.expunge_status, []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bulk_delete(monkeypatch):
|
||||||
|
from routes.email import email_routes as routes
|
||||||
|
|
||||||
|
mailbox = Mailbox()
|
||||||
|
index_delete = Mock()
|
||||||
|
invalidate_cache = Mock()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def imap(account_id=None, owner=""):
|
||||||
|
assert owner == "alice"
|
||||||
|
yield mailbox
|
||||||
|
|
||||||
|
monkeypatch.setattr(routes, "_start_poller", lambda: None)
|
||||||
|
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
|
||||||
|
monkeypatch.setattr(routes, "_imap", imap)
|
||||||
|
monkeypatch.setattr(routes, "_resolve_mail_folder", lambda *args: "Trash")
|
||||||
|
monkeypatch.setattr(routes, "_list_imap_folders", lambda conn: ({}, ["INBOX", "Trash"]))
|
||||||
|
monkeypatch.setattr(routes, "_email_index_delete", index_delete)
|
||||||
|
monkeypatch.setitem(routes._CACHE_HOOKS, "invalidate_list", invalidate_cache)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.setup_email_routes())
|
||||||
|
app.dependency_overrides[routes.require_owner] = lambda: "alice"
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
def delete(uids):
|
||||||
|
response = client.post("/api/email/delete-bulk", json={"uids": uids})
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
yield SimpleNamespace(
|
||||||
|
mailbox=mailbox,
|
||||||
|
delete=delete,
|
||||||
|
index_delete=index_delete,
|
||||||
|
invalidate_cache=invalidate_cache,
|
||||||
|
routes=routes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("copy_status", ["NO", "BAD"])
|
||||||
|
def test_failed_trash_copy_preserves_original(bulk_delete, copy_status):
|
||||||
|
bulk_delete.mailbox.copy_status["101"] = copy_status
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert bulk_delete.mailbox.deleted == set()
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert ("STORE", "101") not in bulk_delete.mailbox.calls
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_batch_only_deletes_messages_copied_to_trash(bulk_delete):
|
||||||
|
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102", "103"])
|
||||||
|
|
||||||
|
assert bulk_delete.mailbox.inbox == {"102"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "103"}
|
||||||
|
assert result["deleted_uids"] == ["101", "103"]
|
||||||
|
assert result["failed_uids"] == ["102"]
|
||||||
|
assert [call.args[-1] for call in bulk_delete.index_delete.call_args_list] == ["101", "103"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expunge_status", ["NO", "BAD", RuntimeError("Disconnected")])
|
||||||
|
def test_failed_expunge_keeps_index_and_reports_failure(bulk_delete, expunge_status):
|
||||||
|
bulk_delete.mailbox.expunge_status = expunge_status
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101", "102"]
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_failure_retains_original_and_index(bulk_delete):
|
||||||
|
bulk_delete.mailbox.store_status["101"] = "NO"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert bulk_delete.mailbox.trash == {"101"}
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_move_needs_no_copy_or_expunge(bulk_delete):
|
||||||
|
bulk_delete.mailbox.move_status = "OK"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["deleted_uids"] == ["101", "102"]
|
||||||
|
assert result["failed_uids"] == []
|
||||||
|
assert bulk_delete.mailbox.inbox == {"103"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||||
|
assert not any(command in {"COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_fallback_expunge_runs_once_for_the_batch(bulk_delete):
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["deleted_uids"] == ["101", "102"]
|
||||||
|
assert result["failed_uids"] == []
|
||||||
|
assert bulk_delete.mailbox.inbox == {"103"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||||
|
assert sum(1 for command, _ in bulk_delete.mailbox.calls if command == "EXPUNGE") == 1
|
||||||
|
assert bulk_delete.index_delete.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_folder_selection_never_changes_messages(bulk_delete):
|
||||||
|
bulk_delete.mailbox.select_status = "NO"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert bulk_delete.mailbox.calls == []
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("expunge_status", ["NO", RuntimeError("Disconnected")])
|
||||||
|
def test_completed_native_move_is_retained_when_fallback_expunge_fails(bulk_delete, expunge_status):
|
||||||
|
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||||
|
bulk_delete.mailbox.expunge_status = expunge_status
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert result["failed_uids"] == ["102"]
|
||||||
|
assert bulk_delete.mailbox.inbox == {"102", "103"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||||
|
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_uid_is_reported_failed_without_deleting_another_message(bulk_delete):
|
||||||
|
result = bulk_delete.delete(["999"])
|
||||||
|
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["999"]
|
||||||
|
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||||
|
assert bulk_delete.mailbox.trash == set()
|
||||||
|
assert not any(command in {"MOVE", "COPY", "STORE", "EXPUNGE"} for command, _ in bulk_delete.mailbox.calls)
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_and_invalid_uids_do_not_repeat_mail_operations(bulk_delete):
|
||||||
|
result = bulk_delete.delete(["101", "101", "", None, "invalid"])
|
||||||
|
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert result["failed_uids"] == []
|
||||||
|
assert bulk_delete.mailbox.calls.count(("COPY", "101")) == 1
|
||||||
|
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bulk_delete_invalidates_source_and_trash_caches(bulk_delete):
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_failure_with_exception_invalidates_caches(bulk_delete):
|
||||||
|
bulk_delete.mailbox.move_status = {"101": "OK"}
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = RuntimeError("Socket reset during expunge")
|
||||||
|
bulk_delete.mailbox.expunge_status = RuntimeError("Socket reset during expunge")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert result["failed_uids"] == ["102"]
|
||||||
|
assert result["error"] == "Mail operation partially failed"
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_failure_returns_success_false(bulk_delete):
|
||||||
|
bulk_delete.mailbox.copy_status["101"] = "NO"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["error"] == "Failed to delete requested emails"
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_expunge_preserves_unrelated_deleted_message(bulk_delete):
|
||||||
|
# Unrelated message 999 is already marked \Deleted before bulk delete
|
||||||
|
bulk_delete.mailbox.inbox.add("999")
|
||||||
|
bulk_delete.mailbox.deleted.add("999")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
# 101 was expunged, 999 remains in inbox and remains marked deleted
|
||||||
|
assert "101" not in bulk_delete.mailbox.inbox
|
||||||
|
assert "999" in bulk_delete.mailbox.inbox
|
||||||
|
assert bulk_delete.mailbox.deleted == {"999"}
|
||||||
|
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_lacking_uidplus_never_calls_mailbox_expunge(bulk_delete):
|
||||||
|
# Server does not support UIDPLUS (UID EXPUNGE returns BAD)
|
||||||
|
# Even if no other deleted messages exist, mailbox-wide EXPUNGE is strictly forbidden.
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = "BAD"
|
||||||
|
assert bulk_delete.mailbox.deleted == set()
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert "101" not in bulk_delete.mailbox.deleted
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_client_marks_unrelated_deleted_message_during_operation(bulk_delete):
|
||||||
|
# Another client marks unrelated UID 999 \Deleted before/during the operation
|
||||||
|
bulk_delete.mailbox.inbox.add("999")
|
||||||
|
bulk_delete.mailbox.deleted.add("999")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
# 101 was expunged, unrelated 999 is NEVER expunged
|
||||||
|
assert "101" not in bulk_delete.mailbox.inbox
|
||||||
|
assert "999" in bulk_delete.mailbox.inbox
|
||||||
|
assert bulk_delete.mailbox.deleted == {"999"}
|
||||||
|
assert ("EXPUNGE", "101") in bulk_delete.mailbox.calls
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_preexisting_deleted_flag_preserved_when_expunge_fails(bulk_delete):
|
||||||
|
# Message 101 already had \Deleted flag before the bulk operation
|
||||||
|
bulk_delete.mailbox.deleted.add("101")
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = "NO"
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
# Pre-existing \Deleted flag must NOT be removed
|
||||||
|
assert "101" in bulk_delete.mailbox.deleted
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_preexisting_deleted_flag_expunged_on_success(bulk_delete):
|
||||||
|
# Message 101 already had \Deleted flag before the bulk operation
|
||||||
|
bulk_delete.mailbox.deleted.add("101")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert "101" not in bulk_delete.mailbox.inbox
|
||||||
|
assert "101" not in bulk_delete.mailbox.deleted
|
||||||
|
assert "101" in bulk_delete.mailbox.trash
|
||||||
|
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["NO", "BAD"])
|
||||||
|
def test_uid_expunge_status_failures(bulk_delete, status):
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = status
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101", "102"]
|
||||||
|
assert bulk_delete.mailbox.inbox == {"101", "102", "103"}
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uid_expunge_connection_exception_handled_conservatively(bulk_delete):
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Connection lost during selective expunge")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_after_copy_before_store_preserves_message(bulk_delete):
|
||||||
|
# COPY succeeds, but STORE raises a disconnect
|
||||||
|
bulk_delete.mailbox.store_status["101"] = ConnectionResetError("Disconnected")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert "101" in bulk_delete.mailbox.trash
|
||||||
|
assert bulk_delete.mailbox.deleted == set()
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_after_store_preserves_messages_and_index(bulk_delete):
|
||||||
|
# COPY and STORE succeed, but UID EXPUNGE disconnects
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert "101" in bulk_delete.mailbox.inbox
|
||||||
|
assert "101" in bulk_delete.mailbox.trash
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_disconnect_with_partial_move_success(bulk_delete):
|
||||||
|
# 101 succeeds via MOVE, 102 falls back to COPY+STORE and UID EXPUNGE disconnects
|
||||||
|
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = ConnectionResetError("Disconnected during expunge")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101", "102"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert result["failed_uids"] == ["102"]
|
||||||
|
assert "101" not in bulk_delete.mailbox.inbox
|
||||||
|
assert "102" in bulk_delete.mailbox.inbox
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_called_once_with("alice", None, "INBOX", "101")
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "INBOX")
|
||||||
|
bulk_delete.invalidate_cache.assert_any_call(None, "Trash")
|
||||||
|
|
||||||
|
|
||||||
|
def test_lost_acknowledgement_retains_local_index_and_permits_safe_retry(bulk_delete):
|
||||||
|
# Server succeeds in moving, but connection drops during acknowledgement
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = OSError("Connection dropped")
|
||||||
|
bulk_delete.mailbox.expunge_status = OSError("Connection dropped")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert result["deleted_uids"] == []
|
||||||
|
assert result["failed_uids"] == ["101"]
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
# Retry when server is reconnected and 101 was already moved (absent from source)
|
||||||
|
bulk_delete.mailbox.inbox.discard("101")
|
||||||
|
bulk_delete.mailbox.uid_expunge_status = "OK"
|
||||||
|
bulk_delete.mailbox.expunge_status = "OK"
|
||||||
|
|
||||||
|
retry_result = bulk_delete.delete(["101"])
|
||||||
|
assert retry_result["deleted_uids"] == []
|
||||||
|
assert retry_result["failed_uids"] == ["101"]
|
||||||
|
bulk_delete.index_delete.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_requests_after_partial_failure(bulk_delete):
|
||||||
|
# Initial request: 101 moves, 102 fails copy
|
||||||
|
bulk_delete.mailbox.move_status = {"101": "OK", "102": "NO"}
|
||||||
|
bulk_delete.mailbox.copy_status["102"] = "NO"
|
||||||
|
|
||||||
|
res1 = bulk_delete.delete(["101", "102"])
|
||||||
|
assert res1["deleted_uids"] == ["101"]
|
||||||
|
assert res1["failed_uids"] == ["102"]
|
||||||
|
|
||||||
|
# Retry with failed UID 102 now succeeding via copy
|
||||||
|
bulk_delete.mailbox.copy_status["102"] = "OK"
|
||||||
|
res2 = bulk_delete.delete(["102"])
|
||||||
|
assert res2["deleted_uids"] == ["102"]
|
||||||
|
assert res2["failed_uids"] == []
|
||||||
|
|
||||||
|
assert bulk_delete.mailbox.inbox == {"103"}
|
||||||
|
assert bulk_delete.mailbox.trash == {"101", "102"}
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_messages_never_expunged(bulk_delete):
|
||||||
|
# Mailbox contains unrelated 888 (with \Deleted) and 999 (without \Deleted)
|
||||||
|
bulk_delete.mailbox.inbox.update({"888", "999"})
|
||||||
|
bulk_delete.mailbox.deleted.add("888")
|
||||||
|
|
||||||
|
result = bulk_delete.delete(["101"])
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted_uids"] == ["101"]
|
||||||
|
assert "101" not in bulk_delete.mailbox.inbox
|
||||||
|
assert "101" in bulk_delete.mailbox.trash
|
||||||
|
# 888 and 999 are completely untouched in inbox
|
||||||
|
assert "888" in bulk_delete.mailbox.inbox
|
||||||
|
assert "999" in bulk_delete.mailbox.inbox
|
||||||
|
assert "888" in bulk_delete.mailbox.deleted
|
||||||
|
assert "888" not in bulk_delete.mailbox.trash
|
||||||
|
assert "999" not in bulk_delete.mailbox.trash
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_expunge_never_invoked_by_fallback(bulk_delete):
|
||||||
|
# Perform operations under multiple fallback conditions; standard expunge is never called
|
||||||
|
bulk_delete.delete(["101"])
|
||||||
|
assert ("EXPUNGE", None) not in bulk_delete.mailbox.calls
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""An exact approval must be able to run a bash/python call the request did not grant (#6635).
|
||||||
|
|
||||||
|
`ToolApprovalStore.create()` sealed the process identity only from the request
|
||||||
|
authority's launch scopes, and those exist only for granted bash/python
|
||||||
|
backends. For an ungranted call the sealing failed silently, so the approval
|
||||||
|
card was created with no process identity and the executor refused it:
|
||||||
|
"Approved action has no sealed process/job identity".
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.agent_runtime.authority import OperationGrant, RequestAuthority
|
||||||
|
from src.tool_approvals import ToolApprovalStore
|
||||||
|
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
|
||||||
|
from src.tool_types import ToolBlock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def admin_owner(monkeypatch):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
monkeypatch.setattr(execution, "_owner_is_admin", lambda owner: True)
|
||||||
|
|
||||||
|
|
||||||
|
def _authority(workspace, *tools):
|
||||||
|
return RequestAuthority("approval-request", "alice", "s", str(workspace),
|
||||||
|
tuple(OperationGrant(t) for t in tools))
|
||||||
|
|
||||||
|
|
||||||
|
def _approve(authority, tool, content, store=None):
|
||||||
|
store = store or ToolApprovalStore()
|
||||||
|
pending = store.create(owner=authority.owner, session_id=authority.session_id, origin_run_id="run",
|
||||||
|
tool_name=tool, content=content, workspace=authority.workspace, request_authority=authority,
|
||||||
|
external_untrusted_context_seen=True, capabilities=capabilities_for_action(tool, content))
|
||||||
|
return pending, store.consume(pending.approval_id, decision="approve",
|
||||||
|
owner=authority.owner, session_id=authority.session_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(authority, tool, content, exact):
|
||||||
|
from src import tool_execution as execution
|
||||||
|
return await execution.execute_tool_block(ToolBlock(tool, content), owner=authority.owner,
|
||||||
|
session_id=authority.session_id, workspace=authority.workspace,
|
||||||
|
request_authority=authority, exact_approval=exact,
|
||||||
|
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_approval_for_ungranted_bash_seals_a_launch_in_the_workspace(tmp_path):
|
||||||
|
authority = _authority(tmp_path, "get_workspace")
|
||||||
|
assert authority.launch_scopes == ()
|
||||||
|
|
||||||
|
pending, _ = _approve(authority, "bash", "printf approved-run")
|
||||||
|
|
||||||
|
bound = pending.process_operation
|
||||||
|
assert bound is not None
|
||||||
|
assert bound.launch is not None
|
||||||
|
assert bound.launch.scope.root.path == str(tmp_path.resolve())
|
||||||
|
assert bound.request_id == authority.request_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_approved_ungranted_bash_runs_once(tmp_path):
|
||||||
|
authority = _authority(tmp_path, "get_workspace")
|
||||||
|
_, exact = _approve(authority, "bash", "printf approved-run")
|
||||||
|
|
||||||
|
description, result = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||||
|
|
||||||
|
assert result.get("failure_kind") != "resource_identity_denied", result
|
||||||
|
assert result.get("exit_code") == 0, result
|
||||||
|
assert "approved-run" in str(result.get("output", "")) + description
|
||||||
|
|
||||||
|
_, replay = await _dispatch(authority, "bash", "printf approved-run", exact)
|
||||||
|
assert replay.get("blocked") is True, replay
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path):
|
||||||
|
# A child (inherited) authority may only narrow its parent, so an approval
|
||||||
|
# it proposes for an ungranted launch must stay unsealed and unrunnable.
|
||||||
|
from dataclasses import replace
|
||||||
|
child = replace(_authority(tmp_path, "get_workspace"), inherited=True)
|
||||||
|
pending, exact = _approve(child, "bash", "printf approved-run")
|
||||||
|
|
||||||
|
assert pending.process_operation is None
|
||||||
|
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
|
||||||
|
assert result.get("blocked") is True
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Exercise publication failure modes without changing the real static tree."""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.cli_loader import load_script
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
build = load_script("build_frontend.py")
|
||||||
|
|
||||||
|
|
||||||
|
def project(tmp_path, entries=None):
|
||||||
|
source = tmp_path / "frontend/src/shared/helper.js"
|
||||||
|
source.parent.mkdir(parents=True)
|
||||||
|
source.write_bytes(b"export const value = 1;\n")
|
||||||
|
manifest = {"version": 1, "modules": entries if entries is not None else [
|
||||||
|
{"source": "shared/helper.js", "deployment": "js/helper.js"}
|
||||||
|
]}
|
||||||
|
(tmp_path / "frontend/modules.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
(tmp_path / ".gitattributes").write_text("* text=auto\nfrontend/src/** text eol=lf\nstatic/js/helper.js text eol=lf\n", encoding="utf-8")
|
||||||
|
return source, tmp_path / "static/js/helper.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_detects_missing_and_stale_output_without_writing(tmp_path):
|
||||||
|
source, output = project(tmp_path)
|
||||||
|
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||||
|
assert not output.exists()
|
||||||
|
assert build.synchronize(tmp_path) == ["static/js/helper.js"]
|
||||||
|
assert output.read_bytes() == source.read_bytes()
|
||||||
|
source.write_bytes(b"export const value = 2;\n")
|
||||||
|
assert build.synchronize(tmp_path, check=True) == ["static/js/helper.js"]
|
||||||
|
assert output.read_bytes() == b"export const value = 1;\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_publication_is_idempotent_and_preserves_unowned_assets(tmp_path):
|
||||||
|
source, output = project(tmp_path)
|
||||||
|
unowned = tmp_path / "static/js/notes.js"
|
||||||
|
unowned.parent.mkdir(parents=True)
|
||||||
|
unowned.write_bytes(b"legacy state owner\n")
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
timestamp = output.stat().st_mtime_ns
|
||||||
|
assert build.synchronize(tmp_path) == []
|
||||||
|
assert output.stat().st_mtime_ns == timestamp
|
||||||
|
assert unowned.read_bytes() == b"legacy state owner\n"
|
||||||
|
assert output.read_bytes() == source.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["../outside.js", "/absolute.js", "shared/../helper.js", "shared\\helper.js", "shared/helper.js?v=1", "shared/helper.js#fragment", "shared/helper.ts"])
|
||||||
|
def test_invalid_plan_does_not_partially_publish(tmp_path, bad):
|
||||||
|
project(tmp_path, [
|
||||||
|
{"source": "shared/helper.js", "deployment": "js/helper.js"},
|
||||||
|
{"source": bad, "deployment": "js/other.js"},
|
||||||
|
])
|
||||||
|
with pytest.raises(ValueError, match="invalid module path"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
assert not (tmp_path / "static").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_outputs_and_unmapped_sources_are_rejected(tmp_path):
|
||||||
|
source, output = project(tmp_path)
|
||||||
|
extra = source.with_name("extra.js")
|
||||||
|
extra.write_bytes(b"export const value = 2;\n")
|
||||||
|
with pytest.raises(ValueError, match="missing from manifest"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
manifest_path = tmp_path / "frontend/modules.json"
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
manifest["modules"].append({"source": "shared/extra.js", "deployment": "js/helper.js"})
|
||||||
|
manifest_path.write_text(json.dumps(manifest))
|
||||||
|
with pytest.raises(ValueError, match="must be unique"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
assert not output.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_symlink_output_cannot_overwrite_another_file(tmp_path):
|
||||||
|
_, output = project(tmp_path)
|
||||||
|
outside = tmp_path / "outside.js"
|
||||||
|
outside.write_bytes(b"untouched\n")
|
||||||
|
output.parent.mkdir(parents=True)
|
||||||
|
output.symlink_to(outside)
|
||||||
|
with pytest.raises(ValueError, match="escapes|symlink"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
assert outside.read_bytes() == b"untouched\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_checkout_check_works_from_another_directory(tmp_path):
|
||||||
|
result = subprocess.run([sys.executable, str(ROOT / "scripts/build_frontend.py"), "--check"], cwd=tmp_path, capture_output=True, text=True, timeout=20)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert (ROOT / "frontend/src/shared/actionMenuOrder.js").read_bytes() == (ROOT / "static/js/actionMenuOrder.js").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_isolated_python_distribution_runs_without_source_or_node():
|
||||||
|
result = subprocess.run([sys.executable, str(ROOT / "scripts/verify_frontend_runtime.py")], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
assert "PASS: isolated Python lifespan" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("check", [False, True])
|
||||||
|
@pytest.mark.parametrize("path,rule", [
|
||||||
|
("frontend/src/shared/helper.js", "text"),
|
||||||
|
("static/js/helper.js", "text"),
|
||||||
|
("frontend/src/shared/helper.js", "text eol=crlf"),
|
||||||
|
("static/js/helper.js", "text=auto eol=lf"),
|
||||||
|
("static/js/helper.js", "-text eol=lf"),
|
||||||
|
])
|
||||||
|
def test_each_manifest_path_requires_lf_even_when_bytes_already_match(tmp_path, check, path, rule):
|
||||||
|
source, output = project(tmp_path)
|
||||||
|
output.parent.mkdir(parents=True)
|
||||||
|
output.write_bytes(source.read_bytes())
|
||||||
|
other = "static/js/helper.js" if path.startswith("frontend/") else "frontend/src/shared/helper.js"
|
||||||
|
(tmp_path / ".gitattributes").write_text(f"* text=auto\n{other} text eol=lf\n{path} {rule}\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="require repository-owned.*" + path):
|
||||||
|
build.synchronize(tmp_path, check=check)
|
||||||
|
assert output.read_bytes() == source.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_manifest_pair_cannot_publish_without_output_attributes(tmp_path):
|
||||||
|
source, output = project(tmp_path)
|
||||||
|
extra = source.with_name("future.js")
|
||||||
|
extra.write_bytes(b"export const future = true;\n")
|
||||||
|
manifest_file = tmp_path / "frontend/modules.json"
|
||||||
|
manifest = json.loads(manifest_file.read_text())
|
||||||
|
manifest["modules"].append({"source": "shared/future.js", "deployment": "js/future.js"})
|
||||||
|
manifest_file.write_text(json.dumps(manifest))
|
||||||
|
with pytest.raises(ValueError, match="static/js/future.js"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
assert not output.exists()
|
||||||
|
assert not (tmp_path / "static/js/future.js").exists()
|
||||||
|
with (tmp_path / ".gitattributes").open("a", encoding="utf-8") as attributes:
|
||||||
|
attributes.write("static/js/future.js text eol=lf\n")
|
||||||
|
assert build.synchronize(tmp_path) == ["static/js/helper.js", "static/js/future.js"]
|
||||||
|
assert build.synchronize(tmp_path, check=True) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_git_attribute_rules_and_paths_with_spaces_are_supported(tmp_path):
|
||||||
|
source, _ = project(tmp_path, [{"source": "shared/helper.js", "deployment": "js/feature space/helper.js"}])
|
||||||
|
attributes = tmp_path / "static/js/feature space/.gitattributes"
|
||||||
|
attributes.parent.mkdir(parents=True)
|
||||||
|
attributes.write_text("*.js text eol=lf\n", encoding="utf-8")
|
||||||
|
assert build.synchronize(tmp_path) == ["static/js/feature space/helper.js"]
|
||||||
|
assert (attributes.parent / "helper.js").read_bytes() == source.read_bytes()
|
||||||
|
attributes.write_text("*.js text eol=crlf\n", encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="eol=crlf"):
|
||||||
|
build.synchronize(tmp_path, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_and_local_git_settings_cannot_mask_missing_repository_rule(tmp_path, monkeypatch):
|
||||||
|
project(tmp_path)
|
||||||
|
(tmp_path / ".gitattributes").write_text("frontend/src/** text eol=lf\n", encoding="utf-8")
|
||||||
|
global_attributes = tmp_path / "global-attributes"
|
||||||
|
global_attributes.write_text("* text eol=lf\n", encoding="utf-8")
|
||||||
|
config = tmp_path / "global-config"
|
||||||
|
config.write_text(f'[core]\n attributesFile = "{global_attributes.as_posix()}"\n autocrlf = true\n', encoding="utf-8")
|
||||||
|
subprocess.run(["git", "init", "--quiet", str(tmp_path)], check=True, capture_output=True)
|
||||||
|
(tmp_path / ".git/info/attributes").write_text("* text eol=lf\n", encoding="utf-8")
|
||||||
|
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config))
|
||||||
|
monkeypatch.setenv("GIT_DIR", str(tmp_path / ".git"))
|
||||||
|
monkeypatch.setenv("GIT_WORK_TREE", str(tmp_path))
|
||||||
|
with pytest.raises(ValueError, match="static/js/helper.js"):
|
||||||
|
build.synchronize(tmp_path, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attribute_enforcement_fails_clearly_when_development_git_is_missing(tmp_path, monkeypatch):
|
||||||
|
_, output = project(tmp_path)
|
||||||
|
monkeypatch.setattr(build.shutil, "which", lambda name: None)
|
||||||
|
with pytest.raises(ValueError, match="Git is required for frontend publication"):
|
||||||
|
build.synchronize(tmp_path)
|
||||||
|
assert not output.exists()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Real Chromium proof at the existing /static/ service-worker scope.
|
||||||
|
|
||||||
|
This deliberately does not widen SW scope or claim offline SPA root reload.
|
||||||
|
The helper is first evaluated offline, after install-time precaching only.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("node") is None, reason="node binary not on PATH")
|
||||||
|
def test_precached_helper_imports_offline_in_a_fresh_page_and_panels_stay_lazy():
|
||||||
|
script = r'''
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`${origin}/static/js/panels.js`);
|
||||||
|
const installed = await page.evaluate(async () => {
|
||||||
|
await navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1');
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
if (!navigator.serviceWorker.controller) await new Promise(resolve => navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }));
|
||||||
|
const response = await caches.match('/static/js/actionMenuOrder.js');
|
||||||
|
return { scope: registration.scope, cached: !!response && response.ok };
|
||||||
|
});
|
||||||
|
assert.equal(installed.scope, `${origin}/static/`);
|
||||||
|
assert.equal(installed.cached, true);
|
||||||
|
// New page gives a fresh ES-module map. No helper import occurred online.
|
||||||
|
await context.setOffline(true);
|
||||||
|
const offline = await context.newPage();
|
||||||
|
await offline.goto(`${origin}/static/js/panels.js`);
|
||||||
|
const requests = [];
|
||||||
|
offline.on('request', request => requests.push(request.url()));
|
||||||
|
const result = await offline.evaluate(async () => {
|
||||||
|
const helper = await import('/static/js/actionMenuOrder.js');
|
||||||
|
const again = await import('./actionMenuOrder.js');
|
||||||
|
const panels = await import('/static/js/panels.js?v=20260909movepicklayer1');
|
||||||
|
return {
|
||||||
|
sameModule: helper === again,
|
||||||
|
exports: Object.keys(helper).sort(),
|
||||||
|
order: helper.orderActionMenuItems([{ label: 'Delete' }, { label: 'Rename' }]).map(i => i.label),
|
||||||
|
panels: panels.panelNames(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(result.sameModule, true);
|
||||||
|
assert.deepEqual(result.order, ['Rename', 'Delete']);
|
||||||
|
assert.ok(result.panels.includes('editor'));
|
||||||
|
assert.equal(requests.some(url => url.includes('/galleryEditor.js')), false);
|
||||||
|
console.log(JSON.stringify({ ...result, ...installed }));
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
'''
|
||||||
|
result = subprocess.run(["node", "--input-type=module", "--eval", script], cwd=ROOT, capture_output=True, text=True, timeout=90)
|
||||||
|
assert result.returncode == 0, result.stdout + result.stderr
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
assert data["exports"] == ["SELECT_MENU_ICON", "actionMenuRank", "orderActionMenuItems"]
|
||||||
@@ -60,7 +60,7 @@ def test_generated_image_headers_include_nosniff():
|
|||||||
assert generated_images.GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
assert generated_images.GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||||
assert (
|
assert (
|
||||||
generated_images.GENERATED_IMAGE_HEADERS["Cache-Control"]
|
generated_images.GENERATED_IMAGE_HEADERS["Cache-Control"]
|
||||||
== "public, max-age=31536000, immutable"
|
== "private, no-cache, must-revalidate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from app import serve_generated_image
|
||||||
|
from src.generated_images import (
|
||||||
|
GENERATED_IMAGE_HEADERS,
|
||||||
|
register_generated_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DummyState:
|
||||||
|
def __init__(self, current_user=None, api_token=False, api_token_owner=None):
|
||||||
|
self.current_user = current_user
|
||||||
|
self.api_token = api_token
|
||||||
|
self.api_token_owner = api_token_owner
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(current_user=None, api_token=False, api_token_owner=None):
|
||||||
|
req = MagicMock(spec=Request)
|
||||||
|
req.state = DummyState(current_user, api_token, api_token_owner)
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_image(tmp_path, monkeypatch):
|
||||||
|
from src import generated_images
|
||||||
|
image_dir = tmp_path / "generated_images"
|
||||||
|
image_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = "0123456789ab.png"
|
||||||
|
img_file = image_dir / filename
|
||||||
|
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||||
|
monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir)
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authorized_cookie_session_owner(sample_image):
|
||||||
|
"""Cookie session owner receives 200 for their owned image."""
|
||||||
|
req = _make_request(current_user="alice")
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = "alice"
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
resp = await serve_generated_image(sample_image, req)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||||
|
assert resp.headers["X-Content-Type-Options"] == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_authorized_api_token_owner(sample_image):
|
||||||
|
"""API token request matching image owner receives 200."""
|
||||||
|
req = _make_request(current_user="api", api_token=True, api_token_owner="alice")
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = "alice"
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
resp = await serve_generated_image(sample_image, req)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cross_user_denial_returns_404(sample_image):
|
||||||
|
"""Bob attempting to access Alice's image receives 404 (preventing existence leak)."""
|
||||||
|
req = _make_request(current_user="bob")
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = "alice"
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert exc_info.value.detail == "Image not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cross_user_api_token_denial_returns_404(sample_image):
|
||||||
|
"""Bob's API token attempting to access Alice's image receives 404."""
|
||||||
|
req = _make_request(current_user="api", api_token=True, api_token_owner="bob")
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = "alice"
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unauthenticated_request_rejected(sample_image):
|
||||||
|
"""Unauthenticated caller (no user identity) is rejected with 401 when auth is enabled."""
|
||||||
|
req = _make_request(current_user=None)
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert exc_info.value.detail == "Not authenticated"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_gallery_row_fails_closed_404(sample_image):
|
||||||
|
"""Image on disk with no gallery row, no registry, and no chat reference returns 404."""
|
||||||
|
req = _make_request(current_user="bob")
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
# No gallery row
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
# No chat message reference
|
||||||
|
mock_db.query.return_value.join.return_value.filter.return_value.first.return_value = None
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert exc_info.value.detail == "Image not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_null_owner_metadata_fails_closed_404(sample_image):
|
||||||
|
"""Gallery row with owner=None and no session link returns 404."""
|
||||||
|
req = _make_request(current_user="bob")
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = None
|
||||||
|
mock_row.session_id = None
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = mock_row
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_linked_null_owner_authorized(sample_image):
|
||||||
|
"""Gallery row with owner=None resolves owner through session_id."""
|
||||||
|
req_alice = _make_request(current_user="alice")
|
||||||
|
req_bob = _make_request(current_user="bob")
|
||||||
|
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.owner = None
|
||||||
|
mock_row.session_id = "sess-123"
|
||||||
|
mock_row.filename = sample_image
|
||||||
|
|
||||||
|
mock_sess = MagicMock()
|
||||||
|
mock_sess.owner = "alice"
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.return_value.filter.return_value.first.side_effect = [
|
||||||
|
mock_row, mock_sess, # for Alice
|
||||||
|
mock_row, mock_sess, # for Bob
|
||||||
|
]
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
# Alice is the session owner -> 200
|
||||||
|
resp = await serve_generated_image(sample_image, req_alice)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Bob is not the session owner -> 404
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req_bob)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_referenced_rowless_image_authorized(sample_image):
|
||||||
|
"""Rowless image referenced in a chat session owned by user is authorized."""
|
||||||
|
req_alice = _make_request(current_user="alice")
|
||||||
|
req_bob = _make_request(current_user="bob")
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
# Row is None
|
||||||
|
mock_db.query.return_value.filter.return_value.first.return_value = None
|
||||||
|
# Alice's chat query returns a match, Bob's does not
|
||||||
|
mock_db.query.return_value.join.return_value.filter.return_value.first.side_effect = [
|
||||||
|
MagicMock(), # Alice has a match
|
||||||
|
None, # Bob has no match
|
||||||
|
]
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
resp = await serve_generated_image(sample_image, req_alice)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req_bob)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_freshly_generated_registry_preserves_access(sample_image):
|
||||||
|
"""Freshly generated image registered to Alice is accessible before DB commit; Bob is denied."""
|
||||||
|
req_alice = _make_request(current_user="alice")
|
||||||
|
req_bob = _make_request(current_user="bob")
|
||||||
|
|
||||||
|
fresh_filename = "fedcba987654.png"
|
||||||
|
from src import generated_images
|
||||||
|
img_file = generated_images.GENERATED_IMAGE_DIR / fresh_filename
|
||||||
|
img_file.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
|
||||||
|
|
||||||
|
register_generated_image(fresh_filename, "alice")
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_sl.side_effect = AssertionError("DB should not be reached for registered owner")
|
||||||
|
|
||||||
|
# Alice retrieves without hitting DB
|
||||||
|
resp = await serve_generated_image(fresh_filename, req_alice)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Bob is denied with 404
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(fresh_filename, req_bob)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_database_failure_fails_closed_503(sample_image):
|
||||||
|
"""Database exception during ownership check raises HTTP 503."""
|
||||||
|
req = _make_request(current_user="alice")
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.side_effect = RuntimeError("SQLite database locked or corrupted")
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
assert exc_info.value.detail == "Ownership verification unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_login_compatibility_auth_disabled(sample_image):
|
||||||
|
"""When AUTH_ENABLED=false, existing images are served with 200, missing images return 404."""
|
||||||
|
req = _make_request(current_user=None)
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=True):
|
||||||
|
resp = await serve_generated_image(sample_image, req)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await serve_generated_image("aaaaaaaaaaaa.png", req)
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_headers_private_no_cache():
|
||||||
|
"""Verify generated image response headers use private, no-cache directives."""
|
||||||
|
assert GENERATED_IMAGE_HEADERS["Cache-Control"] == "private, no-cache, must-revalidate"
|
||||||
|
assert GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_existence_oracle_eliminated_during_outage(sample_image):
|
||||||
|
"""During a DB outage, both existing and non-existent files return 503 without revealing existence."""
|
||||||
|
req = _make_request(current_user="alice")
|
||||||
|
|
||||||
|
with patch("src.auth_helpers.auth_disabled", return_value=False), \
|
||||||
|
patch("core.database.SessionLocal") as mock_sl:
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_db.query.side_effect = RuntimeError("DB down")
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
# Existing file -> 503
|
||||||
|
with pytest.raises(HTTPException) as exc_existing:
|
||||||
|
await serve_generated_image(sample_image, req)
|
||||||
|
assert exc_existing.value.status_code == 503
|
||||||
|
|
||||||
|
# Non-existent file -> 503 (NOT 404)
|
||||||
|
with pytest.raises(HTTPException) as exc_missing:
|
||||||
|
await serve_generated_image("111122223333.png", req)
|
||||||
|
assert exc_missing.value.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_image_generation_passes_owner_arg():
|
||||||
|
"""_call_mcp_tool passes _odysseus_owner to MCP arguments for generate_image."""
|
||||||
|
from src import tool_execution
|
||||||
|
|
||||||
|
mock_mcp = MagicMock()
|
||||||
|
mock_mcp.call_tool = MagicMock()
|
||||||
|
|
||||||
|
async def fake_call(qualified, args):
|
||||||
|
return {"stdout": "Generated image for: cat\nhttps://host/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||||
|
|
||||||
|
mock_mcp.call_tool.side_effect = fake_call
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||||
|
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||||
|
res = await tool_execution._call_mcp_tool("generate_image", "cat", owner="alice")
|
||||||
|
assert res.get("image_url") == "https://host/api/generated-image/1234567890ab.png"
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_fallback_propagates_owner():
|
||||||
|
"""When MCP manager is unavailable, _direct_fallback receives owner."""
|
||||||
|
from src import tool_execution
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=None), \
|
||||||
|
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||||
|
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||||
|
res = await tool_execution._call_mcp_tool("web_search", "query", owner="alice")
|
||||||
|
assert res == {"output": "ok", "exit_code": 0}
|
||||||
|
mock_fallback.assert_called_once_with("web_search", "query", progress_cb=None, owner="alice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trusted_owner_overrides_forged_owner_in_tool_args():
|
||||||
|
"""Trusted owner overrides any forged _odysseus_owner in tool block content."""
|
||||||
|
import json
|
||||||
|
from src import tool_execution
|
||||||
|
|
||||||
|
mock_mcp = MagicMock()
|
||||||
|
mock_mcp.call_tool = MagicMock()
|
||||||
|
|
||||||
|
async def fake_call(qualified, args):
|
||||||
|
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||||
|
|
||||||
|
mock_mcp.call_tool.side_effect = fake_call
|
||||||
|
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||||
|
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||||
|
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="alice")
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_or_empty_owner_removes_forged_owner_claim():
|
||||||
|
"""When caller has no trusted owner (None or empty), forged _odysseus_owner is completely removed."""
|
||||||
|
import json
|
||||||
|
from src import tool_execution
|
||||||
|
|
||||||
|
mock_mcp = MagicMock()
|
||||||
|
mock_mcp.call_tool = MagicMock()
|
||||||
|
|
||||||
|
async def fake_call(qualified, args):
|
||||||
|
return {"stdout": "Generated image for: cat\n/api/generated-image/1234567890ab.png", "exit_code": 0}
|
||||||
|
|
||||||
|
mock_mcp.call_tool.side_effect = fake_call
|
||||||
|
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||||
|
patch.object(tool_execution, "active_backend_operation", return_value=None):
|
||||||
|
# Case 1: owner=None
|
||||||
|
await tool_execution._call_mcp_tool("generate_image", forged_content, owner=None)
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert "_odysseus_owner" not in call_args[1]
|
||||||
|
|
||||||
|
# Case 2: owner=""
|
||||||
|
await tool_execution._call_mcp_tool("generate_image", forged_content, owner="")
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert "_odysseus_owner" not in call_args[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_qualified_mcp_image_gen_sanitizes_forged_owner():
|
||||||
|
"""Qualified mcp__image_gen__generate_image tool dispatches with sanitized owner."""
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from src import tool_execution
|
||||||
|
from src.tool_types import ToolBlock
|
||||||
|
|
||||||
|
mock_mcp = MagicMock()
|
||||||
|
mock_mcp.call_tool = AsyncMock(return_value={"stdout": "ok", "exit_code": 0})
|
||||||
|
forged_content = json.dumps({"prompt": "cat", "_odysseus_owner": "bob"})
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||||
|
patch.object(tool_execution, "_owner_is_admin", return_value=True):
|
||||||
|
# With trusted owner 'alice' -> sets 'alice'
|
||||||
|
await tool_execution._execute_tool_block_impl(
|
||||||
|
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||||
|
owner="alice",
|
||||||
|
)
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert call_args[1].get("_odysseus_owner") == "alice"
|
||||||
|
|
||||||
|
# Without owner -> removes forged owner
|
||||||
|
await tool_execution._execute_tool_block_impl(
|
||||||
|
ToolBlock("mcp__image_gen__generate_image", forged_content),
|
||||||
|
owner=None,
|
||||||
|
)
|
||||||
|
call_args = mock_mcp.call_tool.call_args[0]
|
||||||
|
assert "_odysseus_owner" not in call_args[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_disconnected_fallback_does_not_resurrect_forged_owner():
|
||||||
|
"""When MCP is disconnected, direct fallback receives verified owner context, not forged arguments."""
|
||||||
|
import json
|
||||||
|
from src import tool_execution
|
||||||
|
|
||||||
|
mock_mcp = MagicMock()
|
||||||
|
mock_mcp.call_tool = MagicMock()
|
||||||
|
|
||||||
|
async def fake_call(qualified, args):
|
||||||
|
return {"error": "Server not connected", "exit_code": 1}
|
||||||
|
|
||||||
|
mock_mcp.call_tool.side_effect = fake_call
|
||||||
|
forged_content = json.dumps({"query": "python", "_odysseus_owner": "bob"})
|
||||||
|
|
||||||
|
with patch.object(tool_execution, "get_mcp_manager", return_value=mock_mcp), \
|
||||||
|
patch.object(tool_execution, "active_backend_operation", return_value=None), \
|
||||||
|
patch.object(tool_execution, "_direct_fallback", return_value={"output": "ok", "exit_code": 0}) as mock_fallback:
|
||||||
|
# Trusted owner=None: mock_fallback receives owner=None
|
||||||
|
await tool_execution._call_mcp_tool("web_search", forged_content, owner=None)
|
||||||
|
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner=None)
|
||||||
|
|
||||||
|
# Trusted owner="alice": mock_fallback receives owner="alice"
|
||||||
|
await tool_execution._call_mcp_tool("web_search", forged_content, owner="alice")
|
||||||
|
mock_fallback.assert_called_with("web_search", forged_content, progress_cb=None, owner="alice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_image_gen_server_owner_sanitization():
|
||||||
|
"""mcp_servers.image_gen_server sanitizes whitespace, empty, and None owner claims."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from mcp_servers import image_gen_server
|
||||||
|
|
||||||
|
with patch("src.database.SessionLocal") as mock_sl, \
|
||||||
|
patch("httpx.AsyncClient") as mock_http, \
|
||||||
|
patch("src.ai_interaction._resolve_model", return_value=("https://host/v1/chat/completions", "gpt-image-1", {})), \
|
||||||
|
patch("src.settings.get_setting", return_value="dummy_key"), \
|
||||||
|
patch("src.settings.load_settings", return_value={"image_model": "gpt-image-1"}), \
|
||||||
|
patch("pathlib.Path.write_bytes"):
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_sl.return_value = mock_db
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = {
|
||||||
|
"data": [{"b64_json": "aGVsbG8="}]
|
||||||
|
}
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_http.return_value.__aenter__.return_value = mock_client
|
||||||
|
|
||||||
|
# Call with whitespace owner: " " -> owner becomes None
|
||||||
|
await image_gen_server.call_tool(
|
||||||
|
"generate_image",
|
||||||
|
{"prompt": "cat", "_odysseus_owner": " "},
|
||||||
|
)
|
||||||
|
added = mock_db.add.call_args[0][0]
|
||||||
|
assert added.owner is None
|
||||||
|
|
||||||
|
# Call with valid owner: "alice" -> owner becomes "alice"
|
||||||
|
await image_gen_server.call_tool(
|
||||||
|
"generate_image",
|
||||||
|
{"prompt": "cat", "_odysseus_owner": "alice"},
|
||||||
|
)
|
||||||
|
added = mock_db.add.call_args[0][0]
|
||||||
|
assert added.owner == "alice"
|
||||||
@@ -108,8 +108,12 @@ def _compact_prompt_for(monkeypatch, history):
|
|||||||
import src.model_context as model_context
|
import src.model_context as model_context
|
||||||
|
|
||||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: False)
|
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: False)
|
||||||
def fake_resolve_endpoint(kind, owner=None):
|
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||||
|
if "fallback_url" in kwargs:
|
||||||
|
captured.setdefault("fallback_calls", []).append(
|
||||||
|
(kind, owner, kwargs)
|
||||||
|
)
|
||||||
return None, None, {}
|
return None, None, {}
|
||||||
|
|
||||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||||
@@ -127,6 +131,13 @@ def _compact_prompt_for(monkeypatch, history):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["status"] == "ok"
|
assert response.json()["status"] == "ok"
|
||||||
assert manager.saved is True
|
assert manager.saved is True
|
||||||
|
assert any(
|
||||||
|
kind == "utility"
|
||||||
|
and kwargs.get("fallback_url") == session.endpoint_url
|
||||||
|
and kwargs.get("fallback_model") == session.model
|
||||||
|
and kwargs.get("fallback_headers") == session.headers
|
||||||
|
for kind, _owner, kwargs in captured.get("fallback_calls", [])
|
||||||
|
)
|
||||||
return captured["messages"][1]["content"]
|
return captured["messages"][1]["content"]
|
||||||
|
|
||||||
|
|
||||||
@@ -151,8 +162,12 @@ def _registered_compact_response(monkeypatch, history, active_run=False):
|
|||||||
import src.llm_core as llm_core
|
import src.llm_core as llm_core
|
||||||
|
|
||||||
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: active_run)
|
monkeypatch.setattr(agent_runs, "is_active", lambda session_id: active_run)
|
||||||
def fake_resolve_endpoint(kind, owner=None):
|
def fake_resolve_endpoint(kind, owner=None, **kwargs):
|
||||||
captured.setdefault("resolve_calls", []).append((kind, owner))
|
captured.setdefault("resolve_calls", []).append((kind, owner))
|
||||||
|
if "fallback_url" in kwargs:
|
||||||
|
captured.setdefault("fallback_calls", []).append(
|
||||||
|
(kind, owner, kwargs)
|
||||||
|
)
|
||||||
return None, None, {}
|
return None, None, {}
|
||||||
|
|
||||||
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint", fake_resolve_endpoint)
|
||||||
@@ -237,6 +252,14 @@ def test_registered_manual_compact_route_uses_session_owner(monkeypatch):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert manager.replaced_messages is not None
|
assert manager.replaced_messages is not None
|
||||||
assert ("utility", "session-owner") in captured["resolve_calls"]
|
assert ("utility", "session-owner") in captured["resolve_calls"]
|
||||||
|
assert any(
|
||||||
|
kind == "utility"
|
||||||
|
and owner == "session-owner"
|
||||||
|
and kwargs.get("fallback_url") == manager.session.endpoint_url
|
||||||
|
and kwargs.get("fallback_model") == manager.session.model
|
||||||
|
and kwargs.get("fallback_headers") == manager.session.headers
|
||||||
|
for kind, owner, kwargs in captured.get("fallback_calls", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_registered_manual_compact_route_rejects_active_agent_run(monkeypatch):
|
def test_registered_manual_compact_route_rejects_active_agent_run(monkeypatch):
|
||||||
|
|||||||
@@ -36,7 +36,26 @@ def test_task_parse_resolves_with_owner_scope():
|
|||||||
def test_history_compact_resolves_with_owner_scope():
|
def test_history_compact_resolves_with_owner_scope():
|
||||||
body = _function_source("routes/history/history_routes.py", "compact_session")
|
body = _function_source("routes/history/history_routes.py", "compact_session")
|
||||||
assert "owner = effective_user(request)" in body
|
assert "owner = effective_user(request)" in body
|
||||||
assert 'resolve_endpoint("utility", owner=owner or None)' in body
|
tree = ast.parse(body)
|
||||||
|
calls = [
|
||||||
|
node for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "resolve_endpoint"
|
||||||
|
and node.args
|
||||||
|
and isinstance(node.args[0], ast.Constant)
|
||||||
|
and node.args[0].value == "utility"
|
||||||
|
]
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
kw.arg: ast.unparse(kw.value)
|
||||||
|
for kw in calls[0].keywords
|
||||||
|
}
|
||||||
|
assert kwargs["owner"] == "owner or None"
|
||||||
|
assert kwargs["fallback_url"] == "getattr(session, 'endpoint_url', None)"
|
||||||
|
assert kwargs["fallback_model"] == "getattr(session, 'model', None)"
|
||||||
|
assert kwargs["fallback_headers"] == "getattr(session, 'headers', None)"
|
||||||
|
|
||||||
|
|
||||||
def test_note_reminder_synthesis_resolves_with_owner_scope():
|
def test_note_reminder_synthesis_resolves_with_owner_scope():
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""Background note reminders retry failed primary delivery without losing receipts."""
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers.database import disposable_database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def reminder_scan(tmp_path, monkeypatch):
|
||||||
|
from core import database
|
||||||
|
from routes import note_routes
|
||||||
|
from src import builtin_actions, integrations, settings
|
||||||
|
|
||||||
|
instant = datetime.datetime(2026, 10, 7, 12, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
class Clock(datetime.datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return instant.astimezone(tz) if tz else instant.replace(tzinfo=None)
|
||||||
|
|
||||||
|
calls, notices = [], []
|
||||||
|
statuses = [503, 200]
|
||||||
|
channel = {"reminder_channel": "webhook", "reminder_webhook_integration_id": "audit", "reminder_webhook_payload_template": '{"message":"{{message}}"}'}
|
||||||
|
|
||||||
|
def deliver(request):
|
||||||
|
calls.append(request)
|
||||||
|
return httpx.Response(statuses.pop(0))
|
||||||
|
|
||||||
|
real_client = httpx.AsyncClient
|
||||||
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: real_client(transport=httpx.MockTransport(deliver), **kw))
|
||||||
|
monkeypatch.setattr(settings, "load_settings", lambda: channel)
|
||||||
|
monkeypatch.setattr(integrations, "load_integrations", lambda: [{"id": "audit", "preset": "ntfy", "base_url": "https://reminder.example.test", "enabled": True}])
|
||||||
|
monkeypatch.setattr(note_routes, "_scheduler_ref", SimpleNamespace(add_notification=lambda **kw: notices.append(kw)))
|
||||||
|
monkeypatch.setattr(note_routes, "DATA_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(builtin_actions, "DATA_DIR", str(tmp_path))
|
||||||
|
monkeypatch.setattr(datetime, "datetime", Clock)
|
||||||
|
monkeypatch.setattr("src.url_safety.check_outbound_url", lambda *a, **kw: (True, ""))
|
||||||
|
with disposable_database(tmp_path) as factory:
|
||||||
|
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||||
|
with factory() as db:
|
||||||
|
db.add(database.Note(id="note-1", owner="alice", title="Due note", due_date=instant.isoformat()))
|
||||||
|
db.commit()
|
||||||
|
yield SimpleNamespace(
|
||||||
|
scan=lambda: builtin_actions.action_ping_notes("alice"),
|
||||||
|
path=tmp_path / "note_pings_alice.json", calls=calls, notices=notices,
|
||||||
|
statuses=statuses, channel=channel, instant=instant, factory=factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("channel", ["webhook", "ntfy"])
|
||||||
|
async def test_failed_primary_is_retried_and_receipt_keeps_channel(reminder_scan, channel):
|
||||||
|
from src.builtin_actions import TaskNoop
|
||||||
|
scan = reminder_scan
|
||||||
|
scan.channel["reminder_channel"] = channel
|
||||||
|
with pytest.raises(TaskNoop):
|
||||||
|
await scan.scan()
|
||||||
|
assert len(scan.calls) == 1
|
||||||
|
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
|
||||||
|
message, ok = await scan.scan()
|
||||||
|
assert ok is True and "Pinged 1" in message
|
||||||
|
assert len(scan.calls) == 2
|
||||||
|
assert len(scan.notices) == 1
|
||||||
|
assert json.loads(scan.path.read_text())["note-1"]["channel"] == channel
|
||||||
|
with pytest.raises(TaskNoop):
|
||||||
|
await scan.scan()
|
||||||
|
assert len(scan.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_legacy_browser_receipt_allows_external_retry(reminder_scan):
|
||||||
|
scan = reminder_scan
|
||||||
|
scan.path.write_text(json.dumps({"note-1": scan.instant.isoformat()}))
|
||||||
|
scan.statuses[:] = [200]
|
||||||
|
message, ok = await scan.scan()
|
||||||
|
assert ok is True and "Pinged 1" in message
|
||||||
|
assert len(scan.calls) == 1 and scan.notices == []
|
||||||
|
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "webhook"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_browser_delivery_is_deduped_and_typed(reminder_scan):
|
||||||
|
from src.builtin_actions import TaskNoop
|
||||||
|
scan = reminder_scan
|
||||||
|
scan.channel["reminder_channel"] = "browser"
|
||||||
|
_, ok = await scan.scan()
|
||||||
|
assert ok is True
|
||||||
|
assert json.loads(scan.path.read_text())["note-1"]["channel"] == "browser"
|
||||||
|
with pytest.raises(TaskNoop):
|
||||||
|
await scan.scan()
|
||||||
|
assert len(scan.notices) == 1 and scan.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_total_delivery_failure_does_not_checkpoint(reminder_scan, monkeypatch):
|
||||||
|
from routes import note_routes
|
||||||
|
from src.builtin_actions import TaskNoop
|
||||||
|
scan = reminder_scan
|
||||||
|
monkeypatch.setattr(note_routes, "_scheduler_ref", None)
|
||||||
|
with pytest.raises(TaskNoop):
|
||||||
|
await scan.scan()
|
||||||
|
assert "note-1" not in json.loads(scan.path.read_text())
|
||||||
|
_, ok = await scan.scan()
|
||||||
|
assert ok is True and len(scan.calls) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pruning_retains_dispatcher_receipt_and_other_due_note(reminder_scan):
|
||||||
|
from core import database
|
||||||
|
scan = reminder_scan
|
||||||
|
with scan.factory() as db:
|
||||||
|
db.add(database.Note(id="later", owner="alice", title="Later", due_date=(scan.instant + datetime.timedelta(days=1)).isoformat()))
|
||||||
|
db.commit()
|
||||||
|
scan.path.write_text(json.dumps({"gone": "old", "later": {"at": scan.instant.isoformat(), "channel": "email"}}))
|
||||||
|
scan.statuses[:] = [200]
|
||||||
|
_, ok = await scan.scan()
|
||||||
|
assert ok is True
|
||||||
|
cache = json.loads(scan.path.read_text())
|
||||||
|
assert set(cache) == {"note-1", "later"}
|
||||||
|
assert cache["note-1"]["channel"] == "webhook"
|
||||||
|
assert cache["later"]["channel"] == "email"
|
||||||
@@ -30,23 +30,49 @@ def test_retained_artifacts_match_distributed_provenance_and_notices():
|
|||||||
|
|
||||||
def test_removed_resource_ledger_has_no_runtime_references():
|
def test_removed_resource_ledger_has_no_runtime_references():
|
||||||
ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text()
|
ledger = (ROOT / 'PUBLICATION_ASSET_DECISIONS.md').read_text()
|
||||||
removed = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
|
san_entries = re.findall(r'^- SAN-\d+: `([^`]+)`$', ledger, re.M)
|
||||||
assert len(removed) == 21
|
assert len(san_entries) == 21
|
||||||
# Inspect tracked text, including files omitted by parity-audit exclusions.
|
|
||||||
|
restore_entries = re.findall(r'^- RESTORE-\d+: `([^`]+)` \(SHA-256: `([a-f0-9]{64})`\)', ledger, re.M)
|
||||||
|
assert len(restore_entries) == 14
|
||||||
|
|
||||||
|
restored_paths = {path for path, _ in restore_entries}
|
||||||
|
for path, expected_sha in restore_entries:
|
||||||
|
target = ROOT / path
|
||||||
|
assert target.is_file(), f"restored asset missing on disk: {path}"
|
||||||
|
actual_sha = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||||
|
assert actual_sha == expected_sha, f"sha256 mismatch for {path}"
|
||||||
|
|
||||||
|
still_omitted = [path for path in san_entries if path not in restored_paths]
|
||||||
|
assert len(still_omitted) == 7
|
||||||
|
|
||||||
|
expected_omitted_names = {
|
||||||
|
'GohuFont.ttf',
|
||||||
|
'html2pdf.bundle.min.js',
|
||||||
|
'qrcode.min.js',
|
||||||
|
'ollama-mark.png',
|
||||||
|
'ollama-mark-crop.png',
|
||||||
|
'sglang-logo.png',
|
||||||
|
'sglang-mark.png',
|
||||||
|
}
|
||||||
|
assert {Path(p).name for p in still_omitted} == expected_omitted_names
|
||||||
|
|
||||||
|
# Inspect tracked text for references to still-omitted resources.
|
||||||
tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
|
tracked = subprocess.check_output(['git', 'ls-files', '-z'], cwd=ROOT).decode().split('\0')
|
||||||
for path in removed:
|
for path in still_omitted:
|
||||||
assert not (ROOT / path).exists()
|
assert not (ROOT / path).exists(), f"omitted asset must not exist: {path}"
|
||||||
|
filename = Path(path).name
|
||||||
for name in tracked:
|
for name in tracked:
|
||||||
if name == "PUBLICATION_ASSET_DECISIONS.md":
|
if name in {"PUBLICATION_ASSET_DECISIONS.md", "tests/test_publication_plan_b.py"}:
|
||||||
continue # Historical ledger entries are intentionally non-resource references.
|
continue # Historical ledger and test assertions are intentionally non-resource references.
|
||||||
file = ROOT / name
|
file = ROOT / name
|
||||||
if not file.is_file():
|
if not file.is_file():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
content = file.read_text()
|
content = file.read_text(encoding="utf-8")
|
||||||
except UnicodeError:
|
except UnicodeError:
|
||||||
continue
|
continue
|
||||||
assert Path(path).name not in content, (path, name)
|
assert filename not in content, (path, name)
|
||||||
|
|
||||||
|
|
||||||
def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path):
|
def test_cold_catalog_and_refresh_use_mutable_data(monkeypatch, tmp_path):
|
||||||
@@ -113,12 +139,20 @@ def test_distribution_paths_include_all_notices():
|
|||||||
ignore = (ROOT / '.dockerignore').read_text()
|
ignore = (ROOT / '.dockerignore').read_text()
|
||||||
assert '!ACKNOWLEDGMENTS.md' in ignore
|
assert '!ACKNOWLEDGMENTS.md' in ignore
|
||||||
manifest = json.loads((ROOT / 'static/manifest.json').read_text())
|
manifest = json.loads((ROOT / 'static/manifest.json').read_text())
|
||||||
assert not manifest.get('icons')
|
assert manifest.get('icons')
|
||||||
assert manifest['display'] == 'standalone'
|
assert manifest['display'] == 'standalone'
|
||||||
|
expected_icons = [
|
||||||
|
{"src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
|
||||||
|
{"src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
|
||||||
|
{"src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
|
||||||
|
]
|
||||||
|
assert manifest['icons'] == expected_icons
|
||||||
|
for icon in manifest['icons']:
|
||||||
|
assert (ROOT / 'static' / icon['src']).is_file()
|
||||||
|
|
||||||
|
|
||||||
def test_browser_publication_behaviors():
|
def test_browser_publication_behaviors():
|
||||||
"""Real Chromium DOM: text-only website, saved fonts, escaped print/math."""
|
"""Real Chromium DOM: restored website media, saved fonts, escaped print/math."""
|
||||||
if not shutil.which('node'):
|
if not shutil.which('node'):
|
||||||
pytest.skip('Node is required')
|
pytest.skip('Node is required')
|
||||||
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True)
|
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=ROOT, capture_output=True)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Regression guard for the README title presentation.
|
"""Regression guard for the README title presentation.
|
||||||
|
|
||||||
The original ASCII-art banner needed a code fence to preserve whitespace.
|
The original ASCII-art banner needed a code fence to preserve whitespace.
|
||||||
Publication now opens with a centered text heading after omitting the wordmark
|
Keep the wordmark title identity and the historical ASCII-fencing guard.
|
||||||
artwork. Keep the title identity and the historical ASCII-fencing guard.
|
|
||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,9 +18,9 @@ def _fenced_segments(text: str):
|
|||||||
return parts[1::2]
|
return parts[1::2]
|
||||||
|
|
||||||
|
|
||||||
def test_readme_opens_with_text_title():
|
def test_readme_opens_with_wordmark_title():
|
||||||
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
|
head = "\n".join(README.read_text(encoding="utf-8").splitlines()[:15])
|
||||||
assert head.startswith('<h1 align="center">Odysseus</h1>'), "README must open with the Odysseus text title"
|
assert 'alt="Odysseus"' in head, "README must open with the Odysseus wordmark image"
|
||||||
|
|
||||||
|
|
||||||
def test_reintroduced_ascii_banner_stays_fenced():
|
def test_reintroduced_ascii_banner_stays_fenced():
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Regression tests for untrusted selected-note ID parsing."""
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from routes.chat_routes import _parse_selected_note_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(None, []),
|
||||||
|
("", []),
|
||||||
|
("[]", []),
|
||||||
|
('["one", "two", "one"]', ["one", "two"]),
|
||||||
|
])
|
||||||
|
def test_selected_note_ids_valid(raw, expected):
|
||||||
|
assert _parse_selected_note_ids(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", [
|
||||||
|
"42", "true", "null", '{"id":"a"}',
|
||||||
|
'["one", 5]', '["one", null]', '["one", false]',
|
||||||
|
'[""]', '[" "]', '["one", {}]',
|
||||||
|
"not json", json.dumps(["a"] * 51),
|
||||||
|
json.dumps(["a" * 129]), " " * 8193,
|
||||||
|
])
|
||||||
|
def test_selected_note_ids_rejected(raw):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_parse_selected_note_ids(raw)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Frontend regression tests: verify settings save handlers check HTTP response status.
|
||||||
|
|
||||||
|
Locks in that static/js/settings.js verifies response.ok before displaying success,
|
||||||
|
preventing 401, 403, 400, and 500 responses from reporting false success.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_SETTINGS_JS = _REPO / "static" / "js" / "settings.js"
|
||||||
|
_SETTINGS_DIR = _REPO / "static" / "js" / "settings"
|
||||||
|
_SETTINGS_API_JS = _SETTINGS_DIR / "api.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_settings_js_syntax():
|
||||||
|
"""Verify static/js/settings.js and settings/*.js modules are syntactically valid JavaScript."""
|
||||||
|
js_files = [_SETTINGS_JS] + list(_SETTINGS_DIR.glob("*.js"))
|
||||||
|
for js_file in js_files:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--check", str(js_file)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=str(_REPO),
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, f"JS syntax error in {js_file.name}:\n{proc.stderr}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_settings_helper_verifies_response_ok():
|
||||||
|
"""Centralized postSettings in settings/api.js must verify response.ok and throw on failure."""
|
||||||
|
assert _SETTINGS_API_JS.exists(), f"Expected {_SETTINGS_API_JS} to exist"
|
||||||
|
api_content = _SETTINGS_API_JS.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Verify fetch to /api/auth/settings with POST method
|
||||||
|
match = re.search(r"fetch\(['\"]/api/auth/settings['\"],\s*\{\s*method:\s*['\"]POST['\"]", api_content)
|
||||||
|
assert match is not None, "Expected POST fetch to /api/auth/settings in settings/api.js"
|
||||||
|
|
||||||
|
# Verify .ok is checked within 300 characters of fetch
|
||||||
|
window = api_content[match.start(): match.start() + 300]
|
||||||
|
assert ".ok" in window, f"postSettings must check response.ok:\n{window}"
|
||||||
|
|
||||||
|
# Verify that it throws an error when !response.ok
|
||||||
|
assert "throw error" in api_content or "throw new Error" in api_content, (
|
||||||
|
"postSettings must throw when response is not ok"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_settings_save_handlers_route_through_post_settings():
|
||||||
|
"""All settings save handlers in settings.js and settings/*.js must route saves via postSettings."""
|
||||||
|
settings_content = _SETTINGS_JS.read_text(encoding="utf-8")
|
||||||
|
module_contents = {p.name: p.read_text(encoding="utf-8") for p in _SETTINGS_DIR.glob("*.js")}
|
||||||
|
|
||||||
|
# Assert no unverified direct POST fetch to /api/auth/settings in settings.js
|
||||||
|
direct_posts = list(re.finditer(r"fetch\(['\"]/api/auth/settings['\"],\s*\{\s*method:\s*['\"]POST['\"]", settings_content))
|
||||||
|
assert len(direct_posts) == 0, (
|
||||||
|
f"Found direct POST fetch to /api/auth/settings in settings.js; all writes must route through _postSettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Count _postSettings invocations across settings.js and modules
|
||||||
|
post_settings_calls = len(re.findall(r"_postSettings\(", settings_content))
|
||||||
|
for mod_name, mod_text in module_contents.items():
|
||||||
|
if mod_name != "api.js":
|
||||||
|
post_settings_calls += len(re.findall(r"_postSettings\(", mod_text))
|
||||||
|
|
||||||
|
assert post_settings_calls >= 15, (
|
||||||
|
f"Expected at least 15 _postSettings calls across settings modules, found {post_settings_calls}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_failed_save_shows_failure_in_simulated_dom():
|
||||||
|
"""Verify that when fetch returns !res.ok, save logic catches and flags error."""
|
||||||
|
js = """
|
||||||
|
let msgText = '';
|
||||||
|
let msgColor = '';
|
||||||
|
const msg = {
|
||||||
|
set textContent(v) { msgText = v; },
|
||||||
|
get textContent() { return msgText; },
|
||||||
|
style: { set color(v) { msgColor = v; }, get color() { return msgColor; } }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function save(fetchImpl) {
|
||||||
|
try {
|
||||||
|
var res = await fetchImpl('/api/auth/settings', { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// 1. Rejected fetch (403)
|
||||||
|
await save(async () => ({ ok: false, status: 403 }));
|
||||||
|
const rejectedResult = { text: msgText, color: msgColor };
|
||||||
|
|
||||||
|
// 2. Server error (500)
|
||||||
|
await save(async () => ({ ok: false, status: 500 }));
|
||||||
|
const serverErrResult = { text: msgText, color: msgColor };
|
||||||
|
|
||||||
|
// 3. Successful fetch (200)
|
||||||
|
await save(async () => ({ ok: true, status: 200 }));
|
||||||
|
const okResult = { text: msgText, color: msgColor };
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ rejectedResult, serverErrResult, okResult }));
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
"""
|
||||||
|
proc = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True)
|
||||||
|
import json
|
||||||
|
data = json.loads(proc.stdout)
|
||||||
|
assert data["rejectedResult"] == {"text": "Failed to save", "color": "var(--red)"}
|
||||||
|
assert data["serverErrResult"] == {"text": "Failed to save", "color": "var(--red)"}
|
||||||
|
assert data["okResult"] == {"text": "Saved", "color": "var(--fg)"}
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""index_for() local-model gating: a `remote-only` tag must hide a skill
|
||||||
|
from local/private-endpoint models while staying visible to remote API
|
||||||
|
models and to callers that don't know the endpoint at all.
|
||||||
|
|
||||||
|
Big on-demand reference lookups overwhelm small local contexts, so the
|
||||||
|
skill must vanish from the injected index there. local_model=None (API
|
||||||
|
listings, chat preface) means "don't gate" — same contract as
|
||||||
|
active_toolsets=None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# ── module-load stubbing (matches other tests in this repo) ──────────
|
||||||
|
for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"):
|
||||||
|
if _mod not in sys.modules:
|
||||||
|
try:
|
||||||
|
__import__(_mod)
|
||||||
|
except ImportError:
|
||||||
|
sys.modules[_mod] = MagicMock()
|
||||||
|
|
||||||
|
from services.memory.skills import SkillsManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill_md(skills_root: Path, name: str, *, tags: str = "[]",
|
||||||
|
owner: str = "admin") -> None:
|
||||||
|
skill_dir = skills_root / "general" / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fm = "\n".join([
|
||||||
|
"---",
|
||||||
|
f"name: {name}",
|
||||||
|
"description: test skill",
|
||||||
|
"version: 1.0.0",
|
||||||
|
"category: general",
|
||||||
|
f"tags: [{tags}]",
|
||||||
|
"status: published",
|
||||||
|
"confidence: 0.9",
|
||||||
|
"audit_verdict: pass",
|
||||||
|
"source: learned",
|
||||||
|
f"owner: {owner}",
|
||||||
|
"created: 2026-01-01T00:00:00Z",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## When to Use",
|
||||||
|
"- test",
|
||||||
|
"",
|
||||||
|
"## Procedure",
|
||||||
|
"1. step 1",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
(skill_dir / "SKILL.md").write_text(fm, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _names(idx):
|
||||||
|
return {s["name"] for s in idx}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_only_hidden_from_local_model(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "agents-map" not in _names(
|
||||||
|
sm.index_for(owner="admin", local_model=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_only_visible_to_remote_model_and_unknown_endpoint(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=False))
|
||||||
|
# Unknown endpoint (background callers without a URL) fails open.
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_untagged_skills_are_never_gated(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "plain-skill", tags="reference")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("plain-skill", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=True))
|
||||||
|
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_in_ui_listings_is_not_gated(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
# API listings / UI pass no local_model → must still see the skill.
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||||
@@ -133,3 +133,20 @@ def test_unaudited_or_below_threshold_published_skill_is_not_injected(tmp_path):
|
|||||||
assert not sm.get_relevant_skills(
|
assert not sm.get_relevant_skills(
|
||||||
"premature skill", min_confidence=0.80,
|
"premature skill", min_confidence=0.80,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_approve_off_injects_published_skills_without_an_audit(tmp_path):
|
||||||
|
# #6634: OFF used min_confidence=2.0, which no 0-1 confidence clears, so
|
||||||
|
# every user skill was rejected. Publishing is the approval when OFF.
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
for name, status in (("premature-skill", "published"), ("draft-skill", "draft")):
|
||||||
|
path = _write_skill_md(tmp_path / "skills", name)
|
||||||
|
text = path.read_text(encoding="utf-8").replace("audit_verdict: pass\n", "")
|
||||||
|
path.write_text(text.replace("status: published", f"status: {status}"), encoding="utf-8")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
|
||||||
|
query = "premature skill draft skill"
|
||||||
|
# threshold=0 isolates the eligibility gate from relevance scoring.
|
||||||
|
assert _names(sm.get_relevant_skills(query, threshold=0.0, published_only=True)) == {"premature-skill"}
|
||||||
|
# Auto-approve ON still needs a passing audit, as before.
|
||||||
|
assert not sm.get_relevant_skills(query, threshold=0.0, min_confidence=0.85)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Skill test runs must close the agent stream in their own task (#6635).
|
||||||
|
|
||||||
|
When the agent asks for an exact approval, the skill test pauses by leaving its
|
||||||
|
`async for` over `stream_agent_loop`. The abandoned generator was later closed
|
||||||
|
by asyncio's finalizer in another task, so the ContextVar resets in its cleanup
|
||||||
|
(request_authority, turn_contract, ...) raised "Token ... was created in a
|
||||||
|
different Context". The stream must be closed before the run returns.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
import routes.skills_routes as skills_routes
|
||||||
|
|
||||||
|
_BOUND: ContextVar = ContextVar("skill_test_bound", default=None)
|
||||||
|
|
||||||
|
_APPROVAL = {"kind": "tool_approval", "approval_id": "approval-1", "question": "Allow?"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_stream(events, closed):
|
||||||
|
async def stream_agent_loop(*args, **kwargs):
|
||||||
|
token = _BOUND.set("bound")
|
||||||
|
try:
|
||||||
|
for event in events:
|
||||||
|
yield "data: " + json.dumps(event) + "\n\n"
|
||||||
|
yield "data: [DONE]\n\n"
|
||||||
|
finally:
|
||||||
|
# Raises ValueError when run from another task's context.
|
||||||
|
_BOUND.reset(token)
|
||||||
|
closed.append(asyncio.current_task())
|
||||||
|
return stream_agent_loop
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_skill_test_closes_the_agent_stream_in_its_own_task(monkeypatch):
|
||||||
|
closed = []
|
||||||
|
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||||
|
{"delta": "never reached"}]
|
||||||
|
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||||
|
key = ("alice", "demo")
|
||||||
|
skills_routes._skill_test_jobs[key] = {"log": [], "status": "running"}
|
||||||
|
try:
|
||||||
|
await skills_routes._run_skill_test_job(
|
||||||
|
key, "demo", "# skill", "task", "http://model.test", "m", {}, "alice")
|
||||||
|
job = skills_routes._skill_test_jobs[key]
|
||||||
|
assert job["status"] == "awaiting_approval"
|
||||||
|
assert closed == [asyncio.current_task()]
|
||||||
|
finally:
|
||||||
|
skills_routes._skill_test_jobs.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_audit_arm_closes_the_agent_stream_when_it_stops_for_approval(monkeypatch):
|
||||||
|
closed = []
|
||||||
|
events = [{"type": "tool_output", "output": "blocked", "ask_user": _APPROVAL},
|
||||||
|
{"delta": "never reached"}]
|
||||||
|
monkeypatch.setattr("src.agent_loop.stream_agent_loop", _fake_stream(events, closed))
|
||||||
|
|
||||||
|
_, _, approval = await skills_routes._run_skill_audit_arm(
|
||||||
|
[{"role": "user", "content": "task"}], "http://model.test", "m", {}, "alice")
|
||||||
|
|
||||||
|
assert approval == _APPROVAL
|
||||||
|
assert closed == [asyncio.current_task()]
|
||||||
@@ -222,3 +222,45 @@ def test_untrusted_validation_detail_cannot_masquerade_as_curated_guidance(detai
|
|||||||
assert "TAKEOVER_SECRET" not in public
|
assert "TAKEOVER_SECRET" not in public
|
||||||
assert "/srv/private" not in public
|
assert "/srv/private" not in public
|
||||||
assert "PRIVATE_TOKEN" not in public
|
assert "PRIVATE_TOKEN" not in public
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("tool, arguments, hint", [
|
||||||
|
# Authored by normalized_native_function_argument_error.
|
||||||
|
("pdf_extract", {"url": "Attention Is All You Need"},
|
||||||
|
"pdf_extract requires a public http(s) PDF URL"),
|
||||||
|
# Rebuilt from the schema, not from the caught ValidationError.
|
||||||
|
("manage_notes", {"action": "explode"},
|
||||||
|
"Argument 'action' must be one of: list, search, view"),
|
||||||
|
("python", {},
|
||||||
|
"Missing required argument: 'code'."),
|
||||||
|
])
|
||||||
|
async def test_preview_returns_validator_guidance_to_the_model(monkeypatch, tool, arguments, hint):
|
||||||
|
module = _preview_provider(monkeypatch, [
|
||||||
|
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call-1", "function": {
|
||||||
|
"name": tool, "arguments": json.dumps(arguments),
|
||||||
|
}}]}}]},
|
||||||
|
{"choices": [{"delta": {"content": "The requested call failed."}}]},
|
||||||
|
] * 8)
|
||||||
|
|
||||||
|
async def execute(*args, **kwargs):
|
||||||
|
raise AssertionError("an invalid call must not execute")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "execute_tool_block", execute)
|
||||||
|
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == tool)
|
||||||
|
chunks = await _client_chunks(_preview_generator(module, [schema]), False)
|
||||||
|
tool_event = next(e for e in _events(chunks) if e.get("type") == "tool_output")
|
||||||
|
assert tool_event["error_category"] == "invalid_tool_arguments"
|
||||||
|
assert hint in tool_event["output"]
|
||||||
|
assert "could not be validated" not in tool_event["output"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_hint_ignores_the_caught_exception_text():
|
||||||
|
from src.clean_agent_preview import _schema_argument_hint
|
||||||
|
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s["function"]["name"] == "manage_notes")
|
||||||
|
# A ValidationError raised for arguments that are actually valid yields no
|
||||||
|
# hint, so the caller falls back to the curated schema message.
|
||||||
|
assert _schema_argument_hint({"action": "list"}, schema["function"]["parameters"]) == ""
|
||||||
|
hint = _schema_argument_hint({"action": SENSITIVE}, schema["function"]["parameters"])
|
||||||
|
assert "must be one of" in hint
|
||||||
|
assert "TAKEOVER_SECRET" not in hint
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
"""Opt-in ``utility_reasoning_effort`` for utility/background LLM calls.
|
||||||
|
|
||||||
|
The setting is resolved in ``task_llm_call_async`` and sent only to candidates
|
||||||
|
on the utility chain whose model advertises the level. Explicit caller values
|
||||||
|
win; with the setting empty nothing changes.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import llm_core, task_endpoint
|
||||||
|
from src.chatgpt_subscription import validate_reasoning_effort
|
||||||
|
|
||||||
|
UTIL = ("https://api.openai.example/v1", "gpt-5.5", {})
|
||||||
|
UTIL_FALLBACK = ("https://fallback.example/v1", "gpt-5.5-mini", {})
|
||||||
|
OVERRIDE = ("https://override.example/v1", "gpt-5.5", {})
|
||||||
|
NO_EVIDENCE = ("https://local.example/v1", "tiny-local-model", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_resolvers(monkeypatch, utility):
|
||||||
|
"""Stub the resolvers in both modules that look the utility chain up."""
|
||||||
|
from src import endpoint_resolver
|
||||||
|
|
||||||
|
resolve = lambda prefix, *a, **k: { # noqa: E731
|
||||||
|
"utility": utility, "task": OVERRIDE, "default": NO_EVIDENCE,
|
||||||
|
}[prefix]
|
||||||
|
fallbacks = lambda owner=None: [UTIL_FALLBACK] # noqa: E731
|
||||||
|
for mod in (task_endpoint, endpoint_resolver):
|
||||||
|
monkeypatch.setattr(mod, "resolve_endpoint", resolve)
|
||||||
|
monkeypatch.setattr(mod, "resolve_utility_fallback_candidates", fallbacks)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def chain(monkeypatch):
|
||||||
|
"""Stub the resolvers and capture what reaches llm_call_async per model."""
|
||||||
|
_stub_resolvers(monkeypatch, UTIL)
|
||||||
|
|
||||||
|
async def _quiet(*_a, **_k):
|
||||||
|
return None
|
||||||
|
monkeypatch.setattr(task_endpoint, "wait_for_interactive_quiet", _quiet)
|
||||||
|
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
async def fake_call(url, model, messages, **kw):
|
||||||
|
calls[url] = kw.get("reasoning_effort")
|
||||||
|
if url != UTIL_FALLBACK[0]:
|
||||||
|
raise RuntimeError("force walk down the chain")
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def _set_effort(monkeypatch, value):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.settings.get_user_setting",
|
||||||
|
lambda key, owner="", default=None: value if key == "utility_reasoning_effort" else default,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(**kw):
|
||||||
|
return asyncio.run(task_endpoint.task_llm_call_async(
|
||||||
|
[{"role": "user", "content": "hi"}], **kw,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_applies_only_to_utility_chain(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "high")
|
||||||
|
_run()
|
||||||
|
# The Background Tasks route shares the model id with the utility model but
|
||||||
|
# is a different (url, model) route, so it gets no effort.
|
||||||
|
assert chain[OVERRIDE[0]] is None
|
||||||
|
assert chain[UTIL[0]] == "high"
|
||||||
|
assert chain[UTIL_FALLBACK[0]] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_utility_candidate_gets_validated_effort(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "HIGH")
|
||||||
|
_run()
|
||||||
|
assert chain[UTIL[0]] == "high"
|
||||||
|
|
||||||
|
|
||||||
|
def test_effort_without_evidence_is_dropped(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "high")
|
||||||
|
_stub_resolvers(monkeypatch, ("https://local.example/v1", "tiny-local-model", {}))
|
||||||
|
_run()
|
||||||
|
assert chain["https://local.example/v1"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsupported_level_is_dropped(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "bogus")
|
||||||
|
_run()
|
||||||
|
assert chain[UTIL[0]] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_setting_changes_nothing(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "")
|
||||||
|
_run()
|
||||||
|
assert chain and all(effort is None for effort in chain.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_caller_effort_wins(chain, monkeypatch):
|
||||||
|
_set_effort(monkeypatch, "high")
|
||||||
|
_run(reasoning_effort="low")
|
||||||
|
assert chain and all(effort == "low" for effort in chain.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_factory_can_override_messages_and_kwargs(monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
async def fake_call(url, model, messages, **kw):
|
||||||
|
seen.append((model, messages, kw.get("reasoning_effort"), kw.get("temperature")))
|
||||||
|
if len(seen) == 1:
|
||||||
|
raise RuntimeError("first fails")
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||||
|
|
||||||
|
def factory(index, url, model, headers):
|
||||||
|
return {"kwargs": {"reasoning_effort": f"e{index}"}}
|
||||||
|
|
||||||
|
out = asyncio.run(llm_core.llm_call_async_with_fallback(
|
||||||
|
[("http://a/v1", "m1", {}), ("http://b/v1", "m2", {})],
|
||||||
|
[{"role": "user", "content": "x"}],
|
||||||
|
temperature=0.1,
|
||||||
|
candidate_request_factory=factory,
|
||||||
|
))
|
||||||
|
assert out == "ok"
|
||||||
|
assert [(s[0], s[2], s[3]) for s in seen] == [("m1", "e0", 0.1), ("m2", "e1", 0.1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_reasoning_effort_needs_evidence():
|
||||||
|
assert validate_reasoning_effort("gpt-5.5", "high") == "high"
|
||||||
|
assert validate_reasoning_effort("tiny-local-model", "high") is None
|
||||||
|
assert validate_reasoning_effort("gpt-5.5", "") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_chatgpt_wire_carries_effort_and_default_omits_it():
|
||||||
|
sent = llm_core._build_chatgpt_responses_payload(
|
||||||
|
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64, reasoning_effort="high",
|
||||||
|
)
|
||||||
|
assert sent["reasoning"] == {"effort": "high"}
|
||||||
|
plain = llm_core._build_chatgpt_responses_payload(
|
||||||
|
"gpt-5.5", [{"role": "user", "content": "hi"}], 0.3, 64,
|
||||||
|
)
|
||||||
|
assert "reasoning" not in plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_llm_call_cache_key_includes_effort(monkeypatch):
|
||||||
|
keys = []
|
||||||
|
|
||||||
|
def fake_cached(key):
|
||||||
|
keys.append(key)
|
||||||
|
return "cached"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "_get_cached_response", fake_cached)
|
||||||
|
msgs = [{"role": "user", "content": "hi"}]
|
||||||
|
llm_core.llm_call("http://x/v1", "m", msgs)
|
||||||
|
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="high")
|
||||||
|
llm_core.llm_call("http://x/v1", "m", msgs, reasoning_effort="HIGH")
|
||||||
|
assert keys[0] != keys[1]
|
||||||
|
assert keys[1] == keys[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_is_registered_default_and_per_user():
|
||||||
|
from src.settings import DEFAULT_SETTINGS, _PER_USER_KEYS
|
||||||
|
|
||||||
|
assert DEFAULT_SETTINGS["utility_reasoning_effort"] == ""
|
||||||
|
assert "utility_reasoning_effort" in _PER_USER_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
# --- effort_for_call: setting, then the chat session's own effort ---
|
||||||
|
|
||||||
|
CHAT = ("https://chat.example/v1", "gpt-5.5", {})
|
||||||
|
|
||||||
|
|
||||||
|
class _Sess:
|
||||||
|
def __init__(self, thinking_mode="effort:low", url=CHAT[0], model=CHAT[1], owner="alice"):
|
||||||
|
self.thinking_mode = thinking_mode
|
||||||
|
self.endpoint_url = url
|
||||||
|
self.model = model
|
||||||
|
self.owner = owner
|
||||||
|
self.headers = {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def util(monkeypatch):
|
||||||
|
"""Utility chain = {UTIL}; the setting is controlled via ``settings[owner]``."""
|
||||||
|
from src import utility_effort
|
||||||
|
|
||||||
|
settings = {}
|
||||||
|
monkeypatch.setattr(utility_effort, "utility_routes", lambda owner=None: {(UTIL[0], UTIL[1])})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.settings.get_user_setting",
|
||||||
|
lambda key, owner="", default=None: (
|
||||||
|
settings.get(owner, "") if key == "utility_reasoning_effort" else default
|
||||||
|
),
|
||||||
|
)
|
||||||
|
util_mod = utility_effort
|
||||||
|
util_mod.settings = settings
|
||||||
|
return util_mod
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_applies_to_utility_route_and_wins_over_chat_effort(util):
|
||||||
|
util.settings["alice"] = "medium"
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) == "medium"
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def test_setting_is_per_user(util):
|
||||||
|
util.settings["alice"] = "high"
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "alice") == "high"
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "bob") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_effort_inherited_on_the_session_route(util):
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||||
|
# Endpoint spelling differences (trailing slash) are still the same route.
|
||||||
|
assert util.effort_for_call(CHAT[0] + "/", CHAT[1], "alice", _Sess("effort:low")) == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_effort_not_inherited_on_another_route(util):
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess("effort:low")) is None
|
||||||
|
assert util.effort_for_call(CHAT[0], "gpt-5.5-mini", "alice", _Sess("effort:low")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_effort_off_or_missing_inherits_nothing(util):
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("off")) is None
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_session_means_no_inheritance(util):
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_off_setting_blocks_session_effort_inheritance(util):
|
||||||
|
util.settings["alice"] = "off"
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||||
|
assert util.candidate_effort_factory("alice") is None
|
||||||
|
|
||||||
|
util.settings["alice"] = "default"
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:low")) is None
|
||||||
|
assert util.candidate_effort_factory("alice") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_inherited_effort_is_validated_against_the_model(util):
|
||||||
|
assert util.effort_for_call(
|
||||||
|
NO_EVIDENCE[0], NO_EVIDENCE[1], "alice",
|
||||||
|
_Sess("effort:low", url=NO_EVIDENCE[0], model=NO_EVIDENCE[1]),
|
||||||
|
) is None
|
||||||
|
assert util.effort_for_call(CHAT[0], CHAT[1], "alice", _Sess("effort:bogus")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_effort_for_call_never_raises(util, monkeypatch):
|
||||||
|
def boom(*_a, **_k):
|
||||||
|
raise RuntimeError("db down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(util, "effort_for_route", boom)
|
||||||
|
assert util.effort_for_call(UTIL[0], UTIL[1], "alice", _Sess()) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- no global fill: callers that are not utility call sites never get an effort ---
|
||||||
|
|
||||||
|
def test_llm_call_async_adds_no_default_effort(util, monkeypatch):
|
||||||
|
# The owner-less helpers resolve at global scope; set it there and per user.
|
||||||
|
util.settings[""] = util.settings["alice"] = "high"
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_key(url, model, messages, temperature, max_tokens, headers=None,
|
||||||
|
thinking_mode=None, reasoning_effort=None):
|
||||||
|
seen.append(reasoning_effort)
|
||||||
|
return "k"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "_get_cache_key", fake_key)
|
||||||
|
monkeypatch.setattr(llm_core, "_get_cached_response", lambda key: "cached")
|
||||||
|
msgs = [{"role": "user", "content": "hi"}]
|
||||||
|
# Same (url, model) as the utility route, but not a utility call site.
|
||||||
|
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs))
|
||||||
|
llm_core.llm_call(UTIL[0], UTIL[1], msgs)
|
||||||
|
asyncio.run(llm_core.llm_call_async(UTIL[0], UTIL[1], msgs, reasoning_effort="low"))
|
||||||
|
assert seen == [None, None, "low"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- call sites pass the effort explicitly ---
|
||||||
|
|
||||||
|
def _capture_llm(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_call(url, model, messages, **kw):
|
||||||
|
sent.append((url, model, kw.get("reasoning_effort")))
|
||||||
|
return "A title"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
class _History:
|
||||||
|
role = "user"
|
||||||
|
content = "hello there"
|
||||||
|
|
||||||
|
|
||||||
|
def _title_session(**kw):
|
||||||
|
sess = _Sess(**kw)
|
||||||
|
sess.id = "s1"
|
||||||
|
sess.history = [_History()]
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
def update_session_name(self, *_a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_title_inherits_chat_effort_when_utility_is_blank(util, monkeypatch):
|
||||||
|
from routes import chat_helpers
|
||||||
|
|
||||||
|
sent = _capture_llm(monkeypatch)
|
||||||
|
# Blank utility/task resolves to the session's own route.
|
||||||
|
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (CHAT[0], CHAT[1], {}))
|
||||||
|
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||||
|
assert sent == [(CHAT[0], CHAT[1], "low")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_title_setting_beats_chat_effort(util, monkeypatch):
|
||||||
|
from routes import chat_helpers
|
||||||
|
|
||||||
|
util.settings["alice"] = "medium"
|
||||||
|
sent = _capture_llm(monkeypatch)
|
||||||
|
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||||
|
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||||
|
assert sent == [(UTIL[0], UTIL[1], "medium")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_title_on_a_different_utility_model_gets_no_chat_effort(util, monkeypatch):
|
||||||
|
from routes import chat_helpers
|
||||||
|
|
||||||
|
sent = _capture_llm(monkeypatch)
|
||||||
|
monkeypatch.setattr(task_endpoint, "resolve_task_endpoint", lambda *a, **k: (UTIL[0], UTIL[1], {}))
|
||||||
|
asyncio.run(chat_helpers.auto_name_session(_Manager(), _title_session()))
|
||||||
|
assert sent == [(UTIL[0], UTIL[1], None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_extraction_passes_chat_effort(util, monkeypatch):
|
||||||
|
from services.memory import memory_extractor
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_call(url, model, messages, **kw):
|
||||||
|
sent.append(kw.get("reasoning_effort"))
|
||||||
|
return "[]"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||||
|
|
||||||
|
class Sess(_Sess):
|
||||||
|
def get_context_messages(self):
|
||||||
|
return [{"role": "user", "content": "I live in Oslo"},
|
||||||
|
{"role": "assistant", "content": "Noted"}]
|
||||||
|
|
||||||
|
asyncio.run(memory_extractor.extract_and_store(
|
||||||
|
Sess(), object(), object(), CHAT[0], CHAT[1], {},
|
||||||
|
))
|
||||||
|
assert sent and sent[0] == "low"
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_summary_sends_setting_effort_for_owner_only(util, monkeypatch):
|
||||||
|
from routes.email import email_helpers
|
||||||
|
|
||||||
|
util.settings["alice"] = "medium"
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
async def fake_call(**kw):
|
||||||
|
sent.append(kw.get("reasoning_effort"))
|
||||||
|
return "- point"
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_call)
|
||||||
|
for owner in ("alice", "bob", None):
|
||||||
|
asyncio.run(email_helpers._generate_email_summary(
|
||||||
|
UTIL[0], UTIL[1], "a@x", "s", "body", owner=owner,
|
||||||
|
))
|
||||||
|
assert sent == ["medium", None, None]
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_effort_factory_scopes_to_utility_route(util):
|
||||||
|
from src.utility_effort import candidate_effort_factory
|
||||||
|
|
||||||
|
assert candidate_effort_factory("alice") is None
|
||||||
|
util.settings["alice"] = "medium"
|
||||||
|
factory = candidate_effort_factory("alice")
|
||||||
|
assert factory(0, UTIL[0], UTIL[1], {}) == {"kwargs": {"reasoning_effort": "medium"}}
|
||||||
|
assert factory(1, NO_EVIDENCE[0], NO_EVIDENCE[1], {}) == {}
|
||||||
|
assert candidate_effort_factory("bob") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_route_efforts_and_factory(monkeypatch):
|
||||||
|
from src.utility_effort import candidate_effort_factory, effort_for_route
|
||||||
|
|
||||||
|
def fake_resolve_id(ep_id, model, owner=None):
|
||||||
|
if ep_id == "ep-fallback":
|
||||||
|
return ("https://fallback.example/v1", model, {})
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.endpoint_resolver.resolve_endpoint_by_id", fake_resolve_id)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.settings.load_settings",
|
||||||
|
lambda: {"utility_model_fallbacks": [{"endpoint_id": "ep-fallback", "model": "gpt-5.5-mini", "reasoning_effort": "low"}]},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.settings.get_user_setting",
|
||||||
|
lambda key, owner="", default=None: default,
|
||||||
|
)
|
||||||
|
|
||||||
|
factory = candidate_effort_factory("alice")
|
||||||
|
assert factory is not None
|
||||||
|
assert effort_for_route("https://api.openai.example/v1", "gpt-5.5", "alice") is None
|
||||||
|
assert effort_for_route("https://fallback.example/v1", "gpt-5.5-mini", "alice") == "low"
|
||||||
|
assert factory(0, "https://fallback.example/v1", "gpt-5.5-mini", {}) == {"kwargs": {"reasoning_effort": "low"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
def fake_post(url, headers, **kwargs):
|
||||||
|
sent.append(dict(kwargs["json"]))
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||||
|
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||||
|
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware", fake_post)
|
||||||
|
|
||||||
|
url = "https://example.test/v1"
|
||||||
|
messages = [{"role": "user", "content": "hi"}]
|
||||||
|
|
||||||
|
llm_core.llm_call(
|
||||||
|
url, "gpt-5.5", messages, max_tokens=64,
|
||||||
|
reasoning_effort="HIGH",
|
||||||
|
)
|
||||||
|
llm_core.llm_call(url, "gpt-5.5", messages, max_tokens=64)
|
||||||
|
|
||||||
|
assert sent[0]["reasoning_effort"] == "high"
|
||||||
|
assert "reasoning_effort" not in sent[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_openai_compatible_wire_transmits_explicit_effort(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
import httpx
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def fake_slot(*args, **kwargs):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def fake_post(client, url, headers, **kwargs):
|
||||||
|
sent.append(dict(kwargs["json"]))
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "ok"}}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "_get_cached_response", lambda *_: None)
|
||||||
|
monkeypatch.setattr(llm_core, "_set_cached_response", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(llm_core, "_get_http_client", lambda: object())
|
||||||
|
monkeypatch.setattr(llm_core, "_is_host_dead", lambda *_: False)
|
||||||
|
monkeypatch.setattr(llm_core, "_local_model_slot", fake_slot)
|
||||||
|
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a: None)
|
||||||
|
monkeypatch.setattr(llm_core, "httpx_post_kimi_aware_async", fake_post)
|
||||||
|
|
||||||
|
async def exercise():
|
||||||
|
url = "https://example.test/v1"
|
||||||
|
messages = [{"role": "user", "content": "hi"}]
|
||||||
|
|
||||||
|
await llm_core.llm_call_async(
|
||||||
|
url, "gpt-5.5", messages, max_tokens=0,
|
||||||
|
max_retries=1, reasoning_effort="medium",
|
||||||
|
)
|
||||||
|
await llm_core.llm_call_async(
|
||||||
|
url, "gpt-5.5", messages, max_tokens=0,
|
||||||
|
max_retries=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(exercise())
|
||||||
|
|
||||||
|
assert sent[0]["reasoning_effort"] == "medium"
|
||||||
|
assert "reasoning_effort" not in sent[1]
|
||||||
@@ -131,3 +131,12 @@ where you run it matters:
|
|||||||
>
|
>
|
||||||
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
||||||
> snapshot normally.
|
> snapshot normally.
|
||||||
|
>
|
||||||
|
> **Upgrading from a pre-#6166 install?** The old Compose files mounted
|
||||||
|
> `chromadb-data` at the wrong path (`/chroma/chroma`), so the volume was
|
||||||
|
> always empty while live data accumulated in the container's writable layer
|
||||||
|
> under `/data`. If you have a running pre-#6166 ChromaDB container, follow
|
||||||
|
> [docs/chromadb-volume-migration.md](https://github.com/odysseus-dev/odysseus/blob/dev/docs/chromadb-volume-migration.md)
|
||||||
|
> to rescue that data before recreating or deleting existing containers. The
|
||||||
|
> migration guide provides step-by-step instructions for recovery, backup
|
||||||
|
> verification, volume seeding, rollback, and re-indexing.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user